@burdenoff/microfe-conversations 2026.731.1 → 2026.805.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/conversations/ConversationsAdminRoutes.js +1 -1
- package/dist/conversations/ConversationsAdminRoutes.js.map +1 -1
- package/dist/conversations/ConversationsUserRoutes.js +1 -1
- package/dist/conversations/ConversationsUserRoutes.js.map +1 -1
- package/dist/conversations/components/InboxLayout.js +1 -1
- package/dist/conversations/components/InboxLayout.js.map +1 -1
- package/dist/conversations/components/NewConversationDialog.js +1 -1
- package/dist/conversations/components/NewConversationDialog.js.map +1 -1
- package/dist/conversations/pages/AdminConversationsLandingPage.js +1 -1
- package/dist/conversations/pages/AdminConversationsLandingPage.js.map +1 -1
- package/dist/conversations/pages/ConversationDetailPage.js +1 -1
- package/dist/conversations/pages/ConversationDetailPage.js.map +1 -1
- package/dist/conversations/pages/ConversationListPage.js +1 -1
- package/dist/conversations/pages/ConversationListPage.js.map +1 -1
- package/dist/conversations/pages/ConversationsLandingPage.js +1 -1
- package/dist/conversations/pages/ConversationsLandingPage.js.map +1 -1
- package/dist/conversations/pages/ConversationsSettingsPage.js +1 -1
- package/dist/conversations/pages/ConversationsSettingsPage.js.map +1 -1
- package/dist/conversations/pages/NewConversationPage.js +1 -1
- package/dist/conversations/pages/NewConversationPage.js.map +1 -1
- package/dist/conversations/providers/ConversationsProvider.js +1 -1
- package/dist/conversations/providers/ConversationsProvider.js.map +1 -1
- package/package.json +4 -4
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AdminConversationsLandingPage as e } from "./pages/AdminConversationsLandingPage.js";
|
|
2
2
|
import { AppInstallationsPage as t, BulkOperationsPage as n, ConversationAuditLogPage as r, ModerationQueuePage as i } from "./pages/ConversationAdminPages.js";
|
|
3
|
-
import { Navigate as a, Route as o, Routes as s } from "react-router
|
|
3
|
+
import { Navigate as a, Route as o, Routes as s } from "react-router";
|
|
4
4
|
import { jsx as c, jsxs as l } from "react/jsx-runtime";
|
|
5
5
|
//#region src/conversations/ConversationsAdminRoutes.tsx
|
|
6
6
|
function u() {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ConversationsAdminRoutes.js","names":[],"sources":["../../src/conversations/ConversationsAdminRoutes.tsx"],"sourcesContent":["import { Navigate, Route, Routes } from \"react-router
|
|
1
|
+
{"version":3,"file":"ConversationsAdminRoutes.js","names":[],"sources":["../../src/conversations/ConversationsAdminRoutes.tsx"],"sourcesContent":["import { Navigate, Route, Routes } from \"react-router\";\n\nimport { AdminConversationsLandingPage } from \"./pages/AdminConversationsLandingPage\";\nimport {\n AppInstallationsPage,\n BulkOperationsPage,\n ConversationAuditLogPage,\n ModerationQueuePage,\n} from \"./pages/ConversationAdminPages\";\n\n/**\n * Admin-facing routes for the Conversations microfrontend.\n *\n * All imports in this file are eager — when real admin pages are added\n * and become `React.lazy()`, wrap the `<Routes>` below in\n * `<LazyPageBoundary source=\"admin\">` (from ./components/LazyPageBoundary)\n * to get the shared retry / spinner UI.\n *\n * Routes (relative to basePath, e.g., /conversations):\n * - / -> AdminConversationsLandingPage (overview + section directory)\n * - /moderation -> Moderation queue\n * - /bulk -> Bulk operations\n * - /audit -> Audit log viewer\n * - /apps -> App installations\n * - * -> redirect to landing\n */\nexport function ConversationsAdminRoutes() {\n return (\n <Routes>\n <Route index element={<AdminConversationsLandingPage />} />\n <Route path=\"moderation\" element={<ModerationQueuePage />} />\n <Route path=\"bulk\" element={<BulkOperationsPage />} />\n <Route path=\"audit\" element={<ConversationAuditLogPage />} />\n <Route path=\"apps\" element={<AppInstallationsPage />} />\n <Route path=\"*\" element={<Navigate to=\".\" replace />} />\n </Routes>\n );\n}\n"],"mappings":";;;;;AA0BA,SAAgB,IAA2B;AACzC,QACE,kBAAC,GAAD,EAAA,UAAA;EACE,kBAAC,GAAD;GAAO,OAAA;GAAM,SAAS,kBAAC,GAAD,EAAiC,CAAA;GAAI,CAAA;EAC3D,kBAAC,GAAD;GAAO,MAAK;GAAa,SAAS,kBAAC,GAAD,EAAuB,CAAA;GAAI,CAAA;EAC7D,kBAAC,GAAD;GAAO,MAAK;GAAO,SAAS,kBAAC,GAAD,EAAsB,CAAA;GAAI,CAAA;EACtD,kBAAC,GAAD;GAAO,MAAK;GAAQ,SAAS,kBAAC,GAAD,EAA4B,CAAA;GAAI,CAAA;EAC7D,kBAAC,GAAD;GAAO,MAAK;GAAO,SAAS,kBAAC,GAAD,EAAwB,CAAA;GAAI,CAAA;EACxD,kBAAC,GAAD;GAAO,MAAK;GAAI,SAAS,kBAAC,GAAD;IAAU,IAAG;IAAI,SAAA;IAAU,CAAA;GAAI,CAAA;EACjD,EAAA,CAAA"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { lazyRetry as e } from "./components/lazyRetry.js";
|
|
2
2
|
import { LazyPageBoundary as t } from "./components/LazyPageBoundary.js";
|
|
3
|
-
import { Navigate as n, Route as r, Routes as i } from "react-router
|
|
3
|
+
import { Navigate as n, Route as r, Routes as i } from "react-router";
|
|
4
4
|
import { jsx as a, jsxs as o } from "react/jsx-runtime";
|
|
5
5
|
//#region src/conversations/ConversationsUserRoutes.tsx
|
|
6
6
|
var s = e(() => import("./pages/ConversationListPage.js").then((e) => ({ default: e.ConversationListPage }))), c = e(() => import("./pages/ConversationsLandingPage.js").then((e) => ({ default: e.ConversationsLandingPage }))), l = e(() => import("./pages/NewConversationPage.js").then((e) => ({ default: e.NewConversationPage }))), u = e(() => import("./pages/ConversationsSettingsPage.js").then((e) => ({ default: e.ConversationsSettingsPage })));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ConversationsUserRoutes.js","names":[],"sources":["../../src/conversations/ConversationsUserRoutes.tsx"],"sourcesContent":["import { Navigate, Route, Routes } from \"react-router
|
|
1
|
+
{"version":3,"file":"ConversationsUserRoutes.js","names":[],"sources":["../../src/conversations/ConversationsUserRoutes.tsx"],"sourcesContent":["import { Navigate, Route, Routes } from \"react-router\";\n\nimport { LazyPageBoundary } from \"./components/LazyPageBoundary\";\nimport { lazyRetry } from \"./components/lazyRetry\";\nimport type { ConversationListPageProps } from \"./pages/ConversationListPage\";\n\nconst ConversationListPage = lazyRetry<ConversationListPageProps>(() =>\n import(\"./pages/ConversationListPage\").then((m) => ({\n default: m.ConversationListPage,\n })),\n);\nconst ConversationsLandingPage = lazyRetry<Record<string, never>>(() =>\n import(\"./pages/ConversationsLandingPage\").then((m) => ({\n default: m.ConversationsLandingPage,\n })),\n);\n\nconst NewConversationPage = lazyRetry<Record<string, never>>(() =>\n import(\"./pages/NewConversationPage\").then((m) => ({\n default: m.NewConversationPage,\n })),\n);\nconst ConversationsSettingsPage = lazyRetry<Record<string, never>>(() =>\n import(\"./pages/ConversationsSettingsPage\").then((m) => ({\n default: m.ConversationsSettingsPage,\n })),\n);\n\n/**\n * User-facing routes for the Conversations microfrontend.\n *\n * Routes (relative to basePath, e.g., /conversations):\n * - / -> Inbox (two-pane shell, no conversation selected)\n * - /:conversationId -> Inbox with conversation detail in right pane\n * - /list -> Legacy standalone list (kept for deep-linked callers)\n * - /new -> Legacy create route; backward-compat only.\n * Redirects to `..?new=true` so the inbox opens\n * the create dialog in-place. Kept registered\n * for deep links that may still exist in the\n * wild — the new canonical UX is the\n * `?new=true` query param on the landing page.\n * - /settings -> Personal notification settings\n *\n * Both `/` and `/:conversationId` mount `ConversationsLandingPage`. The page\n * reads `useParams().conversationId` and renders the `ConversationDetailPage`\n * in the right pane when set, otherwise the inbox empty state. This keeps\n * the two-pane experience consistent across deep links and sidebar clicks\n * — no full-page nav happens when a user picks a conversation on md+ screens.\n */\nexport function ConversationsUserRoutes() {\n return (\n <LazyPageBoundary source=\"user\">\n <Routes>\n <Route index element={<ConversationsLandingPage />} />\n <Route path=\"list\" element={<ConversationListPage />} />\n <Route path=\"new\" element={<NewConversationPage />} />\n <Route path=\"settings\" element={<ConversationsSettingsPage />} />\n <Route path=\":conversationId\" element={<ConversationsLandingPage />} />\n <Route path=\"*\" element={<Navigate to=\".\" replace />} />\n </Routes>\n </LazyPageBoundary>\n );\n}\n"],"mappings":";;;;;AAMA,IAAM,IAAuB,QAC3B,OAAO,mCAAgC,MAAM,OAAO,EAClD,SAAS,EAAE,sBACZ,EAAE,CACJ,EACK,IAA2B,QAC/B,OAAO,uCAAoC,MAAM,OAAO,EACtD,SAAS,EAAE,0BACZ,EAAE,CACJ,EAEK,IAAsB,QAC1B,OAAO,kCAA+B,MAAM,OAAO,EACjD,SAAS,EAAE,qBACZ,EAAE,CACJ,EACK,IAA4B,QAChC,OAAO,wCAAqC,MAAM,OAAO,EACvD,SAAS,EAAE,2BACZ,EAAE,CACJ;AAuBD,SAAgB,IAA0B;AACxC,QACE,kBAAC,GAAD;EAAkB,QAAO;YACvB,kBAAC,GAAD,EAAA,UAAA;GACE,kBAAC,GAAD;IAAO,OAAA;IAAM,SAAS,kBAAC,GAAD,EAA4B,CAAA;IAAI,CAAA;GACtD,kBAAC,GAAD;IAAO,MAAK;IAAO,SAAS,kBAAC,GAAD,EAAwB,CAAA;IAAI,CAAA;GACxD,kBAAC,GAAD;IAAO,MAAK;IAAM,SAAS,kBAAC,GAAD,EAAuB,CAAA;IAAI,CAAA;GACtD,kBAAC,GAAD;IAAO,MAAK;IAAW,SAAS,kBAAC,GAAD,EAA6B,CAAA;IAAI,CAAA;GACjE,kBAAC,GAAD;IAAO,MAAK;IAAkB,SAAS,kBAAC,GAAD,EAA4B,CAAA;IAAI,CAAA;GACvE,kBAAC,GAAD;IAAO,MAAK;IAAI,SAAS,kBAAC,GAAD;KAAU,IAAG;KAAI,SAAA;KAAU,CAAA;IAAI,CAAA;GACjD,EAAA,CAAA;EACQ,CAAA"}
|
|
@@ -4,7 +4,7 @@ import { cn as n } from "../utils/cn.js";
|
|
|
4
4
|
import { useConversationsStore as r } from "../store/conversationsStore.js";
|
|
5
5
|
import { useCallback as i, useMemo as a } from "react";
|
|
6
6
|
import { ArrowLeft as o, MessageSquarePlus as s, MessagesSquare as c, Search as l, X as u } from "lucide-react";
|
|
7
|
-
import { useLocation as d, useNavigate as f, useParams as p } from "react-router
|
|
7
|
+
import { useLocation as d, useNavigate as f, useParams as p } from "react-router";
|
|
8
8
|
import { jsx as m, jsxs as h } from "react/jsx-runtime";
|
|
9
9
|
import { AuroraBackground as g, Button as _, GradientText as v, IllustratedEmptyState as y, PagePurpose as b } from "@burdenoff/fe-libs/ui";
|
|
10
10
|
import { Tabs as x } from "@burdenoff/fe-libs/chrome";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"InboxLayout.js","names":[],"sources":["../../../src/conversations/components/InboxLayout.tsx"],"sourcesContent":["import type { ReactNode } from \"react\";\nimport { useCallback, useMemo } from \"react\";\nimport { useLocation, useNavigate, useParams } from \"react-router-dom\";\nimport { Tabs, type TabItem } from \"@burdenoff/fe-libs/chrome\";\nimport {\n Button,\n AuroraBackground,\n GradientText,\n IllustratedEmptyState,\n PagePurpose,\n} from \"@burdenoff/fe-libs/ui\";\nimport {\n ArrowLeft,\n MessageSquarePlus,\n MessagesSquare,\n Search,\n X,\n} from \"lucide-react\";\nimport { cn } from \"../utils/cn\";\nimport { useConversations } from \"../providers/ConversationsProvider\";\nimport { useConversationsStore } from \"../store/conversationsStore\";\nimport { useTr } from \"../utils/i18n\";\n\n/**\n * Identifier for each inbox tab. Drives the conversation list filter and is\n * mirrored to / read from the `?tab=` URL search param.\n */\nexport type InboxTabId = \"all\" | \"direct\" | \"groups\" | \"channels\" | \"archived\";\n\nexport const INBOX_TAB_IDS: InboxTabId[] = [\n \"all\",\n \"direct\",\n \"groups\",\n \"channels\",\n \"archived\",\n];\n\nexport interface FilterChipDescriptor {\n id: \"unread\" | \"pinned\";\n label: string;\n active: boolean;\n onToggle: () => void;\n icon?: ReactNode;\n}\n\nexport interface InboxLayoutProps {\n /**\n * Currently active inbox tab. Drives which list view is rendered in the\n * left pane.\n */\n activeTab: InboxTabId;\n /** Tab change handler. Used by the host page to sync URL state. */\n onTabChange: (id: InboxTabId) => void;\n /**\n * Whether the Archived tab is exposed. Gated behind the `conversations.archived-view`\n * feature flag — when false the tab is hidden from the bar entirely.\n */\n archivedEnabled?: boolean;\n /** Search input value (controlled). */\n searchQuery: string;\n /** Search input change handler. */\n onSearchChange: (value: string) => void;\n /**\n * Quick toggleable filter chips rendered below the tabs. Pass an empty\n * array (or omit) to hide the chip row entirely — useful on the Archived\n * tab where pin/unread filters don't make sense.\n */\n filterChips?: FilterChipDescriptor[];\n /** Scrollable list content rendered in the left pane below the chrome. */\n listSlot: ReactNode;\n /**\n * Right-pane content. When a conversation is selected the parent should\n * pass the conversation detail subtree here. When nothing is selected the\n * empty-state slot below is used instead.\n */\n detailSlot?: ReactNode;\n /**\n * Empty-state rendered in the right pane when `detailSlot` is null /\n * undefined. Defaults to a generic \"Select a conversation\" prompt.\n */\n emptyDetailSlot?: ReactNode;\n}\n\n/**\n * Two-pane inbox shell for the conversations microfrontend.\n *\n * Left pane: fixed 320px (collapses to full-width below `md` when no\n * conversation is selected, the way Slack / Linear / Discord behave on\n * mobile). Holds:\n * - sticky header (title + New CTA)\n * - search input\n * - tabs (All / Direct / Groups / Channels / Archived)\n * - filter chips (Unread / Pinned)\n * - scrollable conversation list\n *\n * Right pane: detail subtree OR empty state.\n *\n * The component is presentation-only — URL <-> tab sync, query\n * construction, and conversation selection all live in the host page.\n */\nexport function InboxLayout({\n activeTab,\n onTabChange,\n archivedEnabled = true,\n searchQuery,\n onSearchChange,\n filterChips,\n listSlot,\n detailSlot,\n emptyDetailSlot,\n}: InboxLayoutProps) {\n const navigate = useNavigate();\n const tr = useTr();\n const { basePath } = useConversations();\n const { setActiveConversationId } = useConversationsStore();\n const params = useParams<{ conversationId?: string }>();\n const location = useLocation();\n // A conversation is \"open\" whenever the URL targets a detail route\n // (/:conversationId). We can't rely solely on a passed detailSlot —\n // routing decides whether to mount the detail page.\n const conversationOpen =\n Boolean(params.conversationId) && Boolean(detailSlot);\n\n const tabs: TabItem[] = useMemo(() => {\n const items: TabItem[] = [\n { id: \"all\", label: tr(\"conversations.tabs.all\", \"All\") },\n { id: \"direct\", label: tr(\"conversations.tabs.direct\", \"Direct\") },\n { id: \"groups\", label: tr(\"conversations.tabs.groups\", \"Groups\") },\n { id: \"channels\", label: tr(\"conversations.tabs.channels\", \"Channels\") },\n ];\n if (archivedEnabled) {\n items.push({\n id: \"archived\",\n label: tr(\"conversations.tabs.archived\", \"Archived\"),\n });\n }\n return items;\n }, [archivedEnabled, tr]);\n\n const handleNewConversation = useCallback(() => {\n // Open the create-conversation dialog in-place via `?new=true` on the\n // inbox landing page (parent route). This replaces the legacy\n // `/conversations/new` full-page navigation — the user stays inside\n // the inbox and the dialog is mounted there.\n const inboxRoot = basePath || \"/conversations\";\n const currentSearch = new URLSearchParams(location.search);\n currentSearch.set(\"new\", \"true\");\n navigate(`${inboxRoot}?${currentSearch.toString()}`);\n }, [basePath, location.search, navigate]);\n\n const handleBackToList = useCallback(() => {\n // Clear the open conversation id so the empty state shows and routing\n // returns to the inbox shell. We keep the current tab via location.search.\n setActiveConversationId(null);\n navigate(`${basePath || \"/conversations\"}${location.search}`);\n }, [basePath, location.search, navigate, setActiveConversationId]);\n\n return (\n <div\n data-tour=\"conversations-inbox\"\n className=\"flex h-full w-full min-h-0 min-w-0 overflow-hidden bg-bg-canvas\"\n >\n {/* Left pane: list. On md+ always visible as a fixed sidebar. Below md,\n hidden when a conversation is open (full-screen detail mode). */}\n <aside\n aria-label={tr(\"conversations.listPaneLabel\", \"Conversations list\")}\n className={cn(\n \"flex flex-col min-h-0 border-r border-border-default bg-bg-surface\",\n \"w-full md:w-[320px] md:flex-shrink-0\",\n conversationOpen ? \"hidden md:flex\" : \"flex\",\n )}\n >\n {/* Sticky header: title + new CTA. Hosts the MFE's single aurora hero\n backdrop (decorative, -z-10 — never moves content). */}\n <div className=\"relative isolate overflow-hidden sticky top-0 z-10 flex flex-col gap-3 border-b border-border-subtle bg-bg-surface px-4 pt-4 pb-3\">\n <AuroraBackground intensity=\"subtle\" />\n <div className=\"flex items-center justify-between gap-2\">\n <h1 className=\"text-base font-semibold text-text-primary\">\n <GradientText>\n {tr(\"conversations.title\", \"Conversations\")}\n </GradientText>\n </h1>\n <Button\n data-tour=\"conversation-new-button\"\n type=\"button\"\n size=\"sm\"\n onClick={handleNewConversation}\n aria-label={tr(\n \"conversations.createNewConversation\",\n \"Create a new conversation\",\n )}\n >\n <MessageSquarePlus size={14} />\n <span className=\"hidden sm:inline\">\n {tr(\"conversations.newConversation\", \"New\")}\n </span>\n </Button>\n </div>\n\n {/* Page purpose — explains, in plain language, what the inbox is\n for so a first-time user understands the surface at a glance. */}\n <PagePurpose icon={<MessagesSquare size={16} aria-hidden=\"true\" />}>\n {tr(\n \"conversations.purpose\",\n \"Your messaging hub: direct messages, group chats, and topic channels with the people in this workspace. Search, filter, or start a new conversation to keep work discussions in one place.\",\n )}\n </PagePurpose>\n\n {/* Search */}\n <div className=\"relative\">\n <Search\n size={14}\n className=\"pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary\"\n aria-hidden=\"true\"\n />\n <input\n data-tour=\"conversation-search\"\n type=\"search\"\n value={searchQuery}\n onChange={(event) => onSearchChange(event.target.value)}\n placeholder={tr(\n \"conversations.search\",\n \"Search conversations...\",\n )}\n aria-label={tr(\n \"conversations.searchAria\",\n \"Search conversations\",\n )}\n className={cn(\n \"w-full rounded-md border border-border-default bg-bg-elevated\",\n \"py-1.5 pl-8 pr-8 text-sm text-text-primary\",\n \"placeholder:text-text-secondary\",\n \"focus:border-action-primary-border focus:outline-none focus:ring-2 focus:ring-action-primary-border/40\",\n )}\n />\n {searchQuery.length > 0 && (\n <button\n type=\"button\"\n onClick={() => onSearchChange(\"\")}\n aria-label={tr(\n \"conversations.clearSearch\",\n \"Clear conversation search\",\n )}\n className=\"absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-text-secondary hover:bg-action-ghost-bg-hover hover:text-text-primary\"\n >\n <X size={12} />\n </button>\n )}\n </div>\n\n {/* Tabs */}\n <div data-tour=\"conversations-tabs\">\n <Tabs\n items={tabs}\n activeTabId={activeTab}\n onTabChange={(id: string) => onTabChange(id as InboxTabId)}\n variant=\"underline\"\n size=\"sm\"\n />\n </div>\n\n {/* Filter chips */}\n {filterChips && filterChips.length > 0 && (\n <div\n data-tour=\"conversations-filter-chips\"\n className=\"flex flex-wrap gap-1.5\"\n >\n {filterChips.map((chip) => (\n <button\n key={chip.id}\n type=\"button\"\n onClick={chip.onToggle}\n aria-pressed={chip.active}\n className={cn(\n \"inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors\",\n chip.active\n ? \"border-action-primary-border bg-action-primary-bg/10 text-action-primary-bg\"\n : \"border-border-default text-text-secondary hover:bg-action-ghost-bg-hover hover:text-text-primary\",\n )}\n >\n {chip.icon}\n {chip.label}\n </button>\n ))}\n </div>\n )}\n </div>\n\n {/* Scrollable list */}\n <div\n data-tour=\"conversations-list-scroll\"\n className=\"flex-1 min-h-0 overflow-y-auto overflow-x-hidden\"\n >\n {listSlot}\n </div>\n </aside>\n\n {/* Right pane: detail OR empty state */}\n <section\n aria-label={tr(\"conversations.detailPaneLabel\", \"Conversation detail\")}\n className={cn(\n \"flex flex-col min-h-0 flex-1 min-w-0 bg-bg-surface\",\n conversationOpen ? \"flex\" : \"hidden md:flex\",\n )}\n >\n {conversationOpen && (\n // Mobile-only back button. On md+ the list and detail render\n // side-by-side so the back arrow would be useless.\n <div className=\"md:hidden border-b border-border-subtle px-3 py-2\">\n <Button\n variant=\"ghost\"\n size=\"sm\"\n onClick={handleBackToList}\n aria-label={tr(\n \"conversations.backToInbox\",\n \"Back to conversations list\",\n )}\n >\n <ArrowLeft size={14} />\n {tr(\"conversations.backToInbox\", \"Back to inbox\")}\n </Button>\n </div>\n )}\n <div className=\"flex-1 min-h-0 min-w-0 overflow-y-auto overflow-x-hidden\">\n {detailSlot ?? emptyDetailSlot ?? (\n <InboxEmptyDetail\n onNew={handleNewConversation}\n title={tr(\n \"conversations.detailEmpty.title\",\n \"Select a conversation\",\n )}\n description={tr(\n \"conversations.detailEmpty.description\",\n \"Pick a conversation from the list — or start a new one.\",\n )}\n newLabel={tr(\n \"conversations.detailEmpty.newCta\",\n \"Start a new conversation\",\n )}\n />\n )}\n </div>\n </section>\n </div>\n );\n}\n\ninterface InboxEmptyDetailProps {\n title: string;\n description: string;\n newLabel: string;\n onNew: () => void;\n}\n\n/**\n * Default empty-state rendered in the detail pane when no conversation is\n * currently selected. Lifted out so the host page (or tests) can override\n * the copy via `emptyDetailSlot`.\n */\nfunction InboxEmptyDetail({\n title,\n description,\n newLabel,\n onNew,\n}: InboxEmptyDetailProps) {\n return (\n <div className=\"flex h-full items-center justify-center p-8\">\n <IllustratedEmptyState\n illustration=\"empty-vibes\"\n title={title}\n description={description}\n action={\n <Button type=\"button\" onClick={onNew}>\n <MessageSquarePlus size={14} />\n {newLabel}\n </Button>\n }\n />\n </div>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"InboxLayout.js","names":[],"sources":["../../../src/conversations/components/InboxLayout.tsx"],"sourcesContent":["import type { ReactNode } from \"react\";\nimport { useCallback, useMemo } from \"react\";\nimport { useLocation, useNavigate, useParams } from \"react-router\";\nimport { Tabs, type TabItem } from \"@burdenoff/fe-libs/chrome\";\nimport {\n Button,\n AuroraBackground,\n GradientText,\n IllustratedEmptyState,\n PagePurpose,\n} from \"@burdenoff/fe-libs/ui\";\nimport {\n ArrowLeft,\n MessageSquarePlus,\n MessagesSquare,\n Search,\n X,\n} from \"lucide-react\";\nimport { cn } from \"../utils/cn\";\nimport { useConversations } from \"../providers/ConversationsProvider\";\nimport { useConversationsStore } from \"../store/conversationsStore\";\nimport { useTr } from \"../utils/i18n\";\n\n/**\n * Identifier for each inbox tab. Drives the conversation list filter and is\n * mirrored to / read from the `?tab=` URL search param.\n */\nexport type InboxTabId = \"all\" | \"direct\" | \"groups\" | \"channels\" | \"archived\";\n\nexport const INBOX_TAB_IDS: InboxTabId[] = [\n \"all\",\n \"direct\",\n \"groups\",\n \"channels\",\n \"archived\",\n];\n\nexport interface FilterChipDescriptor {\n id: \"unread\" | \"pinned\";\n label: string;\n active: boolean;\n onToggle: () => void;\n icon?: ReactNode;\n}\n\nexport interface InboxLayoutProps {\n /**\n * Currently active inbox tab. Drives which list view is rendered in the\n * left pane.\n */\n activeTab: InboxTabId;\n /** Tab change handler. Used by the host page to sync URL state. */\n onTabChange: (id: InboxTabId) => void;\n /**\n * Whether the Archived tab is exposed. Gated behind the `conversations.archived-view`\n * feature flag — when false the tab is hidden from the bar entirely.\n */\n archivedEnabled?: boolean;\n /** Search input value (controlled). */\n searchQuery: string;\n /** Search input change handler. */\n onSearchChange: (value: string) => void;\n /**\n * Quick toggleable filter chips rendered below the tabs. Pass an empty\n * array (or omit) to hide the chip row entirely — useful on the Archived\n * tab where pin/unread filters don't make sense.\n */\n filterChips?: FilterChipDescriptor[];\n /** Scrollable list content rendered in the left pane below the chrome. */\n listSlot: ReactNode;\n /**\n * Right-pane content. When a conversation is selected the parent should\n * pass the conversation detail subtree here. When nothing is selected the\n * empty-state slot below is used instead.\n */\n detailSlot?: ReactNode;\n /**\n * Empty-state rendered in the right pane when `detailSlot` is null /\n * undefined. Defaults to a generic \"Select a conversation\" prompt.\n */\n emptyDetailSlot?: ReactNode;\n}\n\n/**\n * Two-pane inbox shell for the conversations microfrontend.\n *\n * Left pane: fixed 320px (collapses to full-width below `md` when no\n * conversation is selected, the way Slack / Linear / Discord behave on\n * mobile). Holds:\n * - sticky header (title + New CTA)\n * - search input\n * - tabs (All / Direct / Groups / Channels / Archived)\n * - filter chips (Unread / Pinned)\n * - scrollable conversation list\n *\n * Right pane: detail subtree OR empty state.\n *\n * The component is presentation-only — URL <-> tab sync, query\n * construction, and conversation selection all live in the host page.\n */\nexport function InboxLayout({\n activeTab,\n onTabChange,\n archivedEnabled = true,\n searchQuery,\n onSearchChange,\n filterChips,\n listSlot,\n detailSlot,\n emptyDetailSlot,\n}: InboxLayoutProps) {\n const navigate = useNavigate();\n const tr = useTr();\n const { basePath } = useConversations();\n const { setActiveConversationId } = useConversationsStore();\n const params = useParams<{ conversationId?: string }>();\n const location = useLocation();\n // A conversation is \"open\" whenever the URL targets a detail route\n // (/:conversationId). We can't rely solely on a passed detailSlot —\n // routing decides whether to mount the detail page.\n const conversationOpen =\n Boolean(params.conversationId) && Boolean(detailSlot);\n\n const tabs: TabItem[] = useMemo(() => {\n const items: TabItem[] = [\n { id: \"all\", label: tr(\"conversations.tabs.all\", \"All\") },\n { id: \"direct\", label: tr(\"conversations.tabs.direct\", \"Direct\") },\n { id: \"groups\", label: tr(\"conversations.tabs.groups\", \"Groups\") },\n { id: \"channels\", label: tr(\"conversations.tabs.channels\", \"Channels\") },\n ];\n if (archivedEnabled) {\n items.push({\n id: \"archived\",\n label: tr(\"conversations.tabs.archived\", \"Archived\"),\n });\n }\n return items;\n }, [archivedEnabled, tr]);\n\n const handleNewConversation = useCallback(() => {\n // Open the create-conversation dialog in-place via `?new=true` on the\n // inbox landing page (parent route). This replaces the legacy\n // `/conversations/new` full-page navigation — the user stays inside\n // the inbox and the dialog is mounted there.\n const inboxRoot = basePath || \"/conversations\";\n const currentSearch = new URLSearchParams(location.search);\n currentSearch.set(\"new\", \"true\");\n navigate(`${inboxRoot}?${currentSearch.toString()}`);\n }, [basePath, location.search, navigate]);\n\n const handleBackToList = useCallback(() => {\n // Clear the open conversation id so the empty state shows and routing\n // returns to the inbox shell. We keep the current tab via location.search.\n setActiveConversationId(null);\n navigate(`${basePath || \"/conversations\"}${location.search}`);\n }, [basePath, location.search, navigate, setActiveConversationId]);\n\n return (\n <div\n data-tour=\"conversations-inbox\"\n className=\"flex h-full w-full min-h-0 min-w-0 overflow-hidden bg-bg-canvas\"\n >\n {/* Left pane: list. On md+ always visible as a fixed sidebar. Below md,\n hidden when a conversation is open (full-screen detail mode). */}\n <aside\n aria-label={tr(\"conversations.listPaneLabel\", \"Conversations list\")}\n className={cn(\n \"flex flex-col min-h-0 border-r border-border-default bg-bg-surface\",\n \"w-full md:w-[320px] md:flex-shrink-0\",\n conversationOpen ? \"hidden md:flex\" : \"flex\",\n )}\n >\n {/* Sticky header: title + new CTA. Hosts the MFE's single aurora hero\n backdrop (decorative, -z-10 — never moves content). */}\n <div className=\"relative isolate overflow-hidden sticky top-0 z-10 flex flex-col gap-3 border-b border-border-subtle bg-bg-surface px-4 pt-4 pb-3\">\n <AuroraBackground intensity=\"subtle\" />\n <div className=\"flex items-center justify-between gap-2\">\n <h1 className=\"text-base font-semibold text-text-primary\">\n <GradientText>\n {tr(\"conversations.title\", \"Conversations\")}\n </GradientText>\n </h1>\n <Button\n data-tour=\"conversation-new-button\"\n type=\"button\"\n size=\"sm\"\n onClick={handleNewConversation}\n aria-label={tr(\n \"conversations.createNewConversation\",\n \"Create a new conversation\",\n )}\n >\n <MessageSquarePlus size={14} />\n <span className=\"hidden sm:inline\">\n {tr(\"conversations.newConversation\", \"New\")}\n </span>\n </Button>\n </div>\n\n {/* Page purpose — explains, in plain language, what the inbox is\n for so a first-time user understands the surface at a glance. */}\n <PagePurpose icon={<MessagesSquare size={16} aria-hidden=\"true\" />}>\n {tr(\n \"conversations.purpose\",\n \"Your messaging hub: direct messages, group chats, and topic channels with the people in this workspace. Search, filter, or start a new conversation to keep work discussions in one place.\",\n )}\n </PagePurpose>\n\n {/* Search */}\n <div className=\"relative\">\n <Search\n size={14}\n className=\"pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary\"\n aria-hidden=\"true\"\n />\n <input\n data-tour=\"conversation-search\"\n type=\"search\"\n value={searchQuery}\n onChange={(event) => onSearchChange(event.target.value)}\n placeholder={tr(\n \"conversations.search\",\n \"Search conversations...\",\n )}\n aria-label={tr(\n \"conversations.searchAria\",\n \"Search conversations\",\n )}\n className={cn(\n \"w-full rounded-md border border-border-default bg-bg-elevated\",\n \"py-1.5 pl-8 pr-8 text-sm text-text-primary\",\n \"placeholder:text-text-secondary\",\n \"focus:border-action-primary-border focus:outline-none focus:ring-2 focus:ring-action-primary-border/40\",\n )}\n />\n {searchQuery.length > 0 && (\n <button\n type=\"button\"\n onClick={() => onSearchChange(\"\")}\n aria-label={tr(\n \"conversations.clearSearch\",\n \"Clear conversation search\",\n )}\n className=\"absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-text-secondary hover:bg-action-ghost-bg-hover hover:text-text-primary\"\n >\n <X size={12} />\n </button>\n )}\n </div>\n\n {/* Tabs */}\n <div data-tour=\"conversations-tabs\">\n <Tabs\n items={tabs}\n activeTabId={activeTab}\n onTabChange={(id: string) => onTabChange(id as InboxTabId)}\n variant=\"underline\"\n size=\"sm\"\n />\n </div>\n\n {/* Filter chips */}\n {filterChips && filterChips.length > 0 && (\n <div\n data-tour=\"conversations-filter-chips\"\n className=\"flex flex-wrap gap-1.5\"\n >\n {filterChips.map((chip) => (\n <button\n key={chip.id}\n type=\"button\"\n onClick={chip.onToggle}\n aria-pressed={chip.active}\n className={cn(\n \"inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors\",\n chip.active\n ? \"border-action-primary-border bg-action-primary-bg/10 text-action-primary-bg\"\n : \"border-border-default text-text-secondary hover:bg-action-ghost-bg-hover hover:text-text-primary\",\n )}\n >\n {chip.icon}\n {chip.label}\n </button>\n ))}\n </div>\n )}\n </div>\n\n {/* Scrollable list */}\n <div\n data-tour=\"conversations-list-scroll\"\n className=\"flex-1 min-h-0 overflow-y-auto overflow-x-hidden\"\n >\n {listSlot}\n </div>\n </aside>\n\n {/* Right pane: detail OR empty state */}\n <section\n aria-label={tr(\"conversations.detailPaneLabel\", \"Conversation detail\")}\n className={cn(\n \"flex flex-col min-h-0 flex-1 min-w-0 bg-bg-surface\",\n conversationOpen ? \"flex\" : \"hidden md:flex\",\n )}\n >\n {conversationOpen && (\n // Mobile-only back button. On md+ the list and detail render\n // side-by-side so the back arrow would be useless.\n <div className=\"md:hidden border-b border-border-subtle px-3 py-2\">\n <Button\n variant=\"ghost\"\n size=\"sm\"\n onClick={handleBackToList}\n aria-label={tr(\n \"conversations.backToInbox\",\n \"Back to conversations list\",\n )}\n >\n <ArrowLeft size={14} />\n {tr(\"conversations.backToInbox\", \"Back to inbox\")}\n </Button>\n </div>\n )}\n <div className=\"flex-1 min-h-0 min-w-0 overflow-y-auto overflow-x-hidden\">\n {detailSlot ?? emptyDetailSlot ?? (\n <InboxEmptyDetail\n onNew={handleNewConversation}\n title={tr(\n \"conversations.detailEmpty.title\",\n \"Select a conversation\",\n )}\n description={tr(\n \"conversations.detailEmpty.description\",\n \"Pick a conversation from the list — or start a new one.\",\n )}\n newLabel={tr(\n \"conversations.detailEmpty.newCta\",\n \"Start a new conversation\",\n )}\n />\n )}\n </div>\n </section>\n </div>\n );\n}\n\ninterface InboxEmptyDetailProps {\n title: string;\n description: string;\n newLabel: string;\n onNew: () => void;\n}\n\n/**\n * Default empty-state rendered in the detail pane when no conversation is\n * currently selected. Lifted out so the host page (or tests) can override\n * the copy via `emptyDetailSlot`.\n */\nfunction InboxEmptyDetail({\n title,\n description,\n newLabel,\n onNew,\n}: InboxEmptyDetailProps) {\n return (\n <div className=\"flex h-full items-center justify-center p-8\">\n <IllustratedEmptyState\n illustration=\"empty-vibes\"\n title={title}\n description={description}\n action={\n <Button type=\"button\" onClick={onNew}>\n <MessageSquarePlus size={14} />\n {newLabel}\n </Button>\n }\n />\n </div>\n );\n}\n"],"mappings":"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"}
|
|
@@ -7,7 +7,7 @@ import { nativeImpact as te, nativeNotify as i } from "../../utils/nativeBridge.
|
|
|
7
7
|
import { MemberPicker as a } from "./MemberPicker.js";
|
|
8
8
|
import { useCallback as o, useMemo as s, useState as c } from "react";
|
|
9
9
|
import { Globe as l, Hash as u, Lock as d, Megaphone as ne, MessageCircle as re, Shield as ie, Users as ae } from "lucide-react";
|
|
10
|
-
import { useNavigate as oe } from "react-router
|
|
10
|
+
import { useNavigate as oe } from "react-router";
|
|
11
11
|
import { isQuotaExhaustedError as f } from "@burdenoff/fe-libs/shared/utils";
|
|
12
12
|
import { useEventBus as se } from "@burdenoff/fe-libs/shared/events";
|
|
13
13
|
import { Fragment as p, jsx as m, jsxs as h } from "react/jsx-runtime";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"NewConversationDialog.js","names":[],"sources":["../../../src/conversations/components/NewConversationDialog.tsx"],"sourcesContent":["import { useState, useCallback, useMemo } from \"react\";\nimport { useEventBus } from \"@burdenoff/fe-libs/shared/events\";\nimport { isQuotaExhaustedError } from \"@burdenoff/fe-libs/shared/utils\";\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from \"@burdenoff/fe-libs/ui\";\nimport {\n Globe,\n Hash,\n Lock,\n Megaphone,\n MessageCircle,\n Shield,\n Users,\n} from \"lucide-react\";\nimport { useNavigate } from \"react-router-dom\";\nimport { cn } from \"../utils/cn\";\nimport { useTr } from \"../utils/i18n\";\nimport {\n useCreateConversation,\n type WorkspaceDirectoryMember,\n} from \"../hooks/useConversationQueries\";\nimport { useConversations } from \"../providers/ConversationsProvider\";\nimport { MemberPicker } from \"./MemberPicker\";\nimport { QuotaExhaustedDialog } from \"./QuotaExhaustedDialog\";\nimport type { ConversationType, ConversationVisibility } from \"../types\";\nimport { nativeImpact, nativeNotify } from \"../../utils/nativeBridge\";\n\n/** Extract the first meaningful GraphQL error message from an ApolloError. */\nfunction getErrorMessage(error: Error): string {\n if (\"graphQLErrors\" in error) {\n const gqlErrors = (error as { graphQLErrors?: Array<{ message?: string }> })\n .graphQLErrors;\n if (gqlErrors && gqlErrors.length > 0 && gqlErrors[0]?.message) {\n return gqlErrors[0].message;\n }\n }\n return error.message;\n}\n\ninterface ConversationTypeOption {\n type: ConversationType;\n label: string;\n description: string;\n icon: React.ReactNode;\n}\n\nexport interface NewConversationDialogProps {\n /** Whether the dialog is open. */\n open: boolean;\n /** Open-state change handler. Fires with `false` on close (overlay click,\n * Escape, X button, Cancel). */\n onOpenChange: (open: boolean) => void;\n /**\n * Optional quota-failure hook for route wrappers that need to close the\n * create modal without running their normal close navigation.\n */\n onQuotaExhausted?: () => void;\n /** Called when the quota dialog is dismissed without opening billing. */\n onQuotaDialogClose?: () => void;\n /**\n * Called after a conversation is successfully created. Receives the new\n * conversation id so the parent can navigate to the detail route.\n * The dialog also closes itself (via `onOpenChange(false)`) before this\n * fires, so the parent does not need to close the dialog manually.\n */\n onCreated?: (conversationId: string) => void;\n}\n\n/**\n * NewConversationDialog — modal form for creating a new conversation.\n *\n * Renders inside the canonical `<Dialog>` primitive from\n * `@burdenoff/fe-libs/ui` so it inherits theme tokens, focus management,\n * and overlay behaviour from the shell.\n *\n * Form state is owned locally; on successful submit the dialog clears\n * state, closes itself, and invokes `onCreated` with the new\n * conversation id.\n */\nexport function NewConversationDialog({\n open,\n onOpenChange,\n onQuotaExhausted,\n onQuotaDialogClose,\n onCreated,\n}: NewConversationDialogProps) {\n const { createConversation, loading, error } = useCreateConversation();\n const { currentUser } = useConversations();\n const bus = useEventBus();\n const tr = useTr();\n const navigate = useNavigate();\n const [showQuotaExhausted, setShowQuotaExhausted] = useState(false);\n\n const CONVERSATION_TYPES: ConversationTypeOption[] = useMemo(\n () => [\n {\n type: \"DIRECT\" as ConversationType,\n label: tr(\"conversations.directMessage\", \"Direct Message\"),\n description: tr(\n \"conversations.directMessageDescription\",\n \"One-on-one private conversation\",\n ),\n icon: <MessageCircle size={20} />,\n },\n {\n type: \"GROUP\" as ConversationType,\n label: tr(\"conversations.groupChat\", \"Group Chat\"),\n description: tr(\n \"conversations.groupChatDescription\",\n \"Invite multiple people to a group conversation\",\n ),\n icon: <Users size={20} />,\n },\n {\n type: \"CHANNEL\" as ConversationType,\n label: tr(\"conversations.channel\", \"Channel\"),\n description: tr(\n \"conversations.channelDescription\",\n \"A topic-based channel that anyone can join\",\n ),\n icon: <Hash size={20} />,\n },\n {\n type: \"ANNOUNCEMENT\" as ConversationType,\n label: tr(\"conversations.announcement\", \"Announcement\"),\n description: tr(\n \"conversations.announcementDescription\",\n \"Broadcast messages to a large audience\",\n ),\n icon: <Megaphone size={20} />,\n },\n ],\n [tr],\n );\n\n const VISIBILITY_OPTIONS: Array<{\n value: ConversationVisibility;\n label: string;\n description: string;\n icon: React.ReactNode;\n }> = useMemo(\n () => [\n {\n value: \"PUBLIC\" as ConversationVisibility,\n label: tr(\"conversations.public\", \"Public\"),\n description: tr(\n \"conversations.publicDescription\",\n \"Anyone in the workspace can discover and join\",\n ),\n icon: <Globe size={16} />,\n },\n {\n value: \"PRIVATE\" as ConversationVisibility,\n label: tr(\"conversations.private\", \"Private\"),\n description: tr(\n \"conversations.privateDescription\",\n \"Only invited members can access\",\n ),\n icon: <Lock size={16} />,\n },\n {\n value: \"PROTECTED\" as ConversationVisibility,\n label: tr(\"conversations.protected\", \"Protected\"),\n description: tr(\n \"conversations.protectedDescription\",\n \"Discoverable but requires approval to join\",\n ),\n icon: <Shield size={16} />,\n },\n ],\n [tr],\n );\n\n const [selectedType, setSelectedType] = useState<ConversationType>(\"GROUP\");\n const [title, setTitle] = useState(\"\");\n const [description, setDescription] = useState(\"\");\n const [visibility, setVisibility] =\n useState<ConversationVisibility>(\"PRIVATE\");\n const [selectedMembers, setSelectedMembers] = useState<\n WorkspaceDirectoryMember[]\n >([]);\n\n const isDirectMessage = selectedType === \"DIRECT\";\n const needsTitle = !isDirectMessage;\n\n const participantIds = useMemo(\n () => selectedMembers.map((member) => member.id),\n [selectedMembers],\n );\n\n const resetForm = useCallback(() => {\n setSelectedType(\"GROUP\");\n setTitle(\"\");\n setDescription(\"\");\n setVisibility(\"PRIVATE\");\n setSelectedMembers([]);\n }, []);\n\n const addMember = useCallback(\n (member: WorkspaceDirectoryMember) => {\n setSelectedMembers((prev) => {\n if (prev.some((existing) => existing.id === member.id)) return prev;\n // A direct message has exactly one recipient.\n return isDirectMessage ? [member] : [...prev, member];\n });\n },\n [isDirectMessage],\n );\n\n const removeMember = useCallback((userId: string) => {\n setSelectedMembers((prev) => prev.filter((member) => member.id !== userId));\n }, []);\n\n const requiresParticipants =\n selectedType === \"DIRECT\" || selectedType === \"GROUP\";\n\n // Never offer the current user as a participant — they are added implicitly\n // as the conversation creator.\n const excludeIds = useMemo(\n () => (currentUser?.id ? [currentUser.id] : []),\n [currentUser?.id],\n );\n\n const handleOpenChange = useCallback(\n (nextOpen: boolean) => {\n if (!nextOpen) {\n resetForm();\n }\n onOpenChange(nextOpen);\n },\n [onOpenChange, resetForm],\n );\n\n const handleCreate = useCallback(async () => {\n if (requiresParticipants && participantIds.length === 0) return;\n if (needsTitle && !title.trim()) return;\n\n nativeImpact(\"light\");\n try {\n const conversation = await createConversation({\n type: selectedType,\n title: needsTitle ? title.trim() : undefined,\n description: description.trim() || undefined,\n participantIds,\n visibility,\n });\n\n if (!conversation) {\n nativeNotify(\"error\");\n return;\n }\n nativeNotify(\"success\");\n bus.emit(\"conversations.conversation.created\", {\n conversationId: conversation.id,\n conversationType: selectedType,\n source: \"new-conversation-dialog\",\n });\n resetForm();\n onOpenChange(false);\n onCreated?.(conversation.id);\n } catch (error) {\n if (isQuotaExhaustedError(error)) {\n resetForm();\n setShowQuotaExhausted(true);\n if (onQuotaExhausted) {\n onQuotaExhausted();\n } else {\n onOpenChange(false);\n }\n return;\n }\n nativeNotify(\"error\");\n throw error;\n }\n }, [\n bus,\n requiresParticipants,\n participantIds,\n needsTitle,\n title,\n createConversation,\n selectedType,\n description,\n visibility,\n resetForm,\n onOpenChange,\n onQuotaExhausted,\n onCreated,\n ]);\n\n const handleQuotaDialogClose = useCallback(() => {\n setShowQuotaExhausted(false);\n onQuotaDialogClose?.();\n }, [onQuotaDialogClose]);\n\n const canCreate =\n (!requiresParticipants || participantIds.length > 0) &&\n (!needsTitle || title.trim().length > 0);\n\n return (\n <>\n <Dialog open={open} onOpenChange={handleOpenChange}>\n <DialogContent\n className=\"sm:max-w-[640px] max-h-[90vh] overflow-y-auto\"\n data-tour=\"new-conversation-dialog\"\n >\n <DialogHeader>\n <DialogTitle>\n {tr(\"conversations.newConversation\", \"New Conversation\")}\n </DialogTitle>\n <DialogDescription>\n {tr(\n \"conversations.newConversationSubtitle\",\n \"Start a new conversation with your team\",\n )}\n </DialogDescription>\n </DialogHeader>\n\n <div className=\"space-y-6\">\n <section\n data-tour=\"conversation-type-selector\"\n className=\"space-y-3\"\n >\n <h3 className=\"text-sm font-semibold text-text-primary\">\n {tr(\"conversations.conversationType\", \"Conversation Type\")}\n </h3>\n <div className=\"grid grid-cols-1 sm:grid-cols-2 gap-3\">\n {CONVERSATION_TYPES.map((option) => (\n <button\n key={option.type}\n type=\"button\"\n onClick={() => {\n setSelectedType(option.type);\n if (option.type === \"DIRECT\") {\n setSelectedMembers((prev) => prev.slice(0, 1));\n }\n }}\n className={cn(\n \"flex items-start gap-3 p-4 rounded-xl border text-left transition-all duration-150\",\n selectedType === option.type\n ? \"border-primary bg-action-primary-bg/5 ring-1 ring-primary/20\"\n : \"border-border-subtle hover:border-muted-foreground/30 hover:bg-bg-sunken/30\",\n )}\n >\n <div\n className={cn(\n \"p-2 rounded-lg\",\n selectedType === option.type\n ? \"bg-action-primary-bg/10 text-primary\"\n : \"bg-bg-sunken text-text-secondary\",\n )}\n >\n {option.icon}\n </div>\n <div>\n <p className=\"text-sm font-medium text-text-primary\">\n {option.label}\n </p>\n <p className=\"text-xs text-text-secondary mt-0.5\">\n {option.description}\n </p>\n </div>\n </button>\n ))}\n </div>\n </section>\n\n {!isDirectMessage && (\n <section\n data-tour=\"conversation-visibility-selector\"\n className=\"space-y-3\"\n >\n <h3 className=\"text-sm font-semibold text-text-primary\">\n {tr(\"conversations.visibility\", \"Visibility\")}\n </h3>\n <div className=\"flex gap-2 flex-wrap\">\n {VISIBILITY_OPTIONS.map((option) => (\n <button\n key={option.value}\n type=\"button\"\n onClick={() => setVisibility(option.value)}\n className={cn(\n \"flex items-center gap-2 px-4 py-2.5 rounded-lg border text-sm transition-all\",\n visibility === option.value\n ? \"border-primary bg-action-primary-bg/5 text-primary font-medium\"\n : \"border-border-subtle text-text-secondary hover:text-text-primary hover:bg-bg-sunken/30\",\n )}\n >\n {option.icon}\n {option.label}\n </button>\n ))}\n </div>\n <p className=\"text-xs text-text-secondary\">\n {\n VISIBILITY_OPTIONS.find(\n (option) => option.value === visibility,\n )?.description\n }\n </p>\n </section>\n )}\n\n {needsTitle && (\n <section\n data-tour=\"conversation-create-details\"\n className=\"space-y-3\"\n >\n <div>\n <label\n htmlFor=\"conv-title\"\n className=\"text-sm font-semibold text-text-primary\"\n >\n {tr(\"conversations.name\", \"Name\")}{\" \"}\n <span className=\"text-status-error-text\">*</span>\n </label>\n <input\n id=\"conv-title\"\n type=\"text\"\n value={title}\n onChange={(event) => setTitle(event.target.value)}\n placeholder={\n selectedType === \"CHANNEL\"\n ? tr(\n \"conversations.channelNamePlaceholder\",\n \"e.g., engineering-general\",\n )\n : tr(\n \"conversations.conversationNamePlaceholder\",\n \"e.g., Product Launch Q1\",\n )\n }\n maxLength={200}\n className=\"mt-1.5 w-full px-3 py-2 text-sm rounded-lg border border-border-subtle bg-bg-surface placeholder:text-text-secondary focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)]\"\n />\n </div>\n <div>\n <label\n htmlFor=\"conv-desc\"\n className=\"text-sm font-semibold text-text-primary\"\n >\n {tr(\"conversations.description\", \"Description\")}\n </label>\n <textarea\n id=\"conv-desc\"\n value={description}\n onChange={(event) => setDescription(event.target.value)}\n placeholder={tr(\n \"conversations.descriptionPlaceholder\",\n \"What is this conversation about?\",\n )}\n rows={2}\n maxLength={1000}\n className=\"mt-1.5 w-full px-3 py-2 text-sm rounded-lg border border-border-subtle bg-bg-surface placeholder:text-text-secondary focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)] resize-none\"\n />\n </div>\n </section>\n )}\n\n <section\n data-tour=\"conversation-participants\"\n className=\"space-y-3\"\n >\n <h3 className=\"text-sm font-semibold text-text-primary\">\n {isDirectMessage\n ? tr(\"conversations.recipient\", \"Recipient\")\n : tr(\"conversations.participants\", \"Participants\")}\n {requiresParticipants ? (\n <span className=\"ml-1 text-status-error-text\">*</span>\n ) : (\n <span className=\"ml-1.5 text-xs font-normal text-text-secondary\">\n ({tr(\"conversations.optional\", \"optional\")})\n </span>\n )}\n </h3>\n\n <MemberPicker\n selected={selectedMembers}\n onAdd={addMember}\n onRemove={removeMember}\n single={isDirectMessage}\n excludeIds={excludeIds}\n />\n </section>\n\n {error && !isQuotaExhaustedError(error) && (\n <div className=\"text-xs text-status-error-text rounded-lg border border-status-error-border/30 bg-status-error-bg/10 px-3 py-2\">\n {getErrorMessage(error)}\n </div>\n )}\n </div>\n\n <DialogFooter>\n <button\n type=\"button\"\n onClick={() => handleOpenChange(false)}\n className=\"px-4 py-2 text-sm font-medium rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-sunken/50 transition-colors\"\n >\n {tr(\"conversations.cancel\", \"Cancel\")}\n </button>\n <button\n type=\"button\"\n onClick={() => void handleCreate()}\n disabled={!canCreate || loading}\n className={cn(\n \"inline-flex items-center gap-2 px-5 py-2 text-sm font-medium rounded-lg transition-all\",\n canCreate && !loading\n ? \"bg-action-primary-bg text-action-primary-text hover:bg-action-primary-bg/90 shadow-sm\"\n : \"bg-bg-sunken text-text-secondary cursor-not-allowed\",\n )}\n >\n {loading\n ? tr(\"conversations.creating\", \"Creating...\")\n : tr(\"conversations.createConversation\", \"Create Conversation\")}\n </button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n <QuotaExhaustedDialog\n open={showQuotaExhausted}\n title={tr(\n \"conversations.quota.conversations.title\",\n \"Conversation Quota Reached\",\n )}\n description={tr(\n \"conversations.quota.conversations.description\",\n \"You've reached the conversation limit for your current plan. View billing to upgrade or add more capacity.\",\n )}\n onClose={handleQuotaDialogClose}\n onConfirm={() => {\n setShowQuotaExhausted(false);\n navigate(\"/billing\");\n }}\n />\n </>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"NewConversationDialog.js","names":[],"sources":["../../../src/conversations/components/NewConversationDialog.tsx"],"sourcesContent":["import { useState, useCallback, useMemo } from \"react\";\nimport { useEventBus } from \"@burdenoff/fe-libs/shared/events\";\nimport { isQuotaExhaustedError } from \"@burdenoff/fe-libs/shared/utils\";\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from \"@burdenoff/fe-libs/ui\";\nimport {\n Globe,\n Hash,\n Lock,\n Megaphone,\n MessageCircle,\n Shield,\n Users,\n} from \"lucide-react\";\nimport { useNavigate } from \"react-router\";\nimport { cn } from \"../utils/cn\";\nimport { useTr } from \"../utils/i18n\";\nimport {\n useCreateConversation,\n type WorkspaceDirectoryMember,\n} from \"../hooks/useConversationQueries\";\nimport { useConversations } from \"../providers/ConversationsProvider\";\nimport { MemberPicker } from \"./MemberPicker\";\nimport { QuotaExhaustedDialog } from \"./QuotaExhaustedDialog\";\nimport type { ConversationType, ConversationVisibility } from \"../types\";\nimport { nativeImpact, nativeNotify } from \"../../utils/nativeBridge\";\n\n/** Extract the first meaningful GraphQL error message from an ApolloError. */\nfunction getErrorMessage(error: Error): string {\n if (\"graphQLErrors\" in error) {\n const gqlErrors = (error as { graphQLErrors?: Array<{ message?: string }> })\n .graphQLErrors;\n if (gqlErrors && gqlErrors.length > 0 && gqlErrors[0]?.message) {\n return gqlErrors[0].message;\n }\n }\n return error.message;\n}\n\ninterface ConversationTypeOption {\n type: ConversationType;\n label: string;\n description: string;\n icon: React.ReactNode;\n}\n\nexport interface NewConversationDialogProps {\n /** Whether the dialog is open. */\n open: boolean;\n /** Open-state change handler. Fires with `false` on close (overlay click,\n * Escape, X button, Cancel). */\n onOpenChange: (open: boolean) => void;\n /**\n * Optional quota-failure hook for route wrappers that need to close the\n * create modal without running their normal close navigation.\n */\n onQuotaExhausted?: () => void;\n /** Called when the quota dialog is dismissed without opening billing. */\n onQuotaDialogClose?: () => void;\n /**\n * Called after a conversation is successfully created. Receives the new\n * conversation id so the parent can navigate to the detail route.\n * The dialog also closes itself (via `onOpenChange(false)`) before this\n * fires, so the parent does not need to close the dialog manually.\n */\n onCreated?: (conversationId: string) => void;\n}\n\n/**\n * NewConversationDialog — modal form for creating a new conversation.\n *\n * Renders inside the canonical `<Dialog>` primitive from\n * `@burdenoff/fe-libs/ui` so it inherits theme tokens, focus management,\n * and overlay behaviour from the shell.\n *\n * Form state is owned locally; on successful submit the dialog clears\n * state, closes itself, and invokes `onCreated` with the new\n * conversation id.\n */\nexport function NewConversationDialog({\n open,\n onOpenChange,\n onQuotaExhausted,\n onQuotaDialogClose,\n onCreated,\n}: NewConversationDialogProps) {\n const { createConversation, loading, error } = useCreateConversation();\n const { currentUser } = useConversations();\n const bus = useEventBus();\n const tr = useTr();\n const navigate = useNavigate();\n const [showQuotaExhausted, setShowQuotaExhausted] = useState(false);\n\n const CONVERSATION_TYPES: ConversationTypeOption[] = useMemo(\n () => [\n {\n type: \"DIRECT\" as ConversationType,\n label: tr(\"conversations.directMessage\", \"Direct Message\"),\n description: tr(\n \"conversations.directMessageDescription\",\n \"One-on-one private conversation\",\n ),\n icon: <MessageCircle size={20} />,\n },\n {\n type: \"GROUP\" as ConversationType,\n label: tr(\"conversations.groupChat\", \"Group Chat\"),\n description: tr(\n \"conversations.groupChatDescription\",\n \"Invite multiple people to a group conversation\",\n ),\n icon: <Users size={20} />,\n },\n {\n type: \"CHANNEL\" as ConversationType,\n label: tr(\"conversations.channel\", \"Channel\"),\n description: tr(\n \"conversations.channelDescription\",\n \"A topic-based channel that anyone can join\",\n ),\n icon: <Hash size={20} />,\n },\n {\n type: \"ANNOUNCEMENT\" as ConversationType,\n label: tr(\"conversations.announcement\", \"Announcement\"),\n description: tr(\n \"conversations.announcementDescription\",\n \"Broadcast messages to a large audience\",\n ),\n icon: <Megaphone size={20} />,\n },\n ],\n [tr],\n );\n\n const VISIBILITY_OPTIONS: Array<{\n value: ConversationVisibility;\n label: string;\n description: string;\n icon: React.ReactNode;\n }> = useMemo(\n () => [\n {\n value: \"PUBLIC\" as ConversationVisibility,\n label: tr(\"conversations.public\", \"Public\"),\n description: tr(\n \"conversations.publicDescription\",\n \"Anyone in the workspace can discover and join\",\n ),\n icon: <Globe size={16} />,\n },\n {\n value: \"PRIVATE\" as ConversationVisibility,\n label: tr(\"conversations.private\", \"Private\"),\n description: tr(\n \"conversations.privateDescription\",\n \"Only invited members can access\",\n ),\n icon: <Lock size={16} />,\n },\n {\n value: \"PROTECTED\" as ConversationVisibility,\n label: tr(\"conversations.protected\", \"Protected\"),\n description: tr(\n \"conversations.protectedDescription\",\n \"Discoverable but requires approval to join\",\n ),\n icon: <Shield size={16} />,\n },\n ],\n [tr],\n );\n\n const [selectedType, setSelectedType] = useState<ConversationType>(\"GROUP\");\n const [title, setTitle] = useState(\"\");\n const [description, setDescription] = useState(\"\");\n const [visibility, setVisibility] =\n useState<ConversationVisibility>(\"PRIVATE\");\n const [selectedMembers, setSelectedMembers] = useState<\n WorkspaceDirectoryMember[]\n >([]);\n\n const isDirectMessage = selectedType === \"DIRECT\";\n const needsTitle = !isDirectMessage;\n\n const participantIds = useMemo(\n () => selectedMembers.map((member) => member.id),\n [selectedMembers],\n );\n\n const resetForm = useCallback(() => {\n setSelectedType(\"GROUP\");\n setTitle(\"\");\n setDescription(\"\");\n setVisibility(\"PRIVATE\");\n setSelectedMembers([]);\n }, []);\n\n const addMember = useCallback(\n (member: WorkspaceDirectoryMember) => {\n setSelectedMembers((prev) => {\n if (prev.some((existing) => existing.id === member.id)) return prev;\n // A direct message has exactly one recipient.\n return isDirectMessage ? [member] : [...prev, member];\n });\n },\n [isDirectMessage],\n );\n\n const removeMember = useCallback((userId: string) => {\n setSelectedMembers((prev) => prev.filter((member) => member.id !== userId));\n }, []);\n\n const requiresParticipants =\n selectedType === \"DIRECT\" || selectedType === \"GROUP\";\n\n // Never offer the current user as a participant — they are added implicitly\n // as the conversation creator.\n const excludeIds = useMemo(\n () => (currentUser?.id ? [currentUser.id] : []),\n [currentUser?.id],\n );\n\n const handleOpenChange = useCallback(\n (nextOpen: boolean) => {\n if (!nextOpen) {\n resetForm();\n }\n onOpenChange(nextOpen);\n },\n [onOpenChange, resetForm],\n );\n\n const handleCreate = useCallback(async () => {\n if (requiresParticipants && participantIds.length === 0) return;\n if (needsTitle && !title.trim()) return;\n\n nativeImpact(\"light\");\n try {\n const conversation = await createConversation({\n type: selectedType,\n title: needsTitle ? title.trim() : undefined,\n description: description.trim() || undefined,\n participantIds,\n visibility,\n });\n\n if (!conversation) {\n nativeNotify(\"error\");\n return;\n }\n nativeNotify(\"success\");\n bus.emit(\"conversations.conversation.created\", {\n conversationId: conversation.id,\n conversationType: selectedType,\n source: \"new-conversation-dialog\",\n });\n resetForm();\n onOpenChange(false);\n onCreated?.(conversation.id);\n } catch (error) {\n if (isQuotaExhaustedError(error)) {\n resetForm();\n setShowQuotaExhausted(true);\n if (onQuotaExhausted) {\n onQuotaExhausted();\n } else {\n onOpenChange(false);\n }\n return;\n }\n nativeNotify(\"error\");\n throw error;\n }\n }, [\n bus,\n requiresParticipants,\n participantIds,\n needsTitle,\n title,\n createConversation,\n selectedType,\n description,\n visibility,\n resetForm,\n onOpenChange,\n onQuotaExhausted,\n onCreated,\n ]);\n\n const handleQuotaDialogClose = useCallback(() => {\n setShowQuotaExhausted(false);\n onQuotaDialogClose?.();\n }, [onQuotaDialogClose]);\n\n const canCreate =\n (!requiresParticipants || participantIds.length > 0) &&\n (!needsTitle || title.trim().length > 0);\n\n return (\n <>\n <Dialog open={open} onOpenChange={handleOpenChange}>\n <DialogContent\n className=\"sm:max-w-[640px] max-h-[90vh] overflow-y-auto\"\n data-tour=\"new-conversation-dialog\"\n >\n <DialogHeader>\n <DialogTitle>\n {tr(\"conversations.newConversation\", \"New Conversation\")}\n </DialogTitle>\n <DialogDescription>\n {tr(\n \"conversations.newConversationSubtitle\",\n \"Start a new conversation with your team\",\n )}\n </DialogDescription>\n </DialogHeader>\n\n <div className=\"space-y-6\">\n <section\n data-tour=\"conversation-type-selector\"\n className=\"space-y-3\"\n >\n <h3 className=\"text-sm font-semibold text-text-primary\">\n {tr(\"conversations.conversationType\", \"Conversation Type\")}\n </h3>\n <div className=\"grid grid-cols-1 sm:grid-cols-2 gap-3\">\n {CONVERSATION_TYPES.map((option) => (\n <button\n key={option.type}\n type=\"button\"\n onClick={() => {\n setSelectedType(option.type);\n if (option.type === \"DIRECT\") {\n setSelectedMembers((prev) => prev.slice(0, 1));\n }\n }}\n className={cn(\n \"flex items-start gap-3 p-4 rounded-xl border text-left transition-all duration-150\",\n selectedType === option.type\n ? \"border-primary bg-action-primary-bg/5 ring-1 ring-primary/20\"\n : \"border-border-subtle hover:border-muted-foreground/30 hover:bg-bg-sunken/30\",\n )}\n >\n <div\n className={cn(\n \"p-2 rounded-lg\",\n selectedType === option.type\n ? \"bg-action-primary-bg/10 text-primary\"\n : \"bg-bg-sunken text-text-secondary\",\n )}\n >\n {option.icon}\n </div>\n <div>\n <p className=\"text-sm font-medium text-text-primary\">\n {option.label}\n </p>\n <p className=\"text-xs text-text-secondary mt-0.5\">\n {option.description}\n </p>\n </div>\n </button>\n ))}\n </div>\n </section>\n\n {!isDirectMessage && (\n <section\n data-tour=\"conversation-visibility-selector\"\n className=\"space-y-3\"\n >\n <h3 className=\"text-sm font-semibold text-text-primary\">\n {tr(\"conversations.visibility\", \"Visibility\")}\n </h3>\n <div className=\"flex gap-2 flex-wrap\">\n {VISIBILITY_OPTIONS.map((option) => (\n <button\n key={option.value}\n type=\"button\"\n onClick={() => setVisibility(option.value)}\n className={cn(\n \"flex items-center gap-2 px-4 py-2.5 rounded-lg border text-sm transition-all\",\n visibility === option.value\n ? \"border-primary bg-action-primary-bg/5 text-primary font-medium\"\n : \"border-border-subtle text-text-secondary hover:text-text-primary hover:bg-bg-sunken/30\",\n )}\n >\n {option.icon}\n {option.label}\n </button>\n ))}\n </div>\n <p className=\"text-xs text-text-secondary\">\n {\n VISIBILITY_OPTIONS.find(\n (option) => option.value === visibility,\n )?.description\n }\n </p>\n </section>\n )}\n\n {needsTitle && (\n <section\n data-tour=\"conversation-create-details\"\n className=\"space-y-3\"\n >\n <div>\n <label\n htmlFor=\"conv-title\"\n className=\"text-sm font-semibold text-text-primary\"\n >\n {tr(\"conversations.name\", \"Name\")}{\" \"}\n <span className=\"text-status-error-text\">*</span>\n </label>\n <input\n id=\"conv-title\"\n type=\"text\"\n value={title}\n onChange={(event) => setTitle(event.target.value)}\n placeholder={\n selectedType === \"CHANNEL\"\n ? tr(\n \"conversations.channelNamePlaceholder\",\n \"e.g., engineering-general\",\n )\n : tr(\n \"conversations.conversationNamePlaceholder\",\n \"e.g., Product Launch Q1\",\n )\n }\n maxLength={200}\n className=\"mt-1.5 w-full px-3 py-2 text-sm rounded-lg border border-border-subtle bg-bg-surface placeholder:text-text-secondary focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)]\"\n />\n </div>\n <div>\n <label\n htmlFor=\"conv-desc\"\n className=\"text-sm font-semibold text-text-primary\"\n >\n {tr(\"conversations.description\", \"Description\")}\n </label>\n <textarea\n id=\"conv-desc\"\n value={description}\n onChange={(event) => setDescription(event.target.value)}\n placeholder={tr(\n \"conversations.descriptionPlaceholder\",\n \"What is this conversation about?\",\n )}\n rows={2}\n maxLength={1000}\n className=\"mt-1.5 w-full px-3 py-2 text-sm rounded-lg border border-border-subtle bg-bg-surface placeholder:text-text-secondary focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)] resize-none\"\n />\n </div>\n </section>\n )}\n\n <section\n data-tour=\"conversation-participants\"\n className=\"space-y-3\"\n >\n <h3 className=\"text-sm font-semibold text-text-primary\">\n {isDirectMessage\n ? tr(\"conversations.recipient\", \"Recipient\")\n : tr(\"conversations.participants\", \"Participants\")}\n {requiresParticipants ? (\n <span className=\"ml-1 text-status-error-text\">*</span>\n ) : (\n <span className=\"ml-1.5 text-xs font-normal text-text-secondary\">\n ({tr(\"conversations.optional\", \"optional\")})\n </span>\n )}\n </h3>\n\n <MemberPicker\n selected={selectedMembers}\n onAdd={addMember}\n onRemove={removeMember}\n single={isDirectMessage}\n excludeIds={excludeIds}\n />\n </section>\n\n {error && !isQuotaExhaustedError(error) && (\n <div className=\"text-xs text-status-error-text rounded-lg border border-status-error-border/30 bg-status-error-bg/10 px-3 py-2\">\n {getErrorMessage(error)}\n </div>\n )}\n </div>\n\n <DialogFooter>\n <button\n type=\"button\"\n onClick={() => handleOpenChange(false)}\n className=\"px-4 py-2 text-sm font-medium rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-sunken/50 transition-colors\"\n >\n {tr(\"conversations.cancel\", \"Cancel\")}\n </button>\n <button\n type=\"button\"\n onClick={() => void handleCreate()}\n disabled={!canCreate || loading}\n className={cn(\n \"inline-flex items-center gap-2 px-5 py-2 text-sm font-medium rounded-lg transition-all\",\n canCreate && !loading\n ? \"bg-action-primary-bg text-action-primary-text hover:bg-action-primary-bg/90 shadow-sm\"\n : \"bg-bg-sunken text-text-secondary cursor-not-allowed\",\n )}\n >\n {loading\n ? tr(\"conversations.creating\", \"Creating...\")\n : tr(\"conversations.createConversation\", \"Create Conversation\")}\n </button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n <QuotaExhaustedDialog\n open={showQuotaExhausted}\n title={tr(\n \"conversations.quota.conversations.title\",\n \"Conversation Quota Reached\",\n )}\n description={tr(\n \"conversations.quota.conversations.description\",\n \"You've reached the conversation limit for your current plan. View billing to upgrade or add more capacity.\",\n )}\n onClose={handleQuotaDialogClose}\n onConfirm={() => {\n setShowQuotaExhausted(false);\n navigate(\"/billing\");\n }}\n />\n </>\n );\n}\n"],"mappings":"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"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useTr as e } from "../utils/i18n.js";
|
|
2
2
|
import { useMemo as t } from "react";
|
|
3
3
|
import { Activity as n, ChevronRight as r, ListChecks as i, MessageCircle as a, Package as o, ShieldCheck as s } from "lucide-react";
|
|
4
|
-
import { Link as c, useNavigate as l } from "react-router
|
|
4
|
+
import { Link as c, useNavigate as l } from "react-router";
|
|
5
5
|
import { jsx as u, jsxs as d } from "react/jsx-runtime";
|
|
6
6
|
import { EmphasisPanel as f, GlassCard as p, GradientText as m, NextSteps as h, PagePurpose as g } from "@burdenoff/fe-libs/ui";
|
|
7
7
|
//#region src/conversations/pages/AdminConversationsLandingPage.tsx
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AdminConversationsLandingPage.js","names":[],"sources":["../../../src/conversations/pages/AdminConversationsLandingPage.tsx"],"sourcesContent":["import type { ReactNode } from \"react\";\nimport { useMemo } from \"react\";\nimport { Link, useNavigate } from \"react-router-dom\";\nimport {\n MessageCircle,\n ShieldCheck,\n ListChecks,\n Activity,\n Package,\n ChevronRight,\n} from \"lucide-react\";\nimport {\n GlassCard,\n EmphasisPanel,\n GradientText,\n NextSteps,\n PagePurpose,\n type NextStep,\n} from \"@burdenoff/fe-libs/ui\";\nimport { useTr } from \"../utils/i18n\";\n\ninterface AdminSectionDefinition {\n id: string;\n /** i18n key used for the card label */\n labelKey: string;\n /** English fallback rendered when the i18n key is not resolved */\n labelFallback: string;\n /** i18n key used for the card description */\n descriptionKey: string;\n /** English fallback for the description */\n descriptionFallback: string;\n /** Route path relative to the admin basePath */\n path: string;\n icon: ReactNode;\n /** The single featured section — gets the glowing glass treatment. */\n featured?: boolean;\n}\n\n// Module-scoped section catalogue. The icon JSX is stable (purely presentational,\n// no context or dynamic props) so constructing once at parse time avoids rebuilding\n// four lucide nodes on every render. If icons ever need to respond to context\n// (theme, RBAC), move the icon field into the useMemo mapper below.\nconst SECTION_DEFINITIONS: AdminSectionDefinition[] = [\n {\n id: \"moderation\",\n labelKey: \"conversations.admin.moderation\",\n labelFallback: \"Moderation\",\n descriptionKey: \"conversations.admin.moderationDescription\",\n descriptionFallback:\n \"Reported and flagged messages, moderation actions, and reviewer assignments.\",\n path: \"moderation\",\n icon: <ShieldCheck className=\"size-5\" aria-hidden=\"true\" />,\n featured: true,\n },\n {\n id: \"bulk\",\n labelKey: \"conversations.admin.bulkOperations\",\n labelFallback: \"Bulk Operations\",\n descriptionKey: \"conversations.admin.bulkOperationsDescription\",\n descriptionFallback:\n \"Bulk archive / unarchive, participant edits, and cross-conversation actions.\",\n path: \"bulk\",\n icon: <ListChecks className=\"size-5\" aria-hidden=\"true\" />,\n },\n {\n id: \"audit\",\n labelKey: \"conversations.admin.auditLog\",\n labelFallback: \"Audit Log\",\n descriptionKey: \"conversations.admin.auditLogDescription\",\n descriptionFallback:\n \"Activity stream for admin / moderator actions on workspace conversations.\",\n path: \"audit\",\n icon: <Activity className=\"size-5\" aria-hidden=\"true\" />,\n },\n {\n id: \"apps\",\n labelKey: \"conversations.admin.appInstallations\",\n labelFallback: \"App Installations\",\n descriptionKey: \"conversations.admin.appInstallationsDescription\",\n descriptionFallback:\n \"Install, configure, and remove conversation apps and integrations.\",\n path: \"apps\",\n icon: <Package className=\"size-5\" aria-hidden=\"true\" />,\n },\n];\n\n/**\n * Admin-facing landing page for the Conversations microfrontend.\n *\n * Lightweight overview + section directory. Each card links to its canonical\n * sub-route (`/moderation`, `/bulk`, `/audit`, `/apps`) for the API-backed\n * admin surfaces.\n */\nexport function AdminConversationsLandingPage() {\n const tr = useTr();\n const navigate = useNavigate();\n\n const sections = useMemo(\n () =>\n SECTION_DEFINITIONS.map((section) => ({\n ...section,\n label: tr(section.labelKey, section.labelFallback),\n description: tr(section.descriptionKey, section.descriptionFallback),\n })),\n [tr],\n );\n\n // Recommended starting points for an admin landing here for the first time.\n // Each routes to a real admin sub-surface.\n const nextSteps = useMemo<NextStep[]>(\n () => [\n {\n id: \"review-moderation\",\n label: tr(\n \"conversations.admin.nextSteps.moderation.label\",\n \"Clear the moderation queue\",\n ),\n description: tr(\n \"conversations.admin.nextSteps.moderation.description\",\n \"Review reported messages and apply decisions.\",\n ),\n icon: <ShieldCheck className=\"size-4\" aria-hidden=\"true\" />,\n onClick: () => navigate(\"moderation\"),\n },\n {\n id: \"bulk-cleanup\",\n label: tr(\n \"conversations.admin.nextSteps.bulk.label\",\n \"Tidy up conversations in bulk\",\n ),\n description: tr(\n \"conversations.admin.nextSteps.bulk.description\",\n \"Archive or remove stale conversations across the workspace.\",\n ),\n icon: <ListChecks className=\"size-4\" aria-hidden=\"true\" />,\n onClick: () => navigate(\"bulk\"),\n },\n {\n id: \"check-audit\",\n label: tr(\n \"conversations.admin.nextSteps.audit.label\",\n \"Check recent admin activity\",\n ),\n description: tr(\n \"conversations.admin.nextSteps.audit.description\",\n \"See what moderators and admins have changed lately.\",\n ),\n icon: <Activity className=\"size-4\" aria-hidden=\"true\" />,\n onClick: () => navigate(\"audit\"),\n },\n ],\n [navigate, tr],\n );\n\n return (\n <div className=\"flex flex-col h-full\">\n <div className=\"flex-1 min-h-0 overflow-auto p-4 sm:p-6\">\n <div className=\"mx-auto w-full max-w-3xl space-y-6\">\n <PagePurpose icon={<MessageCircle size={16} aria-hidden=\"true\" />}>\n {tr(\n \"conversations.admin.purpose\",\n \"Govern every conversation in this workspace from one place — moderate reported messages, run bulk archive or cleanup operations, audit moderator actions, and manage the apps that plug into conversations.\",\n )}\n </PagePurpose>\n\n {/* Hero / summary zone — the single emphasis panel on this page. */}\n <EmphasisPanel>\n <div className=\"flex items-start gap-3\">\n <div className=\"p-2 rounded-lg bg-gloss-card text-text-secondary border border-border-seam\">\n <MessageCircle className=\"size-5\" aria-hidden=\"true\" />\n </div>\n <div className=\"min-w-0\">\n <h1 className=\"text-lg font-semibold text-text-primary\">\n <GradientText>\n {tr(\n \"conversations.admin.landingTitle\",\n \"Conversations admin\",\n )}\n </GradientText>\n </h1>\n <p className=\"mt-1 text-sm text-text-secondary\">\n {tr(\n \"conversations.admin.landingDescription\",\n \"Governance tools for workspace conversations. Select a section below to continue.\",\n )}\n </p>\n </div>\n </div>\n </EmphasisPanel>\n\n <div className=\"grid gap-3 sm:grid-cols-2\">\n {sections.map((section) => (\n <Link\n key={section.id}\n to={section.path}\n className=\"group rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)]\"\n >\n <GlassCard\n treatment=\"glass\"\n glow={section.featured}\n className=\"flex items-start gap-3 border-border-seam p-4 shadow-[var(--shadow-pop)]\"\n >\n <div className=\"p-2 rounded-md bg-bg-sunken text-text-secondary group-hover:text-text-primary\">\n {section.icon}\n </div>\n <div className=\"flex-1 min-w-0\">\n <div className=\"flex items-center justify-between gap-2\">\n <span className=\"text-sm font-medium text-text-primary\">\n {section.label}\n </span>\n <ChevronRight\n className=\"size-4 text-text-secondary\"\n aria-hidden=\"true\"\n />\n </div>\n <p className=\"mt-1 text-xs text-text-secondary\">\n {section.description}\n </p>\n </div>\n </GlassCard>\n </Link>\n ))}\n </div>\n\n <NextSteps\n storageKey=\"conversations-admin-landing\"\n title={tr(\n \"conversations.admin.nextSteps.title\",\n \"Recommended next steps\",\n )}\n steps={nextSteps}\n />\n </div>\n </div>\n </div>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"AdminConversationsLandingPage.js","names":[],"sources":["../../../src/conversations/pages/AdminConversationsLandingPage.tsx"],"sourcesContent":["import type { ReactNode } from \"react\";\nimport { useMemo } from \"react\";\nimport { Link, useNavigate } from \"react-router\";\nimport {\n MessageCircle,\n ShieldCheck,\n ListChecks,\n Activity,\n Package,\n ChevronRight,\n} from \"lucide-react\";\nimport {\n GlassCard,\n EmphasisPanel,\n GradientText,\n NextSteps,\n PagePurpose,\n type NextStep,\n} from \"@burdenoff/fe-libs/ui\";\nimport { useTr } from \"../utils/i18n\";\n\ninterface AdminSectionDefinition {\n id: string;\n /** i18n key used for the card label */\n labelKey: string;\n /** English fallback rendered when the i18n key is not resolved */\n labelFallback: string;\n /** i18n key used for the card description */\n descriptionKey: string;\n /** English fallback for the description */\n descriptionFallback: string;\n /** Route path relative to the admin basePath */\n path: string;\n icon: ReactNode;\n /** The single featured section — gets the glowing glass treatment. */\n featured?: boolean;\n}\n\n// Module-scoped section catalogue. The icon JSX is stable (purely presentational,\n// no context or dynamic props) so constructing once at parse time avoids rebuilding\n// four lucide nodes on every render. If icons ever need to respond to context\n// (theme, RBAC), move the icon field into the useMemo mapper below.\nconst SECTION_DEFINITIONS: AdminSectionDefinition[] = [\n {\n id: \"moderation\",\n labelKey: \"conversations.admin.moderation\",\n labelFallback: \"Moderation\",\n descriptionKey: \"conversations.admin.moderationDescription\",\n descriptionFallback:\n \"Reported and flagged messages, moderation actions, and reviewer assignments.\",\n path: \"moderation\",\n icon: <ShieldCheck className=\"size-5\" aria-hidden=\"true\" />,\n featured: true,\n },\n {\n id: \"bulk\",\n labelKey: \"conversations.admin.bulkOperations\",\n labelFallback: \"Bulk Operations\",\n descriptionKey: \"conversations.admin.bulkOperationsDescription\",\n descriptionFallback:\n \"Bulk archive / unarchive, participant edits, and cross-conversation actions.\",\n path: \"bulk\",\n icon: <ListChecks className=\"size-5\" aria-hidden=\"true\" />,\n },\n {\n id: \"audit\",\n labelKey: \"conversations.admin.auditLog\",\n labelFallback: \"Audit Log\",\n descriptionKey: \"conversations.admin.auditLogDescription\",\n descriptionFallback:\n \"Activity stream for admin / moderator actions on workspace conversations.\",\n path: \"audit\",\n icon: <Activity className=\"size-5\" aria-hidden=\"true\" />,\n },\n {\n id: \"apps\",\n labelKey: \"conversations.admin.appInstallations\",\n labelFallback: \"App Installations\",\n descriptionKey: \"conversations.admin.appInstallationsDescription\",\n descriptionFallback:\n \"Install, configure, and remove conversation apps and integrations.\",\n path: \"apps\",\n icon: <Package className=\"size-5\" aria-hidden=\"true\" />,\n },\n];\n\n/**\n * Admin-facing landing page for the Conversations microfrontend.\n *\n * Lightweight overview + section directory. Each card links to its canonical\n * sub-route (`/moderation`, `/bulk`, `/audit`, `/apps`) for the API-backed\n * admin surfaces.\n */\nexport function AdminConversationsLandingPage() {\n const tr = useTr();\n const navigate = useNavigate();\n\n const sections = useMemo(\n () =>\n SECTION_DEFINITIONS.map((section) => ({\n ...section,\n label: tr(section.labelKey, section.labelFallback),\n description: tr(section.descriptionKey, section.descriptionFallback),\n })),\n [tr],\n );\n\n // Recommended starting points for an admin landing here for the first time.\n // Each routes to a real admin sub-surface.\n const nextSteps = useMemo<NextStep[]>(\n () => [\n {\n id: \"review-moderation\",\n label: tr(\n \"conversations.admin.nextSteps.moderation.label\",\n \"Clear the moderation queue\",\n ),\n description: tr(\n \"conversations.admin.nextSteps.moderation.description\",\n \"Review reported messages and apply decisions.\",\n ),\n icon: <ShieldCheck className=\"size-4\" aria-hidden=\"true\" />,\n onClick: () => navigate(\"moderation\"),\n },\n {\n id: \"bulk-cleanup\",\n label: tr(\n \"conversations.admin.nextSteps.bulk.label\",\n \"Tidy up conversations in bulk\",\n ),\n description: tr(\n \"conversations.admin.nextSteps.bulk.description\",\n \"Archive or remove stale conversations across the workspace.\",\n ),\n icon: <ListChecks className=\"size-4\" aria-hidden=\"true\" />,\n onClick: () => navigate(\"bulk\"),\n },\n {\n id: \"check-audit\",\n label: tr(\n \"conversations.admin.nextSteps.audit.label\",\n \"Check recent admin activity\",\n ),\n description: tr(\n \"conversations.admin.nextSteps.audit.description\",\n \"See what moderators and admins have changed lately.\",\n ),\n icon: <Activity className=\"size-4\" aria-hidden=\"true\" />,\n onClick: () => navigate(\"audit\"),\n },\n ],\n [navigate, tr],\n );\n\n return (\n <div className=\"flex flex-col h-full\">\n <div className=\"flex-1 min-h-0 overflow-auto p-4 sm:p-6\">\n <div className=\"mx-auto w-full max-w-3xl space-y-6\">\n <PagePurpose icon={<MessageCircle size={16} aria-hidden=\"true\" />}>\n {tr(\n \"conversations.admin.purpose\",\n \"Govern every conversation in this workspace from one place — moderate reported messages, run bulk archive or cleanup operations, audit moderator actions, and manage the apps that plug into conversations.\",\n )}\n </PagePurpose>\n\n {/* Hero / summary zone — the single emphasis panel on this page. */}\n <EmphasisPanel>\n <div className=\"flex items-start gap-3\">\n <div className=\"p-2 rounded-lg bg-gloss-card text-text-secondary border border-border-seam\">\n <MessageCircle className=\"size-5\" aria-hidden=\"true\" />\n </div>\n <div className=\"min-w-0\">\n <h1 className=\"text-lg font-semibold text-text-primary\">\n <GradientText>\n {tr(\n \"conversations.admin.landingTitle\",\n \"Conversations admin\",\n )}\n </GradientText>\n </h1>\n <p className=\"mt-1 text-sm text-text-secondary\">\n {tr(\n \"conversations.admin.landingDescription\",\n \"Governance tools for workspace conversations. Select a section below to continue.\",\n )}\n </p>\n </div>\n </div>\n </EmphasisPanel>\n\n <div className=\"grid gap-3 sm:grid-cols-2\">\n {sections.map((section) => (\n <Link\n key={section.id}\n to={section.path}\n className=\"group rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)]\"\n >\n <GlassCard\n treatment=\"glass\"\n glow={section.featured}\n className=\"flex items-start gap-3 border-border-seam p-4 shadow-[var(--shadow-pop)]\"\n >\n <div className=\"p-2 rounded-md bg-bg-sunken text-text-secondary group-hover:text-text-primary\">\n {section.icon}\n </div>\n <div className=\"flex-1 min-w-0\">\n <div className=\"flex items-center justify-between gap-2\">\n <span className=\"text-sm font-medium text-text-primary\">\n {section.label}\n </span>\n <ChevronRight\n className=\"size-4 text-text-secondary\"\n aria-hidden=\"true\"\n />\n </div>\n <p className=\"mt-1 text-xs text-text-secondary\">\n {section.description}\n </p>\n </div>\n </GlassCard>\n </Link>\n ))}\n </div>\n\n <NextSteps\n storageKey=\"conversations-admin-landing\"\n title={tr(\n \"conversations.admin.nextSteps.title\",\n \"Recommended next steps\",\n )}\n steps={nextSteps}\n />\n </div>\n </div>\n </div>\n );\n}\n"],"mappings":"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"}
|
|
@@ -11,7 +11,7 @@ import { CONVERSATIONS_PERMISSIONS as o } from "../constants/permissions.js";
|
|
|
11
11
|
import { nativeImpact as se, nativeNotify as s } from "../../utils/nativeBridge.js";
|
|
12
12
|
import { useCallback as c, useEffect as l, useMemo as u, useRef as d, useState as f } from "react";
|
|
13
13
|
import { Archive as ce, ArrowLeft as le, Bell as ue, BellOff as de, ExternalLink as fe, Hash as pe, Loader2 as me, LogOut as he, MoreVertical as ge, Pin as p, PinOff as _e, SendHorizontal as m, Users as ve, X as ye } from "lucide-react";
|
|
14
|
-
import { useNavigate as be, useParams as xe } from "react-router
|
|
14
|
+
import { useNavigate as be, useParams as xe } from "react-router";
|
|
15
15
|
import { isQuotaExhaustedError as Se } from "@burdenoff/fe-libs/shared/utils";
|
|
16
16
|
import { useEventBus as Ce } from "@burdenoff/fe-libs/shared/events";
|
|
17
17
|
import { jsx as h, jsxs as g } from "react/jsx-runtime";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ConversationDetailPage.js","names":[],"sources":["../../../src/conversations/pages/ConversationDetailPage.tsx"],"sourcesContent":["import { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { useI18n } from \"@burdenoff/fe-libs/shared/providers/shell/I18nProvider\";\nimport { useEventBus } from \"@burdenoff/fe-libs/shared/events\";\nimport { isQuotaExhaustedError } from \"@burdenoff/fe-libs/shared/utils\";\nimport { useNavigate, useParams } from \"react-router-dom\";\nimport {\n Archive,\n ArrowLeft,\n BellOff,\n Bell,\n ExternalLink,\n Hash,\n LogOut,\n Loader2,\n MoreVertical,\n Pin,\n PinOff,\n SendHorizontal,\n Users,\n X,\n} from \"lucide-react\";\nimport {\n Button,\n IllustratedEmptyState,\n NextSteps,\n type NextStep,\n} from \"@burdenoff/fe-libs/ui\";\nimport { MessageBubble } from \"../components/MessageBubble\";\nimport {\n useGetConversation,\n useConversationActions,\n} from \"../hooks/useConversationQueries\";\nimport {\n useGetMessages,\n useSendMessage,\n useEditMessage,\n useMessageActions,\n} from \"../hooks/useMessageQueries\";\nimport { useMessageStreamForConversation } from \"../hooks/useMessageSubscriptions\";\nimport { useConversations } from \"../providers/ConversationsProvider\";\nimport { useConversationsStore } from \"../store/conversationsStore\";\nimport { cn } from \"../utils/cn\";\nimport { PermissionGuard } from \"../components/PermissionGuard\";\nimport { QuotaExhaustedDialog } from \"../components/QuotaExhaustedDialog\";\nimport { CONVERSATIONS_PERMISSIONS } from \"../constants/permissions\";\nimport { nativeImpact, nativeNotify } from \"../../utils/nativeBridge\";\nimport type { Message } from \"../types\";\n\nconst MESSAGES_PAGE_SIZE = 100;\nconst MESSAGE_REFRESH_FALLBACK_MS = 30_000;\n\nexport function ConversationDetailPage() {\n const { conversationId } = useParams<{ conversationId: string }>();\n const navigate = useNavigate();\n const { basePath, currentUser } = useConversations();\n const bus = useEventBus();\n const { t } = useI18n();\n const tr = useCallback(\n (key: string, fallback: string): string => {\n const value = t(key);\n return value === key ? fallback : value;\n },\n [t],\n );\n const {\n clearMessageDraft,\n isParticipantsPanelOpen,\n messageDrafts,\n setMessageDraft,\n toggleParticipantsPanel,\n } = useConversationsStore();\n\n const [messageInput, setMessageInput] = useState(\n messageDrafts[conversationId ?? \"\"] ?? \"\",\n );\n const [showMessageQuotaExhausted, setShowMessageQuotaExhausted] =\n useState(false);\n const [locallySentMessages, setLocallySentMessages] = useState<Message[]>([]);\n const messagesEndRef = useRef<HTMLDivElement>(null);\n const composerRef = useRef<HTMLTextAreaElement>(null);\n\n const focusComposer = useCallback(() => {\n composerRef.current?.focus();\n composerRef.current?.scrollIntoView({ behavior: \"smooth\" });\n }, []);\n\n const {\n conversation,\n members: conversationMembers,\n membersTotalCount,\n loading: conversationLoading,\n error: conversationError,\n } = useGetConversation(conversationId ?? \"\");\n const {\n messages,\n loading: messagesLoading,\n error: messagesError,\n refetch: refetchMessages,\n } = useGetMessages(conversationId ?? \"\", {\n first: MESSAGES_PAGE_SIZE,\n pollInterval: MESSAGE_REFRESH_FALLBACK_MS,\n });\n // Subscribe to live message events; the query has a light polling fallback for\n // local/federated transports that keep the stream open but miss events.\n useMessageStreamForConversation(conversationId, MESSAGES_PAGE_SIZE);\n const { sendMessage, loading: sendingMessage } = useSendMessage();\n const { editMessage, loading: editingMessage } = useEditMessage();\n const {\n deleteMessage,\n addReaction,\n removeReaction,\n pinMessage,\n unpinMessage,\n } = useMessageActions();\n const {\n archiveConversation,\n pinConversation,\n unpinConversation,\n muteConversation,\n unmuteConversation,\n leaveConversation,\n } = useConversationActions();\n\n const [moreMenuOpen, setMoreMenuOpen] = useState(false);\n const [editingMessageId, setEditingMessageId] = useState<string | null>(null);\n const [editingContent, setEditingContent] = useState(\"\");\n const [replyingTo, setReplyingTo] = useState<{\n id: string;\n content: string;\n sender: string;\n } | null>(null);\n const moreMenuRef = useRef<HTMLDivElement>(null);\n\n const currentUserId = currentUser?.id ?? \"\";\n const sourceContext = conversation?.sourceContext;\n\n const displayedMessages = useMemo(() => {\n const byId = new Map<string, Message>();\n for (const message of locallySentMessages) {\n if (message.conversationId !== conversationId) continue;\n byId.set(message.id, message);\n }\n for (const message of messages) {\n byId.set(message.id, message);\n }\n return Array.from(byId.values()).sort(\n (a, b) =>\n new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),\n );\n }, [conversationId, locallySentMessages, messages]);\n\n useEffect(() => {\n messagesEndRef.current?.scrollIntoView({ behavior: \"smooth\" });\n }, [displayedMessages.length]);\n\n useEffect(() => {\n setLocallySentMessages([]);\n }, [conversationId]);\n\n useEffect(() => {\n if (locallySentMessages.length === 0 || messages.length === 0) return;\n const serverIds = new Set(messages.map((message) => message.id));\n setLocallySentMessages((current) => {\n const pending = current.filter((message) => !serverIds.has(message.id));\n return pending.length === current.length ? current : pending;\n });\n }, [locallySentMessages.length, messages, setLocallySentMessages]);\n\n useEffect(() => {\n if (!conversationId) return;\n if (messageInput.trim()) {\n setMessageDraft(conversationId, messageInput);\n return;\n }\n clearMessageDraft(conversationId);\n }, [messageInput, conversationId, setMessageDraft, clearMessageDraft]);\n\n const messageAuthorNames = useMemo(() => {\n const byAuthor = new Map<string, string>();\n for (const message of displayedMessages) {\n if (!message.authorId || byAuthor.has(message.authorId)) continue;\n if (!message.sender.name) continue;\n byAuthor.set(message.authorId, message.sender.name);\n }\n return byAuthor;\n }, [displayedMessages]);\n\n const participants = useMemo(() => {\n if (conversationMembers.length > 0) {\n return conversationMembers\n .filter((member) => member.status === \"ACTIVE\" && !member.leftAt)\n .map((member) => ({\n id: member.userId,\n name:\n messageAuthorNames.get(member.userId) ||\n member.customTitle ||\n tr(\"conversations.member\", \"Member\"),\n role: member.role,\n }));\n }\n\n return Array.from(messageAuthorNames.entries()).map(([id, name]) => ({\n id,\n name,\n role: tr(\"conversations.member\", \"Member\"),\n }));\n }, [conversationMembers, messageAuthorNames, tr]);\n\n const hiddenParticipantsCount = conversation?.memberCount\n ? Math.max(conversation.memberCount - participants.length, 0)\n : 0;\n\n const handleSendMessage = useCallback(async () => {\n if (!conversationId || !messageInput.trim() || sendingMessage) return;\n let message: Message | null;\n try {\n message = await sendMessage({\n conversationId,\n content: messageInput.trim(),\n type: \"TEXT\",\n parentMessageId: replyingTo?.id,\n refetchFirst: MESSAGES_PAGE_SIZE,\n });\n } catch (error) {\n if (isQuotaExhaustedError(error)) {\n setShowMessageQuotaExhausted(true);\n return;\n }\n nativeNotify(\"error\");\n throw error;\n }\n if (!message) return;\n setLocallySentMessages((current) => [...current, message]);\n bus.emit(\"conversations.message.sent\", {\n messageId: message.id,\n conversationId,\n });\n setMessageInput(\"\");\n setReplyingTo(null);\n clearMessageDraft(conversationId);\n }, [\n bus,\n conversationId,\n messageInput,\n sendingMessage,\n sendMessage,\n clearMessageDraft,\n replyingTo,\n ]);\n\n const handleKeyDown = useCallback(\n (event: React.KeyboardEvent<HTMLTextAreaElement>) => {\n if (event.key !== \"Enter\" || event.shiftKey) return;\n event.preventDefault();\n void handleSendMessage();\n },\n [handleSendMessage],\n );\n\n // Emit opened event so sinks (analytics/audit) can record the view\n useEffect(() => {\n if (!conversationId) return;\n bus.emit(\"conversations.conversation.opened\", { conversationId });\n }, [bus, conversationId]);\n\n // Close more-menu on outside click\n useEffect(() => {\n if (!moreMenuOpen) return;\n const handler = (e: MouseEvent) => {\n if (\n moreMenuRef.current &&\n !moreMenuRef.current.contains(e.target as Node)\n ) {\n setMoreMenuOpen(false);\n }\n };\n document.addEventListener(\"mousedown\", handler);\n return () => document.removeEventListener(\"mousedown\", handler);\n }, [moreMenuOpen]);\n\n const handleReply = useCallback(\n (message: { id: string; content: string; sender: { name: string } }) => {\n setReplyingTo({\n id: message.id,\n content: message.content,\n sender: message.sender.name,\n });\n },\n [],\n );\n\n const handleReact = useCallback(\n async (messageId: string, emoji: string) => {\n try {\n await addReaction(messageId, emoji);\n } catch {\n // If already reacted, toggle off\n await removeReaction(messageId, emoji).catch(() => {});\n }\n },\n [addReaction, removeReaction],\n );\n\n const handleEdit = useCallback((message: { id: string; content: string }) => {\n setEditingMessageId(message.id);\n setEditingContent(message.content);\n }, []);\n\n const handleEditSubmit = useCallback(async () => {\n if (!editingMessageId || !editingContent.trim() || editingMessage) return;\n await editMessage(editingMessageId, editingContent.trim());\n setEditingMessageId(null);\n setEditingContent(\"\");\n }, [editingMessageId, editingContent, editingMessage, editMessage]);\n\n const handleEditCancel = useCallback(() => {\n setEditingMessageId(null);\n setEditingContent(\"\");\n }, []);\n\n const handleDelete = useCallback(\n async (messageId: string) => {\n const confirmed = window.confirm(\n tr(\n \"conversations.confirmDeleteMessage\",\n \"Delete this message? This action cannot be undone.\",\n ),\n );\n if (!confirmed) return;\n nativeImpact(\"medium\");\n try {\n await deleteMessage(messageId);\n void refetchMessages();\n nativeNotify(\"success\");\n } catch (error) {\n console.error(\"[microfe-conversations] message delete failed\", error);\n nativeNotify(\"error\");\n }\n },\n [deleteMessage, refetchMessages, tr],\n );\n\n const handleTogglePinMessage = useCallback(\n async (messageId: string, isPinned: boolean) => {\n if (isPinned) {\n await unpinMessage(messageId);\n return;\n }\n await pinMessage(messageId);\n },\n [pinMessage, unpinMessage],\n );\n\n const handlePin = useCallback(async () => {\n if (!conversationId) return;\n if (conversation?.isPinned) {\n await unpinConversation(conversationId);\n } else {\n await pinConversation(conversationId);\n }\n setMoreMenuOpen(false);\n }, [\n conversationId,\n conversation?.isPinned,\n pinConversation,\n unpinConversation,\n ]);\n\n const handleMute = useCallback(async () => {\n if (!conversationId) return;\n if (conversation?.isMuted) {\n await unmuteConversation(conversationId);\n } else {\n await muteConversation(conversationId);\n }\n setMoreMenuOpen(false);\n }, [\n conversationId,\n conversation?.isMuted,\n muteConversation,\n unmuteConversation,\n ]);\n\n const handleArchive = useCallback(async () => {\n if (!conversationId) return;\n try {\n await archiveConversation(conversationId);\n bus.emit(\"conversations.conversation.archived\", { conversationId });\n setMoreMenuOpen(false);\n navigate(basePath || \"/conversations\");\n } catch (error) {\n console.error(\n \"[microfe-conversations] conversation archive failed\",\n error,\n );\n nativeNotify(\"error\");\n }\n }, [archiveConversation, basePath, bus, conversationId, navigate]);\n\n const handleLeave = useCallback(async () => {\n if (!conversationId) return;\n const confirmed = window.confirm(\n tr(\n \"conversations.confirmLeaveConversation\",\n \"Leave this conversation? You will lose access unless someone adds you again.\",\n ),\n );\n if (!confirmed) return;\n try {\n await leaveConversation(conversationId);\n if (currentUserId) {\n bus.emit(\"conversations.member.removed\", {\n conversationId,\n userId: currentUserId,\n });\n }\n setMoreMenuOpen(false);\n navigate(basePath || \"/conversations\");\n } catch (error) {\n console.error(\"[microfe-conversations] conversation leave failed\", error);\n nativeNotify(\"error\");\n }\n }, [\n basePath,\n bus,\n conversationId,\n currentUserId,\n leaveConversation,\n navigate,\n tr,\n ]);\n\n const handleOpenContext = useCallback(() => {\n if (!sourceContext?.contextHref) return;\n try {\n const url = new URL(sourceContext.contextHref);\n if (url.origin === window.location.origin) {\n navigate(`${url.pathname}${url.search}${url.hash}`);\n return;\n }\n } catch {\n // Fall through to full navigation for relative or invalid URLs.\n }\n\n window.location.assign(sourceContext.contextHref);\n }, [navigate, sourceContext?.contextHref]);\n\n // Concrete next actions surfaced on the (complex) detail page so a new user\n // isn't left staring at an unfamiliar surface. Each step is wired to a real\n // handler already present on this page; steps that don't apply to the\n // current conversation type/state are filtered out.\n const detailNextSteps = useMemo<NextStep[]>(() => {\n const steps: NextStep[] = [\n {\n id: \"send-message\",\n label: tr(\n \"conversations.nextSteps.send.label\",\n \"Send your first reply\",\n ),\n description: tr(\n \"conversations.nextSteps.send.description\",\n \"Jump to the composer and start the conversation.\",\n ),\n icon: <SendHorizontal size={14} aria-hidden=\"true\" />,\n onClick: focusComposer,\n done: displayedMessages.length > 0,\n },\n ];\n if (conversation?.isGroup) {\n steps.push({\n id: \"view-participants\",\n label: tr(\n \"conversations.nextSteps.participants.label\",\n \"Review participants\",\n ),\n description: tr(\n \"conversations.nextSteps.participants.description\",\n \"See who can read and post in this conversation.\",\n ),\n icon: <Users size={14} aria-hidden=\"true\" />,\n onClick: () => {\n if (!isParticipantsPanelOpen) toggleParticipantsPanel();\n },\n });\n }\n if (sourceContext?.contextHref) {\n steps.push({\n id: \"open-context\",\n label: tr(\n \"conversations.nextSteps.context.label\",\n \"Open the related item\",\n ),\n description: tr(\n \"conversations.nextSteps.context.description\",\n \"Jump to the page or record this conversation is about.\",\n ),\n icon: <ExternalLink size={14} aria-hidden=\"true\" />,\n onClick: handleOpenContext,\n });\n }\n steps.push({\n id: \"pin-conversation\",\n label: conversation?.isPinned\n ? tr(\"conversations.nextSteps.unpin.label\", \"Unpin this conversation\")\n : tr(\"conversations.nextSteps.pin.label\", \"Pin for quick access\"),\n description: tr(\n \"conversations.nextSteps.pin.description\",\n \"Keep important conversations at the top of your inbox.\",\n ),\n icon: <Pin size={14} aria-hidden=\"true\" />,\n onClick: () => void handlePin(),\n done: conversation?.isPinned,\n });\n return steps;\n }, [\n conversation?.isGroup,\n conversation?.isPinned,\n displayedMessages.length,\n focusComposer,\n handleOpenContext,\n handlePin,\n isParticipantsPanelOpen,\n sourceContext?.contextHref,\n toggleParticipantsPanel,\n tr,\n ]);\n\n if (!conversation && conversationLoading) {\n return (\n <div className=\"flex items-center justify-center h-[calc(100vh-64px)] text-sm text-text-secondary\">\n {tr(\"conversations.loading\", \"Loading conversation...\")}\n </div>\n );\n }\n\n if (!conversation || conversationError) {\n return (\n <div className=\"flex h-[calc(100vh-64px)] items-center justify-center p-8\">\n <IllustratedEmptyState\n illustration=\"empty-generic\"\n title={\n conversationError\n ? tr(\"conversations.unavailableTitle\", \"Conversation unavailable\")\n : tr(\"conversations.notFound\", \"Conversation not found\")\n }\n description={\n conversationError\n ? tr(\n \"conversations.unavailable\",\n \"We couldn't load this conversation. Please try again.\",\n )\n : tr(\n \"conversations.notFoundDescription\",\n \"This conversation may have been deleted, archived, or you no longer have access to it.\",\n )\n }\n action={\n <Button\n type=\"button\"\n onClick={() => navigate(basePath || \"/conversations\")}\n >\n {tr(\"conversations.back\", \"Back to conversations\")}\n </Button>\n }\n />\n </div>\n );\n }\n\n return (\n <div className=\"relative flex h-[calc(100vh-64px)] bg-bg-surface\">\n <div className=\"flex-1 flex flex-col min-w-0\">\n <header\n data-tour=\"conversation-detail-header\"\n className=\"flex items-center justify-between px-4 py-3 border-b border-border-seam bg-bg-surface/95 backdrop-blur-sm\"\n >\n <div className=\"flex items-center gap-3 min-w-0\">\n <button\n type=\"button\"\n onClick={() => navigate(basePath || \"/conversations\")}\n className=\"flex min-h-10 min-w-10 items-center justify-center rounded-lg text-text-secondary transition-colors hover:bg-bg-sunken/50 hover:text-text-primary\"\n >\n <ArrowLeft size={18} />\n </button>\n <div className=\"min-w-0\">\n <div className=\"flex items-center gap-1.5\">\n {conversation.type === \"CHANNEL\" && (\n <Hash\n size={14}\n className=\"text-text-secondary flex-shrink-0\"\n />\n )}\n <h2 className=\"text-sm font-semibold text-text-primary truncate\">\n {conversation.title}\n </h2>\n </div>\n <p className=\"text-xs text-text-secondary\">\n {conversation.type === \"DIRECT\"\n ? tr(\n \"conversations.directConversation\",\n \"Direct conversation\",\n )\n : conversation.type === \"CHANNEL\"\n ? `${tr(\"conversations.channel\", \"Channel\")} · ${conversation.memberCount} ${tr(\"conversations.membersCountLabel\", \"members\")}`\n : conversation.type === \"ANNOUNCEMENT\"\n ? `${tr(\"conversations.announcement\", \"Announcement\")} · ${conversation.memberCount} ${tr(\"conversations.membersCountLabel\", \"members\")}`\n : `${conversation.memberCount} ${tr(\"conversations.membersCountLabel\", \"members\")}`}\n </p>\n </div>\n </div>\n\n <div className=\"flex shrink-0 items-center gap-1\">\n {sourceContext?.contextHref && (\n <HeaderButton\n dataTour=\"conversation-open-context\"\n icon={<ExternalLink size={16} />}\n label={tr(\"conversations.openContext\", \"Open context\")}\n onClick={handleOpenContext}\n />\n )}\n {conversation.isGroup && (\n <HeaderButton\n dataTour=\"conversation-participants-button\"\n icon={<Users size={16} />}\n label={tr(\"conversations.participants\", \"Participants\")}\n active={isParticipantsPanelOpen}\n onClick={toggleParticipantsPanel}\n />\n )}\n <div className=\"relative\" ref={moreMenuRef}>\n <HeaderButton\n icon={<MoreVertical size={16} />}\n label={tr(\"conversations.moreOptions\", \"More options\")}\n active={moreMenuOpen}\n onClick={() => setMoreMenuOpen((prev) => !prev)}\n />\n {moreMenuOpen && (\n <div className=\"absolute right-0 top-full mt-1 w-48 bg-popover border border-border-subtle rounded-lg shadow-lg z-50 py-1 animate-in fade-in-0 zoom-in-95 duration-100\">\n <DropdownItem\n icon={\n conversation.isPinned ? (\n <PinOff size={14} />\n ) : (\n <Pin size={14} />\n )\n }\n label={\n conversation.isPinned\n ? tr(\n \"conversations.unpinConversation\",\n \"Unpin conversation\",\n )\n : tr(\n \"conversations.pinConversation\",\n \"Pin conversation\",\n )\n }\n onClick={() => void handlePin()}\n />\n <DropdownItem\n icon={\n conversation.isMuted ? (\n <Bell size={14} />\n ) : (\n <BellOff size={14} />\n )\n }\n label={\n conversation.isMuted\n ? tr(\n \"conversations.unmuteConversation\",\n \"Unmute conversation\",\n )\n : tr(\n \"conversations.muteConversation\",\n \"Mute conversation\",\n )\n }\n onClick={() => void handleMute()}\n />\n <div className=\"h-px bg-border my-1\" />\n <DropdownItem\n icon={<Archive size={14} />}\n label={tr(\n \"conversations.archiveConversation\",\n \"Archive conversation\",\n )}\n onClick={() => void handleArchive()}\n destructive\n />\n {conversation.isGroup && (\n <DropdownItem\n icon={<LogOut size={14} />}\n label={tr(\n \"conversations.leaveConversation\",\n \"Leave conversation\",\n )}\n onClick={() => void handleLeave()}\n destructive\n />\n )}\n </div>\n )}\n </div>\n </div>\n </header>\n\n <div className=\"flex-1 overflow-y-auto py-4 space-y-0.5\">\n {sourceContext?.pagePath && (\n <div className=\"mx-4 mb-4 rounded-lg border border-border-subtle bg-bg-sunken/20 px-3 py-2 text-xs text-text-secondary\">\n <span className=\"font-medium text-text-primary\">\n {sourceContext.contextTitle || sourceContext.pagePath}\n </span>\n {sourceContext.entityType && sourceContext.entityId\n ? ` · ${sourceContext.entityType} ${sourceContext.entityId}`\n : \"\"}\n </div>\n )}\n {messagesError && (\n <div className=\"mx-4 mb-4 rounded-lg border border-status-warning-border bg-status-warning-bg px-3 py-2 text-sm text-status-warning-text\">\n {tr(\n \"conversations.messagesUnavailable\",\n \"Messages couldn't be loaded right now. The conversation header and actions are still available.\",\n )}\n </div>\n )}\n {messagesLoading && (\n <div className=\"mx-4 mb-4 flex items-center gap-2 rounded-lg border border-border-subtle bg-bg-sunken/20 px-3 py-2 text-sm text-text-secondary\">\n <Loader2 size={14} className=\"animate-spin\" />\n {tr(\"conversations.loadingMessages\", \"Loading messages...\")}\n </div>\n )}\n {!messagesLoading &&\n !messagesError &&\n displayedMessages.length === 0 && (\n <div className=\"mx-4 mb-4 flex flex-col items-center gap-5 py-6\">\n <IllustratedEmptyState\n illustration=\"empty-vibes\"\n title={tr(\"conversations.noMessagesTitle\", \"No messages yet\")}\n description={tr(\n \"conversations.noMessagesYet\",\n \"Start the conversation below — say hello, ask a question, or share an update.\",\n )}\n action={\n <Button type=\"button\" onClick={focusComposer}>\n <SendHorizontal size={14} aria-hidden=\"true\" />\n {tr(\"conversations.writeFirstMessage\", \"Write a message\")}\n </Button>\n }\n />\n <NextSteps\n className=\"w-full max-w-md\"\n storageKey=\"conversations-detail\"\n title={tr(\n \"conversations.nextSteps.title\",\n \"Get the most out of this conversation\",\n )}\n steps={detailNextSteps}\n />\n </div>\n )}\n {displayedMessages.map((message, index) => {\n const prevMessage = displayedMessages[index - 1];\n const showAvatar =\n !prevMessage ||\n prevMessage.authorId !== message.authorId ||\n prevMessage.type === \"SYSTEM\";\n\n return (\n <div key={message.id}>\n {editingMessageId === message.id ? (\n <div className=\"px-4 py-2\">\n <div className=\"flex items-center gap-2 text-xs text-text-secondary mb-1\">\n <span>\n {tr(\"conversations.editingMessage\", \"Editing message\")}\n </span>\n <button\n type=\"button\"\n onClick={handleEditCancel}\n className=\"hover:text-text-primary\"\n >\n <X size={12} />\n </button>\n </div>\n <div className=\"flex items-end gap-2\">\n <textarea\n aria-label=\"Edit message\"\n value={editingContent}\n onChange={(e) => setEditingContent(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === \"Enter\" && !e.shiftKey) {\n e.preventDefault();\n void handleEditSubmit();\n }\n if (e.key === \"Escape\") handleEditCancel();\n }}\n className=\"flex-1 px-3 py-2 text-sm rounded-lg border border-border-subtle bg-bg-sunken/30 resize-none focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)]\"\n rows={2}\n />\n <button\n type=\"button\"\n onClick={() => void handleEditSubmit()}\n disabled={!editingContent.trim() || editingMessage}\n className=\"px-3 py-2 text-xs font-medium rounded-lg bg-action-primary-bg text-action-primary-text hover:bg-action-primary-bg/90 disabled:opacity-50\"\n >\n {tr(\"conversations.save\", \"Save\")}\n </button>\n </div>\n </div>\n ) : (\n <MessageBubble\n message={message}\n isOwnMessage={message.authorId === currentUserId}\n showAvatar={showAvatar}\n showSender={showAvatar}\n onReply={handleReply}\n onReact={(id, emoji) => void handleReact(id, emoji)}\n onEdit={handleEdit}\n onDelete={(id) => void handleDelete(id)}\n onTogglePin={(id, isPinned) =>\n void handleTogglePinMessage(id, isPinned)\n }\n />\n )}\n </div>\n );\n })}\n <div ref={messagesEndRef} />\n </div>\n\n <div\n data-tour=\"conversation-message-composer\"\n className=\"border-t border-border-seam px-4 py-3 bg-bg-surface\"\n >\n {/* Reply-to indicator */}\n {replyingTo && (\n <div className=\"flex items-center gap-2 mb-2 px-3 py-1.5 rounded-lg bg-bg-sunken/50 text-xs text-text-secondary\">\n <span>\n {tr(\"conversations.replyingTo\", \"Replying to\")}{\" \"}\n <strong className=\"text-text-primary\">\n {replyingTo.sender}\n </strong>\n : {replyingTo.content.slice(0, 80)}\n {replyingTo.content.length > 80 ? \"…\" : \"\"}\n </span>\n <button\n type=\"button\"\n onClick={() => setReplyingTo(null)}\n aria-label={tr(\"conversations.cancelReply\", \"Cancel reply\")}\n className=\"ml-auto flex min-h-9 min-w-9 items-center justify-center rounded-md hover:bg-bg-sunken/70 hover:text-text-primary\"\n >\n <X size={12} />\n </button>\n </div>\n )}\n <div className=\"flex items-end gap-2\">\n <div className=\"flex-1 relative\">\n <textarea\n aria-label={tr(\n \"conversations.messageComposer\",\n \"Message composer\",\n )}\n ref={composerRef}\n value={messageInput}\n onChange={(event) => setMessageInput(event.target.value)}\n onKeyDown={handleKeyDown}\n placeholder={`${tr(\"conversations.messageConversationPrefix\", \"Message\")} ${conversation.title}...`}\n rows={1}\n className={cn(\n \"w-full px-4 py-2.5 text-sm rounded-xl border border-border-subtle bg-bg-sunken/30\",\n \"resize-none overflow-hidden placeholder:text-text-secondary\",\n \"focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)] focus:border-transparent max-h-32\",\n )}\n style={{\n height: \"auto\",\n minHeight: \"40px\",\n }}\n onInput={(event) => {\n const target = event.target as HTMLTextAreaElement;\n target.style.height = \"auto\";\n target.style.height = `${Math.min(target.scrollHeight, 128)}px`;\n }}\n />\n </div>\n\n <button\n type=\"button\"\n aria-label={tr(\"conversations.sendMessage\", \"Send message\")}\n onClick={() => void handleSendMessage()}\n disabled={!messageInput.trim() || sendingMessage}\n className={cn(\n \"p-2.5 rounded-xl transition-all duration-150\",\n messageInput.trim() && !sendingMessage\n ? \"bg-action-primary-bg text-action-primary-text hover:bg-action-primary-bg/90 shadow-sm\"\n : \"bg-bg-sunken text-text-secondary cursor-not-allowed\",\n )}\n >\n {sendingMessage ? (\n <Loader2 size={18} className=\"animate-spin\" />\n ) : (\n <SendHorizontal size={18} />\n )}\n </button>\n </div>\n </div>\n </div>\n\n {isParticipantsPanelOpen && conversation.isGroup && (\n <aside className=\"absolute inset-y-0 right-0 z-20 w-full max-w-72 border-l border-border-seam bg-bg-surface shadow-lg animate-in slide-in-from-right-4 duration-200 md:relative md:z-auto md:w-64 md:shadow-none\">\n <div className=\"p-4\">\n <div className=\"flex items-center justify-between mb-4\">\n <h3 className=\"text-sm font-semibold text-text-primary\">\n {tr(\"conversations.members\", \"Members\")} (\n {conversation.memberCount ?? participants.length})\n </h3>\n <button\n type=\"button\"\n onClick={toggleParticipantsPanel}\n className=\"p-1 rounded text-text-secondary hover:text-text-primary\"\n >\n <Pin size={14} />\n </button>\n </div>\n\n <div className=\"space-y-1\">\n {participants.map((participant) => (\n <div\n key={participant.id}\n className=\"flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-bg-sunken/50\"\n >\n <div className=\"size-7 rounded-full bg-bg-sunken flex items-center justify-center text-xs font-medium text-text-secondary\">\n {participant.name\n .split(\" \")\n .map((word) => word[0])\n .join(\"\")\n .slice(0, 2)}\n </div>\n <div className=\"min-w-0\">\n <p className=\"text-xs font-medium text-text-primary truncate\">\n {participant.name}\n </p>\n <p className=\"text-[10px] text-text-secondary\">\n {participant.role}\n </p>\n </div>\n </div>\n ))}\n {hiddenParticipantsCount > 0 && (\n <p className=\"px-2 pt-2 text-xs text-text-secondary\">\n {tr(\"conversations.moreMembersPrefix\", \"and\")}{\" \"}\n {hiddenParticipantsCount}{\" \"}\n {tr(\"conversations.moreMembersSuffix\", \"more\")}\n </p>\n )}\n </div>\n </div>\n </aside>\n )}\n <QuotaExhaustedDialog\n open={showMessageQuotaExhausted}\n title={tr(\n \"conversations.quota.messages.title\",\n \"Message Quota Reached\",\n )}\n description={tr(\n \"conversations.quota.messages.description\",\n \"You've reached the message limit for your current plan. View billing to upgrade or add more capacity.\",\n )}\n onClose={() => setShowMessageQuotaExhausted(false)}\n onConfirm={() => {\n setShowMessageQuotaExhausted(false);\n navigate(\"/billing\");\n }}\n />\n </div>\n );\n}\n\nfunction DropdownItem({\n icon,\n label,\n onClick,\n destructive,\n}: {\n icon: React.ReactNode;\n label: string;\n onClick: () => void;\n destructive?: boolean;\n}) {\n return (\n <button\n type=\"button\"\n onClick={onClick}\n className={cn(\n \"flex items-center gap-2.5 w-full px-3 py-2 text-sm transition-colors text-left\",\n destructive\n ? \"text-status-error-text hover:bg-status-error-bg/10\"\n : \"text-text-primary hover:bg-bg-sunken/50\",\n )}\n >\n {icon}\n <span>{label}</span>\n </button>\n );\n}\n\nfunction HeaderButton({\n icon,\n label,\n active,\n dataTour,\n onClick,\n}: {\n icon: React.ReactNode;\n label: string;\n active?: boolean;\n dataTour?: string;\n onClick?: () => void;\n}) {\n return (\n <PermissionGuard\n permission={CONVERSATIONS_PERMISSIONS.CONVERSATION_READ}\n allowUnavailablePermissions\n >\n <button\n data-tour={dataTour}\n type=\"button\"\n onClick={onClick}\n aria-label={label}\n title={label}\n className={cn(\n \"flex min-h-10 min-w-10 items-center justify-center rounded-lg transition-colors\",\n active\n ? \"bg-action-primary-bg/10 text-primary\"\n : \"text-text-secondary hover:text-text-primary hover:bg-bg-sunken/50\",\n )}\n >\n {icon}\n </button>\n </PermissionGuard>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ConversationDetailPage.js","names":[],"sources":["../../../src/conversations/pages/ConversationDetailPage.tsx"],"sourcesContent":["import { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { useI18n } from \"@burdenoff/fe-libs/shared/providers/shell/I18nProvider\";\nimport { useEventBus } from \"@burdenoff/fe-libs/shared/events\";\nimport { isQuotaExhaustedError } from \"@burdenoff/fe-libs/shared/utils\";\nimport { useNavigate, useParams } from \"react-router\";\nimport {\n Archive,\n ArrowLeft,\n BellOff,\n Bell,\n ExternalLink,\n Hash,\n LogOut,\n Loader2,\n MoreVertical,\n Pin,\n PinOff,\n SendHorizontal,\n Users,\n X,\n} from \"lucide-react\";\nimport {\n Button,\n IllustratedEmptyState,\n NextSteps,\n type NextStep,\n} from \"@burdenoff/fe-libs/ui\";\nimport { MessageBubble } from \"../components/MessageBubble\";\nimport {\n useGetConversation,\n useConversationActions,\n} from \"../hooks/useConversationQueries\";\nimport {\n useGetMessages,\n useSendMessage,\n useEditMessage,\n useMessageActions,\n} from \"../hooks/useMessageQueries\";\nimport { useMessageStreamForConversation } from \"../hooks/useMessageSubscriptions\";\nimport { useConversations } from \"../providers/ConversationsProvider\";\nimport { useConversationsStore } from \"../store/conversationsStore\";\nimport { cn } from \"../utils/cn\";\nimport { PermissionGuard } from \"../components/PermissionGuard\";\nimport { QuotaExhaustedDialog } from \"../components/QuotaExhaustedDialog\";\nimport { CONVERSATIONS_PERMISSIONS } from \"../constants/permissions\";\nimport { nativeImpact, nativeNotify } from \"../../utils/nativeBridge\";\nimport type { Message } from \"../types\";\n\nconst MESSAGES_PAGE_SIZE = 100;\nconst MESSAGE_REFRESH_FALLBACK_MS = 30_000;\n\nexport function ConversationDetailPage() {\n const { conversationId } = useParams<{ conversationId: string }>();\n const navigate = useNavigate();\n const { basePath, currentUser } = useConversations();\n const bus = useEventBus();\n const { t } = useI18n();\n const tr = useCallback(\n (key: string, fallback: string): string => {\n const value = t(key);\n return value === key ? fallback : value;\n },\n [t],\n );\n const {\n clearMessageDraft,\n isParticipantsPanelOpen,\n messageDrafts,\n setMessageDraft,\n toggleParticipantsPanel,\n } = useConversationsStore();\n\n const [messageInput, setMessageInput] = useState(\n messageDrafts[conversationId ?? \"\"] ?? \"\",\n );\n const [showMessageQuotaExhausted, setShowMessageQuotaExhausted] =\n useState(false);\n const [locallySentMessages, setLocallySentMessages] = useState<Message[]>([]);\n const messagesEndRef = useRef<HTMLDivElement>(null);\n const composerRef = useRef<HTMLTextAreaElement>(null);\n\n const focusComposer = useCallback(() => {\n composerRef.current?.focus();\n composerRef.current?.scrollIntoView({ behavior: \"smooth\" });\n }, []);\n\n const {\n conversation,\n members: conversationMembers,\n membersTotalCount,\n loading: conversationLoading,\n error: conversationError,\n } = useGetConversation(conversationId ?? \"\");\n const {\n messages,\n loading: messagesLoading,\n error: messagesError,\n refetch: refetchMessages,\n } = useGetMessages(conversationId ?? \"\", {\n first: MESSAGES_PAGE_SIZE,\n pollInterval: MESSAGE_REFRESH_FALLBACK_MS,\n });\n // Subscribe to live message events; the query has a light polling fallback for\n // local/federated transports that keep the stream open but miss events.\n useMessageStreamForConversation(conversationId, MESSAGES_PAGE_SIZE);\n const { sendMessage, loading: sendingMessage } = useSendMessage();\n const { editMessage, loading: editingMessage } = useEditMessage();\n const {\n deleteMessage,\n addReaction,\n removeReaction,\n pinMessage,\n unpinMessage,\n } = useMessageActions();\n const {\n archiveConversation,\n pinConversation,\n unpinConversation,\n muteConversation,\n unmuteConversation,\n leaveConversation,\n } = useConversationActions();\n\n const [moreMenuOpen, setMoreMenuOpen] = useState(false);\n const [editingMessageId, setEditingMessageId] = useState<string | null>(null);\n const [editingContent, setEditingContent] = useState(\"\");\n const [replyingTo, setReplyingTo] = useState<{\n id: string;\n content: string;\n sender: string;\n } | null>(null);\n const moreMenuRef = useRef<HTMLDivElement>(null);\n\n const currentUserId = currentUser?.id ?? \"\";\n const sourceContext = conversation?.sourceContext;\n\n const displayedMessages = useMemo(() => {\n const byId = new Map<string, Message>();\n for (const message of locallySentMessages) {\n if (message.conversationId !== conversationId) continue;\n byId.set(message.id, message);\n }\n for (const message of messages) {\n byId.set(message.id, message);\n }\n return Array.from(byId.values()).sort(\n (a, b) =>\n new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),\n );\n }, [conversationId, locallySentMessages, messages]);\n\n useEffect(() => {\n messagesEndRef.current?.scrollIntoView({ behavior: \"smooth\" });\n }, [displayedMessages.length]);\n\n useEffect(() => {\n setLocallySentMessages([]);\n }, [conversationId]);\n\n useEffect(() => {\n if (locallySentMessages.length === 0 || messages.length === 0) return;\n const serverIds = new Set(messages.map((message) => message.id));\n setLocallySentMessages((current) => {\n const pending = current.filter((message) => !serverIds.has(message.id));\n return pending.length === current.length ? current : pending;\n });\n }, [locallySentMessages.length, messages, setLocallySentMessages]);\n\n useEffect(() => {\n if (!conversationId) return;\n if (messageInput.trim()) {\n setMessageDraft(conversationId, messageInput);\n return;\n }\n clearMessageDraft(conversationId);\n }, [messageInput, conversationId, setMessageDraft, clearMessageDraft]);\n\n const messageAuthorNames = useMemo(() => {\n const byAuthor = new Map<string, string>();\n for (const message of displayedMessages) {\n if (!message.authorId || byAuthor.has(message.authorId)) continue;\n if (!message.sender.name) continue;\n byAuthor.set(message.authorId, message.sender.name);\n }\n return byAuthor;\n }, [displayedMessages]);\n\n const participants = useMemo(() => {\n if (conversationMembers.length > 0) {\n return conversationMembers\n .filter((member) => member.status === \"ACTIVE\" && !member.leftAt)\n .map((member) => ({\n id: member.userId,\n name:\n messageAuthorNames.get(member.userId) ||\n member.customTitle ||\n tr(\"conversations.member\", \"Member\"),\n role: member.role,\n }));\n }\n\n return Array.from(messageAuthorNames.entries()).map(([id, name]) => ({\n id,\n name,\n role: tr(\"conversations.member\", \"Member\"),\n }));\n }, [conversationMembers, messageAuthorNames, tr]);\n\n const hiddenParticipantsCount = conversation?.memberCount\n ? Math.max(conversation.memberCount - participants.length, 0)\n : 0;\n\n const handleSendMessage = useCallback(async () => {\n if (!conversationId || !messageInput.trim() || sendingMessage) return;\n let message: Message | null;\n try {\n message = await sendMessage({\n conversationId,\n content: messageInput.trim(),\n type: \"TEXT\",\n parentMessageId: replyingTo?.id,\n refetchFirst: MESSAGES_PAGE_SIZE,\n });\n } catch (error) {\n if (isQuotaExhaustedError(error)) {\n setShowMessageQuotaExhausted(true);\n return;\n }\n nativeNotify(\"error\");\n throw error;\n }\n if (!message) return;\n setLocallySentMessages((current) => [...current, message]);\n bus.emit(\"conversations.message.sent\", {\n messageId: message.id,\n conversationId,\n });\n setMessageInput(\"\");\n setReplyingTo(null);\n clearMessageDraft(conversationId);\n }, [\n bus,\n conversationId,\n messageInput,\n sendingMessage,\n sendMessage,\n clearMessageDraft,\n replyingTo,\n ]);\n\n const handleKeyDown = useCallback(\n (event: React.KeyboardEvent<HTMLTextAreaElement>) => {\n if (event.key !== \"Enter\" || event.shiftKey) return;\n event.preventDefault();\n void handleSendMessage();\n },\n [handleSendMessage],\n );\n\n // Emit opened event so sinks (analytics/audit) can record the view\n useEffect(() => {\n if (!conversationId) return;\n bus.emit(\"conversations.conversation.opened\", { conversationId });\n }, [bus, conversationId]);\n\n // Close more-menu on outside click\n useEffect(() => {\n if (!moreMenuOpen) return;\n const handler = (e: MouseEvent) => {\n if (\n moreMenuRef.current &&\n !moreMenuRef.current.contains(e.target as Node)\n ) {\n setMoreMenuOpen(false);\n }\n };\n document.addEventListener(\"mousedown\", handler);\n return () => document.removeEventListener(\"mousedown\", handler);\n }, [moreMenuOpen]);\n\n const handleReply = useCallback(\n (message: { id: string; content: string; sender: { name: string } }) => {\n setReplyingTo({\n id: message.id,\n content: message.content,\n sender: message.sender.name,\n });\n },\n [],\n );\n\n const handleReact = useCallback(\n async (messageId: string, emoji: string) => {\n try {\n await addReaction(messageId, emoji);\n } catch {\n // If already reacted, toggle off\n await removeReaction(messageId, emoji).catch(() => {});\n }\n },\n [addReaction, removeReaction],\n );\n\n const handleEdit = useCallback((message: { id: string; content: string }) => {\n setEditingMessageId(message.id);\n setEditingContent(message.content);\n }, []);\n\n const handleEditSubmit = useCallback(async () => {\n if (!editingMessageId || !editingContent.trim() || editingMessage) return;\n await editMessage(editingMessageId, editingContent.trim());\n setEditingMessageId(null);\n setEditingContent(\"\");\n }, [editingMessageId, editingContent, editingMessage, editMessage]);\n\n const handleEditCancel = useCallback(() => {\n setEditingMessageId(null);\n setEditingContent(\"\");\n }, []);\n\n const handleDelete = useCallback(\n async (messageId: string) => {\n const confirmed = window.confirm(\n tr(\n \"conversations.confirmDeleteMessage\",\n \"Delete this message? This action cannot be undone.\",\n ),\n );\n if (!confirmed) return;\n nativeImpact(\"medium\");\n try {\n await deleteMessage(messageId);\n void refetchMessages();\n nativeNotify(\"success\");\n } catch (error) {\n console.error(\"[microfe-conversations] message delete failed\", error);\n nativeNotify(\"error\");\n }\n },\n [deleteMessage, refetchMessages, tr],\n );\n\n const handleTogglePinMessage = useCallback(\n async (messageId: string, isPinned: boolean) => {\n if (isPinned) {\n await unpinMessage(messageId);\n return;\n }\n await pinMessage(messageId);\n },\n [pinMessage, unpinMessage],\n );\n\n const handlePin = useCallback(async () => {\n if (!conversationId) return;\n if (conversation?.isPinned) {\n await unpinConversation(conversationId);\n } else {\n await pinConversation(conversationId);\n }\n setMoreMenuOpen(false);\n }, [\n conversationId,\n conversation?.isPinned,\n pinConversation,\n unpinConversation,\n ]);\n\n const handleMute = useCallback(async () => {\n if (!conversationId) return;\n if (conversation?.isMuted) {\n await unmuteConversation(conversationId);\n } else {\n await muteConversation(conversationId);\n }\n setMoreMenuOpen(false);\n }, [\n conversationId,\n conversation?.isMuted,\n muteConversation,\n unmuteConversation,\n ]);\n\n const handleArchive = useCallback(async () => {\n if (!conversationId) return;\n try {\n await archiveConversation(conversationId);\n bus.emit(\"conversations.conversation.archived\", { conversationId });\n setMoreMenuOpen(false);\n navigate(basePath || \"/conversations\");\n } catch (error) {\n console.error(\n \"[microfe-conversations] conversation archive failed\",\n error,\n );\n nativeNotify(\"error\");\n }\n }, [archiveConversation, basePath, bus, conversationId, navigate]);\n\n const handleLeave = useCallback(async () => {\n if (!conversationId) return;\n const confirmed = window.confirm(\n tr(\n \"conversations.confirmLeaveConversation\",\n \"Leave this conversation? You will lose access unless someone adds you again.\",\n ),\n );\n if (!confirmed) return;\n try {\n await leaveConversation(conversationId);\n if (currentUserId) {\n bus.emit(\"conversations.member.removed\", {\n conversationId,\n userId: currentUserId,\n });\n }\n setMoreMenuOpen(false);\n navigate(basePath || \"/conversations\");\n } catch (error) {\n console.error(\"[microfe-conversations] conversation leave failed\", error);\n nativeNotify(\"error\");\n }\n }, [\n basePath,\n bus,\n conversationId,\n currentUserId,\n leaveConversation,\n navigate,\n tr,\n ]);\n\n const handleOpenContext = useCallback(() => {\n if (!sourceContext?.contextHref) return;\n try {\n const url = new URL(sourceContext.contextHref);\n if (url.origin === window.location.origin) {\n navigate(`${url.pathname}${url.search}${url.hash}`);\n return;\n }\n } catch {\n // Fall through to full navigation for relative or invalid URLs.\n }\n\n window.location.assign(sourceContext.contextHref);\n }, [navigate, sourceContext?.contextHref]);\n\n // Concrete next actions surfaced on the (complex) detail page so a new user\n // isn't left staring at an unfamiliar surface. Each step is wired to a real\n // handler already present on this page; steps that don't apply to the\n // current conversation type/state are filtered out.\n const detailNextSteps = useMemo<NextStep[]>(() => {\n const steps: NextStep[] = [\n {\n id: \"send-message\",\n label: tr(\n \"conversations.nextSteps.send.label\",\n \"Send your first reply\",\n ),\n description: tr(\n \"conversations.nextSteps.send.description\",\n \"Jump to the composer and start the conversation.\",\n ),\n icon: <SendHorizontal size={14} aria-hidden=\"true\" />,\n onClick: focusComposer,\n done: displayedMessages.length > 0,\n },\n ];\n if (conversation?.isGroup) {\n steps.push({\n id: \"view-participants\",\n label: tr(\n \"conversations.nextSteps.participants.label\",\n \"Review participants\",\n ),\n description: tr(\n \"conversations.nextSteps.participants.description\",\n \"See who can read and post in this conversation.\",\n ),\n icon: <Users size={14} aria-hidden=\"true\" />,\n onClick: () => {\n if (!isParticipantsPanelOpen) toggleParticipantsPanel();\n },\n });\n }\n if (sourceContext?.contextHref) {\n steps.push({\n id: \"open-context\",\n label: tr(\n \"conversations.nextSteps.context.label\",\n \"Open the related item\",\n ),\n description: tr(\n \"conversations.nextSteps.context.description\",\n \"Jump to the page or record this conversation is about.\",\n ),\n icon: <ExternalLink size={14} aria-hidden=\"true\" />,\n onClick: handleOpenContext,\n });\n }\n steps.push({\n id: \"pin-conversation\",\n label: conversation?.isPinned\n ? tr(\"conversations.nextSteps.unpin.label\", \"Unpin this conversation\")\n : tr(\"conversations.nextSteps.pin.label\", \"Pin for quick access\"),\n description: tr(\n \"conversations.nextSteps.pin.description\",\n \"Keep important conversations at the top of your inbox.\",\n ),\n icon: <Pin size={14} aria-hidden=\"true\" />,\n onClick: () => void handlePin(),\n done: conversation?.isPinned,\n });\n return steps;\n }, [\n conversation?.isGroup,\n conversation?.isPinned,\n displayedMessages.length,\n focusComposer,\n handleOpenContext,\n handlePin,\n isParticipantsPanelOpen,\n sourceContext?.contextHref,\n toggleParticipantsPanel,\n tr,\n ]);\n\n if (!conversation && conversationLoading) {\n return (\n <div className=\"flex items-center justify-center h-[calc(100vh-64px)] text-sm text-text-secondary\">\n {tr(\"conversations.loading\", \"Loading conversation...\")}\n </div>\n );\n }\n\n if (!conversation || conversationError) {\n return (\n <div className=\"flex h-[calc(100vh-64px)] items-center justify-center p-8\">\n <IllustratedEmptyState\n illustration=\"empty-generic\"\n title={\n conversationError\n ? tr(\"conversations.unavailableTitle\", \"Conversation unavailable\")\n : tr(\"conversations.notFound\", \"Conversation not found\")\n }\n description={\n conversationError\n ? tr(\n \"conversations.unavailable\",\n \"We couldn't load this conversation. Please try again.\",\n )\n : tr(\n \"conversations.notFoundDescription\",\n \"This conversation may have been deleted, archived, or you no longer have access to it.\",\n )\n }\n action={\n <Button\n type=\"button\"\n onClick={() => navigate(basePath || \"/conversations\")}\n >\n {tr(\"conversations.back\", \"Back to conversations\")}\n </Button>\n }\n />\n </div>\n );\n }\n\n return (\n <div className=\"relative flex h-[calc(100vh-64px)] bg-bg-surface\">\n <div className=\"flex-1 flex flex-col min-w-0\">\n <header\n data-tour=\"conversation-detail-header\"\n className=\"flex items-center justify-between px-4 py-3 border-b border-border-seam bg-bg-surface/95 backdrop-blur-sm\"\n >\n <div className=\"flex items-center gap-3 min-w-0\">\n <button\n type=\"button\"\n onClick={() => navigate(basePath || \"/conversations\")}\n className=\"flex min-h-10 min-w-10 items-center justify-center rounded-lg text-text-secondary transition-colors hover:bg-bg-sunken/50 hover:text-text-primary\"\n >\n <ArrowLeft size={18} />\n </button>\n <div className=\"min-w-0\">\n <div className=\"flex items-center gap-1.5\">\n {conversation.type === \"CHANNEL\" && (\n <Hash\n size={14}\n className=\"text-text-secondary flex-shrink-0\"\n />\n )}\n <h2 className=\"text-sm font-semibold text-text-primary truncate\">\n {conversation.title}\n </h2>\n </div>\n <p className=\"text-xs text-text-secondary\">\n {conversation.type === \"DIRECT\"\n ? tr(\n \"conversations.directConversation\",\n \"Direct conversation\",\n )\n : conversation.type === \"CHANNEL\"\n ? `${tr(\"conversations.channel\", \"Channel\")} · ${conversation.memberCount} ${tr(\"conversations.membersCountLabel\", \"members\")}`\n : conversation.type === \"ANNOUNCEMENT\"\n ? `${tr(\"conversations.announcement\", \"Announcement\")} · ${conversation.memberCount} ${tr(\"conversations.membersCountLabel\", \"members\")}`\n : `${conversation.memberCount} ${tr(\"conversations.membersCountLabel\", \"members\")}`}\n </p>\n </div>\n </div>\n\n <div className=\"flex shrink-0 items-center gap-1\">\n {sourceContext?.contextHref && (\n <HeaderButton\n dataTour=\"conversation-open-context\"\n icon={<ExternalLink size={16} />}\n label={tr(\"conversations.openContext\", \"Open context\")}\n onClick={handleOpenContext}\n />\n )}\n {conversation.isGroup && (\n <HeaderButton\n dataTour=\"conversation-participants-button\"\n icon={<Users size={16} />}\n label={tr(\"conversations.participants\", \"Participants\")}\n active={isParticipantsPanelOpen}\n onClick={toggleParticipantsPanel}\n />\n )}\n <div className=\"relative\" ref={moreMenuRef}>\n <HeaderButton\n icon={<MoreVertical size={16} />}\n label={tr(\"conversations.moreOptions\", \"More options\")}\n active={moreMenuOpen}\n onClick={() => setMoreMenuOpen((prev) => !prev)}\n />\n {moreMenuOpen && (\n <div className=\"absolute right-0 top-full mt-1 w-48 bg-popover border border-border-subtle rounded-lg shadow-lg z-50 py-1 animate-in fade-in-0 zoom-in-95 duration-100\">\n <DropdownItem\n icon={\n conversation.isPinned ? (\n <PinOff size={14} />\n ) : (\n <Pin size={14} />\n )\n }\n label={\n conversation.isPinned\n ? tr(\n \"conversations.unpinConversation\",\n \"Unpin conversation\",\n )\n : tr(\n \"conversations.pinConversation\",\n \"Pin conversation\",\n )\n }\n onClick={() => void handlePin()}\n />\n <DropdownItem\n icon={\n conversation.isMuted ? (\n <Bell size={14} />\n ) : (\n <BellOff size={14} />\n )\n }\n label={\n conversation.isMuted\n ? tr(\n \"conversations.unmuteConversation\",\n \"Unmute conversation\",\n )\n : tr(\n \"conversations.muteConversation\",\n \"Mute conversation\",\n )\n }\n onClick={() => void handleMute()}\n />\n <div className=\"h-px bg-border my-1\" />\n <DropdownItem\n icon={<Archive size={14} />}\n label={tr(\n \"conversations.archiveConversation\",\n \"Archive conversation\",\n )}\n onClick={() => void handleArchive()}\n destructive\n />\n {conversation.isGroup && (\n <DropdownItem\n icon={<LogOut size={14} />}\n label={tr(\n \"conversations.leaveConversation\",\n \"Leave conversation\",\n )}\n onClick={() => void handleLeave()}\n destructive\n />\n )}\n </div>\n )}\n </div>\n </div>\n </header>\n\n <div className=\"flex-1 overflow-y-auto py-4 space-y-0.5\">\n {sourceContext?.pagePath && (\n <div className=\"mx-4 mb-4 rounded-lg border border-border-subtle bg-bg-sunken/20 px-3 py-2 text-xs text-text-secondary\">\n <span className=\"font-medium text-text-primary\">\n {sourceContext.contextTitle || sourceContext.pagePath}\n </span>\n {sourceContext.entityType && sourceContext.entityId\n ? ` · ${sourceContext.entityType} ${sourceContext.entityId}`\n : \"\"}\n </div>\n )}\n {messagesError && (\n <div className=\"mx-4 mb-4 rounded-lg border border-status-warning-border bg-status-warning-bg px-3 py-2 text-sm text-status-warning-text\">\n {tr(\n \"conversations.messagesUnavailable\",\n \"Messages couldn't be loaded right now. The conversation header and actions are still available.\",\n )}\n </div>\n )}\n {messagesLoading && (\n <div className=\"mx-4 mb-4 flex items-center gap-2 rounded-lg border border-border-subtle bg-bg-sunken/20 px-3 py-2 text-sm text-text-secondary\">\n <Loader2 size={14} className=\"animate-spin\" />\n {tr(\"conversations.loadingMessages\", \"Loading messages...\")}\n </div>\n )}\n {!messagesLoading &&\n !messagesError &&\n displayedMessages.length === 0 && (\n <div className=\"mx-4 mb-4 flex flex-col items-center gap-5 py-6\">\n <IllustratedEmptyState\n illustration=\"empty-vibes\"\n title={tr(\"conversations.noMessagesTitle\", \"No messages yet\")}\n description={tr(\n \"conversations.noMessagesYet\",\n \"Start the conversation below — say hello, ask a question, or share an update.\",\n )}\n action={\n <Button type=\"button\" onClick={focusComposer}>\n <SendHorizontal size={14} aria-hidden=\"true\" />\n {tr(\"conversations.writeFirstMessage\", \"Write a message\")}\n </Button>\n }\n />\n <NextSteps\n className=\"w-full max-w-md\"\n storageKey=\"conversations-detail\"\n title={tr(\n \"conversations.nextSteps.title\",\n \"Get the most out of this conversation\",\n )}\n steps={detailNextSteps}\n />\n </div>\n )}\n {displayedMessages.map((message, index) => {\n const prevMessage = displayedMessages[index - 1];\n const showAvatar =\n !prevMessage ||\n prevMessage.authorId !== message.authorId ||\n prevMessage.type === \"SYSTEM\";\n\n return (\n <div key={message.id}>\n {editingMessageId === message.id ? (\n <div className=\"px-4 py-2\">\n <div className=\"flex items-center gap-2 text-xs text-text-secondary mb-1\">\n <span>\n {tr(\"conversations.editingMessage\", \"Editing message\")}\n </span>\n <button\n type=\"button\"\n onClick={handleEditCancel}\n className=\"hover:text-text-primary\"\n >\n <X size={12} />\n </button>\n </div>\n <div className=\"flex items-end gap-2\">\n <textarea\n aria-label=\"Edit message\"\n value={editingContent}\n onChange={(e) => setEditingContent(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === \"Enter\" && !e.shiftKey) {\n e.preventDefault();\n void handleEditSubmit();\n }\n if (e.key === \"Escape\") handleEditCancel();\n }}\n className=\"flex-1 px-3 py-2 text-sm rounded-lg border border-border-subtle bg-bg-sunken/30 resize-none focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)]\"\n rows={2}\n />\n <button\n type=\"button\"\n onClick={() => void handleEditSubmit()}\n disabled={!editingContent.trim() || editingMessage}\n className=\"px-3 py-2 text-xs font-medium rounded-lg bg-action-primary-bg text-action-primary-text hover:bg-action-primary-bg/90 disabled:opacity-50\"\n >\n {tr(\"conversations.save\", \"Save\")}\n </button>\n </div>\n </div>\n ) : (\n <MessageBubble\n message={message}\n isOwnMessage={message.authorId === currentUserId}\n showAvatar={showAvatar}\n showSender={showAvatar}\n onReply={handleReply}\n onReact={(id, emoji) => void handleReact(id, emoji)}\n onEdit={handleEdit}\n onDelete={(id) => void handleDelete(id)}\n onTogglePin={(id, isPinned) =>\n void handleTogglePinMessage(id, isPinned)\n }\n />\n )}\n </div>\n );\n })}\n <div ref={messagesEndRef} />\n </div>\n\n <div\n data-tour=\"conversation-message-composer\"\n className=\"border-t border-border-seam px-4 py-3 bg-bg-surface\"\n >\n {/* Reply-to indicator */}\n {replyingTo && (\n <div className=\"flex items-center gap-2 mb-2 px-3 py-1.5 rounded-lg bg-bg-sunken/50 text-xs text-text-secondary\">\n <span>\n {tr(\"conversations.replyingTo\", \"Replying to\")}{\" \"}\n <strong className=\"text-text-primary\">\n {replyingTo.sender}\n </strong>\n : {replyingTo.content.slice(0, 80)}\n {replyingTo.content.length > 80 ? \"…\" : \"\"}\n </span>\n <button\n type=\"button\"\n onClick={() => setReplyingTo(null)}\n aria-label={tr(\"conversations.cancelReply\", \"Cancel reply\")}\n className=\"ml-auto flex min-h-9 min-w-9 items-center justify-center rounded-md hover:bg-bg-sunken/70 hover:text-text-primary\"\n >\n <X size={12} />\n </button>\n </div>\n )}\n <div className=\"flex items-end gap-2\">\n <div className=\"flex-1 relative\">\n <textarea\n aria-label={tr(\n \"conversations.messageComposer\",\n \"Message composer\",\n )}\n ref={composerRef}\n value={messageInput}\n onChange={(event) => setMessageInput(event.target.value)}\n onKeyDown={handleKeyDown}\n placeholder={`${tr(\"conversations.messageConversationPrefix\", \"Message\")} ${conversation.title}...`}\n rows={1}\n className={cn(\n \"w-full px-4 py-2.5 text-sm rounded-xl border border-border-subtle bg-bg-sunken/30\",\n \"resize-none overflow-hidden placeholder:text-text-secondary\",\n \"focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)] focus:border-transparent max-h-32\",\n )}\n style={{\n height: \"auto\",\n minHeight: \"40px\",\n }}\n onInput={(event) => {\n const target = event.target as HTMLTextAreaElement;\n target.style.height = \"auto\";\n target.style.height = `${Math.min(target.scrollHeight, 128)}px`;\n }}\n />\n </div>\n\n <button\n type=\"button\"\n aria-label={tr(\"conversations.sendMessage\", \"Send message\")}\n onClick={() => void handleSendMessage()}\n disabled={!messageInput.trim() || sendingMessage}\n className={cn(\n \"p-2.5 rounded-xl transition-all duration-150\",\n messageInput.trim() && !sendingMessage\n ? \"bg-action-primary-bg text-action-primary-text hover:bg-action-primary-bg/90 shadow-sm\"\n : \"bg-bg-sunken text-text-secondary cursor-not-allowed\",\n )}\n >\n {sendingMessage ? (\n <Loader2 size={18} className=\"animate-spin\" />\n ) : (\n <SendHorizontal size={18} />\n )}\n </button>\n </div>\n </div>\n </div>\n\n {isParticipantsPanelOpen && conversation.isGroup && (\n <aside className=\"absolute inset-y-0 right-0 z-20 w-full max-w-72 border-l border-border-seam bg-bg-surface shadow-lg animate-in slide-in-from-right-4 duration-200 md:relative md:z-auto md:w-64 md:shadow-none\">\n <div className=\"p-4\">\n <div className=\"flex items-center justify-between mb-4\">\n <h3 className=\"text-sm font-semibold text-text-primary\">\n {tr(\"conversations.members\", \"Members\")} (\n {conversation.memberCount ?? participants.length})\n </h3>\n <button\n type=\"button\"\n onClick={toggleParticipantsPanel}\n className=\"p-1 rounded text-text-secondary hover:text-text-primary\"\n >\n <Pin size={14} />\n </button>\n </div>\n\n <div className=\"space-y-1\">\n {participants.map((participant) => (\n <div\n key={participant.id}\n className=\"flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-bg-sunken/50\"\n >\n <div className=\"size-7 rounded-full bg-bg-sunken flex items-center justify-center text-xs font-medium text-text-secondary\">\n {participant.name\n .split(\" \")\n .map((word) => word[0])\n .join(\"\")\n .slice(0, 2)}\n </div>\n <div className=\"min-w-0\">\n <p className=\"text-xs font-medium text-text-primary truncate\">\n {participant.name}\n </p>\n <p className=\"text-[10px] text-text-secondary\">\n {participant.role}\n </p>\n </div>\n </div>\n ))}\n {hiddenParticipantsCount > 0 && (\n <p className=\"px-2 pt-2 text-xs text-text-secondary\">\n {tr(\"conversations.moreMembersPrefix\", \"and\")}{\" \"}\n {hiddenParticipantsCount}{\" \"}\n {tr(\"conversations.moreMembersSuffix\", \"more\")}\n </p>\n )}\n </div>\n </div>\n </aside>\n )}\n <QuotaExhaustedDialog\n open={showMessageQuotaExhausted}\n title={tr(\n \"conversations.quota.messages.title\",\n \"Message Quota Reached\",\n )}\n description={tr(\n \"conversations.quota.messages.description\",\n \"You've reached the message limit for your current plan. View billing to upgrade or add more capacity.\",\n )}\n onClose={() => setShowMessageQuotaExhausted(false)}\n onConfirm={() => {\n setShowMessageQuotaExhausted(false);\n navigate(\"/billing\");\n }}\n />\n </div>\n );\n}\n\nfunction DropdownItem({\n icon,\n label,\n onClick,\n destructive,\n}: {\n icon: React.ReactNode;\n label: string;\n onClick: () => void;\n destructive?: boolean;\n}) {\n return (\n <button\n type=\"button\"\n onClick={onClick}\n className={cn(\n \"flex items-center gap-2.5 w-full px-3 py-2 text-sm transition-colors text-left\",\n destructive\n ? \"text-status-error-text hover:bg-status-error-bg/10\"\n : \"text-text-primary hover:bg-bg-sunken/50\",\n )}\n >\n {icon}\n <span>{label}</span>\n </button>\n );\n}\n\nfunction HeaderButton({\n icon,\n label,\n active,\n dataTour,\n onClick,\n}: {\n icon: React.ReactNode;\n label: string;\n active?: boolean;\n dataTour?: string;\n onClick?: () => void;\n}) {\n return (\n <PermissionGuard\n permission={CONVERSATIONS_PERMISSIONS.CONVERSATION_READ}\n allowUnavailablePermissions\n >\n <button\n data-tour={dataTour}\n type=\"button\"\n onClick={onClick}\n aria-label={label}\n title={label}\n className={cn(\n \"flex min-h-10 min-w-10 items-center justify-center rounded-lg transition-colors\",\n active\n ? \"bg-action-primary-bg/10 text-primary\"\n : \"text-text-secondary hover:text-text-primary hover:bg-bg-sunken/50\",\n )}\n >\n {icon}\n </button>\n </PermissionGuard>\n );\n}\n"],"mappings":"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"}
|
|
@@ -9,7 +9,7 @@ import { CONVERSATIONS_PERMISSIONS as s } from "../constants/permissions.js";
|
|
|
9
9
|
import { ConversationRow as c } from "../components/ConversationRow.js";
|
|
10
10
|
import { useCallback as te, useDeferredValue as l, useEffect as u, useMemo as d, useState as f } from "react";
|
|
11
11
|
import { AlertCircle as p, Archive as m, Hash as h, MessageCircle as g, Pin as ne, Users as re } from "lucide-react";
|
|
12
|
-
import { useNavigate as ie, useSearchParams as ae } from "react-router
|
|
12
|
+
import { useNavigate as ie, useSearchParams as ae } from "react-router";
|
|
13
13
|
import { useEventBus as _ } from "@burdenoff/fe-libs/shared/events";
|
|
14
14
|
import { Fragment as v, jsx as y, jsxs as b } from "react/jsx-runtime";
|
|
15
15
|
import { EmptyState as x, IllustratedEmptyState as S } from "@burdenoff/fe-libs/ui";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ConversationListPage.js","names":[],"sources":["../../../src/conversations/pages/ConversationListPage.tsx"],"sourcesContent":["import {\n useDeferredValue,\n useEffect,\n useMemo,\n useState,\n useCallback,\n} from \"react\";\nimport { useEventBus } from \"@burdenoff/fe-libs/shared/events\";\nimport { useNavigate, useSearchParams } from \"react-router-dom\";\nimport { EmptyState, IllustratedEmptyState } from \"@burdenoff/fe-libs/ui\";\nimport {\n Archive,\n AlertCircle,\n Hash,\n MessageCircle,\n Pin,\n Users,\n} from \"lucide-react\";\nimport { ConversationRow } from \"../components/ConversationRow\";\nimport {\n useSearchConversations,\n useGetConversations,\n} from \"../hooks/useConversationQueries\";\nimport { useConversationStream } from \"../hooks/useMessageSubscriptions\";\nimport { useConversations } from \"../providers/ConversationsProvider\";\nimport { useConversationsStore } from \"../store/conversationsStore\";\nimport type {\n Conversation,\n ConversationFilter,\n ConversationState,\n} from \"../types\";\nimport { PermissionGuard } from \"../components/PermissionGuard\";\nimport { CONVERSATIONS_PERMISSIONS } from \"../constants/permissions\";\nimport { useTr } from \"../utils/i18n\";\nimport { joinPath } from \"../utils/navigation\";\n\n// Default filter shape applied whenever the `?filter=` URL param is absent\n// or unrecognised. Covers every dimension on `ConversationFilter` except\n// `searchQuery` — the text search is independent of URL-driven filters and\n// must not be wiped when the URL flips between filter values.\n//\n// The boolean toggles (hasUnread / isPinned / isMuted) are explicitly set\n// to `undefined` here (rather than omitted). Zustand's `set` merges\n// shallowly — the store's `updateConversationFilter` does\n// `{ ...currentFilter, ...partial }` — so an omitted key would preserve the\n// previous value, leaving a toggle like `hasUnread: true` sticky across a\n// URL that drops the filter param. Typing the constant as\n// `Partial<ConversationFilter>` widens the type so the explicit\n// `undefined` entries are accepted as `boolean | undefined` rather than\n// narrowed to the literal `undefined` under `as const`.\nconst FILTER_DEFAULTS: Partial<ConversationFilter> = {\n state: \"all\",\n type: \"all\",\n visibility: \"all\",\n hasUnread: undefined,\n isPinned: undefined,\n isMuted: undefined,\n};\n\nfunction useDebouncedValue<T>(value: T, delayMs: number): T {\n const [debouncedValue, setDebouncedValue] = useState(value);\n\n useEffect(() => {\n const timeoutId = window.setTimeout(\n () => setDebouncedValue(value),\n delayMs,\n );\n return () => window.clearTimeout(timeoutId);\n }, [delayMs, value]);\n\n return debouncedValue;\n}\n\nexport interface ConversationListPageProps {\n /**\n * Filter dimension forced by the parent inbox shell (driven by the\n * currently active tab). Mutually exclusive with the `?filter=` URL\n * param. When set, the URL param is ignored and the store filter is\n * driven by the prop on every change.\n */\n forcedFilterParam?: \"channel\" | \"direct\" | \"group\" | \"archived\";\n /**\n * When true the page renders bare-list chrome (no own header/search/\n * filter chips) so it can be embedded inside `InboxLayout`. Defaults to\n * false for backwards compatibility with the legacy `/conversations/list`\n * route.\n */\n embedded?: boolean;\n}\n\nexport function ConversationListPage({\n forcedFilterParam,\n embedded = false,\n}: ConversationListPageProps = {}) {\n const navigate = useNavigate();\n const { basePath, onConversationSelect } = useConversations();\n const bus = useEventBus();\n const tr = useTr();\n const {\n searchQuery,\n conversationFilter,\n updateConversationFilter,\n activeConversationId,\n setActiveConversationId,\n } = useConversationsStore();\n const [searchParams] = useSearchParams();\n const debouncedSearchQuery = useDebouncedValue(searchQuery, 250);\n const deferredSearchQuery = useDeferredValue(debouncedSearchQuery);\n const trimmedSearchQuery = deferredSearchQuery.trim();\n const isSearchActive = trimmedSearchQuery.length >= 2;\n\n // Sync URL filter param (?filter=channel|direct|group|archived) to the store\n // on mount / URL change. When the param is absent or unrecognised, reset\n // every filter dimension to its module-level default (`FILTER_DEFAULTS`) —\n // but deliberately do NOT touch `searchQuery`. The list's text search is\n // independent of URL-driven filters; zeroing it here would wipe in-progress\n // searches every time the URL flips between filter values or drops the\n // param entirely.\n useEffect(() => {\n const filterParam = forcedFilterParam ?? searchParams.get(\"filter\");\n\n if (!filterParam) {\n updateConversationFilter(FILTER_DEFAULTS);\n return;\n }\n\n const filterMap: Record<string, string> = {\n channel: \"CHANNEL\",\n direct: \"DIRECT\",\n group: \"GROUP\",\n archived: \"ARCHIVED\",\n };\n\n const mapped = filterMap[filterParam.toLowerCase()];\n if (!mapped) {\n updateConversationFilter(FILTER_DEFAULTS);\n return;\n }\n\n if (mapped === \"ARCHIVED\") {\n updateConversationFilter({ ...FILTER_DEFAULTS, state: \"ARCHIVED\" });\n } else {\n updateConversationFilter({\n ...FILTER_DEFAULTS,\n type: mapped as \"CHANNEL\" | \"DIRECT\" | \"GROUP\",\n });\n }\n }, [forcedFilterParam, searchParams, updateConversationFilter]);\n\n // Map filter.state (\"all\" | \"ACTIVE\" | \"ARCHIVED\" | \"DELETED\") to the\n // GraphQL `states: [ConversationState!]` filter. When the user is on the\n // Archived tab we send `states: [ARCHIVED]`; otherwise we omit the filter\n // so the backend default (ACTIVE only) kicks in.\n const stateFilter = useMemo<ConversationState[] | undefined>(() => {\n if (\n conversationFilter.state === \"all\" ||\n conversationFilter.state === \"ACTIVE\"\n ) {\n return undefined;\n }\n return [conversationFilter.state as ConversationState];\n }, [conversationFilter.state]);\n\n const {\n conversations: baseConversations,\n loading,\n error,\n } = useGetConversations({\n first: 50,\n types:\n conversationFilter.type === \"all\" ? undefined : [conversationFilter.type],\n states: stateFilter,\n hasUnread: conversationFilter.hasUnread,\n isPinned: conversationFilter.isPinned,\n });\n\n // Live conversation lifecycle stream — keeps the list current without polling.\n // Apollo cache updates are handled inside the hook (append on add, evict on\n // delete, normalisation handles updates).\n useConversationStream();\n\n const {\n conversations: searchedConversations,\n loading: searchLoading,\n error: searchError,\n } = useSearchConversations(deferredSearchQuery);\n\n const conversations = isSearchActive\n ? searchedConversations\n : baseConversations;\n\n // When the BE is queried with states:[ARCHIVED] the inbox tab is \"archived\",\n // so we skip the client-side state filter to avoid double-filtering.\n // For the inbox view the BE returns ACTIVE by default; no extra filter\n // needed here either.\n const filteredConversations = conversations;\n\n const pinnedConversations = useMemo(\n () => filteredConversations.filter((conversation) => conversation.isPinned),\n [filteredConversations],\n );\n\n const unpinnedConversations = useMemo(\n () =>\n filteredConversations.filter((conversation) => !conversation.isPinned),\n [filteredConversations],\n );\n\n const handleConversationClick = useCallback(\n (conversation: Conversation) => {\n setActiveConversationId(conversation.id);\n onConversationSelect?.(conversation);\n bus.emit(\"conversations.conversation.opened\", {\n conversationId: conversation.id,\n conversationType: conversation.type,\n });\n\n if (conversation.sourceContext?.contextHref) {\n try {\n const url = new URL(conversation.sourceContext.contextHref);\n if (url.origin === window.location.origin) {\n navigate(`${url.pathname}${url.search}${url.hash}`);\n return;\n }\n } catch {\n // Fall back to conversation detail below.\n }\n }\n\n navigate(joinPath(basePath || \"/conversations\", conversation.id));\n },\n [basePath, bus, navigate, onConversationSelect, setActiveConversationId],\n );\n\n const isLoading = loading || searchLoading;\n const activeError = error ?? searchError;\n const isArchivedTab =\n forcedFilterParam === \"archived\" || conversationFilter.state === \"ARCHIVED\";\n\n // Empty-state copy is tab-aware so users understand why a list is blank.\n const emptyState = useMemo(() => {\n if (isSearchActive) {\n return {\n title: tr(\"conversations.empty.search.title\", \"No matches\"),\n description: `${tr(\n \"conversations.empty.search.description\",\n \"No conversations matching\",\n )} \"${trimmedSearchQuery}\"`,\n icon: <MessageCircle size={28} className=\"text-text-secondary\" />,\n illustration: \"empty-search\",\n };\n }\n if (isArchivedTab) {\n return {\n title: tr(\n \"conversations.empty.archived.title\",\n \"No archived conversations\",\n ),\n description: tr(\n \"conversations.empty.archived.description\",\n \"Archived conversations are hidden from your main inbox. Move conversations here from the row actions menu.\",\n ),\n icon: <Archive size={28} className=\"text-text-secondary\" />,\n illustration: \"empty-files\",\n };\n }\n switch (forcedFilterParam) {\n case \"direct\":\n return {\n title: tr(\n \"conversations.empty.direct.title\",\n \"No direct conversations yet\",\n ),\n description: tr(\n \"conversations.empty.direct.description\",\n \"Start one with a workspace member from the New conversation button above.\",\n ),\n icon: <MessageCircle size={28} className=\"text-text-secondary\" />,\n illustration: \"empty-vibes\",\n };\n case \"group\":\n return {\n title: tr(\"conversations.empty.groups.title\", \"Create a group\"),\n description: tr(\n \"conversations.empty.groups.description\",\n \"Groups let you chat with multiple people in one thread. Tap New to start one.\",\n ),\n icon: <Users size={28} className=\"text-text-secondary\" />,\n illustration: \"empty-people\",\n };\n case \"channel\":\n return {\n title: tr(\n \"conversations.empty.channels.title\",\n \"Subscribe to a channel\",\n ),\n description: tr(\n \"conversations.empty.channels.description\",\n \"Channels are topic-based discussions visible to everyone who joins them.\",\n ),\n icon: <Hash size={28} className=\"text-text-secondary\" />,\n illustration: \"empty-vibes\",\n };\n default:\n return {\n title: tr(\"conversations.empty.all.title\", \"No conversations yet\"),\n description: tr(\n \"conversations.empty.all.description\",\n \"Start your first conversation with someone in this workspace.\",\n ),\n icon: <MessageCircle size={28} className=\"text-text-secondary\" />,\n illustration: \"empty-vibes\",\n };\n }\n }, [\n forcedFilterParam,\n isArchivedTab,\n isSearchActive,\n tr,\n trimmedSearchQuery,\n ]);\n\n // ─── Embedded mode: bare list, no own chrome (used inside InboxLayout) ───\n if (embedded) {\n return (\n <PermissionGuard\n permission={CONVERSATIONS_PERMISSIONS.CONVERSATION_READ}\n allowUnavailablePermissions\n >\n <div\n data-tour=\"conversation-list\"\n className=\"flex h-full min-h-0 w-full flex-col\"\n aria-live=\"polite\"\n >\n {isLoading ? (\n <div\n role=\"status\"\n className=\"px-4 py-8 text-sm text-text-secondary\"\n >\n {tr(\"conversations.loading\", \"Loading conversations...\")}\n </div>\n ) : activeError ? (\n <EmptyState\n icon={\n <AlertCircle\n size={28}\n className=\"text-status-error-text\"\n aria-hidden=\"true\"\n />\n }\n title={tr(\n \"conversations.couldNotLoad\",\n \"Could not load conversations\",\n )}\n description={activeError.message}\n />\n ) : filteredConversations.length === 0 ? (\n <IllustratedEmptyState\n illustration={emptyState.illustration}\n title={emptyState.title}\n description={emptyState.description}\n />\n ) : (\n <div\n data-tour=\"conversation-list-results\"\n className=\"flex flex-col gap-0.5 px-2 py-2\"\n >\n {pinnedConversations.length > 0 && (\n <>\n <div className=\"flex items-center gap-1.5 px-2 pt-2 pb-1\">\n <Pin\n size={11}\n className=\"text-text-secondary\"\n aria-hidden=\"true\"\n />\n <span className=\"text-[10px] font-semibold uppercase tracking-wider text-text-secondary\">\n {tr(\"conversations.pinned\", \"Pinned\")}\n </span>\n </div>\n {pinnedConversations.map((conversation) => (\n <ConversationRow\n key={conversation.id}\n conversation={conversation}\n isActive={activeConversationId === conversation.id}\n onClick={handleConversationClick}\n />\n ))}\n {unpinnedConversations.length > 0 && (\n <div className=\"flex items-center gap-1.5 px-2 pt-3 pb-1\">\n <span className=\"text-[10px] font-semibold uppercase tracking-wider text-text-secondary\">\n {tr(\"conversations.tabs.all\", \"All\")}\n </span>\n </div>\n )}\n </>\n )}\n {unpinnedConversations.map((conversation) => (\n <ConversationRow\n key={conversation.id}\n conversation={conversation}\n isActive={activeConversationId === conversation.id}\n onClick={handleConversationClick}\n />\n ))}\n </div>\n )}\n </div>\n </PermissionGuard>\n );\n }\n\n // ─── Standalone mode (legacy /conversations/list route) ───\n // Retains the original look but uses semantic tokens.\n return (\n <PermissionGuard\n permission={CONVERSATIONS_PERMISSIONS.CONVERSATION_READ}\n allowUnavailablePermissions\n >\n <div\n data-tour=\"conversation-list-standalone\"\n className=\"flex h-full min-h-0 w-full flex-col bg-bg-surface\"\n >\n {isLoading ? (\n <div className=\"px-4 py-8 text-sm text-text-secondary\">\n {tr(\"conversations.loading\", \"Loading conversations...\")}\n </div>\n ) : activeError ? (\n <EmptyState\n icon={\n <AlertCircle\n size={28}\n className=\"text-status-error-text\"\n aria-hidden=\"true\"\n />\n }\n title={tr(\n \"conversations.couldNotLoad\",\n \"Could not load conversations\",\n )}\n description={activeError.message}\n />\n ) : filteredConversations.length === 0 ? (\n <IllustratedEmptyState\n illustration={emptyState.illustration}\n title={emptyState.title}\n description={emptyState.description}\n />\n ) : (\n <div className=\"flex flex-col gap-0.5 px-2 py-2\">\n {filteredConversations.map((conversation) => (\n <ConversationRow\n key={conversation.id}\n conversation={conversation}\n isActive={activeConversationId === conversation.id}\n onClick={handleConversationClick}\n />\n ))}\n </div>\n )}\n </div>\n </PermissionGuard>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ConversationListPage.js","names":[],"sources":["../../../src/conversations/pages/ConversationListPage.tsx"],"sourcesContent":["import {\n useDeferredValue,\n useEffect,\n useMemo,\n useState,\n useCallback,\n} from \"react\";\nimport { useEventBus } from \"@burdenoff/fe-libs/shared/events\";\nimport { useNavigate, useSearchParams } from \"react-router\";\nimport { EmptyState, IllustratedEmptyState } from \"@burdenoff/fe-libs/ui\";\nimport {\n Archive,\n AlertCircle,\n Hash,\n MessageCircle,\n Pin,\n Users,\n} from \"lucide-react\";\nimport { ConversationRow } from \"../components/ConversationRow\";\nimport {\n useSearchConversations,\n useGetConversations,\n} from \"../hooks/useConversationQueries\";\nimport { useConversationStream } from \"../hooks/useMessageSubscriptions\";\nimport { useConversations } from \"../providers/ConversationsProvider\";\nimport { useConversationsStore } from \"../store/conversationsStore\";\nimport type {\n Conversation,\n ConversationFilter,\n ConversationState,\n} from \"../types\";\nimport { PermissionGuard } from \"../components/PermissionGuard\";\nimport { CONVERSATIONS_PERMISSIONS } from \"../constants/permissions\";\nimport { useTr } from \"../utils/i18n\";\nimport { joinPath } from \"../utils/navigation\";\n\n// Default filter shape applied whenever the `?filter=` URL param is absent\n// or unrecognised. Covers every dimension on `ConversationFilter` except\n// `searchQuery` — the text search is independent of URL-driven filters and\n// must not be wiped when the URL flips between filter values.\n//\n// The boolean toggles (hasUnread / isPinned / isMuted) are explicitly set\n// to `undefined` here (rather than omitted). Zustand's `set` merges\n// shallowly — the store's `updateConversationFilter` does\n// `{ ...currentFilter, ...partial }` — so an omitted key would preserve the\n// previous value, leaving a toggle like `hasUnread: true` sticky across a\n// URL that drops the filter param. Typing the constant as\n// `Partial<ConversationFilter>` widens the type so the explicit\n// `undefined` entries are accepted as `boolean | undefined` rather than\n// narrowed to the literal `undefined` under `as const`.\nconst FILTER_DEFAULTS: Partial<ConversationFilter> = {\n state: \"all\",\n type: \"all\",\n visibility: \"all\",\n hasUnread: undefined,\n isPinned: undefined,\n isMuted: undefined,\n};\n\nfunction useDebouncedValue<T>(value: T, delayMs: number): T {\n const [debouncedValue, setDebouncedValue] = useState(value);\n\n useEffect(() => {\n const timeoutId = window.setTimeout(\n () => setDebouncedValue(value),\n delayMs,\n );\n return () => window.clearTimeout(timeoutId);\n }, [delayMs, value]);\n\n return debouncedValue;\n}\n\nexport interface ConversationListPageProps {\n /**\n * Filter dimension forced by the parent inbox shell (driven by the\n * currently active tab). Mutually exclusive with the `?filter=` URL\n * param. When set, the URL param is ignored and the store filter is\n * driven by the prop on every change.\n */\n forcedFilterParam?: \"channel\" | \"direct\" | \"group\" | \"archived\";\n /**\n * When true the page renders bare-list chrome (no own header/search/\n * filter chips) so it can be embedded inside `InboxLayout`. Defaults to\n * false for backwards compatibility with the legacy `/conversations/list`\n * route.\n */\n embedded?: boolean;\n}\n\nexport function ConversationListPage({\n forcedFilterParam,\n embedded = false,\n}: ConversationListPageProps = {}) {\n const navigate = useNavigate();\n const { basePath, onConversationSelect } = useConversations();\n const bus = useEventBus();\n const tr = useTr();\n const {\n searchQuery,\n conversationFilter,\n updateConversationFilter,\n activeConversationId,\n setActiveConversationId,\n } = useConversationsStore();\n const [searchParams] = useSearchParams();\n const debouncedSearchQuery = useDebouncedValue(searchQuery, 250);\n const deferredSearchQuery = useDeferredValue(debouncedSearchQuery);\n const trimmedSearchQuery = deferredSearchQuery.trim();\n const isSearchActive = trimmedSearchQuery.length >= 2;\n\n // Sync URL filter param (?filter=channel|direct|group|archived) to the store\n // on mount / URL change. When the param is absent or unrecognised, reset\n // every filter dimension to its module-level default (`FILTER_DEFAULTS`) —\n // but deliberately do NOT touch `searchQuery`. The list's text search is\n // independent of URL-driven filters; zeroing it here would wipe in-progress\n // searches every time the URL flips between filter values or drops the\n // param entirely.\n useEffect(() => {\n const filterParam = forcedFilterParam ?? searchParams.get(\"filter\");\n\n if (!filterParam) {\n updateConversationFilter(FILTER_DEFAULTS);\n return;\n }\n\n const filterMap: Record<string, string> = {\n channel: \"CHANNEL\",\n direct: \"DIRECT\",\n group: \"GROUP\",\n archived: \"ARCHIVED\",\n };\n\n const mapped = filterMap[filterParam.toLowerCase()];\n if (!mapped) {\n updateConversationFilter(FILTER_DEFAULTS);\n return;\n }\n\n if (mapped === \"ARCHIVED\") {\n updateConversationFilter({ ...FILTER_DEFAULTS, state: \"ARCHIVED\" });\n } else {\n updateConversationFilter({\n ...FILTER_DEFAULTS,\n type: mapped as \"CHANNEL\" | \"DIRECT\" | \"GROUP\",\n });\n }\n }, [forcedFilterParam, searchParams, updateConversationFilter]);\n\n // Map filter.state (\"all\" | \"ACTIVE\" | \"ARCHIVED\" | \"DELETED\") to the\n // GraphQL `states: [ConversationState!]` filter. When the user is on the\n // Archived tab we send `states: [ARCHIVED]`; otherwise we omit the filter\n // so the backend default (ACTIVE only) kicks in.\n const stateFilter = useMemo<ConversationState[] | undefined>(() => {\n if (\n conversationFilter.state === \"all\" ||\n conversationFilter.state === \"ACTIVE\"\n ) {\n return undefined;\n }\n return [conversationFilter.state as ConversationState];\n }, [conversationFilter.state]);\n\n const {\n conversations: baseConversations,\n loading,\n error,\n } = useGetConversations({\n first: 50,\n types:\n conversationFilter.type === \"all\" ? undefined : [conversationFilter.type],\n states: stateFilter,\n hasUnread: conversationFilter.hasUnread,\n isPinned: conversationFilter.isPinned,\n });\n\n // Live conversation lifecycle stream — keeps the list current without polling.\n // Apollo cache updates are handled inside the hook (append on add, evict on\n // delete, normalisation handles updates).\n useConversationStream();\n\n const {\n conversations: searchedConversations,\n loading: searchLoading,\n error: searchError,\n } = useSearchConversations(deferredSearchQuery);\n\n const conversations = isSearchActive\n ? searchedConversations\n : baseConversations;\n\n // When the BE is queried with states:[ARCHIVED] the inbox tab is \"archived\",\n // so we skip the client-side state filter to avoid double-filtering.\n // For the inbox view the BE returns ACTIVE by default; no extra filter\n // needed here either.\n const filteredConversations = conversations;\n\n const pinnedConversations = useMemo(\n () => filteredConversations.filter((conversation) => conversation.isPinned),\n [filteredConversations],\n );\n\n const unpinnedConversations = useMemo(\n () =>\n filteredConversations.filter((conversation) => !conversation.isPinned),\n [filteredConversations],\n );\n\n const handleConversationClick = useCallback(\n (conversation: Conversation) => {\n setActiveConversationId(conversation.id);\n onConversationSelect?.(conversation);\n bus.emit(\"conversations.conversation.opened\", {\n conversationId: conversation.id,\n conversationType: conversation.type,\n });\n\n if (conversation.sourceContext?.contextHref) {\n try {\n const url = new URL(conversation.sourceContext.contextHref);\n if (url.origin === window.location.origin) {\n navigate(`${url.pathname}${url.search}${url.hash}`);\n return;\n }\n } catch {\n // Fall back to conversation detail below.\n }\n }\n\n navigate(joinPath(basePath || \"/conversations\", conversation.id));\n },\n [basePath, bus, navigate, onConversationSelect, setActiveConversationId],\n );\n\n const isLoading = loading || searchLoading;\n const activeError = error ?? searchError;\n const isArchivedTab =\n forcedFilterParam === \"archived\" || conversationFilter.state === \"ARCHIVED\";\n\n // Empty-state copy is tab-aware so users understand why a list is blank.\n const emptyState = useMemo(() => {\n if (isSearchActive) {\n return {\n title: tr(\"conversations.empty.search.title\", \"No matches\"),\n description: `${tr(\n \"conversations.empty.search.description\",\n \"No conversations matching\",\n )} \"${trimmedSearchQuery}\"`,\n icon: <MessageCircle size={28} className=\"text-text-secondary\" />,\n illustration: \"empty-search\",\n };\n }\n if (isArchivedTab) {\n return {\n title: tr(\n \"conversations.empty.archived.title\",\n \"No archived conversations\",\n ),\n description: tr(\n \"conversations.empty.archived.description\",\n \"Archived conversations are hidden from your main inbox. Move conversations here from the row actions menu.\",\n ),\n icon: <Archive size={28} className=\"text-text-secondary\" />,\n illustration: \"empty-files\",\n };\n }\n switch (forcedFilterParam) {\n case \"direct\":\n return {\n title: tr(\n \"conversations.empty.direct.title\",\n \"No direct conversations yet\",\n ),\n description: tr(\n \"conversations.empty.direct.description\",\n \"Start one with a workspace member from the New conversation button above.\",\n ),\n icon: <MessageCircle size={28} className=\"text-text-secondary\" />,\n illustration: \"empty-vibes\",\n };\n case \"group\":\n return {\n title: tr(\"conversations.empty.groups.title\", \"Create a group\"),\n description: tr(\n \"conversations.empty.groups.description\",\n \"Groups let you chat with multiple people in one thread. Tap New to start one.\",\n ),\n icon: <Users size={28} className=\"text-text-secondary\" />,\n illustration: \"empty-people\",\n };\n case \"channel\":\n return {\n title: tr(\n \"conversations.empty.channels.title\",\n \"Subscribe to a channel\",\n ),\n description: tr(\n \"conversations.empty.channels.description\",\n \"Channels are topic-based discussions visible to everyone who joins them.\",\n ),\n icon: <Hash size={28} className=\"text-text-secondary\" />,\n illustration: \"empty-vibes\",\n };\n default:\n return {\n title: tr(\"conversations.empty.all.title\", \"No conversations yet\"),\n description: tr(\n \"conversations.empty.all.description\",\n \"Start your first conversation with someone in this workspace.\",\n ),\n icon: <MessageCircle size={28} className=\"text-text-secondary\" />,\n illustration: \"empty-vibes\",\n };\n }\n }, [\n forcedFilterParam,\n isArchivedTab,\n isSearchActive,\n tr,\n trimmedSearchQuery,\n ]);\n\n // ─── Embedded mode: bare list, no own chrome (used inside InboxLayout) ───\n if (embedded) {\n return (\n <PermissionGuard\n permission={CONVERSATIONS_PERMISSIONS.CONVERSATION_READ}\n allowUnavailablePermissions\n >\n <div\n data-tour=\"conversation-list\"\n className=\"flex h-full min-h-0 w-full flex-col\"\n aria-live=\"polite\"\n >\n {isLoading ? (\n <div\n role=\"status\"\n className=\"px-4 py-8 text-sm text-text-secondary\"\n >\n {tr(\"conversations.loading\", \"Loading conversations...\")}\n </div>\n ) : activeError ? (\n <EmptyState\n icon={\n <AlertCircle\n size={28}\n className=\"text-status-error-text\"\n aria-hidden=\"true\"\n />\n }\n title={tr(\n \"conversations.couldNotLoad\",\n \"Could not load conversations\",\n )}\n description={activeError.message}\n />\n ) : filteredConversations.length === 0 ? (\n <IllustratedEmptyState\n illustration={emptyState.illustration}\n title={emptyState.title}\n description={emptyState.description}\n />\n ) : (\n <div\n data-tour=\"conversation-list-results\"\n className=\"flex flex-col gap-0.5 px-2 py-2\"\n >\n {pinnedConversations.length > 0 && (\n <>\n <div className=\"flex items-center gap-1.5 px-2 pt-2 pb-1\">\n <Pin\n size={11}\n className=\"text-text-secondary\"\n aria-hidden=\"true\"\n />\n <span className=\"text-[10px] font-semibold uppercase tracking-wider text-text-secondary\">\n {tr(\"conversations.pinned\", \"Pinned\")}\n </span>\n </div>\n {pinnedConversations.map((conversation) => (\n <ConversationRow\n key={conversation.id}\n conversation={conversation}\n isActive={activeConversationId === conversation.id}\n onClick={handleConversationClick}\n />\n ))}\n {unpinnedConversations.length > 0 && (\n <div className=\"flex items-center gap-1.5 px-2 pt-3 pb-1\">\n <span className=\"text-[10px] font-semibold uppercase tracking-wider text-text-secondary\">\n {tr(\"conversations.tabs.all\", \"All\")}\n </span>\n </div>\n )}\n </>\n )}\n {unpinnedConversations.map((conversation) => (\n <ConversationRow\n key={conversation.id}\n conversation={conversation}\n isActive={activeConversationId === conversation.id}\n onClick={handleConversationClick}\n />\n ))}\n </div>\n )}\n </div>\n </PermissionGuard>\n );\n }\n\n // ─── Standalone mode (legacy /conversations/list route) ───\n // Retains the original look but uses semantic tokens.\n return (\n <PermissionGuard\n permission={CONVERSATIONS_PERMISSIONS.CONVERSATION_READ}\n allowUnavailablePermissions\n >\n <div\n data-tour=\"conversation-list-standalone\"\n className=\"flex h-full min-h-0 w-full flex-col bg-bg-surface\"\n >\n {isLoading ? (\n <div className=\"px-4 py-8 text-sm text-text-secondary\">\n {tr(\"conversations.loading\", \"Loading conversations...\")}\n </div>\n ) : activeError ? (\n <EmptyState\n icon={\n <AlertCircle\n size={28}\n className=\"text-status-error-text\"\n aria-hidden=\"true\"\n />\n }\n title={tr(\n \"conversations.couldNotLoad\",\n \"Could not load conversations\",\n )}\n description={activeError.message}\n />\n ) : filteredConversations.length === 0 ? (\n <IllustratedEmptyState\n illustration={emptyState.illustration}\n title={emptyState.title}\n description={emptyState.description}\n />\n ) : (\n <div className=\"flex flex-col gap-0.5 px-2 py-2\">\n {filteredConversations.map((conversation) => (\n <ConversationRow\n key={conversation.id}\n conversation={conversation}\n isActive={activeConversationId === conversation.id}\n onClick={handleConversationClick}\n />\n ))}\n </div>\n )}\n </div>\n </PermissionGuard>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAkDA,IAAM,IAA+C;CACnD,OAAO;CACP,MAAM;CACN,YAAY;CACZ,WAAW,KAAA;CACX,UAAU,KAAA;CACV,SAAS,KAAA;CACV;AAED,SAAS,EAAqB,GAAU,GAAoB;CAC1D,IAAM,CAAC,GAAgB,KAAqB,EAAS,EAAM;AAU3D,QARA,QAAgB;EACd,IAAM,IAAY,OAAO,iBACjB,EAAkB,EAAM,EAC9B,EACD;AACD,eAAa,OAAO,aAAa,EAAU;IAC1C,CAAC,GAAS,EAAM,CAAC,EAEb;;AAoBT,SAAgB,EAAqB,EACnC,sBACA,cAAW,OACkB,EAAE,EAAE;CACjC,IAAM,IAAW,IAAa,EACxB,EAAE,aAAU,4BAAyB,GAAkB,EACvD,IAAM,GAAa,EACnB,IAAK,GAAO,EACZ,EACJ,iBACA,uBACA,6BACA,yBACA,+BACE,GAAuB,EACrB,CAAC,KAAgB,IAAiB,EAElC,IAAsB,EADC,EAAkB,IAAa,IAAI,CACE,EAC5D,IAAqB,EAAoB,MAAM,EAC/C,IAAiB,EAAmB,UAAU;AASpD,SAAgB;EACd,IAAM,IAAc,KAAqB,EAAa,IAAI,SAAS;AAEnE,MAAI,CAAC,GAAa;AAChB,KAAyB,EAAgB;AACzC;;EAUF,IAAM,IAPoC;GACxC,SAAS;GACT,QAAQ;GACR,OAAO;GACP,UAAU;GACX,CAEwB,EAAY,aAAa;AAClD,MAAI,CAAC,GAAQ;AACX,KAAyB,EAAgB;AACzC;;AAGF,EACE,EADE,MAAW,aACY;GAAE,GAAG;GAAiB,OAAO;GAAY,GAEzC;GACvB,GAAG;GACH,MAAM;GACP,CAAC;IAEH;EAAC;EAAmB;EAAc;EAAyB,CAAC;CAM/D,IAAM,IAAc,QAA+C;AAE/D,UAAmB,UAAU,SAC7B,EAAmB,UAAU,UAI/B,QAAO,CAAC,EAAmB,MAA2B;IACrD,CAAC,EAAmB,MAAM,CAAC,EAExB,EACJ,eAAe,GACf,YACA,aACE,EAAoB;EACtB,OAAO;EACP,OACE,EAAmB,SAAS,QAAQ,KAAA,IAAY,CAAC,EAAmB,KAAK;EAC3E,QAAQ;EACR,WAAW,EAAmB;EAC9B,UAAU,EAAmB;EAC9B,CAAC;AAKF,IAAuB;CAEvB,IAAM,EACJ,eAAe,GACf,SAAS,GACT,OAAO,MACL,GAAuB,EAAoB,EAUzC,IARgB,IAClB,IACA,GAQE,IAAsB,QACpB,EAAsB,QAAQ,MAAiB,EAAa,SAAS,EAC3E,CAAC,EAAsB,CACxB,EAEK,IAAwB,QAE1B,EAAsB,QAAQ,MAAiB,CAAC,EAAa,SAAS,EACxE,CAAC,EAAsB,CACxB,EAEK,IAA0B,IAC7B,MAA+B;AAQ9B,MAPA,EAAwB,EAAa,GAAG,EACxC,IAAuB,EAAa,EACpC,EAAI,KAAK,qCAAqC;GAC5C,gBAAgB,EAAa;GAC7B,kBAAkB,EAAa;GAChC,CAAC,EAEE,EAAa,eAAe,YAC9B,KAAI;GACF,IAAM,IAAM,IAAI,IAAI,EAAa,cAAc,YAAY;AAC3D,OAAI,EAAI,WAAW,OAAO,SAAS,QAAQ;AACzC,MAAS,GAAG,EAAI,WAAW,EAAI,SAAS,EAAI,OAAO;AACnD;;UAEI;AAKV,IAAS,EAAS,KAAY,kBAAkB,EAAa,GAAG,CAAC;IAEnE;EAAC;EAAU;EAAK;EAAU;EAAsB;EAAwB,CACzE,EAEK,IAAY,KAAW,GACvB,IAAc,KAAS,GACvB,IACJ,MAAsB,cAAc,EAAmB,UAAU,YAG7D,IAAa,QAAc;AAC/B,MAAI,EACF,QAAO;GACL,OAAO,EAAG,oCAAoC,aAAa;GAC3D,aAAa,GAAG,EACd,0CACA,4BACD,CAAC,IAAI,EAAmB;GACzB,MAAM,kBAAC,GAAD;IAAe,MAAM;IAAI,WAAU;IAAwB,CAAA;GACjE,cAAc;GACf;AAEH,MAAI,EACF,QAAO;GACL,OAAO,EACL,sCACA,4BACD;GACD,aAAa,EACX,4CACA,6GACD;GACD,MAAM,kBAAC,GAAD;IAAS,MAAM;IAAI,WAAU;IAAwB,CAAA;GAC3D,cAAc;GACf;AAEH,UAAQ,GAAR;GACE,KAAK,SACH,QAAO;IACL,OAAO,EACL,oCACA,8BACD;IACD,aAAa,EACX,0CACA,4EACD;IACD,MAAM,kBAAC,GAAD;KAAe,MAAM;KAAI,WAAU;KAAwB,CAAA;IACjE,cAAc;IACf;GACH,KAAK,QACH,QAAO;IACL,OAAO,EAAG,oCAAoC,iBAAiB;IAC/D,aAAa,EACX,0CACA,gFACD;IACD,MAAM,kBAAC,IAAD;KAAO,MAAM;KAAI,WAAU;KAAwB,CAAA;IACzD,cAAc;IACf;GACH,KAAK,UACH,QAAO;IACL,OAAO,EACL,sCACA,yBACD;IACD,aAAa,EACX,4CACA,2EACD;IACD,MAAM,kBAAC,GAAD;KAAM,MAAM;KAAI,WAAU;KAAwB,CAAA;IACxD,cAAc;IACf;GACH,QACE,QAAO;IACL,OAAO,EAAG,iCAAiC,uBAAuB;IAClE,aAAa,EACX,uCACA,gEACD;IACD,MAAM,kBAAC,GAAD;KAAe,MAAM;KAAI,WAAU;KAAwB,CAAA;IACjE,cAAc;IACf;;IAEJ;EACD;EACA;EACA;EACA;EACA;EACD,CAAC;AA6FF,QA1FI,IAEA,kBAAC,GAAD;EACE,YAAY,EAA0B;EACtC,6BAAA;YAEA,kBAAC,OAAD;GACE,aAAU;GACV,WAAU;GACV,aAAU;aAET,IACC,kBAAC,OAAD;IACE,MAAK;IACL,WAAU;cAET,EAAG,yBAAyB,2BAA2B;IACpD,CAAA,GACJ,IACF,kBAAC,GAAD;IACE,MACE,kBAAC,GAAD;KACE,MAAM;KACN,WAAU;KACV,eAAY;KACZ,CAAA;IAEJ,OAAO,EACL,8BACA,+BACD;IACD,aAAa,EAAY;IACzB,CAAA,GACA,EAAsB,WAAW,IACnC,kBAAC,GAAD;IACE,cAAc,EAAW;IACzB,OAAO,EAAW;IAClB,aAAa,EAAW;IACxB,CAAA,GAEF,kBAAC,OAAD;IACE,aAAU;IACV,WAAU;cAFZ,CAIG,EAAoB,SAAS,KAC5B,kBAAA,GAAA,EAAA,UAAA;KACE,kBAAC,OAAD;MAAK,WAAU;gBAAf,CACE,kBAAC,IAAD;OACE,MAAM;OACN,WAAU;OACV,eAAY;OACZ,CAAA,EACF,kBAAC,QAAD;OAAM,WAAU;iBACb,EAAG,wBAAwB,SAAS;OAChC,CAAA,CACH;;KACL,EAAoB,KAAK,MACxB,kBAAC,GAAD;MAEgB;MACd,UAAU,MAAyB,EAAa;MAChD,SAAS;MACT,EAJK,EAAa,GAIlB,CACF;KACD,EAAsB,SAAS,KAC9B,kBAAC,OAAD;MAAK,WAAU;gBACb,kBAAC,QAAD;OAAM,WAAU;iBACb,EAAG,0BAA0B,MAAM;OAC/B,CAAA;MACH,CAAA;KAEP,EAAA,CAAA,EAEJ,EAAsB,KAAK,MAC1B,kBAAC,GAAD;KAEgB;KACd,UAAU,MAAyB,EAAa;KAChD,SAAS;KACT,EAJK,EAAa,GAIlB,CACF,CACE;;GAEJ,CAAA;EACU,CAAA,GAOpB,kBAAC,GAAD;EACE,YAAY,EAA0B;EACtC,6BAAA;YAEA,kBAAC,OAAD;GACE,aAAU;GACV,WAAU;aAET,IACC,kBAAC,OAAD;IAAK,WAAU;cACZ,EAAG,yBAAyB,2BAA2B;IACpD,CAAA,GACJ,IACF,kBAAC,GAAD;IACE,MACE,kBAAC,GAAD;KACE,MAAM;KACN,WAAU;KACV,eAAY;KACZ,CAAA;IAEJ,OAAO,EACL,8BACA,+BACD;IACD,aAAa,EAAY;IACzB,CAAA,GACA,EAAsB,WAAW,IACnC,kBAAC,GAAD;IACE,cAAc,EAAW;IACzB,OAAO,EAAW;IAClB,aAAa,EAAW;IACxB,CAAA,GAEF,kBAAC,OAAD;IAAK,WAAU;cACZ,EAAsB,KAAK,MAC1B,kBAAC,GAAD;KAEgB;KACd,UAAU,MAAyB,EAAa;KAChD,SAAS;KACT,EAJK,EAAa,GAIlB,CACF;IACE,CAAA;GAEJ,CAAA;EACU,CAAA"}
|
|
@@ -10,7 +10,7 @@ import { NewConversationDialog as c } from "../components/NewConversationDialog.
|
|
|
10
10
|
import { useUserFeatureFlags as l } from "../hooks/useUserFeatureFlags.js";
|
|
11
11
|
import { Suspense as u, lazy as d, useCallback as f, useEffect as p, useMemo as m } from "react";
|
|
12
12
|
import { Filter as h, Loader2 as g, Pin as _ } from "lucide-react";
|
|
13
|
-
import { useNavigate as v, useParams as y, useSearchParams as b } from "react-router
|
|
13
|
+
import { useNavigate as v, useParams as y, useSearchParams as b } from "react-router";
|
|
14
14
|
import { jsx as x, jsxs as S } from "react/jsx-runtime";
|
|
15
15
|
import { PageContainer as C } from "@burdenoff/fe-libs/ui";
|
|
16
16
|
//#region src/conversations/pages/ConversationsLandingPage.tsx
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ConversationsLandingPage.js","names":[],"sources":["../../../src/conversations/pages/ConversationsLandingPage.tsx"],"sourcesContent":["import { Suspense, useCallback, useEffect, useMemo, lazy } from \"react\";\nimport { useNavigate, useParams, useSearchParams } from \"react-router-dom\";\nimport { PageContainer } from \"@burdenoff/fe-libs/ui\";\nimport { Pin, Filter, Loader2 } from \"lucide-react\";\nimport { ConversationListPage } from \"./ConversationListPage\";\nimport { PermissionGuard } from \"../components/PermissionGuard\";\nimport {\n InboxLayout,\n type FilterChipDescriptor,\n type InboxTabId,\n} from \"../components/InboxLayout\";\nimport { NewConversationDialog } from \"../components/NewConversationDialog\";\nimport { CONVERSATIONS_PERMISSIONS } from \"../constants/permissions\";\nimport { useUserFeatureFlags } from \"../hooks/useUserFeatureFlags\";\nimport { useConversationsStore } from \"../store/conversationsStore\";\nimport { useTr } from \"../utils/i18n\";\nimport { useConversations } from \"../providers/ConversationsProvider\";\nimport { joinPath } from \"../utils/navigation\";\n\n// Lazy-load the detail page so the inbox bundle stays small for users who\n// never open a conversation in this session. The detail subtree is the\n// heaviest by far (~19kB gz including MessageBubble).\nconst ConversationDetailPage = lazy(() =>\n import(\"./ConversationDetailPage\").then((m) => ({\n default: m.ConversationDetailPage,\n })),\n);\n\nconst DEFAULT_TAB_ID: InboxTabId = \"all\";\n\n// Maps the inbox tab id to the `forcedFilterParam` prop accepted by\n// ConversationListPage. The \"all\" tab passes undefined so the list shows\n// every conversation type. The archived tab triggers the BE state filter\n// (states: [ARCHIVED]) handled inside ConversationListPage.\nconst TAB_TO_LIST_FILTER: Record<\n InboxTabId,\n \"channel\" | \"direct\" | \"group\" | \"archived\" | undefined\n> = {\n all: undefined,\n direct: \"direct\",\n groups: \"group\",\n channels: \"channel\",\n archived: \"archived\",\n};\n\n/**\n * Landing page for `/conversations`.\n *\n * Renders the canonical inbox: two-pane shell with the conversation list on\n * the left and an empty detail panel on the right. The detail panel is\n * filled in by the router on `/conversations/:conversationId`; this page\n * itself only renders the chrome and the list.\n *\n * Tabs (All / Direct / Groups / Channels / Archived) are stored in the\n * `?tab=` URL search param so refreshing the page keeps the user on the\n * same view.\n */\nexport function ConversationsLandingPage() {\n const [params, setParams] = useSearchParams();\n const { conversationId } = useParams<{ conversationId?: string }>();\n const navigate = useNavigate();\n const { basePath } = useConversations();\n const tr = useTr();\n const { archivedEnabled } = useUserFeatureFlags();\n const {\n searchQuery,\n setSearchQuery,\n conversationFilter,\n updateConversationFilter,\n } = useConversationsStore();\n\n // `?new=true` opens the create-conversation dialog in-place. The dialog\n // is dismissed by either (a) the user closing it, in which case we drop\n // the param via setParams (replace), or (b) a successful create, in\n // which case we strip the param and navigate to the new detail route.\n const isNewDialogOpen = params.get(\"new\") === \"true\";\n\n const closeNewDialog = useCallback(() => {\n setParams(\n (prev) => {\n const next = new URLSearchParams(prev);\n next.delete(\"new\");\n return next;\n },\n { replace: true },\n );\n }, [setParams]);\n\n const handleNewDialogOpenChange = useCallback(\n (open: boolean) => {\n if (!open) closeNewDialog();\n },\n [closeNewDialog],\n );\n\n const handleNewDialogCreated = useCallback(\n (newConversationId: string) => {\n // The dialog closes itself before invoking onCreated; we just need\n // to route to the new conversation detail page (which lives under\n // the conversations basePath) without the lingering ?new=true.\n navigate(joinPath(basePath || \"/conversations\", newConversationId), {\n replace: true,\n });\n },\n [basePath, navigate],\n );\n\n const requestedTab = (params.get(\"tab\") ?? DEFAULT_TAB_ID) as InboxTabId;\n // Strip the archived tab when the feature flag is off — the side effect\n // below also lazily cleans the URL so a deep link doesn't leave the user\n // staring at an unknown tab.\n const activeTab: InboxTabId =\n requestedTab === \"archived\" && !archivedEnabled\n ? DEFAULT_TAB_ID\n : requestedTab;\n\n useEffect(() => {\n if (!archivedEnabled && requestedTab === \"archived\") {\n // Updater form reads the freshest params at flush time to preserve\n // concurrent query-param changes elsewhere in the tree.\n setParams(\n (prev) => {\n const next = new URLSearchParams(prev);\n next.delete(\"tab\");\n return next;\n },\n { replace: true },\n );\n }\n }, [archivedEnabled, requestedTab, setParams]);\n\n const handleTabChange = useCallback(\n (id: InboxTabId) => {\n setParams(\n (prev) => {\n const next = new URLSearchParams(prev);\n if (id === DEFAULT_TAB_ID) {\n next.delete(\"tab\");\n } else {\n next.set(\"tab\", id);\n }\n return next;\n },\n { replace: true },\n );\n },\n [setParams],\n );\n\n // Filter chips are only meaningful on tabs where they can hide items. On\n // the Archived tab the only sane secondary toggle is \"Pinned\" (unread\n // doesn't make sense for archived conversations); on the inbox tabs we\n // expose both unread + pinned.\n const filterChips = useMemo<FilterChipDescriptor[]>(() => {\n if (activeTab === \"archived\") return [];\n const unreadActive = conversationFilter.hasUnread === true;\n const pinnedActive = conversationFilter.isPinned === true;\n return [\n {\n id: \"unread\",\n label: tr(\"conversations.unread\", \"Unread\"),\n active: unreadActive,\n onToggle: () =>\n updateConversationFilter({\n hasUnread: unreadActive ? undefined : true,\n }),\n icon: <Filter size={11} aria-hidden=\"true\" />,\n },\n {\n id: \"pinned\",\n label: tr(\"conversations.pinned\", \"Pinned\"),\n active: pinnedActive,\n onToggle: () =>\n updateConversationFilter({\n isPinned: pinnedActive ? undefined : true,\n }),\n icon: <Pin size={11} aria-hidden=\"true\" />,\n },\n ];\n }, [\n activeTab,\n conversationFilter.hasUnread,\n conversationFilter.isPinned,\n tr,\n updateConversationFilter,\n ]);\n\n const forcedFilter = TAB_TO_LIST_FILTER[activeTab];\n\n return (\n <PermissionGuard\n permission={CONVERSATIONS_PERMISSIONS.CONVERSATION_READ}\n allowUnavailablePermissions\n >\n <PageContainer\n data-tour=\"conversations-landing\"\n padded={false}\n className=\"h-full min-h-0\"\n >\n <h1 id=\"conversations-page-heading\" className=\"sr-only\">\n {tr(\"conversations.title\", \"Conversations\")}\n </h1>\n <InboxLayout\n activeTab={activeTab}\n onTabChange={handleTabChange}\n archivedEnabled={archivedEnabled}\n searchQuery={searchQuery}\n onSearchChange={setSearchQuery}\n filterChips={filterChips}\n listSlot={\n <ConversationListPage embedded forcedFilterParam={forcedFilter} />\n }\n detailSlot={\n conversationId ? (\n <Suspense\n fallback={\n <div\n role=\"status\"\n aria-live=\"polite\"\n className=\"flex h-full items-center justify-center gap-2 text-sm text-text-secondary\"\n >\n <Loader2\n size={16}\n className=\"animate-spin\"\n aria-hidden=\"true\"\n />\n {tr(\"conversations.detailLoading\", \"Loading conversation…\")}\n </div>\n }\n >\n <ConversationDetailPage />\n </Suspense>\n ) : undefined\n }\n />\n <NewConversationDialog\n open={isNewDialogOpen}\n onOpenChange={handleNewDialogOpenChange}\n onCreated={handleNewDialogCreated}\n />\n </PageContainer>\n </PermissionGuard>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ConversationsLandingPage.js","names":[],"sources":["../../../src/conversations/pages/ConversationsLandingPage.tsx"],"sourcesContent":["import { Suspense, useCallback, useEffect, useMemo, lazy } from \"react\";\nimport { useNavigate, useParams, useSearchParams } from \"react-router\";\nimport { PageContainer } from \"@burdenoff/fe-libs/ui\";\nimport { Pin, Filter, Loader2 } from \"lucide-react\";\nimport { ConversationListPage } from \"./ConversationListPage\";\nimport { PermissionGuard } from \"../components/PermissionGuard\";\nimport {\n InboxLayout,\n type FilterChipDescriptor,\n type InboxTabId,\n} from \"../components/InboxLayout\";\nimport { NewConversationDialog } from \"../components/NewConversationDialog\";\nimport { CONVERSATIONS_PERMISSIONS } from \"../constants/permissions\";\nimport { useUserFeatureFlags } from \"../hooks/useUserFeatureFlags\";\nimport { useConversationsStore } from \"../store/conversationsStore\";\nimport { useTr } from \"../utils/i18n\";\nimport { useConversations } from \"../providers/ConversationsProvider\";\nimport { joinPath } from \"../utils/navigation\";\n\n// Lazy-load the detail page so the inbox bundle stays small for users who\n// never open a conversation in this session. The detail subtree is the\n// heaviest by far (~19kB gz including MessageBubble).\nconst ConversationDetailPage = lazy(() =>\n import(\"./ConversationDetailPage\").then((m) => ({\n default: m.ConversationDetailPage,\n })),\n);\n\nconst DEFAULT_TAB_ID: InboxTabId = \"all\";\n\n// Maps the inbox tab id to the `forcedFilterParam` prop accepted by\n// ConversationListPage. The \"all\" tab passes undefined so the list shows\n// every conversation type. The archived tab triggers the BE state filter\n// (states: [ARCHIVED]) handled inside ConversationListPage.\nconst TAB_TO_LIST_FILTER: Record<\n InboxTabId,\n \"channel\" | \"direct\" | \"group\" | \"archived\" | undefined\n> = {\n all: undefined,\n direct: \"direct\",\n groups: \"group\",\n channels: \"channel\",\n archived: \"archived\",\n};\n\n/**\n * Landing page for `/conversations`.\n *\n * Renders the canonical inbox: two-pane shell with the conversation list on\n * the left and an empty detail panel on the right. The detail panel is\n * filled in by the router on `/conversations/:conversationId`; this page\n * itself only renders the chrome and the list.\n *\n * Tabs (All / Direct / Groups / Channels / Archived) are stored in the\n * `?tab=` URL search param so refreshing the page keeps the user on the\n * same view.\n */\nexport function ConversationsLandingPage() {\n const [params, setParams] = useSearchParams();\n const { conversationId } = useParams<{ conversationId?: string }>();\n const navigate = useNavigate();\n const { basePath } = useConversations();\n const tr = useTr();\n const { archivedEnabled } = useUserFeatureFlags();\n const {\n searchQuery,\n setSearchQuery,\n conversationFilter,\n updateConversationFilter,\n } = useConversationsStore();\n\n // `?new=true` opens the create-conversation dialog in-place. The dialog\n // is dismissed by either (a) the user closing it, in which case we drop\n // the param via setParams (replace), or (b) a successful create, in\n // which case we strip the param and navigate to the new detail route.\n const isNewDialogOpen = params.get(\"new\") === \"true\";\n\n const closeNewDialog = useCallback(() => {\n setParams(\n (prev) => {\n const next = new URLSearchParams(prev);\n next.delete(\"new\");\n return next;\n },\n { replace: true },\n );\n }, [setParams]);\n\n const handleNewDialogOpenChange = useCallback(\n (open: boolean) => {\n if (!open) closeNewDialog();\n },\n [closeNewDialog],\n );\n\n const handleNewDialogCreated = useCallback(\n (newConversationId: string) => {\n // The dialog closes itself before invoking onCreated; we just need\n // to route to the new conversation detail page (which lives under\n // the conversations basePath) without the lingering ?new=true.\n navigate(joinPath(basePath || \"/conversations\", newConversationId), {\n replace: true,\n });\n },\n [basePath, navigate],\n );\n\n const requestedTab = (params.get(\"tab\") ?? DEFAULT_TAB_ID) as InboxTabId;\n // Strip the archived tab when the feature flag is off — the side effect\n // below also lazily cleans the URL so a deep link doesn't leave the user\n // staring at an unknown tab.\n const activeTab: InboxTabId =\n requestedTab === \"archived\" && !archivedEnabled\n ? DEFAULT_TAB_ID\n : requestedTab;\n\n useEffect(() => {\n if (!archivedEnabled && requestedTab === \"archived\") {\n // Updater form reads the freshest params at flush time to preserve\n // concurrent query-param changes elsewhere in the tree.\n setParams(\n (prev) => {\n const next = new URLSearchParams(prev);\n next.delete(\"tab\");\n return next;\n },\n { replace: true },\n );\n }\n }, [archivedEnabled, requestedTab, setParams]);\n\n const handleTabChange = useCallback(\n (id: InboxTabId) => {\n setParams(\n (prev) => {\n const next = new URLSearchParams(prev);\n if (id === DEFAULT_TAB_ID) {\n next.delete(\"tab\");\n } else {\n next.set(\"tab\", id);\n }\n return next;\n },\n { replace: true },\n );\n },\n [setParams],\n );\n\n // Filter chips are only meaningful on tabs where they can hide items. On\n // the Archived tab the only sane secondary toggle is \"Pinned\" (unread\n // doesn't make sense for archived conversations); on the inbox tabs we\n // expose both unread + pinned.\n const filterChips = useMemo<FilterChipDescriptor[]>(() => {\n if (activeTab === \"archived\") return [];\n const unreadActive = conversationFilter.hasUnread === true;\n const pinnedActive = conversationFilter.isPinned === true;\n return [\n {\n id: \"unread\",\n label: tr(\"conversations.unread\", \"Unread\"),\n active: unreadActive,\n onToggle: () =>\n updateConversationFilter({\n hasUnread: unreadActive ? undefined : true,\n }),\n icon: <Filter size={11} aria-hidden=\"true\" />,\n },\n {\n id: \"pinned\",\n label: tr(\"conversations.pinned\", \"Pinned\"),\n active: pinnedActive,\n onToggle: () =>\n updateConversationFilter({\n isPinned: pinnedActive ? undefined : true,\n }),\n icon: <Pin size={11} aria-hidden=\"true\" />,\n },\n ];\n }, [\n activeTab,\n conversationFilter.hasUnread,\n conversationFilter.isPinned,\n tr,\n updateConversationFilter,\n ]);\n\n const forcedFilter = TAB_TO_LIST_FILTER[activeTab];\n\n return (\n <PermissionGuard\n permission={CONVERSATIONS_PERMISSIONS.CONVERSATION_READ}\n allowUnavailablePermissions\n >\n <PageContainer\n data-tour=\"conversations-landing\"\n padded={false}\n className=\"h-full min-h-0\"\n >\n <h1 id=\"conversations-page-heading\" className=\"sr-only\">\n {tr(\"conversations.title\", \"Conversations\")}\n </h1>\n <InboxLayout\n activeTab={activeTab}\n onTabChange={handleTabChange}\n archivedEnabled={archivedEnabled}\n searchQuery={searchQuery}\n onSearchChange={setSearchQuery}\n filterChips={filterChips}\n listSlot={\n <ConversationListPage embedded forcedFilterParam={forcedFilter} />\n }\n detailSlot={\n conversationId ? (\n <Suspense\n fallback={\n <div\n role=\"status\"\n aria-live=\"polite\"\n className=\"flex h-full items-center justify-center gap-2 text-sm text-text-secondary\"\n >\n <Loader2\n size={16}\n className=\"animate-spin\"\n aria-hidden=\"true\"\n />\n {tr(\"conversations.detailLoading\", \"Loading conversation…\")}\n </div>\n }\n >\n <ConversationDetailPage />\n </Suspense>\n ) : undefined\n }\n />\n <NewConversationDialog\n open={isNewDialogOpen}\n onOpenChange={handleNewDialogOpenChange}\n onCreated={handleNewDialogCreated}\n />\n </PageContainer>\n </PermissionGuard>\n );\n}\n"],"mappings":"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"}
|
|
@@ -10,7 +10,7 @@ import { nativeImpact as l, nativeNotify as u } from "../../utils/nativeBridge.j
|
|
|
10
10
|
import { useExportConversation as d } from "../hooks/useExportConversation.js";
|
|
11
11
|
import { useEffect as f, useMemo as ee, useRef as p, useState as m } from "react";
|
|
12
12
|
import { ArrowLeft as h, Bell as g, Download as _, Eye as v, Save as y, Settings as b } from "lucide-react";
|
|
13
|
-
import { useNavigate as x } from "react-router
|
|
13
|
+
import { useNavigate as x } from "react-router";
|
|
14
14
|
import { Fragment as S, jsx as C, jsxs as w } from "react/jsx-runtime";
|
|
15
15
|
import { Button as T, GlassCard as E, PageContainer as D, PagePurpose as O } from "@burdenoff/fe-libs/ui";
|
|
16
16
|
import { Breadcrumbs as k } from "@burdenoff/fe-libs/chrome";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ConversationsSettingsPage.js","names":[],"sources":["../../../src/conversations/pages/ConversationsSettingsPage.tsx"],"sourcesContent":["import { useEffect, useMemo, useRef, useState } from \"react\";\nimport { useNavigate } from \"react-router-dom\";\nimport { ArrowLeft, Bell, Download, Eye, Save, Settings } from \"lucide-react\";\nimport { Breadcrumbs, type BreadcrumbItem } from \"@burdenoff/fe-libs/chrome\";\nimport {\n Button,\n GlassCard,\n PageContainer,\n PagePurpose,\n} from \"@burdenoff/fe-libs/ui\";\nimport { PageLayout, PageSection } from \"../components/PageLayout\";\nimport {\n useGetConversation,\n useUpdateConversation,\n} from \"../hooks/useConversationQueries\";\nimport { useExportConversation } from \"../hooks/useExportConversation\";\nimport { useConversations } from \"../providers/ConversationsProvider\";\nimport { useConversationsStore } from \"../store/conversationsStore\";\nimport { PermissionGuard } from \"../components/PermissionGuard\";\nimport { CONVERSATIONS_PERMISSIONS } from \"../constants/permissions\";\nimport { nativeImpact, nativeNotify } from \"../../utils/nativeBridge\";\nimport { useTr } from \"../utils/i18n\";\n\nexport function ConversationsSettingsPage() {\n const navigate = useNavigate();\n const { basePath } = useConversations();\n const { activeConversationId } = useConversationsStore();\n const tr = useTr();\n const { conversation, loading: conversationLoading } = useGetConversation(\n activeConversationId ?? \"\",\n );\n const {\n updateConversation,\n loading: saving,\n error: saveError,\n } = useUpdateConversation();\n\n const [showTypingIndicators, setShowTypingIndicators] = useState(true);\n const [showReadReceipts, setShowReadReceipts] = useState(true);\n const [defaultNotificationLevel, setDefaultNotificationLevel] =\n useState(\"ALL\");\n const [saved, setSaved] = useState(false);\n const downloadLinkRef = useRef<HTMLAnchorElement>(null);\n const {\n exportComments,\n loading: exporting,\n downloadUrl,\n } = useExportConversation();\n\n useEffect(() => {\n const settings =\n conversation?.settings && typeof conversation.settings === \"object\"\n ? (conversation.settings as Record<string, unknown>)\n : null;\n if (!settings) return;\n if (typeof settings.showTypingIndicators === \"boolean\") {\n setShowTypingIndicators(settings.showTypingIndicators);\n }\n if (typeof settings.showReadReceipts === \"boolean\") {\n setShowReadReceipts(settings.showReadReceipts);\n }\n if (typeof settings.defaultNotificationLevel === \"string\") {\n setDefaultNotificationLevel(settings.defaultNotificationLevel);\n }\n }, [conversation]);\n\n const handleSave = async () => {\n if (!conversation?.id) return;\n nativeImpact(\"light\");\n try {\n const updated = await updateConversation({\n id: conversation.id,\n settings: {\n showTypingIndicators,\n showReadReceipts,\n defaultNotificationLevel,\n },\n });\n const ok = Boolean(updated);\n nativeNotify(ok ? \"success\" : \"error\");\n setSaved(ok);\n } catch (error) {\n nativeNotify(\"error\");\n throw error;\n }\n };\n\n const inboxHref = basePath || \"/conversations\";\n const breadcrumbItems = useMemo<BreadcrumbItem[]>(\n () => [\n {\n label: tr(\"conversations.title\", \"Conversations\"),\n href: inboxHref,\n onClick: () => navigate(inboxHref),\n },\n {\n label: tr(\"conversations.settings\", \"Settings\"),\n isCurrentPage: true,\n },\n ],\n [inboxHref, navigate, tr],\n );\n\n return (\n <PageContainer padded={false} className=\"h-full min-h-0\">\n <div className=\"border-b border-border-subtle bg-bg-surface px-4 py-3 sm:px-6\">\n <Breadcrumbs items={breadcrumbItems} />\n </div>\n <PageLayout\n dataTour=\"conversation-settings-page\"\n title={tr(\"conversations.settings\", \"Settings\")}\n subtitle={\n conversation\n ? `${tr(\"conversations.settingsFor\", \"Conversation settings for\")} ${conversation.title}`\n : tr(\n \"conversations.selectConversationForSettings\",\n \"Select a conversation to edit settings\",\n )\n }\n icon={<Settings size={24} />}\n maxWidth=\"lg\"\n headerActions={\n <div className=\"flex items-center gap-2\">\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => navigate(inboxHref)}\n aria-label={tr(\n \"conversations.backToInbox\",\n \"Back to conversations\",\n )}\n >\n <ArrowLeft size={14} />\n {tr(\"conversations.back\", \"Back\")}\n </Button>\n <Button\n type=\"button\"\n onClick={() => void handleSave()}\n disabled={saving || !conversation}\n size=\"sm\"\n >\n <Save size={14} />\n {saving\n ? tr(\"conversations.saving\", \"Saving...\")\n : tr(\"conversations.saveChanges\", \"Save Changes\")}\n </Button>\n </div>\n }\n >\n {conversationLoading ? (\n <div className=\"text-sm text-text-secondary\">\n {tr(\"conversations.loadingSettings\", \"Loading settings...\")}\n </div>\n ) : !conversation ? (\n <div className=\"text-sm text-text-secondary\">\n {tr(\n \"conversations.openConversationFirst\",\n \"Open a conversation first, then come back to settings.\",\n )}\n </div>\n ) : (\n <>\n <PagePurpose icon={<Settings size={16} aria-hidden=\"true\" />}>\n {tr(\n \"conversations.settingsPurpose\",\n \"Fine-tune how this conversation behaves for you — choose how loudly it notifies you, whether typing and read receipts are shown, and export the full message history for records or compliance.\",\n )}\n </PagePurpose>\n\n <PageSection\n title={tr(\"conversations.notifications\", \"Notifications\")}\n description={tr(\n \"conversations.notificationsDescription\",\n \"Configure delivery behavior for this conversation\",\n )}\n >\n {/* The single emphasis zone on this page — notification level is\n the most consequential setting here. */}\n <GlassCard\n treatment=\"glass\"\n glow\n className=\"space-y-4 border-border-seam p-4\"\n >\n <div className=\"flex items-start justify-between gap-4\">\n <div className=\"flex items-start gap-3\">\n <div className=\"p-2 rounded-lg bg-status-info-bg-subtle text-status-info-text\">\n <Bell size={16} />\n </div>\n <div>\n <p className=\"text-sm font-medium text-text-primary\">\n {tr(\n \"conversations.defaultNotificationLevel\",\n \"Default Notification Level\",\n )}\n </p>\n <p className=\"text-xs text-text-secondary mt-0.5\">\n {tr(\n \"conversations.defaultNotificationLevelHint\",\n \"This updates conversation settings JSON via GraphQL.\",\n )}\n </p>\n </div>\n </div>\n <select\n value={defaultNotificationLevel}\n onChange={(event) =>\n setDefaultNotificationLevel(event.target.value)\n }\n aria-label={tr(\n \"conversations.defaultNotificationLevel\",\n \"Default Notification Level\",\n )}\n className=\"min-h-11 px-3 py-1.5 text-sm rounded-lg border border-border-seam bg-bg-surface focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)]\"\n >\n <option value=\"ALL\">\n {tr(\"conversations.allMessages\", \"All Messages\")}\n </option>\n <option value=\"MENTIONS\">\n {tr(\"conversations.mentionsOnly\", \"Mentions Only\")}\n </option>\n <option value=\"DIRECT\">\n {tr(\"conversations.directOnly\", \"Direct Only\")}\n </option>\n <option value=\"NONE\">\n {tr(\"conversations.none\", \"None\")}\n </option>\n </select>\n </div>\n </GlassCard>\n </PageSection>\n\n <PageSection\n title={tr(\"conversations.messageBehavior\", \"Message Behavior\")}\n description={tr(\n \"conversations.messageBehaviorDescription\",\n \"Tune read and typing experience\",\n )}\n >\n <GlassCard\n treatment=\"glass\"\n className=\"space-y-4 border-border-seam p-4\"\n >\n <ToggleSetting\n icon={<Eye size={16} />}\n label={tr(\n \"conversations.typingIndicators\",\n \"Typing Indicators\",\n )}\n description={tr(\n \"conversations.typingIndicatorsDescription\",\n \"Show others when someone is typing\",\n )}\n checked={showTypingIndicators}\n onChange={setShowTypingIndicators}\n />\n <ToggleSetting\n icon={<Eye size={16} />}\n label={tr(\"conversations.readReceipts\", \"Read Receipts\")}\n description={tr(\n \"conversations.readReceiptsDescription\",\n \"Display read status for conversation participants\",\n )}\n checked={showReadReceipts}\n onChange={setShowReadReceipts}\n />\n </GlassCard>\n </PageSection>\n\n <PageSection\n title={tr(\"conversations.exportData\", \"Export Data\")}\n description={tr(\n \"conversations.exportDataDescription\",\n \"Download conversation comments in your preferred format\",\n )}\n >\n <GlassCard\n treatment=\"glass\"\n className=\"space-y-3 border-border-seam p-4\"\n >\n <div className=\"flex flex-wrap gap-2\">\n {([\"JSON\", \"CSV\", \"MARKDOWN\"] as const).map((format) => (\n <PermissionGuard\n key={format}\n permission={CONVERSATIONS_PERMISSIONS.CONVERSATION_READ}\n >\n <button\n type=\"button\"\n disabled={exporting || !conversation}\n onClick={async () => {\n const url = await exportComments(\n \"conversation\",\n conversation!.id,\n format,\n );\n if (url && downloadLinkRef.current) {\n downloadLinkRef.current.href = url;\n downloadLinkRef.current.download = `conversation-${conversation!.id}.${format.toLowerCase()}`;\n downloadLinkRef.current.click();\n }\n }}\n className=\"inline-flex min-h-11 items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border-seam text-text-secondary hover:text-text-primary hover:bg-bg-sunken/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)] disabled:opacity-50\"\n >\n <Download size={12} />\n {exporting\n ? tr(\"conversations.exporting\", \"Exporting…\")\n : format}\n </button>\n </PermissionGuard>\n ))}\n </div>\n {downloadUrl && (\n <p className=\"text-xs text-status-success-text\">\n {tr(\n \"conversations.exportReady\",\n \"Export ready — downloading…\",\n )}\n </p>\n )}\n {/* Hidden anchor driven programmatically for download trigger.\n href is set imperatively before .click() — placeholder satisfies\n anchor-is-valid; tabIndex=-1 keeps it out of focus order. */}\n <a\n ref={downloadLinkRef}\n href=\"#download\"\n tabIndex={-1}\n className=\"hidden\"\n aria-hidden=\"true\"\n />\n </GlassCard>\n </PageSection>\n\n {saveError && (\n <div className=\"text-xs text-status-error-text rounded-lg border border-status-error-border/30 bg-status-error-bg/10 px-3 py-2\">\n {saveError.message}\n </div>\n )}\n {saved && !saveError && (\n <div className=\"text-xs text-status-success-text rounded-lg border border-border-default bg-status-success-bg-subtle px-3 py-2\">\n {tr(\"conversations.settingsSaved\", \"Settings saved.\")}\n </div>\n )}\n </>\n )}\n </PageLayout>\n </PageContainer>\n );\n}\n\nfunction ToggleSetting({\n icon,\n label,\n description,\n checked,\n onChange,\n}: {\n icon: React.ReactNode;\n label: string;\n description: string;\n checked: boolean;\n onChange: (value: boolean) => void;\n}) {\n return (\n <PermissionGuard permission={CONVERSATIONS_PERMISSIONS.CONVERSATION_UPDATE}>\n <div className=\"flex items-start justify-between gap-4\">\n <div className=\"flex items-start gap-3\">\n <div className=\"p-2 rounded-lg bg-bg-sunken text-text-secondary\">\n {icon}\n </div>\n <div>\n <p className=\"text-sm font-medium text-text-primary\">{label}</p>\n <p className=\"text-xs text-text-secondary mt-0.5\">{description}</p>\n </div>\n </div>\n <button\n type=\"button\"\n role=\"switch\"\n aria-checked={checked}\n aria-label={label}\n onClick={() => onChange(!checked)}\n className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${checked ? \"bg-action-primary-bg\" : \"bg-bg-sunken\"}`}\n >\n <span\n className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-bg-surface shadow transition ${checked ? \"translate-x-5\" : \"translate-x-0\"}`}\n />\n </button>\n </div>\n </PermissionGuard>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ConversationsSettingsPage.js","names":[],"sources":["../../../src/conversations/pages/ConversationsSettingsPage.tsx"],"sourcesContent":["import { useEffect, useMemo, useRef, useState } from \"react\";\nimport { useNavigate } from \"react-router\";\nimport { ArrowLeft, Bell, Download, Eye, Save, Settings } from \"lucide-react\";\nimport { Breadcrumbs, type BreadcrumbItem } from \"@burdenoff/fe-libs/chrome\";\nimport {\n Button,\n GlassCard,\n PageContainer,\n PagePurpose,\n} from \"@burdenoff/fe-libs/ui\";\nimport { PageLayout, PageSection } from \"../components/PageLayout\";\nimport {\n useGetConversation,\n useUpdateConversation,\n} from \"../hooks/useConversationQueries\";\nimport { useExportConversation } from \"../hooks/useExportConversation\";\nimport { useConversations } from \"../providers/ConversationsProvider\";\nimport { useConversationsStore } from \"../store/conversationsStore\";\nimport { PermissionGuard } from \"../components/PermissionGuard\";\nimport { CONVERSATIONS_PERMISSIONS } from \"../constants/permissions\";\nimport { nativeImpact, nativeNotify } from \"../../utils/nativeBridge\";\nimport { useTr } from \"../utils/i18n\";\n\nexport function ConversationsSettingsPage() {\n const navigate = useNavigate();\n const { basePath } = useConversations();\n const { activeConversationId } = useConversationsStore();\n const tr = useTr();\n const { conversation, loading: conversationLoading } = useGetConversation(\n activeConversationId ?? \"\",\n );\n const {\n updateConversation,\n loading: saving,\n error: saveError,\n } = useUpdateConversation();\n\n const [showTypingIndicators, setShowTypingIndicators] = useState(true);\n const [showReadReceipts, setShowReadReceipts] = useState(true);\n const [defaultNotificationLevel, setDefaultNotificationLevel] =\n useState(\"ALL\");\n const [saved, setSaved] = useState(false);\n const downloadLinkRef = useRef<HTMLAnchorElement>(null);\n const {\n exportComments,\n loading: exporting,\n downloadUrl,\n } = useExportConversation();\n\n useEffect(() => {\n const settings =\n conversation?.settings && typeof conversation.settings === \"object\"\n ? (conversation.settings as Record<string, unknown>)\n : null;\n if (!settings) return;\n if (typeof settings.showTypingIndicators === \"boolean\") {\n setShowTypingIndicators(settings.showTypingIndicators);\n }\n if (typeof settings.showReadReceipts === \"boolean\") {\n setShowReadReceipts(settings.showReadReceipts);\n }\n if (typeof settings.defaultNotificationLevel === \"string\") {\n setDefaultNotificationLevel(settings.defaultNotificationLevel);\n }\n }, [conversation]);\n\n const handleSave = async () => {\n if (!conversation?.id) return;\n nativeImpact(\"light\");\n try {\n const updated = await updateConversation({\n id: conversation.id,\n settings: {\n showTypingIndicators,\n showReadReceipts,\n defaultNotificationLevel,\n },\n });\n const ok = Boolean(updated);\n nativeNotify(ok ? \"success\" : \"error\");\n setSaved(ok);\n } catch (error) {\n nativeNotify(\"error\");\n throw error;\n }\n };\n\n const inboxHref = basePath || \"/conversations\";\n const breadcrumbItems = useMemo<BreadcrumbItem[]>(\n () => [\n {\n label: tr(\"conversations.title\", \"Conversations\"),\n href: inboxHref,\n onClick: () => navigate(inboxHref),\n },\n {\n label: tr(\"conversations.settings\", \"Settings\"),\n isCurrentPage: true,\n },\n ],\n [inboxHref, navigate, tr],\n );\n\n return (\n <PageContainer padded={false} className=\"h-full min-h-0\">\n <div className=\"border-b border-border-subtle bg-bg-surface px-4 py-3 sm:px-6\">\n <Breadcrumbs items={breadcrumbItems} />\n </div>\n <PageLayout\n dataTour=\"conversation-settings-page\"\n title={tr(\"conversations.settings\", \"Settings\")}\n subtitle={\n conversation\n ? `${tr(\"conversations.settingsFor\", \"Conversation settings for\")} ${conversation.title}`\n : tr(\n \"conversations.selectConversationForSettings\",\n \"Select a conversation to edit settings\",\n )\n }\n icon={<Settings size={24} />}\n maxWidth=\"lg\"\n headerActions={\n <div className=\"flex items-center gap-2\">\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => navigate(inboxHref)}\n aria-label={tr(\n \"conversations.backToInbox\",\n \"Back to conversations\",\n )}\n >\n <ArrowLeft size={14} />\n {tr(\"conversations.back\", \"Back\")}\n </Button>\n <Button\n type=\"button\"\n onClick={() => void handleSave()}\n disabled={saving || !conversation}\n size=\"sm\"\n >\n <Save size={14} />\n {saving\n ? tr(\"conversations.saving\", \"Saving...\")\n : tr(\"conversations.saveChanges\", \"Save Changes\")}\n </Button>\n </div>\n }\n >\n {conversationLoading ? (\n <div className=\"text-sm text-text-secondary\">\n {tr(\"conversations.loadingSettings\", \"Loading settings...\")}\n </div>\n ) : !conversation ? (\n <div className=\"text-sm text-text-secondary\">\n {tr(\n \"conversations.openConversationFirst\",\n \"Open a conversation first, then come back to settings.\",\n )}\n </div>\n ) : (\n <>\n <PagePurpose icon={<Settings size={16} aria-hidden=\"true\" />}>\n {tr(\n \"conversations.settingsPurpose\",\n \"Fine-tune how this conversation behaves for you — choose how loudly it notifies you, whether typing and read receipts are shown, and export the full message history for records or compliance.\",\n )}\n </PagePurpose>\n\n <PageSection\n title={tr(\"conversations.notifications\", \"Notifications\")}\n description={tr(\n \"conversations.notificationsDescription\",\n \"Configure delivery behavior for this conversation\",\n )}\n >\n {/* The single emphasis zone on this page — notification level is\n the most consequential setting here. */}\n <GlassCard\n treatment=\"glass\"\n glow\n className=\"space-y-4 border-border-seam p-4\"\n >\n <div className=\"flex items-start justify-between gap-4\">\n <div className=\"flex items-start gap-3\">\n <div className=\"p-2 rounded-lg bg-status-info-bg-subtle text-status-info-text\">\n <Bell size={16} />\n </div>\n <div>\n <p className=\"text-sm font-medium text-text-primary\">\n {tr(\n \"conversations.defaultNotificationLevel\",\n \"Default Notification Level\",\n )}\n </p>\n <p className=\"text-xs text-text-secondary mt-0.5\">\n {tr(\n \"conversations.defaultNotificationLevelHint\",\n \"This updates conversation settings JSON via GraphQL.\",\n )}\n </p>\n </div>\n </div>\n <select\n value={defaultNotificationLevel}\n onChange={(event) =>\n setDefaultNotificationLevel(event.target.value)\n }\n aria-label={tr(\n \"conversations.defaultNotificationLevel\",\n \"Default Notification Level\",\n )}\n className=\"min-h-11 px-3 py-1.5 text-sm rounded-lg border border-border-seam bg-bg-surface focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)]\"\n >\n <option value=\"ALL\">\n {tr(\"conversations.allMessages\", \"All Messages\")}\n </option>\n <option value=\"MENTIONS\">\n {tr(\"conversations.mentionsOnly\", \"Mentions Only\")}\n </option>\n <option value=\"DIRECT\">\n {tr(\"conversations.directOnly\", \"Direct Only\")}\n </option>\n <option value=\"NONE\">\n {tr(\"conversations.none\", \"None\")}\n </option>\n </select>\n </div>\n </GlassCard>\n </PageSection>\n\n <PageSection\n title={tr(\"conversations.messageBehavior\", \"Message Behavior\")}\n description={tr(\n \"conversations.messageBehaviorDescription\",\n \"Tune read and typing experience\",\n )}\n >\n <GlassCard\n treatment=\"glass\"\n className=\"space-y-4 border-border-seam p-4\"\n >\n <ToggleSetting\n icon={<Eye size={16} />}\n label={tr(\n \"conversations.typingIndicators\",\n \"Typing Indicators\",\n )}\n description={tr(\n \"conversations.typingIndicatorsDescription\",\n \"Show others when someone is typing\",\n )}\n checked={showTypingIndicators}\n onChange={setShowTypingIndicators}\n />\n <ToggleSetting\n icon={<Eye size={16} />}\n label={tr(\"conversations.readReceipts\", \"Read Receipts\")}\n description={tr(\n \"conversations.readReceiptsDescription\",\n \"Display read status for conversation participants\",\n )}\n checked={showReadReceipts}\n onChange={setShowReadReceipts}\n />\n </GlassCard>\n </PageSection>\n\n <PageSection\n title={tr(\"conversations.exportData\", \"Export Data\")}\n description={tr(\n \"conversations.exportDataDescription\",\n \"Download conversation comments in your preferred format\",\n )}\n >\n <GlassCard\n treatment=\"glass\"\n className=\"space-y-3 border-border-seam p-4\"\n >\n <div className=\"flex flex-wrap gap-2\">\n {([\"JSON\", \"CSV\", \"MARKDOWN\"] as const).map((format) => (\n <PermissionGuard\n key={format}\n permission={CONVERSATIONS_PERMISSIONS.CONVERSATION_READ}\n >\n <button\n type=\"button\"\n disabled={exporting || !conversation}\n onClick={async () => {\n const url = await exportComments(\n \"conversation\",\n conversation!.id,\n format,\n );\n if (url && downloadLinkRef.current) {\n downloadLinkRef.current.href = url;\n downloadLinkRef.current.download = `conversation-${conversation!.id}.${format.toLowerCase()}`;\n downloadLinkRef.current.click();\n }\n }}\n className=\"inline-flex min-h-11 items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border-seam text-text-secondary hover:text-text-primary hover:bg-bg-sunken/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)] disabled:opacity-50\"\n >\n <Download size={12} />\n {exporting\n ? tr(\"conversations.exporting\", \"Exporting…\")\n : format}\n </button>\n </PermissionGuard>\n ))}\n </div>\n {downloadUrl && (\n <p className=\"text-xs text-status-success-text\">\n {tr(\n \"conversations.exportReady\",\n \"Export ready — downloading…\",\n )}\n </p>\n )}\n {/* Hidden anchor driven programmatically for download trigger.\n href is set imperatively before .click() — placeholder satisfies\n anchor-is-valid; tabIndex=-1 keeps it out of focus order. */}\n <a\n ref={downloadLinkRef}\n href=\"#download\"\n tabIndex={-1}\n className=\"hidden\"\n aria-hidden=\"true\"\n />\n </GlassCard>\n </PageSection>\n\n {saveError && (\n <div className=\"text-xs text-status-error-text rounded-lg border border-status-error-border/30 bg-status-error-bg/10 px-3 py-2\">\n {saveError.message}\n </div>\n )}\n {saved && !saveError && (\n <div className=\"text-xs text-status-success-text rounded-lg border border-border-default bg-status-success-bg-subtle px-3 py-2\">\n {tr(\"conversations.settingsSaved\", \"Settings saved.\")}\n </div>\n )}\n </>\n )}\n </PageLayout>\n </PageContainer>\n );\n}\n\nfunction ToggleSetting({\n icon,\n label,\n description,\n checked,\n onChange,\n}: {\n icon: React.ReactNode;\n label: string;\n description: string;\n checked: boolean;\n onChange: (value: boolean) => void;\n}) {\n return (\n <PermissionGuard permission={CONVERSATIONS_PERMISSIONS.CONVERSATION_UPDATE}>\n <div className=\"flex items-start justify-between gap-4\">\n <div className=\"flex items-start gap-3\">\n <div className=\"p-2 rounded-lg bg-bg-sunken text-text-secondary\">\n {icon}\n </div>\n <div>\n <p className=\"text-sm font-medium text-text-primary\">{label}</p>\n <p className=\"text-xs text-text-secondary mt-0.5\">{description}</p>\n </div>\n </div>\n <button\n type=\"button\"\n role=\"switch\"\n aria-checked={checked}\n aria-label={label}\n onClick={() => onChange(!checked)}\n className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${checked ? \"bg-action-primary-bg\" : \"bg-bg-sunken\"}`}\n >\n <span\n className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-bg-surface shadow transition ${checked ? \"translate-x-5\" : \"translate-x-0\"}`}\n />\n </button>\n </div>\n </PermissionGuard>\n );\n}\n"],"mappings":"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"}
|
|
@@ -4,7 +4,7 @@ import { PermissionGuard as n } from "../components/PermissionGuard.js";
|
|
|
4
4
|
import { CONVERSATIONS_PERMISSIONS as r } from "../constants/permissions.js";
|
|
5
5
|
import { NewConversationDialog as i } from "../components/NewConversationDialog.js";
|
|
6
6
|
import { useCallback as a, useState as o } from "react";
|
|
7
|
-
import { useNavigate as s } from "react-router
|
|
7
|
+
import { useNavigate as s } from "react-router";
|
|
8
8
|
import { jsx as c } from "react/jsx-runtime";
|
|
9
9
|
//#region src/conversations/pages/NewConversationPage.tsx
|
|
10
10
|
function l() {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"NewConversationPage.js","names":[],"sources":["../../../src/conversations/pages/NewConversationPage.tsx"],"sourcesContent":["import { useCallback, useState } from \"react\";\nimport { useNavigate } from \"react-router
|
|
1
|
+
{"version":3,"file":"NewConversationPage.js","names":[],"sources":["../../../src/conversations/pages/NewConversationPage.tsx"],"sourcesContent":["import { useCallback, useState } from \"react\";\nimport { useNavigate } from \"react-router\";\nimport { PermissionGuard } from \"../components/PermissionGuard\";\nimport { CONVERSATIONS_PERMISSIONS } from \"../constants/permissions\";\nimport { NewConversationDialog } from \"../components/NewConversationDialog\";\nimport { useConversations } from \"../providers/ConversationsProvider\";\nimport { joinPath } from \"../utils/navigation\";\n\n/**\n * Legacy `/conversations/new` route.\n *\n * The canonical UX is now the in-place `<NewConversationDialog>` opened via\n * the `?new=true` URL param on the landing page — no full-page navigation.\n * This wrapper preserves the old route for any deep links that still exist\n * in the wild: it mounts the dialog as already-open and routes the user\n * back to the inbox (with `?new=true`) on close, so the dialog stays open\n * and they end up in the canonical surface.\n *\n * On successful create the dialog itself navigates to the new conversation\n * detail page via the `onCreated` callback.\n */\nexport function NewConversationPage() {\n const navigate = useNavigate();\n const { basePath } = useConversations();\n const root = basePath || \"/conversations\";\n const [dialogOpen, setDialogOpen] = useState(true);\n\n const handleOpenChange = useCallback(\n (open: boolean) => {\n if (open) {\n setDialogOpen(true);\n return;\n }\n // Closing the dialog from the legacy route lands the user on the\n // inbox; we keep `?new=true` so the dialog re-opens in-place there\n // — this mirrors what would have happened had they clicked the\n // primary CTA on the inbox itself.\n navigate(`${root}?new=true`, { replace: true });\n },\n [navigate, root],\n );\n\n const handleQuotaExhausted = useCallback(() => {\n setDialogOpen(false);\n }, []);\n\n const handleQuotaDialogClose = useCallback(() => {\n navigate(root, { replace: true });\n }, [navigate, root]);\n\n const handleCreated = useCallback(\n (conversationId: string) => {\n navigate(joinPath(root, conversationId));\n },\n [navigate, root],\n );\n\n return (\n <PermissionGuard\n permission={CONVERSATIONS_PERMISSIONS.CONVERSATION_CREATE}\n allowUnavailablePermissions\n >\n <NewConversationDialog\n open={dialogOpen}\n onOpenChange={handleOpenChange}\n onQuotaExhausted={handleQuotaExhausted}\n onQuotaDialogClose={handleQuotaDialogClose}\n onCreated={handleCreated}\n />\n </PermissionGuard>\n );\n}\n"],"mappings":";;;;;;;;;AAqBA,SAAgB,IAAsB;CACpC,IAAM,IAAW,GAAa,EACxB,EAAE,gBAAa,GAAkB,EACjC,IAAO,KAAY,kBACnB,CAAC,GAAY,KAAiB,EAAS,GAAK,EAE5C,IAAmB,GACtB,MAAkB;AACjB,MAAI,GAAM;AACR,KAAc,GAAK;AACnB;;AAMF,IAAS,GAAG,EAAK,YAAY,EAAE,SAAS,IAAM,CAAC;IAEjD,CAAC,GAAU,EAAK,CACjB,EAEK,IAAuB,QAAkB;AAC7C,IAAc,GAAM;IACnB,EAAE,CAAC,EAEA,IAAyB,QAAkB;AAC/C,IAAS,GAAM,EAAE,SAAS,IAAM,CAAC;IAChC,CAAC,GAAU,EAAK,CAAC,EAEd,IAAgB,GACnB,MAA2B;AAC1B,IAAS,EAAS,GAAM,EAAe,CAAC;IAE1C,CAAC,GAAU,EAAK,CACjB;AAED,QACE,kBAAC,GAAD;EACE,YAAY,EAA0B;EACtC,6BAAA;YAEA,kBAAC,GAAD;GACE,MAAM;GACN,cAAc;GACd,kBAAkB;GAClB,oBAAoB;GACpB,WAAW;GACX,CAAA;EACc,CAAA"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createContext as e, use as t, useMemo as n, useRef as r } from "react";
|
|
2
|
-
import { MemoryRouter as i } from "react-router
|
|
2
|
+
import { MemoryRouter as i } from "react-router";
|
|
3
3
|
import { MutationCache as a, QueryClient as o, QueryClientProvider as s } from "@tanstack/react-query";
|
|
4
4
|
import { ApolloClient as c, HttpLink as l, InMemoryCache as u, Observable as d, from as f } from "@apollo/client";
|
|
5
5
|
import { onError as p } from "@apollo/client/link/error";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ConversationsProvider.js","names":[],"sources":["../../../src/conversations/providers/ConversationsProvider.tsx"],"sourcesContent":["import type { FC, PropsWithChildren } from \"react\";\nimport { createContext, use, useMemo, useRef } from \"react\";\nimport { MemoryRouter } from \"react-router-dom\";\nimport {\n MutationCache,\n QueryClient,\n QueryClientProvider,\n} from \"@tanstack/react-query\";\nimport {\n ApolloClient,\n InMemoryCache,\n HttpLink,\n from,\n Observable,\n} from \"@apollo/client\";\nimport { onError } from \"@apollo/client/link/error\";\nimport { ApolloProvider, useApolloClient } from \"@apollo/client/react\";\nimport { PermissionProvider } from \"@burdenoff/fe-libs/shared/providers/shell\";\nimport {\n isAuthNetworkError,\n redirectToLogin,\n} from \"@burdenoff/fe-libs/shared/utils\";\nimport { useEvent } from \"@burdenoff/fe-libs/shared/events\";\nimport type { ConversationsRootProps } from \"../types\";\n\n/**\n * Clears Apollo cache on auth.signed_out so stale conversation data\n * is not retained across sessions. Must render inside ApolloProvider.\n */\nfunction AuthCacheClearer() {\n const client = useApolloClient();\n useEvent(\"auth.signed_out\", () => {\n void client.clearStore();\n });\n return null;\n}\n\nfunction hasSessionAuthErrors(\n errors: ReadonlyArray<{\n message?: string;\n extensions?: Record<string, unknown>;\n }> = [],\n): boolean {\n return errors.some((error) => {\n const code =\n typeof error.extensions?.code === \"string\" ? error.extensions.code : \"\";\n const errorCode =\n typeof error.extensions?.errorCode === \"string\"\n ? error.extensions.errorCode\n : \"\";\n const message = (error.message ?? \"\").toLowerCase();\n\n return (\n code === \"UNAUTHENTICATED\" ||\n errorCode === \"TOKEN_EXPIRED\" ||\n message.includes(\"token_expired\") ||\n message.includes(\"token expired\") ||\n message.includes(\"session expired\") ||\n message.includes(\"unauthorized\") ||\n message.includes(\"unauthenticated\") ||\n message.includes(\"authentication required\") ||\n message.includes(\"workspace context missing\")\n );\n });\n}\n\n/**\n * Context value for Conversations microfrontend\n */\ntype ConversationsContextValue = ConversationsRootProps;\n\nconst ConversationsContext = createContext<ConversationsContextValue | null>(\n null,\n);\n\n/**\n * Hook to access Conversations context\n */\nexport const useConversations = () => {\n const context = use(ConversationsContext);\n if (!context) {\n throw new Error(\n \"useConversations must be used within ConversationsProvider\",\n );\n }\n return context;\n};\n\n/**\n * Provider component for Conversations microfrontend\n * Sets up routing, context, Apollo Client, and React Query client\n */\nexport const ConversationsProvider: FC<\n PropsWithChildren<ConversationsRootProps>\n> = ({\n children,\n basePath,\n navigate,\n currentUser,\n workspaceId,\n apiGatewayUrl,\n authToken,\n workspaceToken,\n getWorkspaceToken,\n onConversationSelect,\n onMessageSend,\n defaultView,\n variant,\n}) => {\n const contextValue: ConversationsContextValue = {\n basePath,\n navigate,\n currentUser,\n workspaceId,\n apiGatewayUrl,\n authToken,\n workspaceToken,\n getWorkspaceToken,\n onConversationSelect,\n onMessageSend,\n defaultView,\n variant,\n };\n\n const isRefreshingRef = useRef(false);\n const getWorkspaceTokenRef = useRef(getWorkspaceToken);\n getWorkspaceTokenRef.current = getWorkspaceToken;\n // PermissionProvider requires the shell's AuthProvider — only mount it in integrated mode.\n const isIntegrated = !!basePath;\n\n // Create QueryClient instance (memoized to avoid recreation on re-renders)\n const queryClient = useMemo(() => {\n const client = new QueryClient({\n mutationCache: new MutationCache({\n onSuccess: () => {\n void client.invalidateQueries();\n },\n }),\n defaultOptions: {\n queries: {\n retry: 1,\n refetchOnWindowFocus: false,\n staleTime: 5 * 60 * 1000, // 5 minutes\n },\n },\n });\n return client;\n }, []);\n\n // Create ApolloClient instance (memoized, recreated when tokens change)\n // The workspace gateway requires dual-token auth:\n // Authorization: Bearer <authToken> (platform identity)\n // X-Workspace-Authorization: Bearer <workspaceToken> (workspace access)\n const apolloClient = useMemo(() => {\n if (isIntegrated) return null;\n\n const isLocalDirectMode =\n typeof window !== \"undefined\" &&\n (window.location.hostname === \"localhost\" ||\n window.location.hostname.endsWith(\".local.burdenoff.com\"));\n const workspaceEndpoint = isLocalDirectMode\n ? `${window.location.origin}/__local/conversations/graphql`\n : apiGatewayUrl\n ? `${apiGatewayUrl}/workspaces/graphql`\n : \"/workspaces/graphql\";\n\n const headers: Record<string, string> = {};\n if (authToken) {\n headers[\"Authorization\"] = `Bearer ${authToken}`;\n }\n if (workspaceToken) {\n headers[\"X-Workspace-Authorization\"] = `Bearer ${workspaceToken}`;\n }\n if (workspaceId) {\n headers[\"x-workspace-id\"] = workspaceId;\n }\n if (isLocalDirectMode && currentUser?.id) {\n headers[\"x-actor-id\"] = currentUser.id;\n headers[\"x-actor-type\"] = \"user\";\n }\n\n const httpLink = new HttpLink({\n uri: workspaceEndpoint,\n headers: Object.keys(headers).length > 0 ? headers : undefined,\n });\n\n const errorLink = onError(({ error, operation, forward }) => {\n if (!error) return;\n\n const errors =\n \"errors\" in error && Array.isArray(error.errors) ? error.errors : [];\n const hasAuthError = hasSessionAuthErrors(errors);\n\n if (\n !getWorkspaceTokenRef.current ||\n !hasAuthError ||\n isRefreshingRef.current\n ) {\n if (error instanceof Error && isAuthNetworkError(error)) {\n redirectToLogin();\n }\n return;\n }\n\n return new Observable((observer) => {\n isRefreshingRef.current = true;\n getWorkspaceTokenRef\n .current?.()\n .then((freshToken) => {\n isRefreshingRef.current = false;\n if (!freshToken) {\n redirectToLogin();\n observer.error(new Error(\"Failed to refresh workspace token\"));\n return;\n }\n\n operation.setContext(\n ({\n headers: existingHeaders = {},\n }: {\n headers?: Record<string, string>;\n }) => ({\n headers: {\n ...existingHeaders,\n \"X-Workspace-Authorization\": `Bearer ${freshToken}`,\n ...(workspaceId ? { \"x-workspace-id\": workspaceId } : {}),\n },\n }),\n );\n\n const subscriber = forward(operation).subscribe(observer);\n return () => subscriber.unsubscribe();\n })\n .catch((refreshError: unknown) => {\n isRefreshingRef.current = false;\n observer.error(refreshError);\n });\n });\n });\n\n return new ApolloClient({\n link: from([errorLink, httpLink]),\n cache: new InMemoryCache(),\n defaultOptions: {\n watchQuery: {\n fetchPolicy: \"cache-and-network\",\n },\n },\n });\n }, [\n apiGatewayUrl,\n authToken,\n currentUser?.id,\n isIntegrated,\n workspaceId,\n workspaceToken,\n ]);\n\n // Wrap content with QueryClientProvider and ConversationsContext.Provider\n const content = (\n <QueryClientProvider client={queryClient}>\n <ConversationsContext.Provider value={contextValue}>\n {isIntegrated ? (\n <PermissionProvider scopeId={workspaceId ?? null} gateway=\"workspace\">\n <AuthCacheClearer />\n {children}\n </PermissionProvider>\n ) : (\n <>\n <AuthCacheClearer />\n {children}\n </>\n )}\n </ConversationsContext.Provider>\n </QueryClientProvider>\n );\n\n // When integrated into a shell (basePath provided), the shell already has a router and ApolloProvider\n // so we don't create another one. When standalone (no basePath), use MemoryRouter and ApolloProvider.\n if (isIntegrated) {\n return content;\n }\n\n if (!apolloClient) {\n return null;\n }\n\n return (\n <ApolloProvider client={apolloClient}>\n <MemoryRouter>{content}</MemoryRouter>\n </ApolloProvider>\n );\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ConversationsProvider.js","names":[],"sources":["../../../src/conversations/providers/ConversationsProvider.tsx"],"sourcesContent":["import type { FC, PropsWithChildren } from \"react\";\nimport { createContext, use, useMemo, useRef } from \"react\";\nimport { MemoryRouter } from \"react-router\";\nimport {\n MutationCache,\n QueryClient,\n QueryClientProvider,\n} from \"@tanstack/react-query\";\nimport {\n ApolloClient,\n InMemoryCache,\n HttpLink,\n from,\n Observable,\n} from \"@apollo/client\";\nimport { onError } from \"@apollo/client/link/error\";\nimport { ApolloProvider, useApolloClient } from \"@apollo/client/react\";\nimport { PermissionProvider } from \"@burdenoff/fe-libs/shared/providers/shell\";\nimport {\n isAuthNetworkError,\n redirectToLogin,\n} from \"@burdenoff/fe-libs/shared/utils\";\nimport { useEvent } from \"@burdenoff/fe-libs/shared/events\";\nimport type { ConversationsRootProps } from \"../types\";\n\n/**\n * Clears Apollo cache on auth.signed_out so stale conversation data\n * is not retained across sessions. Must render inside ApolloProvider.\n */\nfunction AuthCacheClearer() {\n const client = useApolloClient();\n useEvent(\"auth.signed_out\", () => {\n void client.clearStore();\n });\n return null;\n}\n\nfunction hasSessionAuthErrors(\n errors: ReadonlyArray<{\n message?: string;\n extensions?: Record<string, unknown>;\n }> = [],\n): boolean {\n return errors.some((error) => {\n const code =\n typeof error.extensions?.code === \"string\" ? error.extensions.code : \"\";\n const errorCode =\n typeof error.extensions?.errorCode === \"string\"\n ? error.extensions.errorCode\n : \"\";\n const message = (error.message ?? \"\").toLowerCase();\n\n return (\n code === \"UNAUTHENTICATED\" ||\n errorCode === \"TOKEN_EXPIRED\" ||\n message.includes(\"token_expired\") ||\n message.includes(\"token expired\") ||\n message.includes(\"session expired\") ||\n message.includes(\"unauthorized\") ||\n message.includes(\"unauthenticated\") ||\n message.includes(\"authentication required\") ||\n message.includes(\"workspace context missing\")\n );\n });\n}\n\n/**\n * Context value for Conversations microfrontend\n */\ntype ConversationsContextValue = ConversationsRootProps;\n\nconst ConversationsContext = createContext<ConversationsContextValue | null>(\n null,\n);\n\n/**\n * Hook to access Conversations context\n */\nexport const useConversations = () => {\n const context = use(ConversationsContext);\n if (!context) {\n throw new Error(\n \"useConversations must be used within ConversationsProvider\",\n );\n }\n return context;\n};\n\n/**\n * Provider component for Conversations microfrontend\n * Sets up routing, context, Apollo Client, and React Query client\n */\nexport const ConversationsProvider: FC<\n PropsWithChildren<ConversationsRootProps>\n> = ({\n children,\n basePath,\n navigate,\n currentUser,\n workspaceId,\n apiGatewayUrl,\n authToken,\n workspaceToken,\n getWorkspaceToken,\n onConversationSelect,\n onMessageSend,\n defaultView,\n variant,\n}) => {\n const contextValue: ConversationsContextValue = {\n basePath,\n navigate,\n currentUser,\n workspaceId,\n apiGatewayUrl,\n authToken,\n workspaceToken,\n getWorkspaceToken,\n onConversationSelect,\n onMessageSend,\n defaultView,\n variant,\n };\n\n const isRefreshingRef = useRef(false);\n const getWorkspaceTokenRef = useRef(getWorkspaceToken);\n getWorkspaceTokenRef.current = getWorkspaceToken;\n // PermissionProvider requires the shell's AuthProvider — only mount it in integrated mode.\n const isIntegrated = !!basePath;\n\n // Create QueryClient instance (memoized to avoid recreation on re-renders)\n const queryClient = useMemo(() => {\n const client = new QueryClient({\n mutationCache: new MutationCache({\n onSuccess: () => {\n void client.invalidateQueries();\n },\n }),\n defaultOptions: {\n queries: {\n retry: 1,\n refetchOnWindowFocus: false,\n staleTime: 5 * 60 * 1000, // 5 minutes\n },\n },\n });\n return client;\n }, []);\n\n // Create ApolloClient instance (memoized, recreated when tokens change)\n // The workspace gateway requires dual-token auth:\n // Authorization: Bearer <authToken> (platform identity)\n // X-Workspace-Authorization: Bearer <workspaceToken> (workspace access)\n const apolloClient = useMemo(() => {\n if (isIntegrated) return null;\n\n const isLocalDirectMode =\n typeof window !== \"undefined\" &&\n (window.location.hostname === \"localhost\" ||\n window.location.hostname.endsWith(\".local.burdenoff.com\"));\n const workspaceEndpoint = isLocalDirectMode\n ? `${window.location.origin}/__local/conversations/graphql`\n : apiGatewayUrl\n ? `${apiGatewayUrl}/workspaces/graphql`\n : \"/workspaces/graphql\";\n\n const headers: Record<string, string> = {};\n if (authToken) {\n headers[\"Authorization\"] = `Bearer ${authToken}`;\n }\n if (workspaceToken) {\n headers[\"X-Workspace-Authorization\"] = `Bearer ${workspaceToken}`;\n }\n if (workspaceId) {\n headers[\"x-workspace-id\"] = workspaceId;\n }\n if (isLocalDirectMode && currentUser?.id) {\n headers[\"x-actor-id\"] = currentUser.id;\n headers[\"x-actor-type\"] = \"user\";\n }\n\n const httpLink = new HttpLink({\n uri: workspaceEndpoint,\n headers: Object.keys(headers).length > 0 ? headers : undefined,\n });\n\n const errorLink = onError(({ error, operation, forward }) => {\n if (!error) return;\n\n const errors =\n \"errors\" in error && Array.isArray(error.errors) ? error.errors : [];\n const hasAuthError = hasSessionAuthErrors(errors);\n\n if (\n !getWorkspaceTokenRef.current ||\n !hasAuthError ||\n isRefreshingRef.current\n ) {\n if (error instanceof Error && isAuthNetworkError(error)) {\n redirectToLogin();\n }\n return;\n }\n\n return new Observable((observer) => {\n isRefreshingRef.current = true;\n getWorkspaceTokenRef\n .current?.()\n .then((freshToken) => {\n isRefreshingRef.current = false;\n if (!freshToken) {\n redirectToLogin();\n observer.error(new Error(\"Failed to refresh workspace token\"));\n return;\n }\n\n operation.setContext(\n ({\n headers: existingHeaders = {},\n }: {\n headers?: Record<string, string>;\n }) => ({\n headers: {\n ...existingHeaders,\n \"X-Workspace-Authorization\": `Bearer ${freshToken}`,\n ...(workspaceId ? { \"x-workspace-id\": workspaceId } : {}),\n },\n }),\n );\n\n const subscriber = forward(operation).subscribe(observer);\n return () => subscriber.unsubscribe();\n })\n .catch((refreshError: unknown) => {\n isRefreshingRef.current = false;\n observer.error(refreshError);\n });\n });\n });\n\n return new ApolloClient({\n link: from([errorLink, httpLink]),\n cache: new InMemoryCache(),\n defaultOptions: {\n watchQuery: {\n fetchPolicy: \"cache-and-network\",\n },\n },\n });\n }, [\n apiGatewayUrl,\n authToken,\n currentUser?.id,\n isIntegrated,\n workspaceId,\n workspaceToken,\n ]);\n\n // Wrap content with QueryClientProvider and ConversationsContext.Provider\n const content = (\n <QueryClientProvider client={queryClient}>\n <ConversationsContext.Provider value={contextValue}>\n {isIntegrated ? (\n <PermissionProvider scopeId={workspaceId ?? null} gateway=\"workspace\">\n <AuthCacheClearer />\n {children}\n </PermissionProvider>\n ) : (\n <>\n <AuthCacheClearer />\n {children}\n </>\n )}\n </ConversationsContext.Provider>\n </QueryClientProvider>\n );\n\n // When integrated into a shell (basePath provided), the shell already has a router and ApolloProvider\n // so we don't create another one. When standalone (no basePath), use MemoryRouter and ApolloProvider.\n if (isIntegrated) {\n return content;\n }\n\n if (!apolloClient) {\n return null;\n }\n\n return (\n <ApolloProvider client={apolloClient}>\n <MemoryRouter>{content}</MemoryRouter>\n </ApolloProvider>\n );\n};\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@burdenoff/microfe-conversations",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.805.1",
|
|
4
4
|
"description": "Burdenoff Conversations Microfrontend - Complete messaging and conversation interface",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -43,14 +43,14 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@apollo/client": "^4.0.11",
|
|
46
|
-
"@burdenoff/fe-libs": "2026.
|
|
46
|
+
"@burdenoff/fe-libs": "2026.801.1",
|
|
47
47
|
"@tanstack/react-query": "^5.90.16",
|
|
48
48
|
"clsx": "^2.1.1",
|
|
49
49
|
"graphql": "^16.10.0",
|
|
50
50
|
"lucide-react": "^0.468.0",
|
|
51
51
|
"react": "^19.0.0",
|
|
52
52
|
"react-dom": "^19.0.0",
|
|
53
|
-
"react-router
|
|
53
|
+
"react-router": "^8.3.0",
|
|
54
54
|
"tailwind-merge": "^3.4.0",
|
|
55
55
|
"zustand": "^5.0.3"
|
|
56
56
|
},
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"zustand": "^5.0.0"
|
|
94
94
|
},
|
|
95
95
|
"publishConfig": {
|
|
96
|
-
"access": "
|
|
96
|
+
"access": "public"
|
|
97
97
|
},
|
|
98
98
|
"overrides": {
|
|
99
99
|
"lodash": ">=4.17.21",
|