@graph8/sdk 0.373.0 → 0.374.0
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/README.md +3 -3
- package/blocks/_foundation/README.md +47 -0
- package/blocks/_foundation/block.json +13 -0
- package/blocks/_foundation/components/auth-gate.tsx +363 -0
- package/blocks/_foundation/server/auth.ts +815 -0
- package/blocks/campaigns/README.md +1 -1
- package/blocks/campaigns/block.json +34 -1
- package/blocks/campaigns/components/campaign-links.tsx +180 -0
- package/blocks/company-intelligence/components/research-run.tsx +6 -2
- package/blocks/crm/README.md +19 -0
- package/blocks/crm/block.json +205 -6
- package/blocks/crm/components/bulk-update.tsx +119 -0
- package/blocks/crm/components/contact-documents-tab.tsx +144 -0
- package/blocks/crm/components/contact-insights-tab.tsx +118 -0
- package/blocks/crm/components/contact-lookup-link.tsx +47 -6
- package/blocks/crm/components/contact-row-actions.tsx +43 -0
- package/blocks/crm/components/contact-tag-chips.tsx +113 -0
- package/blocks/crm/components/contacts-screen.tsx +46 -5
- package/blocks/crm/components/owner-assign.tsx +132 -0
- package/blocks/crm/components/record-open-links.tsx +79 -0
- package/blocks/crm/components/tag-assign.tsx +112 -0
- package/blocks/crm/components/tag-manager.tsx +178 -0
- package/blocks/crm/components/unlock-info-button.tsx +82 -0
- package/blocks/crm/components/verify-email-button.tsx +93 -0
- package/blocks/crm/hooks/use-contact-tags.ts +29 -0
- package/blocks/crm/lib/record-actions.ts +102 -0
- package/blocks/deals/README.md +1 -0
- package/blocks/deals/block.json +17 -0
- package/blocks/deals/components/create-deal-button.tsx +109 -0
- package/blocks/deals/components/deal-detail.tsx +5 -3
- package/blocks/dialer/components/call-log-screen.tsx +6 -1
- package/blocks/documents/README.md +86 -0
- package/blocks/documents/block.json +378 -0
- package/blocks/documents/components/account-bar.tsx +48 -0
- package/blocks/documents/components/correspondence-form.tsx +95 -0
- package/blocks/documents/components/correspondence-import.tsx +106 -0
- package/blocks/documents/components/correspondence-panel.tsx +191 -0
- package/blocks/documents/components/documents-screen.tsx +76 -0
- package/blocks/documents/components/library-document-detail.tsx +175 -0
- package/blocks/documents/components/library-document-form.tsx +105 -0
- package/blocks/documents/components/library-folder-form.tsx +74 -0
- package/blocks/documents/components/library-panel.tsx +235 -0
- package/blocks/documents/components/library-upload.tsx +160 -0
- package/blocks/documents/components/question-row.tsx +130 -0
- package/blocks/documents/components/questionnaire-detail.tsx +197 -0
- package/blocks/documents/components/questionnaires-panel.tsx +173 -0
- package/blocks/documents/components/record-documents-tab.tsx +109 -0
- package/blocks/documents/lib/documents-types.ts +252 -0
- package/blocks/documents/lib/download.ts +64 -0
- package/blocks/documents/recipes/follow-a-questionnaire.ts +26 -0
- package/blocks/documents/recipes/log-account-correspondence.ts +27 -0
- package/blocks/documents/recipes/organize-the-library.ts +25 -0
- package/blocks/email-signatures/README.md +123 -0
- package/blocks/email-signatures/block.json +341 -0
- package/blocks/email-signatures/components/email-signatures-screen.tsx +166 -0
- package/blocks/email-signatures/components/signature-assign-template.tsx +119 -0
- package/blocks/email-signatures/components/signature-block-editor.tsx +87 -0
- package/blocks/email-signatures/components/signature-campaign-form.tsx +165 -0
- package/blocks/email-signatures/components/signature-campaigns.tsx +75 -0
- package/blocks/email-signatures/components/signature-identity-form.tsx +131 -0
- package/blocks/email-signatures/components/signature-mailboxes.tsx +195 -0
- package/blocks/email-signatures/components/signature-policy.tsx +130 -0
- package/blocks/email-signatures/components/signature-preview.tsx +119 -0
- package/blocks/email-signatures/components/signature-report.tsx +77 -0
- package/blocks/email-signatures/components/signature-template-details.tsx +112 -0
- package/blocks/email-signatures/components/signature-template-form.tsx +109 -0
- package/blocks/email-signatures/hooks/use-signatures-data.ts +93 -0
- package/blocks/email-signatures/lib/signatures-types.ts +392 -0
- package/blocks/email-signatures/recipes/assign-a-signature-to-a-new-hire.ts +32 -0
- package/blocks/email-signatures/recipes/create-a-template-and-make-it-default.ts +35 -0
- package/blocks/email-signatures/recipes/signature-engagement-report.ts +16 -0
- package/blocks/enrichment/README.md +22 -4
- package/blocks/enrichment/block.json +145 -5
- package/blocks/enrichment/components/auto-enrichment-configs.tsx +134 -0
- package/blocks/enrichment/components/auto-enrichment-settings.tsx +82 -0
- package/blocks/enrichment/components/bulk-enrich-panel.tsx +7 -7
- package/blocks/enrichment/components/enrichment-activity.tsx +93 -0
- package/blocks/enrichment/components/enrichment-job-detail.tsx +18 -0
- package/blocks/enrichment/components/enrichment-screen.tsx +34 -3
- package/blocks/enrichment/components/list-pipelines.tsx +97 -0
- package/blocks/enrichment/components/pipeline-detail.tsx +214 -0
- package/blocks/enrichment/components/pipeline-editor.tsx +144 -0
- package/blocks/enrichment/components/pipeline-templates.tsx +90 -0
- package/blocks/enrichment/lib/enrichment-types.ts +12 -0
- package/blocks/enrichment/lib/pipeline-types.ts +234 -0
- package/blocks/forms/README.md +2 -0
- package/blocks/forms/components/forms-screen.tsx +6 -3
- package/blocks/inbox/README.md +40 -4
- package/blocks/inbox/block.json +130 -8
- package/blocks/inbox/components/blocked-senders.tsx +103 -0
- package/blocks/inbox/components/compose-message.tsx +193 -0
- package/blocks/inbox/components/inbox-screen.tsx +54 -6
- package/blocks/inbox/components/inbox-search.tsx +106 -0
- package/blocks/inbox/components/tag-manager.tsx +136 -0
- package/blocks/inbox/components/thread-actions.tsx +133 -0
- package/blocks/inbox/components/thread-view.tsx +2 -0
- package/blocks/inbox/hooks/use-inbox-mailboxes.ts +31 -0
- package/blocks/inbox/lib/inbox-manage.ts +227 -0
- package/blocks/newsletter/components/newsletter-screen.tsx +3 -1
- package/blocks/pages/block.json +10 -1
- package/blocks/pages/hooks/use-landing-page-paths.ts +60 -0
- package/blocks/programmatic-pages/README.md +82 -0
- package/blocks/programmatic-pages/block.json +292 -0
- package/blocks/programmatic-pages/components/programmatic-campaign-form.tsx +136 -0
- package/blocks/programmatic-pages/components/programmatic-generate.tsx +112 -0
- package/blocks/programmatic-pages/components/programmatic-generated-pages.tsx +210 -0
- package/blocks/programmatic-pages/components/programmatic-overview.tsx +84 -0
- package/blocks/programmatic-pages/components/programmatic-pages-screen.tsx +221 -0
- package/blocks/programmatic-pages/components/programmatic-preview.tsx +66 -0
- package/blocks/programmatic-pages/components/programmatic-publish.tsx +102 -0
- package/blocks/programmatic-pages/components/programmatic-settings.tsx +96 -0
- package/blocks/programmatic-pages/components/programmatic-source-fields.tsx +75 -0
- package/blocks/programmatic-pages/components/programmatic-source.tsx +106 -0
- package/blocks/programmatic-pages/components/programmatic-variables.tsx +84 -0
- package/blocks/programmatic-pages/hooks/use-programmatic-workspace.ts +19 -0
- package/blocks/programmatic-pages/lib/programmatic-types.ts +330 -0
- package/blocks/programmatic-pages/recipes/create-a-programmatic-campaign.ts +23 -0
- package/blocks/programmatic-pages/recipes/render-and-follow.ts +21 -0
- package/blocks/programmatic-pages/recipes/review-then-publish.ts +17 -0
- package/blocks/quotes/README.md +25 -1
- package/blocks/quotes/block.json +123 -6
- package/blocks/quotes/components/manual-products.tsx +144 -0
- package/blocks/quotes/components/quote-batch-actions.tsx +86 -0
- package/blocks/quotes/components/quote-documents.tsx +94 -0
- package/blocks/quotes/components/quote-evidence.tsx +95 -0
- package/blocks/quotes/components/quote-list.tsx +23 -5
- package/blocks/quotes/components/quote-settings.tsx +184 -0
- package/blocks/quotes/components/quote-templates.tsx +189 -0
- package/blocks/quotes/components/quote-viewer.tsx +16 -3
- package/blocks/quotes/components/quotes-screen.tsx +15 -5
- package/blocks/quotes/lib/quote-admin.ts +264 -0
- package/blocks/sequencer/README.md +55 -0
- package/blocks/sequencer/block.json +172 -5
- package/blocks/sequencer/components/enrollment-withdraw.tsx +71 -0
- package/blocks/sequencer/components/sequence-builder.tsx +13 -3
- package/blocks/sequencer/components/sequence-campaign-link.tsx +78 -0
- package/blocks/sequencer/components/sequence-detail.tsx +6 -0
- package/blocks/sequencer/components/sequence-edit-form.tsx +111 -0
- package/blocks/sequencer/components/sequence-enrollments.tsx +2 -1
- package/blocks/sequencer/components/sequence-manage-actions.tsx +119 -0
- package/blocks/sequencer/components/sequence-senders-panel.tsx +84 -0
- package/blocks/sequencer/components/sequence-templates.tsx +160 -0
- package/blocks/sequencer/components/sequences-screen.tsx +36 -6
- package/blocks/sequencer/components/step-ai-assist.tsx +196 -0
- package/blocks/sequencer/components/step-editor.tsx +10 -1
- package/blocks/sequencer/components/step-templates.tsx +224 -0
- package/blocks/sequencer/lib/sequencer-manage.ts +235 -0
- package/blocks/sequencer/lib/sequencer-utils.ts +4 -0
- package/blocks/signals/README.md +39 -0
- package/blocks/signals/block.json +287 -0
- package/blocks/signals/components/auto-seed-panel.tsx +111 -0
- package/blocks/signals/components/listener-form.tsx +184 -0
- package/blocks/signals/components/matched-posts.tsx +195 -0
- package/blocks/signals/components/post-engagements.tsx +114 -0
- package/blocks/signals/components/record-social-tab.tsx +49 -0
- package/blocks/signals/components/social-listeners-screen.tsx +250 -0
- package/blocks/signals/components/social-settings.tsx +128 -0
- package/blocks/signals/components/source-lists.tsx +83 -0
- package/blocks/signals/components/top-engagers.tsx +68 -0
- package/blocks/signals/components/x-integration.tsx +129 -0
- package/blocks/signals/lib/signals-types.ts +228 -0
- package/blocks/signals/recipes/preview-and-create-a-listener.ts +16 -0
- package/blocks/signals/recipes/spend-against-caps.ts +14 -0
- package/blocks/signals/recipes/triage-matched-posts.ts +18 -0
- package/blocks/visitors/components/traffic-chart.tsx +3 -2
- package/blocks/web-analytics/README.md +102 -0
- package/blocks/web-analytics/block.json +292 -0
- package/blocks/web-analytics/components/acquisition-tab.tsx +154 -0
- package/blocks/web-analytics/components/analytics-filters.tsx +161 -0
- package/blocks/web-analytics/components/audience-tab.tsx +135 -0
- package/blocks/web-analytics/components/conversions-tab.tsx +128 -0
- package/blocks/web-analytics/components/engagement-tab.tsx +123 -0
- package/blocks/web-analytics/components/overview-tab.tsx +102 -0
- package/blocks/web-analytics/components/pages-tab.tsx +148 -0
- package/blocks/web-analytics/components/ranked-table.tsx +103 -0
- package/blocks/web-analytics/components/realtime-tab.tsx +103 -0
- package/blocks/web-analytics/components/report-card.tsx +71 -0
- package/blocks/web-analytics/components/web-analytics-screen.tsx +90 -0
- package/blocks/web-analytics/hooks/use-report.ts +48 -0
- package/blocks/web-analytics/lib/analytics-scope.ts +155 -0
- package/blocks/web-analytics/lib/page-path.ts +14 -0
- package/blocks/web-analytics/lib/web-analytics-types.ts +187 -0
- package/blocks/web-analytics/recipes/best-landing-pages.ts +26 -0
- package/blocks/web-analytics/recipes/form-conversion-check.ts +19 -0
- package/blocks/web-analytics/recipes/weekly-traffic-report.ts +37 -0
- package/blocks/workbench/README.md +54 -2
- package/blocks/workbench/block.json +234 -5
- package/blocks/workbench/components/workbench-auto-enrich.tsx +88 -0
- package/blocks/workbench/components/workbench-automation.tsx +36 -0
- package/blocks/workbench/components/workbench-connections.tsx +136 -0
- package/blocks/workbench/components/workbench-crm-import.tsx +91 -0
- package/blocks/workbench/components/workbench-datafile-import.tsx +154 -0
- package/blocks/workbench/components/workbench-grid.tsx +70 -7
- package/blocks/workbench/components/workbench-pipelines.tsx +173 -0
- package/blocks/workbench/components/workbench-row-actions.tsx +8 -1
- package/blocks/workbench/components/workbench-schedule.tsx +191 -0
- package/blocks/workbench/components/workbench-screen.tsx +50 -17
- package/blocks/workbench/components/workbench-send-to-sequence.tsx +116 -0
- package/blocks/workbench/components/workbench-table-settings.tsx +53 -0
- package/blocks/workbench/components/workbench-triggers.tsx +178 -0
- package/blocks/workbench/hooks/use-connected-crms.ts +29 -0
- package/blocks/workbench/lib/api-status.ts +8 -0
- package/blocks/workbench/lib/automation-types.ts +257 -0
- package/dist/cli.js +257 -51
- package/dist/index.browser.js +1 -1
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.mjs +1 -1
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/react.browser.js +1 -1
- package/dist/react.browser.js.map +1 -1
- package/dist/react.browser.mjs +1 -1
- package/dist/react.browser.mjs.map +1 -1
- package/dist/react.js +1 -1
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +1 -1
- package/dist/react.mjs.map +1 -1
- package/dist/types/blocks/auth.d.ts +45 -0
- package/dist/types/blocks/create.d.ts +5 -2
- package/dist/types/version.d.ts +2 -2
- package/docs/api/forms.md +1 -1
- package/package.json +1 -1
- package/stacks/6sense/pages/contacts.tsx.tmpl +2 -0
- package/stacks/6sense.json +20 -0
- package/stacks/apollo/pages/calls.tsx.tmpl +9 -9
- package/stacks/apollo/pages/campaigns.tsx.tmpl +27 -0
- package/stacks/apollo/pages/contacts.tsx.tmpl +6 -0
- package/stacks/apollo/pages/inbox-contact.tsx.tmpl +3 -3
- package/stacks/apollo/pages/lists.tsx.tmpl +14 -2
- package/stacks/apollo/pages/sequences.tsx.tmpl +8 -2
- package/stacks/apollo.json +33 -2
- package/stacks/clay/pages/contacts.tsx.tmpl +2 -0
- package/stacks/clay/pages/lists.tsx.tmpl +14 -2
- package/stacks/gong/pages/calls.tsx.tmpl +9 -9
- package/stacks/gong/pages/contacts.tsx.tmpl +2 -0
- package/stacks/hootsuite/pages/contacts.tsx.tmpl +2 -0
- package/stacks/hootsuite.json +10 -0
- package/stacks/hubspot/pages/campaigns.tsx.tmpl +30 -0
- package/stacks/hubspot/pages/companies.tsx.tmpl +2 -0
- package/stacks/hubspot/pages/contacts.tsx.tmpl +6 -0
- package/stacks/hubspot/pages/deals.tsx.tmpl +4 -0
- package/stacks/hubspot/pages/forms.tsx.tmpl +36 -0
- package/stacks/hubspot/pages/inbox.tsx.tmpl +2 -2
- package/stacks/hubspot/pages/sequences.tsx.tmpl +8 -2
- package/stacks/hubspot/pages/web-analytics.tsx.tmpl +14 -0
- package/stacks/hubspot.json +45 -2
- package/stacks/intercom/pages/inbox-contact.tsx.tmpl +3 -3
- package/stacks/intercom.json +10 -0
- package/stacks/mailchimp/pages/contacts.tsx.tmpl +2 -0
- package/stacks/mailchimp/pages/forms.tsx.tmpl +36 -0
- package/stacks/mailchimp.json +2 -1
- package/stacks/outreach/pages/calls.tsx.tmpl +9 -9
- package/stacks/outreach/pages/campaigns.tsx.tmpl +27 -0
- package/stacks/outreach/pages/contacts.tsx.tmpl +2 -0
- package/stacks/outreach/pages/inbox-contact.tsx.tmpl +3 -3
- package/stacks/outreach/pages/sequences.tsx.tmpl +8 -2
- package/stacks/outreach.json +13 -2
- package/stacks/pipedrive/pages/contacts.tsx.tmpl +2 -0
- package/stacks/pipedrive/pages/inbox.tsx.tmpl +2 -2
- package/stacks/pipedrive.json +10 -0
- package/stacks/salesforce/pages/companies.tsx.tmpl +2 -0
- package/stacks/salesforce/pages/contacts.tsx.tmpl +6 -0
- package/stacks/salesforce/pages/deals.tsx.tmpl +4 -0
- package/stacks/salesforce.json +10 -0
- package/stacks/zoominfo/pages/contacts.tsx.tmpl +4 -0
- package/stacks/zoominfo/pages/lists.tsx.tmpl +14 -2
- package/stacks/zoominfo.json +20 -0
- package/styles/styles.css +1 -1
- package/templates/auth/auth-route.ts.tmpl +16 -0
- package/templates/auth/g8.auth.anonymous.ts.tmpl +15 -0
- package/templates/auth/g8.auth.basic.ts.tmpl +17 -0
- package/templates/auth/g8.auth.jwt.ts.tmpl +23 -0
- package/templates/auth/g8.auth.password.ts.tmpl +18 -0
- package/templates/auth/g8.auth.session.ts.tmpl +23 -0
- package/templates/auth/middleware.ts.tmpl +14 -0
- package/templates/next-app/README.md.tmpl +5 -0
|
@@ -15,13 +15,23 @@
|
|
|
15
15
|
* renders its own ScreenLayout header ("Inbox" h1, stats). Opt out with `urlState={false}` (selection kept in component
|
|
16
16
|
* state, the URL untouched) and/or `hideHeader` (no title or stats, just the toolbar and the two panes). Defaults keep the new look.
|
|
17
17
|
*
|
|
18
|
+
* Management (all optional, on by default; `showManagement={false}` hides the three toolbar buttons):
|
|
19
|
+
* - "New message" (ComposeMessage): write a new email or text from a sender you choose (external, confirmed).
|
|
20
|
+
* - "Search all" (InboxSearch): search every email thread or LinkedIn conversation on the server, then open a result.
|
|
21
|
+
* - "Manage" (TagManager + BlockedSenders): create, rename, delete tags; block and unblock senders. The tag list it
|
|
22
|
+
* loads also feeds the tag filter and the tag picker.
|
|
23
|
+
* - In an open conversation, with `showThreadActions` (default off), "Manage conversation" (ThreadActions): mark read or unread, remove a tag, unassign, block sender.
|
|
24
|
+
* SDK operations of its own: none; the parts above own theirs and their confirm dialogs.
|
|
25
|
+
* A conversation this key cannot see (or that was removed) answers 404; the conversation view says so instead of failing.
|
|
26
|
+
*
|
|
18
27
|
* Props are backwards compatible: `initialFilters`, `availableTags`, `className` as before; everything else is optional glue.
|
|
19
28
|
*/
|
|
20
29
|
import { useMemo, useState, type ReactNode } from "react";
|
|
21
|
-
import { Inbox as InboxIcon, Mail, RefreshCw } from "lucide-react";
|
|
30
|
+
import { Inbox as InboxIcon, Mail, PenSquare, RefreshCw, Search, Tags } from "lucide-react";
|
|
22
31
|
import { Button, Skeleton } from "@graph8/sdk/ui";
|
|
23
32
|
import { ApiError } from "@/g8/components/api-error";
|
|
24
33
|
import { EmptyState } from "@/g8/components/empty-state";
|
|
34
|
+
import { FormSheet } from "@/g8/components/screen/form-sheet";
|
|
25
35
|
import { DetailPanel } from "@/g8/components/screen/detail-panel";
|
|
26
36
|
import { EntityAvatar } from "@/g8/components/screen/entity-avatar";
|
|
27
37
|
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
@@ -42,7 +52,13 @@ import {
|
|
|
42
52
|
type InboxTag,
|
|
43
53
|
type InboxThread,
|
|
44
54
|
} from "../lib/inbox-types";
|
|
55
|
+
import { isNotFound } from "../lib/inbox-manage";
|
|
56
|
+
import { BlockedSenders } from "./blocked-senders";
|
|
57
|
+
import { ComposeMessage } from "./compose-message";
|
|
58
|
+
import { InboxSearch } from "./inbox-search";
|
|
45
59
|
import { ReplyComposer } from "./reply-composer";
|
|
60
|
+
import { TagManager } from "./tag-manager";
|
|
61
|
+
import { ThreadActions } from "./thread-actions";
|
|
46
62
|
import { TagAndAssign } from "./tag-and-assign";
|
|
47
63
|
import { ThreadView } from "./thread-view";
|
|
48
64
|
import { useBrand } from "@/g8/hooks/use-brand";
|
|
@@ -66,6 +82,10 @@ export interface InboxScreenProps {
|
|
|
66
82
|
urlState?: boolean;
|
|
67
83
|
/** Skip the screen header (title, description, stats) when the host page already has one. Default false. */
|
|
68
84
|
hideHeader?: boolean;
|
|
85
|
+
/** Show the New message, Search all and Manage buttons. Default true. */
|
|
86
|
+
showManagement?: boolean;
|
|
87
|
+
/** Show "Manage conversation" (mark read, remove tag, unassign, block sender) under the composer. Default false, so existing embedders see no new controls. */
|
|
88
|
+
showThreadActions?: boolean;
|
|
69
89
|
}
|
|
70
90
|
|
|
71
91
|
/** `useUrlState` or plain state, chosen once per mount (the flag must not change while mounted). */
|
|
@@ -160,6 +180,7 @@ function ListEmpty({ filtered, onClear }: { filtered: boolean; onClear: () => vo
|
|
|
160
180
|
|
|
161
181
|
function ThreadDetails({ thread }: { thread: Selected }) {
|
|
162
182
|
const { data: t, loading, error, refetch } = useInboxThread(thread.id, thread.channel);
|
|
183
|
+
if (isNotFound(error)) return <EmptyState compact title="Conversation not found" description="It may have been removed, or it is not shared with this API key." />;
|
|
163
184
|
if (error) return <ApiError error={error} title="Could not load this conversation" onRetry={() => void refetch()} />;
|
|
164
185
|
if (!t) return loading ? <Skeleton className="h-24 w-full" /> : null;
|
|
165
186
|
const dash = "";
|
|
@@ -185,15 +206,18 @@ function ThreadDetails({ thread }: { thread: Selected }) {
|
|
|
185
206
|
|
|
186
207
|
function ExtraTab({ thread, render }: { thread: Selected; render: (thread: InboxThread) => ReactNode }) {
|
|
187
208
|
const { data, loading, error, refetch } = useInboxThread(thread.id, thread.channel);
|
|
209
|
+
if (isNotFound(error)) return <EmptyState compact title="Conversation not found" description="It may have been removed, or it is not shared with this API key." />;
|
|
188
210
|
if (error) return <ApiError error={error} title="Could not load this conversation" onRetry={() => void refetch()} />;
|
|
189
211
|
if (!data) return loading ? <Skeleton className="h-24 w-full" /> : null;
|
|
190
212
|
return <>{render(data)}</>;
|
|
191
213
|
}
|
|
192
214
|
|
|
193
|
-
export function InboxScreen({ initialFilters, availableTags, className, toolbarActions, renderRowActions, renderDetailActions, extraDetailTabs, pageSize = 25, urlState = true, hideHeader = false }: InboxScreenProps) {
|
|
215
|
+
export function InboxScreen({ initialFilters, availableTags, className, toolbarActions, renderRowActions, renderDetailActions, extraDetailTabs, pageSize = 25, urlState = true, hideHeader = false, showManagement = true, showThreadActions = false }: InboxScreenProps) {
|
|
194
216
|
const [filters, setFilters] = useState<InboxFilters>(initialFilters ?? {});
|
|
195
217
|
const [page, setPage] = useState(1);
|
|
196
218
|
const [search, setSearch] = useState("");
|
|
219
|
+
const [sheet, setSheet] = useState<"compose" | "search" | "manage" | null>(null);
|
|
220
|
+
const [managed, setManaged] = useState<InboxTag[]>([]);
|
|
197
221
|
const [openKey, setOpenKey] = useSelectionState("id", urlState);
|
|
198
222
|
const [view, setView] = useSelectionState("view", urlState);
|
|
199
223
|
const { threads, loading, error, hasNext, total, refetch } = useInboxThreads(filters, page, pageSize);
|
|
@@ -202,9 +226,9 @@ export function InboxScreen({ initialFilters, availableTags, className, toolbarA
|
|
|
202
226
|
|
|
203
227
|
const tags = useMemo(() => {
|
|
204
228
|
const seen = new Map<string, InboxTag>();
|
|
205
|
-
for (const t of [...(availableTags ?? []), ...threads.flatMap((th) => th.tags)]) if (t.id) seen.set(t.id, t);
|
|
229
|
+
for (const t of [...(availableTags ?? []), ...managed, ...threads.flatMap((th) => th.tags)]) if (t.id) seen.set(t.id, t);
|
|
206
230
|
return [...seen.values()];
|
|
207
|
-
}, [availableTags, threads]);
|
|
231
|
+
}, [availableTags, managed, threads]);
|
|
208
232
|
const needle = search.trim().toLowerCase();
|
|
209
233
|
const rows = useMemo(
|
|
210
234
|
() => (needle ? threads.filter((t) => `${contactLabel(t)} ${t.contact?.email ?? ""} ${t.contact?.company ?? ""} ${t.subject ?? ""} ${previewText(t, 300)}`.toLowerCase().includes(needle)) : threads),
|
|
@@ -222,6 +246,27 @@ export function InboxScreen({ initialFilters, availableTags, className, toolbarA
|
|
|
222
246
|
const count = (c: string) => threads.filter((t) => t.channel === c).length;
|
|
223
247
|
const first = open ? threads.find((t) => t.id === open.id && t.channel === open.channel) : undefined;
|
|
224
248
|
|
|
249
|
+
const management = showManagement ? (
|
|
250
|
+
<>
|
|
251
|
+
<Button size="sm" variant="outline" onClick={() => setSheet("search")}><Search className="mr-1 size-4" />Search all</Button>
|
|
252
|
+
<Button size="sm" variant="outline" onClick={() => setSheet("manage")}><Tags className="mr-1 size-4" />Manage</Button>
|
|
253
|
+
<Button size="sm" onClick={() => setSheet("compose")}><PenSquare className="mr-1 size-4" />New message</Button>
|
|
254
|
+
</>
|
|
255
|
+
) : null;
|
|
256
|
+
const sheets = showManagement ? (
|
|
257
|
+
<>
|
|
258
|
+
<FormSheet open={sheet === "compose"} onOpenChange={(o) => !o && setSheet(null)} width="lg" title="New message" description="Write to someone new. Nothing is sent until you confirm.">
|
|
259
|
+
{sheet === "compose" ? <ComposeMessage onCancel={() => setSheet(null)} onSent={() => { setSheet(null); void refetch(); }} /> : null}
|
|
260
|
+
</FormSheet>
|
|
261
|
+
<FormSheet open={sheet === "search"} onOpenChange={(o) => !o && setSheet(null)} width="lg" title="Search all conversations" description="Searches your email and LinkedIn on the server, beyond the page loaded here.">
|
|
262
|
+
{sheet === "search" ? <InboxSearch onOpen={(hit) => { setOpenKey(`${hit.channel}:${hit.id}`); setView(null); setSheet(null); }} /> : null}
|
|
263
|
+
</FormSheet>
|
|
264
|
+
<FormSheet open={sheet === "manage"} onOpenChange={(o) => !o && setSheet(null)} width="lg" title="Manage the inbox" description="Tags and blocked senders.">
|
|
265
|
+
{sheet === "manage" ? <div className="space-y-6"><TagManager onTags={setManaged} /><BlockedSenders /></div> : null}
|
|
266
|
+
</FormSheet>
|
|
267
|
+
</>
|
|
268
|
+
) : null;
|
|
269
|
+
|
|
225
270
|
const panes = (
|
|
226
271
|
<>
|
|
227
272
|
<div className={expanded ? "grid gap-4" : "grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]"}>
|
|
@@ -268,6 +313,7 @@ export function InboxScreen({ initialFilters, availableTags, className, toolbarA
|
|
|
268
313
|
{renderDetailActions ? <div className="flex flex-wrap gap-2">{renderDetailActions(thread)}</div> : null}
|
|
269
314
|
<ReplyComposer thread={thread} onSent={reload} />
|
|
270
315
|
<TagAndAssign thread={thread} availableTags={tags} onChanged={reload} />
|
|
316
|
+
{showThreadActions ? <ThreadActions thread={thread} onChanged={reload} /> : null}
|
|
271
317
|
</div>
|
|
272
318
|
)}
|
|
273
319
|
</ThreadView>
|
|
@@ -289,9 +335,10 @@ export function InboxScreen({ initialFilters, availableTags, className, toolbarA
|
|
|
289
335
|
<div className={className ? `space-y-4 ${className}` : "space-y-4"}>
|
|
290
336
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
291
337
|
<Toolbar search={{ value: search, onChange: setSearch, placeholder: "Search this page" }} filters={<Filters filters={filters} tags={tags} update={update} />} />
|
|
292
|
-
<div className="flex gap-2"><Button size="sm" variant="outline" onClick={() => void refetch()}><RefreshCw className="mr-1 size-4" />Refresh</Button>{toolbarActions}</div>
|
|
338
|
+
<div className="flex gap-2"><Button size="sm" variant="outline" onClick={() => void refetch()}><RefreshCw className="mr-1 size-4" />Refresh</Button>{management}{toolbarActions}</div>
|
|
293
339
|
</div>
|
|
294
340
|
{panes}
|
|
341
|
+
{sheets}
|
|
295
342
|
</div>
|
|
296
343
|
);
|
|
297
344
|
}
|
|
@@ -301,7 +348,7 @@ export function InboxScreen({ initialFilters, availableTags, className, toolbarA
|
|
|
301
348
|
title="Inbox"
|
|
302
349
|
description="Every reply across email, SMS and LinkedIn in one queue."
|
|
303
350
|
icon={<InboxIcon className="size-5" />}
|
|
304
|
-
actions={<><Button size="sm" variant="outline" onClick={() => void refetch()}><RefreshCw className="mr-1 size-4" />Refresh</Button>{toolbarActions}</>}
|
|
351
|
+
actions={<><Button size="sm" variant="outline" onClick={() => void refetch()}><RefreshCw className="mr-1 size-4" />Refresh</Button>{management}{toolbarActions}</>}
|
|
305
352
|
stats={[
|
|
306
353
|
{ label: "Conversations", value: total != null ? total.toLocaleString() : threads.length, hint: total != null ? "in this view" : "on this page" },
|
|
307
354
|
{ label: "Drafts waiting", value: drafts, hint: "on this page", tone: drafts > 0 ? "warning" : "neutral" },
|
|
@@ -311,6 +358,7 @@ export function InboxScreen({ initialFilters, availableTags, className, toolbarA
|
|
|
311
358
|
toolbar={<Toolbar search={{ value: search, onChange: setSearch, placeholder: "Search this page" }} filters={<Filters filters={filters} tags={tags} update={update} />} />}
|
|
312
359
|
>
|
|
313
360
|
{panes}
|
|
361
|
+
{sheets}
|
|
314
362
|
</ScreenLayout>
|
|
315
363
|
);
|
|
316
364
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* InboxSearch: search ALL of your email threads or LinkedIn conversations on the server, not just the page the list
|
|
5
|
+
* has loaded. Type, press Enter (or Search), pick a result; `onOpen` receives the thread's channel and id so a screen
|
|
6
|
+
* can open it in its conversation view.
|
|
7
|
+
*
|
|
8
|
+
* SDK operations, in order:
|
|
9
|
+
* Email: 1. GET /inbox/mailboxes/paginated (aiInbox.listInboxMailboxesPaginated) READ - the mailboxes searched
|
|
10
|
+
* 2. POST /inbox/emails/search?page&page_size (inbox.searchInboxEmails) READ - `mailboxes` is required
|
|
11
|
+
* (an empty mailbox list is "no result", never "all", so every mailbox you can see is sent)
|
|
12
|
+
* LinkedIn: POST /inbox/linkedin/conversations/search (aiInbox.searchInboxLinkedinConversations) READ
|
|
13
|
+
* SMS threads have no search route in the API; they are found in the list. Searching sends nothing and costs nothing.
|
|
14
|
+
* Result rows are untyped in the contract, so they are read defensively; opening one loads the real thread
|
|
15
|
+
* (ThreadView), and a thread the key may not see answers a uniform 404 which the thread view explains.
|
|
16
|
+
*/
|
|
17
|
+
import { useState } from "react";
|
|
18
|
+
import { Search } from "lucide-react";
|
|
19
|
+
import { Button, Input, Skeleton } from "@graph8/sdk/ui";
|
|
20
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
21
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
22
|
+
import { g8Post } from "@/g8/lib/g8-client";
|
|
23
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
24
|
+
import { FirstNotice } from "@/g8/components/paging-notices";
|
|
25
|
+
import { useInboxMailboxes } from "../hooks/use-inbox-mailboxes";
|
|
26
|
+
import { formatWhen } from "../lib/inbox-types";
|
|
27
|
+
import { rowsFrom, searchHit } from "../lib/inbox-manage";
|
|
28
|
+
import type { SearchHit } from "../lib/inbox-manage";
|
|
29
|
+
|
|
30
|
+
export interface InboxSearchProps {
|
|
31
|
+
/** Called with the chosen result's channel and thread id. */
|
|
32
|
+
onOpen?: (hit: { channel: "email" | "linkedin"; id: string }) => void;
|
|
33
|
+
pageSize?: number;
|
|
34
|
+
className?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type Channel = "email" | "linkedin";
|
|
38
|
+
|
|
39
|
+
export function InboxSearch({ onOpen, pageSize = 20, className }: InboxSearchProps) {
|
|
40
|
+
const [channel, setChannel] = useState<Channel>("email");
|
|
41
|
+
const [text, setText] = useState("");
|
|
42
|
+
const [submitted, setSubmitted] = useState<{ channel: Channel; q: string; page: number } | null>(null);
|
|
43
|
+
const mailboxes = useInboxMailboxes({ enabled: channel === "email" });
|
|
44
|
+
const results = useG8Query(
|
|
45
|
+
async (signal): Promise<{ hits: SearchHit[]; hasNext: boolean }> => {
|
|
46
|
+
const s = submitted!;
|
|
47
|
+
if (s.channel === "email") {
|
|
48
|
+
const env = await g8Post("/inbox/emails/search", { mailboxes: (mailboxes.data ?? []).map((m) => m.address), search: s.q, sort_by: "updated_at", sort_order: "desc" }, { query: { page: s.page, page_size: pageSize }, signal });
|
|
49
|
+
const hits = rowsFrom<Record<string, unknown>>(env, "emails").map((r) => searchHit("email", r)).filter((h): h is SearchHit => h != null);
|
|
50
|
+
return { hits, hasNext: Boolean((env.pagination as { has_next?: boolean } | undefined)?.has_next) || hits.length >= pageSize };
|
|
51
|
+
}
|
|
52
|
+
const env = await g8Post("/inbox/linkedin/conversations/search", { search: s.q, limit: pageSize, offset: (s.page - 1) * pageSize, sort_order: "desc" }, { signal });
|
|
53
|
+
const hits = rowsFrom<Record<string, unknown>>(env, "conversations").map((r) => searchHit("linkedin", r)).filter((h): h is SearchHit => h != null);
|
|
54
|
+
return { hits, hasNext: hits.length >= pageSize };
|
|
55
|
+
},
|
|
56
|
+
[submitted?.channel, submitted?.q, submitted?.page, submitted ? (mailboxes.data ?? []).length : 0],
|
|
57
|
+
{ enabled: submitted != null && (submitted.channel !== "email" || mailboxes.data != null) },
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const run = () => {
|
|
61
|
+
const q = text.trim();
|
|
62
|
+
if (q) setSubmitted({ channel, q, page: 1 });
|
|
63
|
+
};
|
|
64
|
+
const hits = results.data?.hits ?? [];
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className={`space-y-3 ${className ?? ""}`}>
|
|
68
|
+
<form className="flex flex-wrap items-center gap-2" role="search" onSubmit={(e) => { e.preventDefault(); run(); }}>
|
|
69
|
+
<div role="group" aria-label="Search in" className="flex gap-1">
|
|
70
|
+
<Button type="button" size="sm" variant={channel === "email" ? "default" : "outline"} onClick={() => { setChannel("email"); setSubmitted(null); }}>Email</Button>
|
|
71
|
+
<Button type="button" size="sm" variant={channel === "linkedin" ? "default" : "outline"} onClick={() => { setChannel("linkedin"); setSubmitted(null); }}>LinkedIn</Button>
|
|
72
|
+
</div>
|
|
73
|
+
<Input aria-label="Search all conversations" className="min-w-48 flex-1" placeholder={channel === "email" ? "Subject, sender or text" : "Name or message text"} value={text} onChange={(e) => setText(e.target.value)} />
|
|
74
|
+
<Button type="submit" size="sm" disabled={!text.trim()}><Search className="mr-1 size-4" aria-hidden />Search</Button>
|
|
75
|
+
</form>
|
|
76
|
+
{channel === "email" ? <FirstNotice shown={mailboxes.items.length} limit={0} force={mailboxes.truncated} noun="mailboxes" /> : null}
|
|
77
|
+
{mailboxes.error && channel === "email" ? <ApiError error={mailboxes.error} title="Could not load your mailboxes" onRetry={() => void mailboxes.refetch()} /> : null}
|
|
78
|
+
{channel === "email" && mailboxes.data && mailboxes.data.length === 0 ? <p className="text-sm text-muted-foreground">No mailbox is connected yet, so there is nothing to search.</p> : null}
|
|
79
|
+
{results.error ? <ApiError error={results.error} title="The search failed" onRetry={() => void results.refetch()} /> : null}
|
|
80
|
+
{submitted && results.loading && !results.data ? <div aria-busy="true" className="space-y-2"><Skeleton className="h-14 w-full" /><Skeleton className="h-14 w-full" /></div> : null}
|
|
81
|
+
{submitted && results.data && hits.length === 0 ? <EmptyState compact title="No conversations found" description={`Nothing matches "${submitted.q}".`} /> : null}
|
|
82
|
+
{hits.length > 0 ? (
|
|
83
|
+
<ul aria-label="Search results" className="divide-y rounded-md border border-border">
|
|
84
|
+
{hits.map((h) => (
|
|
85
|
+
<li key={`${h.channel}:${h.id}`}>
|
|
86
|
+
<button type="button" className="flex w-full flex-col gap-0.5 p-3 text-left hover:bg-muted/50" onClick={() => onOpen?.({ channel: h.channel, id: h.id })}>
|
|
87
|
+
<span className="flex items-center justify-between gap-2"><span className="truncate text-sm font-medium">{h.title}</span><span className="shrink-0 text-xs text-muted-foreground">{formatWhen(h.when)}</span></span>
|
|
88
|
+
{h.subtitle ? <span className="truncate text-xs text-muted-foreground">{h.subtitle}</span> : null}
|
|
89
|
+
{h.preview ? <span className="truncate text-xs">{h.preview}</span> : null}
|
|
90
|
+
</button>
|
|
91
|
+
</li>
|
|
92
|
+
))}
|
|
93
|
+
</ul>
|
|
94
|
+
) : null}
|
|
95
|
+
{submitted && results.data && (submitted.page > 1 || results.data.hasNext) ? (
|
|
96
|
+
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
|
97
|
+
<span>Page {submitted.page}</span>
|
|
98
|
+
<div className="flex gap-2">
|
|
99
|
+
<Button size="sm" variant="outline" disabled={submitted.page <= 1 || results.loading} onClick={() => setSubmitted({ ...submitted, page: submitted.page - 1 })}>Previous</Button>
|
|
100
|
+
<Button size="sm" variant="outline" disabled={!results.data.hasNext || results.loading} onClick={() => setSubmitted({ ...submitted, page: submitted.page + 1 })}>Next</Button>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
) : null}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TagManager: the inbox tag library. See every tag, create one, rename or recolour one, delete one.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in order:
|
|
7
|
+
* 1. GET /workflows/inbox-tags (workflows.listInboxTags) READ - every tag, with its built-in policy
|
|
8
|
+
* 2. POST /inbox/tags (aiInbox.createInboxTag) write
|
|
9
|
+
* 3. PUT /inbox/tags/{tag_id} (aiInbox.updateInboxTag) write - ONLY the fields that changed
|
|
10
|
+
* 4. DELETE /inbox/tags/{tag_id} (aiInbox.deleteInboxTag) DESTRUCTIVE -> ConfirmSpendDialog first
|
|
11
|
+
*
|
|
12
|
+
* Built-in tags follow the API's policy: "system" tags cannot be edited or deleted, "sdr_ai" tags cannot be renamed
|
|
13
|
+
* or deleted. Deleting a tag removes the label from every conversation that carries it (nothing else is removed).
|
|
14
|
+
* The edit form is seeded from a LOADED row and its Save stays disabled until something changed; it never writes a
|
|
15
|
+
* tag that was not loaded, and the request carries only the changed fields (a partial PUT must not reset
|
|
16
|
+
* `ai_can_apply` or the rules, which this form does not show). Applying a tag to a conversation is TagAndAssign.
|
|
17
|
+
*/
|
|
18
|
+
import { useEffect, useState } from "react";
|
|
19
|
+
import { Pencil, Plus, Trash2 } from "lucide-react";
|
|
20
|
+
import { Button, Input, Skeleton } from "@graph8/sdk/ui";
|
|
21
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
22
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
23
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
24
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
25
|
+
import { g8Delete, g8Get, g8Post, g8Put } from "@/g8/lib/g8-client";
|
|
26
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
27
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
28
|
+
import type { InboxTag } from "../lib/inbox-types";
|
|
29
|
+
import { asInboxTags, canDeleteTag, canEditTag, canRenameTag, isHexColor, managedTags, tagChanges } from "../lib/inbox-manage";
|
|
30
|
+
import type { ManagedTag } from "../lib/inbox-manage";
|
|
31
|
+
|
|
32
|
+
export interface TagManagerProps {
|
|
33
|
+
/** Called with the current tag list after every load and change, so a screen can keep its pickers in step. */
|
|
34
|
+
onTags?: (tags: InboxTag[]) => void;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function TagForm({ tag, onDone }: { tag: ManagedTag | null; onDone: (changed: boolean) => void }) {
|
|
39
|
+
const [name, setName] = useState(tag?.name ?? "");
|
|
40
|
+
const [description, setDescription] = useState(tag?.description ?? "");
|
|
41
|
+
const [color, setColor] = useState(tag?.color ?? "");
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
setName(tag?.name ?? "");
|
|
44
|
+
setDescription(tag?.description ?? "");
|
|
45
|
+
setColor(tag?.color ?? "");
|
|
46
|
+
}, [tag?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
47
|
+
const changes = tag ? tagChanges(tag, { name, description, color }) : {};
|
|
48
|
+
const save = useG8Mutation(async () => {
|
|
49
|
+
if (tag) await g8Put(`/inbox/tags/${encodeURIComponent(tag.id)}`, changes);
|
|
50
|
+
else await g8Post("/inbox/tags", { name: name.trim(), ...(description.trim() ? { description: description.trim() } : {}), ...(color.trim() ? { color: color.trim() } : {}) });
|
|
51
|
+
}, { invalidates: ["inbox"] });
|
|
52
|
+
const valid = name.trim().length > 0 && isHexColor(color) && (tag ? Object.keys(changes).length > 0 : true);
|
|
53
|
+
const submit = async () => {
|
|
54
|
+
try {
|
|
55
|
+
await save.mutateAsync(undefined);
|
|
56
|
+
onDone(true);
|
|
57
|
+
} catch {
|
|
58
|
+
/* the error renders in the form; the typed values stay */
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
return (
|
|
62
|
+
<form className="space-y-2 rounded-md border border-border p-3" onSubmit={(e) => { e.preventDefault(); if (valid) void submit(); }} aria-label={tag ? `Edit ${tag.name}` : "New tag"}>
|
|
63
|
+
<Input aria-label="Tag name" placeholder="Tag name" value={name} disabled={tag ? !canRenameTag(tag) : false} onChange={(e) => setName(e.target.value)} />
|
|
64
|
+
<Input aria-label="Description" placeholder="What this tag means (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
|
|
65
|
+
<div className="flex items-center gap-2">
|
|
66
|
+
<Input aria-label="Colour" className="w-32" placeholder="#eab308" value={color} onChange={(e) => setColor(e.target.value)} />
|
|
67
|
+
{!isHexColor(color) ? <span role="alert" className="text-xs text-error-foreground">Use a hex colour such as #eab308.</span> : null}
|
|
68
|
+
</div>
|
|
69
|
+
{save.error ? <ApiError error={save.error} title={tag ? "Could not save the tag" : "Could not create the tag"} /> : null}
|
|
70
|
+
<div className="flex justify-end gap-2">
|
|
71
|
+
<Button type="button" variant="outline" size="sm" onClick={() => onDone(false)}>Cancel</Button>
|
|
72
|
+
<Button type="submit" size="sm" disabled={!valid || save.loading}>{tag ? "Save changes" : "Create tag"}</Button>
|
|
73
|
+
</div>
|
|
74
|
+
</form>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function TagManager({ onTags, className }: TagManagerProps) {
|
|
79
|
+
const [editing, setEditing] = useState<ManagedTag | "new" | null>(null);
|
|
80
|
+
const [deleting, setDeleting] = useState<ManagedTag | null>(null);
|
|
81
|
+
const tags = useG8Query(async (signal) => managedTags(await g8Get("/workflows/inbox-tags", undefined, { signal })), [], { key: ["inbox", "tags"] });
|
|
82
|
+
useEffect(() => { if (tags.data) onTags?.(asInboxTags(tags.data)); }, [tags.data]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
83
|
+
const remove = useG8Mutation(async (tag: ManagedTag) => {
|
|
84
|
+
await g8Delete(`/inbox/tags/${encodeURIComponent(tag.id)}`);
|
|
85
|
+
setDeleting(null);
|
|
86
|
+
await tags.refetch();
|
|
87
|
+
}, { invalidates: ["inbox"], onError: () => setDeleting(null) });
|
|
88
|
+
|
|
89
|
+
if (tags.error) return <ApiError error={tags.error} title="Could not load the tags" onRetry={() => void tags.refetch()} className={className} />;
|
|
90
|
+
if (tags.loading && !tags.data) return <div aria-busy="true" className={`space-y-2 ${className ?? ""}`}><Skeleton className="h-10 w-full" /><Skeleton className="h-10 w-full" /></div>;
|
|
91
|
+
const rows = tags.data ?? [];
|
|
92
|
+
const done = (changed: boolean) => { setEditing(null); if (changed) void tags.refetch(); };
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div className={`space-y-3 ${className ?? ""}`}>
|
|
96
|
+
<div className="flex items-center justify-between">
|
|
97
|
+
<h3 className="font-medium">Tags</h3>
|
|
98
|
+
<Button size="sm" variant="outline" disabled={editing != null} onClick={() => setEditing("new")}><Plus className="mr-1 size-4" aria-hidden />New tag</Button>
|
|
99
|
+
</div>
|
|
100
|
+
{editing === "new" ? <TagForm tag={null} onDone={done} /> : null}
|
|
101
|
+
{rows.length === 0 && editing !== "new" ? (
|
|
102
|
+
<EmptyState compact title="No tags yet" description="Tags label conversations so you can filter and route them." action={<Button size="sm" onClick={() => setEditing("new")}>Create a tag</Button>} />
|
|
103
|
+
) : (
|
|
104
|
+
<ul aria-label="Tags" className="divide-y rounded-md border border-border">
|
|
105
|
+
{rows.map((t) => (
|
|
106
|
+
<li key={t.id} className="p-2 text-sm">
|
|
107
|
+
{editing !== "new" && editing?.id === t.id ? (
|
|
108
|
+
<TagForm tag={t} onDone={done} />
|
|
109
|
+
) : (
|
|
110
|
+
<div className="flex items-center gap-2">
|
|
111
|
+
<span aria-hidden className="size-3 shrink-0 rounded-full border border-border" style={t.color ? { backgroundColor: t.color } : undefined} />
|
|
112
|
+
<span className="min-w-0 flex-1"><span className="font-medium">{t.name}</span>{t.description ? <span className="block truncate text-xs text-muted-foreground">{t.description}</span> : null}</span>
|
|
113
|
+
{t.policy !== "user" ? <StatusPill tone="neutral">built in</StatusPill> : null}
|
|
114
|
+
<Button size="sm" variant="ghost" aria-label={`Edit ${t.name}`} disabled={!canEditTag(t) || editing != null} onClick={() => setEditing(t)}><Pencil className="size-4" aria-hidden /></Button>
|
|
115
|
+
<Button size="sm" variant="ghost" aria-label={`Delete ${t.name}`} disabled={!canDeleteTag(t)} onClick={() => setDeleting(t)}><Trash2 className="size-4" aria-hidden /></Button>
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
</li>
|
|
119
|
+
))}
|
|
120
|
+
</ul>
|
|
121
|
+
)}
|
|
122
|
+
{remove.error ? <ApiError error={remove.error} title="Could not delete the tag" /> : null}
|
|
123
|
+
<ConfirmSpendDialog
|
|
124
|
+
open={deleting != null}
|
|
125
|
+
tier="destructive"
|
|
126
|
+
title={`Delete the tag "${deleting?.name ?? ""}"?`}
|
|
127
|
+
description="Every conversation loses this label. The conversations themselves are not touched. This cannot be undone."
|
|
128
|
+
confirmLabel="Delete tag"
|
|
129
|
+
affected={1}
|
|
130
|
+
confirming={remove.loading}
|
|
131
|
+
onConfirm={() => deleting && void remove.mutate(deleting)}
|
|
132
|
+
onCancel={() => setDeleting(null)}
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ThreadActions: the small things you do to one conversation besides replying: mark it read (or, on LinkedIn,
|
|
5
|
+
* unread), take a tag off, take a teammate off, and block the sender.
|
|
6
|
+
*
|
|
7
|
+
* SDK operations, in order (the thread's own id and channel decide the route):
|
|
8
|
+
* Read state POST /inbox/emails/{id}/mark-read (aiInbox.markInboxEmailRead) write
|
|
9
|
+
* POST /inbox/sms/{id}/mark-read (aiInbox.markInboxSmsRead) write
|
|
10
|
+
* POST /inbox/linkedin/accounts/{account}/conversations/{id}/read|unread (aiInbox.markInboxLinkedinConversationRead|Unread) write
|
|
11
|
+
* There is no "mark unread" for email or SMS in the API, so those offer only "Mark as read".
|
|
12
|
+
* Remove tag DELETE /inbox/channels/{id}/tags?tag_id&channel_type (aiInbox.removeChannelTag) DESTRUCTIVE -> ConfirmSpendDialog
|
|
13
|
+
* Unassign DELETE /inbox/channels/{id}/assign?assignee_email&channel_type (aiInbox.unassignChannel) DESTRUCTIVE -> ConfirmSpendDialog
|
|
14
|
+
* Block sender POST /inbox/blocked-senders { mailbox, sender_email } (aiInbox.blockInboxSender) write, email threads only;
|
|
15
|
+
* asked inline first because future mail from them stops arriving (BlockedSenders can undo it)
|
|
16
|
+
* Applying a tag and assigning are TagAndAssign. Nothing here sends a message.
|
|
17
|
+
*/
|
|
18
|
+
import { useState } from "react";
|
|
19
|
+
import { Ban, CheckCheck, MailOpen, X } from "lucide-react";
|
|
20
|
+
import { Button } from "@graph8/sdk/ui";
|
|
21
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
22
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
23
|
+
import { g8Delete, g8Post } from "@/g8/lib/g8-client";
|
|
24
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
25
|
+
import { canMarkRead, canMarkUnread, channelTypeOf } from "../lib/inbox-manage";
|
|
26
|
+
import type { InboxThread } from "../lib/inbox-types";
|
|
27
|
+
|
|
28
|
+
type Pending = { kind: "tag"; id: string; name: string } | { kind: "assignee"; email: string; name: string } | null;
|
|
29
|
+
|
|
30
|
+
export interface ThreadActionsProps {
|
|
31
|
+
thread: InboxThread;
|
|
32
|
+
/** Called after any change; reload the thread here. */
|
|
33
|
+
onChanged?: () => void;
|
|
34
|
+
className?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readPath(thread: InboxThread, read: boolean): string | null {
|
|
38
|
+
const id = encodeURIComponent(thread.id);
|
|
39
|
+
if (thread.channel === "sms") return read ? `/inbox/sms/${id}/mark-read` : null;
|
|
40
|
+
if (thread.channel === "linkedin") {
|
|
41
|
+
return thread.linkedin_account_id == null ? null : `/inbox/linkedin/accounts/${thread.linkedin_account_id}/conversations/${id}/${read ? "read" : "unread"}`;
|
|
42
|
+
}
|
|
43
|
+
return read ? `/inbox/emails/${id}/mark-read` : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function ThreadActions({ thread, onChanged, className }: ThreadActionsProps) {
|
|
47
|
+
const [pending, setPending] = useState<Pending>(null);
|
|
48
|
+
const [blocking, setBlocking] = useState(false);
|
|
49
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
50
|
+
const channelType = channelTypeOf(thread.channel);
|
|
51
|
+
const sender = thread.channel === "email" ? (thread.contact?.email ?? "") : "";
|
|
52
|
+
const channelQuery = { channel_type: channelType, linkedin_account_id: thread.channel === "linkedin" ? thread.linkedin_account_id : undefined };
|
|
53
|
+
const done = (message: string) => { setNotice(message); onChanged?.(); };
|
|
54
|
+
|
|
55
|
+
const mark = useG8Mutation(async (read: boolean) => {
|
|
56
|
+
const path = readPath(thread, read);
|
|
57
|
+
if (!path) throw new Error("This conversation cannot be marked that way.");
|
|
58
|
+
await g8Post(path);
|
|
59
|
+
done(read ? "Marked as read." : "Marked as unread.");
|
|
60
|
+
}, { invalidates: ["inbox"] });
|
|
61
|
+
const remove = useG8Mutation(async (p: NonNullable<Pending>) => {
|
|
62
|
+
const base = `/inbox/channels/${encodeURIComponent(thread.id)}`;
|
|
63
|
+
if (p.kind === "tag") await g8Delete(`${base}/tags`, { query: { ...channelQuery, tag_id: p.id } });
|
|
64
|
+
else await g8Delete(`${base}/assign`, { query: { ...channelQuery, assignee_email: p.email } });
|
|
65
|
+
done(p.kind === "tag" ? `Removed the tag "${p.name}".` : `Unassigned ${p.name}.`);
|
|
66
|
+
}, { invalidates: ["inbox"] });
|
|
67
|
+
const block = useG8Mutation(async () => {
|
|
68
|
+
await g8Post("/inbox/blocked-senders", { mailbox: thread.mailbox, sender_email: sender });
|
|
69
|
+
setBlocking(false);
|
|
70
|
+
done(`Blocked ${sender}.`);
|
|
71
|
+
}, { invalidates: ["inbox"] });
|
|
72
|
+
|
|
73
|
+
const confirm = async () => {
|
|
74
|
+
const current = pending;
|
|
75
|
+
if (!current) return;
|
|
76
|
+
try {
|
|
77
|
+
await remove.mutateAsync(current);
|
|
78
|
+
} catch {
|
|
79
|
+
/* surfaced below */
|
|
80
|
+
}
|
|
81
|
+
setPending(null);
|
|
82
|
+
};
|
|
83
|
+
const canBlock = thread.channel === "email" && Boolean(thread.mailbox) && Boolean(sender);
|
|
84
|
+
const label = (n: string | null | undefined, fallback: string) => n || fallback;
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<section aria-label="Conversation actions" className={`space-y-3 rounded-lg border p-3 text-sm ${className ?? ""}`}>
|
|
88
|
+
<h3 className="font-medium">Manage conversation</h3>
|
|
89
|
+
<div className="flex flex-wrap gap-2">
|
|
90
|
+
{canMarkRead(thread) ? <Button size="sm" variant="outline" loading={mark.loading} onClick={() => void mark.mutate(true)}><MailOpen className="mr-1 size-4" aria-hidden />Mark as read</Button> : null}
|
|
91
|
+
{canMarkUnread(thread) ? <Button size="sm" variant="outline" disabled={mark.loading} onClick={() => void mark.mutate(false)}>Mark as unread</Button> : null}
|
|
92
|
+
{canBlock && !blocking ? <Button size="sm" variant="outline" onClick={() => setBlocking(true)}><Ban className="mr-1 size-4" aria-hidden />Block sender</Button> : null}
|
|
93
|
+
</div>
|
|
94
|
+
{blocking ? (
|
|
95
|
+
<div role="group" aria-label="Block this sender" className="flex flex-wrap items-center gap-2 rounded-md border border-border p-2">
|
|
96
|
+
<span>Stop receiving mail from {sender} in {thread.mailbox}?</span>
|
|
97
|
+
<Button size="sm" loading={block.loading} onClick={() => void block.mutate(undefined)}>Block {sender}</Button>
|
|
98
|
+
<Button size="sm" variant="ghost" onClick={() => setBlocking(false)}>Cancel</Button>
|
|
99
|
+
</div>
|
|
100
|
+
) : null}
|
|
101
|
+
{thread.tags.length > 0 || thread.assignees.length > 0 ? (
|
|
102
|
+
<ul aria-label="Remove from conversation" className="space-y-1">
|
|
103
|
+
{thread.tags.filter((t) => t.id).map((t) => (
|
|
104
|
+
<li key={`t-${t.id}`} className="flex items-center justify-between gap-2">
|
|
105
|
+
<span>Tag: {label(t.name, String(t.id))}</span>
|
|
106
|
+
<Button size="sm" variant="ghost" aria-label={`Remove tag ${label(t.name, String(t.id))}`} onClick={() => setPending({ kind: "tag", id: String(t.id), name: label(t.name, String(t.id)) })}><X className="size-4" aria-hidden /></Button>
|
|
107
|
+
</li>
|
|
108
|
+
))}
|
|
109
|
+
{thread.assignees.map((a) => (
|
|
110
|
+
<li key={`a-${a.email}`} className="flex items-center justify-between gap-2">
|
|
111
|
+
<span>Assigned to {label(a.name, a.email)}</span>
|
|
112
|
+
<Button size="sm" variant="ghost" aria-label={`Unassign ${label(a.name, a.email)}`} onClick={() => setPending({ kind: "assignee", email: a.email, name: label(a.name, a.email) })}><X className="size-4" aria-hidden /></Button>
|
|
113
|
+
</li>
|
|
114
|
+
))}
|
|
115
|
+
</ul>
|
|
116
|
+
) : null}
|
|
117
|
+
{[mark.error, remove.error, block.error].map((e, i) => (e ? <ApiError key={i} error={e} title="Could not update the conversation" /> : null))}
|
|
118
|
+
{notice ? <p role="status" className="text-xs text-muted-foreground"><CheckCheck className="mr-1 inline size-3" aria-hidden />{notice}</p> : null}
|
|
119
|
+
|
|
120
|
+
<ConfirmSpendDialog
|
|
121
|
+
open={pending != null}
|
|
122
|
+
tier="destructive"
|
|
123
|
+
title={pending?.kind === "tag" ? `Remove the tag "${pending.name}"?` : `Unassign ${pending?.name ?? ""}?`}
|
|
124
|
+
description={pending?.kind === "tag" ? "The tag comes off this conversation only. Other conversations keep it." : "They no longer own this conversation. You can assign them again later."}
|
|
125
|
+
confirmLabel={pending?.kind === "tag" ? "Remove tag" : "Unassign"}
|
|
126
|
+
affected={1}
|
|
127
|
+
confirming={remove.loading}
|
|
128
|
+
onConfirm={() => void confirm()}
|
|
129
|
+
onCancel={() => setPending(null)}
|
|
130
|
+
/>
|
|
131
|
+
</section>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -18,6 +18,7 @@ import { ApiError } from "@/g8/components/api-error";
|
|
|
18
18
|
import { EmptyState } from "@/g8/components/empty-state";
|
|
19
19
|
import { useInboxThread } from "../hooks/use-inbox-thread";
|
|
20
20
|
import { contactLabel, formatWhen, stripHtml, type InboxMessage, type InboxThread } from "../lib/inbox-types";
|
|
21
|
+
import { isNotFound } from "../lib/inbox-manage";
|
|
21
22
|
|
|
22
23
|
export interface ThreadViewProps {
|
|
23
24
|
/** A row from InboxList. Only `id` and `channel` are required; the rest is loaded fresh. */
|
|
@@ -73,6 +74,7 @@ function ThreadHeader({ thread }: { thread: InboxThread }) {
|
|
|
73
74
|
export function ThreadView({ thread: row, children, className }: ThreadViewProps) {
|
|
74
75
|
const { data, loading, error, refetch } = useInboxThread(row.id, row.channel);
|
|
75
76
|
|
|
77
|
+
if (isNotFound(error)) return <EmptyState title="Conversation not found" description="It may have been removed, or it is not shared with this API key. Hidden conversations answer exactly like missing ones." />;
|
|
76
78
|
if (error) return <ApiError error={error} title="Could not load this conversation" onRetry={() => void refetch()} />;
|
|
77
79
|
if (loading && !data) {
|
|
78
80
|
return (
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useInboxMailboxes: EVERY mailbox the caller can see, with whether they may send as it. One page of 100 is read, then the
|
|
5
|
+
* next, until the API says there is no more (at most 20 pages); `truncated` is true when that cap was hit, and the picker
|
|
6
|
+
* must say so (FirstNotice). A mailbox beyond the first page can therefore never be silently missing from a sender picker.
|
|
7
|
+
*
|
|
8
|
+
* SDK operations: GET /inbox/mailboxes/paginated?page&page_size (aiInbox.listInboxMailboxesPaginated), read tier.
|
|
9
|
+
*/
|
|
10
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
11
|
+
import { useAllPages } from "@/g8/hooks/use-all-pages";
|
|
12
|
+
import { hasNextOf } from "@/g8/lib/paging";
|
|
13
|
+
import { mailboxChoices } from "../lib/inbox-manage";
|
|
14
|
+
import type { MailboxChoice } from "../lib/inbox-manage";
|
|
15
|
+
|
|
16
|
+
const PAGE = 100;
|
|
17
|
+
|
|
18
|
+
export function useInboxMailboxes(options: { enabled?: boolean } = {}) {
|
|
19
|
+
const all = useAllPages<MailboxChoice>(
|
|
20
|
+
async (page, signal) => {
|
|
21
|
+
const env = await g8Get("/inbox/mailboxes/paginated", { page, page_size: PAGE }, { signal });
|
|
22
|
+
const rows = mailboxChoices(env);
|
|
23
|
+
return { rows, hasNext: hasNextOf(env, PAGE, rows.length) };
|
|
24
|
+
},
|
|
25
|
+
[],
|
|
26
|
+
{ enabled: options.enabled, pageSize: PAGE },
|
|
27
|
+
);
|
|
28
|
+
const seen = new Set<string>();
|
|
29
|
+
const items = all.items.filter((m) => !seen.has(m.address.toLowerCase()) && seen.add(m.address.toLowerCase()));
|
|
30
|
+
return { ...all, items, data: all.loaded ? items : undefined };
|
|
31
|
+
}
|