Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e70904a219 | |||
| 036ed84255 | |||
| 7bcd1a2a2d | |||
| efd31d9293 | |||
| a2a7e71f59 |
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
// The SDK ships ESM/TS; let Next transpile it.
|
// The SDKs ship ESM; let Next transpile them.
|
||||||
transpilePackages: ["@abe-kap/appshell-sdk"],
|
transpilePackages: ["@abe-kap/appshell-sdk", "@insignia/iios-messaging-ui"],
|
||||||
// The browser calls the Shell BFF same-origin under /shell (so the HttpOnly
|
// The browser calls the Shell BFF same-origin under /shell (so the HttpOnly
|
||||||
// session cookie flows). We deliberately use /shell (NOT /api) to avoid
|
// session cookie flows). We deliberately use /shell (NOT /api) to avoid
|
||||||
// clobbering the existing /api/geo route. Point BFF_ORIGIN at the deployed BFF.
|
// clobbering the existing /api/geo route. Point BFF_ORIGIN at the deployed BFF.
|
||||||
|
|||||||
Generated
+15
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@abe-kap/appshell-sdk": "^0.2.6",
|
"@abe-kap/appshell-sdk": "^0.2.6",
|
||||||
"@insignia/iios-kernel-client": "^0.1.4",
|
"@insignia/iios-kernel-client": "^0.1.4",
|
||||||
|
"@insignia/iios-messaging-ui": "file:../iios/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.21.0",
|
"lucide-react": "^1.21.0",
|
||||||
"next": "16.2.9",
|
"next": "16.2.9",
|
||||||
@@ -1026,6 +1027,20 @@
|
|||||||
"socket.io-client": "^4.8.1"
|
"socket.io-client": "^4.8.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@insignia/iios-messaging-ui": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "file:../iios/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-6pSVWACjex1sXWzVqx+42rM6vgWEJ+1ud5y3DLKogJiQmfgFZgCB8vTw0PN/5FECugXC7iGuc9rV9RsHsP+EDw==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@insignia/iios-kernel-client": "*",
|
||||||
|
"react": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@insignia/iios-kernel-client": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@abe-kap/appshell-sdk": "^0.2.6",
|
"@abe-kap/appshell-sdk": "^0.2.6",
|
||||||
"@insignia/iios-kernel-client": "^0.1.4",
|
"@insignia/iios-kernel-client": "^0.1.4",
|
||||||
|
"@insignia/iios-messaging-ui": "file:../iios/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.21.0",
|
"lucide-react": "^1.21.0",
|
||||||
"next": "16.2.9",
|
"next": "16.2.9",
|
||||||
|
|||||||
@@ -133,6 +133,20 @@
|
|||||||
|
|
||||||
.dash-content { padding: 22px 28px 40px; width: 100%; }
|
.dash-content { padding: 22px 28px 40px; width: 100%; }
|
||||||
.sec-title { font-size: 15px; font-weight: 700; margin: 6px 0 14px; }
|
.sec-title { font-size: 15px; font-weight: 700; margin: 6px 0 14px; }
|
||||||
|
|
||||||
|
/* Host for @insignia/iios-messaging-ui: a fixed-height card that maps the SDK's --miu-* tokens
|
||||||
|
onto the CRM design system, so the drop-in SDK matches the rest of the app. */
|
||||||
|
.dash-root .miu-host { height: 620px; border: 1px solid var(--border); border-radius: 16px; overflow: hidden; }
|
||||||
|
.dash-root .miu-host .miu-messenger {
|
||||||
|
--miu-bg: var(--bg);
|
||||||
|
--miu-panel: var(--panel);
|
||||||
|
--miu-panel-2: var(--panel-2);
|
||||||
|
--miu-border: var(--border);
|
||||||
|
--miu-text: var(--text);
|
||||||
|
--miu-muted: var(--muted);
|
||||||
|
--miu-accent: var(--orange);
|
||||||
|
--miu-accent-text: #1a1206;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- grid helpers ---- */
|
/* ---- grid helpers ---- */
|
||||||
.grid { display: grid; gap: 16px; }
|
.grid { display: grid; gap: 16px; }
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { Support } from "./support";
|
|||||||
import { Rules } from "./rules";
|
import { Rules } from "./rules";
|
||||||
import { AiAssistant } from "./ai-assistant";
|
import { AiAssistant } from "./ai-assistant";
|
||||||
import { TeamManagement } from "./team-management";
|
import { TeamManagement } from "./team-management";
|
||||||
import { Messenger } from "./messenger";
|
import { MessengerSdk } from "./messenger-sdk";
|
||||||
import { Inbox } from "./inbox";
|
import { Inbox } from "./inbox";
|
||||||
import "../../app/dashboard/dashboard.css";
|
import "../../app/dashboard/dashboard.css";
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ export function Dashboard() {
|
|||||||
: active === "support" ? <Support />
|
: active === "support" ? <Support />
|
||||||
: active === "rules" ? <Rules />
|
: active === "rules" ? <Rules />
|
||||||
: active === "ai" ? <AiAssistant />
|
: active === "ai" ? <AiAssistant />
|
||||||
: active === "messenger" ? <Messenger />
|
: active === "messenger" ? <MessengerSdk />
|
||||||
: active === "inbox" ? <Inbox />
|
: active === "inbox" ? <Inbox />
|
||||||
: active === "team" ? <TeamManagement />
|
: active === "team" ? <TeamManagement />
|
||||||
: <ComingSoon title={title} icon={item?.icon ?? "dashboard"} onGo={setActive} />}
|
: <ComingSoon title={title} icon={item?.icon ?? "dashboard"} onGo={setActive} />}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useInboxData, type InboxState, type UiInboxItem } from "@/lib/inbox-api
|
|||||||
import { MailReader, NewMailModal } from "./mail";
|
import { MailReader, NewMailModal } from "./mail";
|
||||||
|
|
||||||
const KIND_LABEL: Record<string, string> = {
|
const KIND_LABEL: Record<string, string> = {
|
||||||
|
MAIL: "Mail",
|
||||||
MENTION: "Mention", NEEDS_REPLY: "Needs reply", NEEDS_REVIEW: "Needs review", NEEDS_APPROVAL: "Needs approval",
|
MENTION: "Mention", NEEDS_REPLY: "Needs reply", NEEDS_REVIEW: "Needs review", NEEDS_APPROVAL: "Needs approval",
|
||||||
SUPPORT_UPDATE: "Support", MEETING_FOLLOWUP: "Meeting", DIGEST: "Digest", SYSTEM_ALERT: "Alert", CRM_OWNER_INTEREST: "Owner",
|
SUPPORT_UPDATE: "Support", MEETING_FOLLOWUP: "Meeting", DIGEST: "Digest", SYSTEM_ALERT: "Alert", CRM_OWNER_INTEREST: "Owner",
|
||||||
};
|
};
|
||||||
@@ -103,7 +104,7 @@ function ItemRow({ it, active, onClick }: { it: UiInboxItem; active: boolean; on
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ marginTop: 2, color: isMention ? "var(--orange)" : "var(--text-2)", flexShrink: 0 }}>
|
<span style={{ marginTop: 2, color: isMention ? "var(--orange)" : "var(--text-2)", flexShrink: 0 }}>
|
||||||
<Icon name={it.threadId ? "chat" : isMention ? "chat" : "bell"} size={18} />
|
<Icon name={it.kind === "MAIL" ? "mail" : it.threadId ? "chat" : isMention ? "chat" : "bell"} size={18} />
|
||||||
</span>
|
</span>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
|
||||||
@@ -122,8 +123,9 @@ function Detail({ it, onError, onDone, onSnooze, onArchive }: {
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Item actions bar (works for every item, threaded or not) */}
|
{/* Item actions bar — only for real inbox work-items. Mail isn't an inbox item
|
||||||
{it.state === "OPEN" && (
|
(no crm.inbox.transition), so it gets read/reply only, no Done/Snooze/Archive. */}
|
||||||
|
{it.state === "OPEN" && it.kind !== "MAIL" && (
|
||||||
<div style={{ display: "flex", gap: 6, padding: "10px 16px", borderBottom: "1px solid var(--border)", justifyContent: "flex-end" }}>
|
<div style={{ display: "flex", gap: 6, padding: "10px 16px", borderBottom: "1px solid var(--border)", justifyContent: "flex-end" }}>
|
||||||
<Btn variant="ghost" icon="clock" onClick={onSnooze}>Snooze</Btn>
|
<Btn variant="ghost" icon="clock" onClick={onSnooze}>Snooze</Btn>
|
||||||
<Btn variant="outline" icon="check" onClick={onDone}>Done</Btn>
|
<Btn variant="outline" icon="check" onClick={onDone}>Done</Btn>
|
||||||
@@ -133,8 +135,10 @@ function Detail({ it, onError, onDone, onSnooze, onArchive }: {
|
|||||||
|
|
||||||
{it.threadId ? (
|
{it.threadId ? (
|
||||||
// A message/mail item → open the conversation to read + reply.
|
// A message/mail item → open the conversation to read + reply.
|
||||||
|
// key by threadId: the SDK's useQuery only refetches when the ACTION changes, not the
|
||||||
|
// variables — so switching items must remount MailReader to load the new thread's history.
|
||||||
<div style={{ flex: 1, minHeight: 0 }}>
|
<div style={{ flex: 1, minHeight: 0 }}>
|
||||||
<MailReader threadId={it.threadId} subject={it.title} onError={onError} />
|
<MailReader key={it.threadId} threadId={it.threadId} subject={it.title} onError={onError} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// A non-threaded item (e.g. a system alert) → show its detail.
|
// A non-threaded item (e.g. a system alert) → show its detail.
|
||||||
|
|||||||
@@ -216,13 +216,16 @@ export function NewMailModal({ open, onClose, onSent, onError }: { open: boolean
|
|||||||
<Field label="Subject"><input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject" style={inputStyle} /></Field>
|
<Field label="Subject"><input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject" style={inputStyle} /></Field>
|
||||||
<Field label="Message"><textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" rows={6} style={{ ...inputStyle, resize: "vertical" }} /></Field>
|
<Field label="Message"><textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" rows={6} style={{ ...inputStyle, resize: "vertical" }} /></Field>
|
||||||
|
|
||||||
<Field label="Attachments">
|
{/* NOT a <Field> (which is a <label>): a label wrapping the file input would hijack the
|
||||||
|
Attach button's click via label→input association and open the picker erratically. */}
|
||||||
|
<div className="ds-field">
|
||||||
|
<span className="ds-field-lbl">Attachments</span>
|
||||||
<input ref={fileRef} type="file" multiple style={{ display: "none" }} onChange={onPickFile} />
|
<input ref={fileRef} type="file" multiple style={{ display: "none" }} onChange={onPickFile} />
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
|
||||||
<Btn variant="outline" icon="paperclip" onClick={() => fileRef.current?.click()} disabled={uploading || staged.length >= 10}>{uploading ? "Uploading…" : "Attach"}</Btn>
|
<Btn variant="outline" icon="paperclip" onClick={() => fileRef.current?.click()} disabled={uploading || staged.length >= 10}>{uploading ? "Uploading…" : "Attach"}</Btn>
|
||||||
{staged.map((f, i) => <StagedChip key={`${f.contentRef}_${i}`} file={f} onRemove={() => setStaged((s) => s.filter((_, j) => j !== i))} />)}
|
{staged.map((f, i) => <StagedChip key={`${f.contentRef}_${i}`} file={f} onRemove={() => setStaged((s) => s.filter((_, j) => j !== i))} />)}
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// The CRM messenger, now rendered by the shared @insignia/iios-messaging-ui SDK instead of a
|
||||||
|
// bespoke in-CRM implementation. The CRM only supplies an adapter (transport) + theming; all the
|
||||||
|
// UI + messaging logic lives in the SDK. Live path = the be-crm data door (CrmMessagingAdapter);
|
||||||
|
// demo path = the SDK's own MockAdapter.
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useAppShell, useAuth } from "@abe-kap/appshell-sdk/react";
|
||||||
|
import { MessagingProvider, Messenger as SdkMessenger, type MessagingAdapter } from "@insignia/iios-messaging-ui";
|
||||||
|
import { MockAdapter } from "@insignia/iios-messaging-ui/adapters/mock";
|
||||||
|
import "@insignia/iios-messaging-ui/styles.css";
|
||||||
|
import { isShellConfigured } from "@/lib/appshell";
|
||||||
|
import { CrmMessagingAdapter, type DataDoor } from "@/lib/crm-messaging-adapter";
|
||||||
|
import { PageHead } from "./ui";
|
||||||
|
|
||||||
|
const SHELL = isShellConfigured();
|
||||||
|
|
||||||
|
export function MessengerSdk() {
|
||||||
|
return (
|
||||||
|
<div className="view">
|
||||||
|
<PageHead
|
||||||
|
eyebrow="Communication"
|
||||||
|
title="Messenger"
|
||||||
|
subtitle="Chat with your team and clients — rendered by the messaging SDK"
|
||||||
|
icon="send"
|
||||||
|
/>
|
||||||
|
{!SHELL && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
margin: "0 0 14px",
|
||||||
|
padding: "8px 14px",
|
||||||
|
borderRadius: 10,
|
||||||
|
background: "var(--panel-2)",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontSize: 13,
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Demo mode — running on the SDK's mock adapter.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="miu-host">{SHELL ? <LiveHost /> : <DemoHost />}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHELL is a build-time constant, so exactly one of these mounts for the life of the app
|
||||||
|
// (Rules-of-Hooks safe — the other branch never renders).
|
||||||
|
function DemoHost() {
|
||||||
|
const adapter = useMemo<MessagingAdapter>(() => new MockAdapter(), []);
|
||||||
|
return (
|
||||||
|
<MessagingProvider adapter={adapter}>
|
||||||
|
<SdkMessenger />
|
||||||
|
</MessagingProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LiveHost() {
|
||||||
|
const { sdk } = useAppShell();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const adapter = useMemo<MessagingAdapter | null>(
|
||||||
|
() => (user?.id ? new CrmMessagingAdapter(sdk as unknown as DataDoor, user.id) : null),
|
||||||
|
[sdk, user?.id],
|
||||||
|
);
|
||||||
|
if (!adapter) return <div className="miu-empty">Loading…</div>;
|
||||||
|
return (
|
||||||
|
<MessagingProvider adapter={adapter}>
|
||||||
|
<SdkMessenger />
|
||||||
|
</MessagingProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// The CRM's implementation of the SDK's MessagingAdapter, over the be-crm data door
|
||||||
|
// (appshell `crm.messenger.*`). This is how the CRM consumes @insignia/iios-messaging-ui
|
||||||
|
// instead of embedding its own messenger: the SDK renders, this adapter transports.
|
||||||
|
//
|
||||||
|
// Data (list/open/history/send) goes through the BFF, which attaches the session + tenancy
|
||||||
|
// server-side. Live updates are polled here for a first cut; realtime (the IIOS socket the CRM
|
||||||
|
// already opens in messenger-socket.tsx) can be layered in by emitting into the same registry.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Conversation,
|
||||||
|
Membership,
|
||||||
|
Message,
|
||||||
|
MessageEvent,
|
||||||
|
MessagingAdapter,
|
||||||
|
SendOpts,
|
||||||
|
Unsubscribe,
|
||||||
|
} from "@insignia/iios-messaging-ui";
|
||||||
|
|
||||||
|
/** The imperative appshell data door (useAppShell().sdk). Typed structurally so we don't couple to its class. */
|
||||||
|
export interface DataDoor {
|
||||||
|
query<T>(action: string, variables?: Record<string, unknown>): Promise<T>;
|
||||||
|
command<T>(action: string, variables?: Record<string, unknown>): Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DirectoryDTO { id: string; displayName: string; kind: "staff" | "customer" }
|
||||||
|
interface ConversationDTO {
|
||||||
|
threadId: string; subject: string | null; membership: Membership | null;
|
||||||
|
participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
|
||||||
|
}
|
||||||
|
interface MessageDTO { interactionId: string; actorId: string | null; kind: string; occurredAt: string; text: string | null }
|
||||||
|
|
||||||
|
const POLL_MS = 4000;
|
||||||
|
|
||||||
|
interface Poll {
|
||||||
|
cbs: Set<(e: MessageEvent) => void>;
|
||||||
|
seen: Set<string>;
|
||||||
|
primed: boolean;
|
||||||
|
timer: ReturnType<typeof setInterval> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CrmMessagingAdapter implements MessagingAdapter {
|
||||||
|
private names: Map<string, string> | null = null;
|
||||||
|
private readonly polls = new Map<string, Poll>();
|
||||||
|
|
||||||
|
constructor(private readonly sdk: DataDoor, private readonly me: string) {}
|
||||||
|
|
||||||
|
currentActorId(): string {
|
||||||
|
return this.me;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listConversations(): Promise<Conversation[]> {
|
||||||
|
const [convs, names] = await Promise.all([
|
||||||
|
this.sdk.query<ConversationDTO[]>("crm.messenger.conversation.list", {}),
|
||||||
|
this.directory(),
|
||||||
|
]);
|
||||||
|
return convs.map((c) => this.toConversation(c, names));
|
||||||
|
}
|
||||||
|
|
||||||
|
async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
|
||||||
|
const res = await this.sdk.command<{ threadId: string }>("crm.messenger.conversation.open", {
|
||||||
|
participantIds: p.participantIds,
|
||||||
|
...(p.membership ? { membership: p.membership } : {}),
|
||||||
|
...(p.subject ? { subject: p.subject } : {}),
|
||||||
|
});
|
||||||
|
return { threadId: res.threadId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async history(threadId: string): Promise<Message[]> {
|
||||||
|
const msgs = await this.sdk.query<MessageDTO[]>("crm.messenger.history", { threadId });
|
||||||
|
return msgs.map((m) => this.toMessage(m));
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(threadId: string, content: string, opts?: SendOpts): Promise<Message> {
|
||||||
|
const m = await this.sdk.command<MessageDTO>("crm.messenger.send", {
|
||||||
|
threadId,
|
||||||
|
content,
|
||||||
|
...(opts?.attachment ? { attachment: opts.attachment } : {}),
|
||||||
|
});
|
||||||
|
const msg = this.toMessage(m);
|
||||||
|
// Mark it seen so the poll doesn't re-emit our own message on top of useMessages' optimistic row.
|
||||||
|
this.polls.get(threadId)?.seen.add(msg.id);
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe {
|
||||||
|
let poll = this.polls.get(threadId);
|
||||||
|
if (!poll) {
|
||||||
|
poll = { cbs: new Set(), seen: new Set(), primed: false, timer: null };
|
||||||
|
this.polls.set(threadId, poll);
|
||||||
|
const tick = async (): Promise<void> => {
|
||||||
|
const p = this.polls.get(threadId);
|
||||||
|
if (!p) return;
|
||||||
|
try {
|
||||||
|
const msgs = await this.sdk.query<MessageDTO[]>("crm.messenger.history", { threadId });
|
||||||
|
for (const m of msgs) {
|
||||||
|
if (p.seen.has(m.interactionId)) continue;
|
||||||
|
p.seen.add(m.interactionId);
|
||||||
|
// The first pass just records what's already loaded (useMessages fetched history);
|
||||||
|
// only genuinely new messages after that are pushed to the UI.
|
||||||
|
if (p.primed) p.cbs.forEach((f) => f({ kind: "message", message: this.toMessage(m) }));
|
||||||
|
}
|
||||||
|
p.primed = true;
|
||||||
|
} catch {
|
||||||
|
/* transient BFF error — try again next tick */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void tick();
|
||||||
|
poll.timer = setInterval(tick, POLL_MS);
|
||||||
|
}
|
||||||
|
poll.cbs.add(cb);
|
||||||
|
return () => {
|
||||||
|
const p = this.polls.get(threadId);
|
||||||
|
if (!p) return;
|
||||||
|
p.cbs.delete(cb);
|
||||||
|
if (p.cbs.size === 0) {
|
||||||
|
if (p.timer) clearInterval(p.timer);
|
||||||
|
this.polls.delete(threadId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTyping(): void {
|
||||||
|
// No BFF verb for typing; realtime is a follow-up (the IIOS socket). No-op keeps the contract.
|
||||||
|
}
|
||||||
|
|
||||||
|
async markRead(): Promise<void> {
|
||||||
|
// No BFF verb for read receipts here; follow-up via the socket. No-op resolves the contract.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mapping ────────────────────────────────────────────────────
|
||||||
|
private async directory(): Promise<Map<string, string>> {
|
||||||
|
if (!this.names) {
|
||||||
|
const dir = await this.sdk.query<DirectoryDTO[]>("crm.messenger.directory", { kind: "all", limit: 200 });
|
||||||
|
this.names = new Map(dir.map((d) => [d.id, d.displayName]));
|
||||||
|
}
|
||||||
|
return this.names;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toConversation(c: ConversationDTO, names: Map<string, string>): Conversation {
|
||||||
|
const others = c.participants.filter((p) => p !== this.me);
|
||||||
|
const title =
|
||||||
|
c.subject?.trim() ||
|
||||||
|
others.map((id) => names.get(id) ?? id).join(", ") ||
|
||||||
|
"Conversation";
|
||||||
|
return {
|
||||||
|
threadId: c.threadId,
|
||||||
|
title,
|
||||||
|
subject: c.subject,
|
||||||
|
membership: c.membership,
|
||||||
|
participants: c.participants,
|
||||||
|
unread: c.unread,
|
||||||
|
...(c.lastMessage ? { lastMessage: c.lastMessage } : {}),
|
||||||
|
...(c.lastAt ? { lastAt: c.lastAt } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toMessage(m: MessageDTO): Message {
|
||||||
|
return {
|
||||||
|
id: m.interactionId,
|
||||||
|
actorId: m.actorId,
|
||||||
|
text: m.text ?? "",
|
||||||
|
at: m.occurredAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
-2
@@ -8,9 +8,10 @@
|
|||||||
// query crm.inbox.list { state? } -> InboxItem[]
|
// query crm.inbox.list { state? } -> InboxItem[]
|
||||||
// cmd crm.inbox.transition { id, state, reason? } -> InboxItem
|
// cmd crm.inbox.transition { id, state, reason? } -> InboxItem
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
|
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
|
||||||
import { isShellConfigured } from "./appshell";
|
import { isShellConfigured } from "./appshell";
|
||||||
|
import type { MailThread } from "./mail-api";
|
||||||
|
|
||||||
export type InboxState = "OPEN" | "SNOOZED" | "DONE" | "ARCHIVED" | "CANCELLED" | "STALE";
|
export type InboxState = "OPEN" | "SNOOZED" | "DONE" | "ARCHIVED" | "CANCELLED" | "STALE";
|
||||||
export interface UiInboxItem {
|
export interface UiInboxItem {
|
||||||
@@ -34,11 +35,48 @@ export function useInboxData(state?: InboxState): InboxData {
|
|||||||
function useLiveInbox(state?: InboxState): InboxData {
|
function useLiveInbox(state?: InboxState): InboxData {
|
||||||
const { sdk } = useAppShell();
|
const { sdk } = useAppShell();
|
||||||
const q = useQuery<UiInboxItem[]>("crm.inbox.list", state ? { state } : {});
|
const q = useQuery<UiInboxItem[]>("crm.inbox.list", state ? { state } : {});
|
||||||
|
// Mail lives in crm-mail threads, NOT the inbox projection — fold it into the one unified
|
||||||
|
// surface. Mail has no inbox work-item state, so it only shows in the Open (or unfiltered) view.
|
||||||
|
const showMail = !state || state === "OPEN";
|
||||||
|
const mq = useQuery<MailThread[]>("crm.mail.list", {});
|
||||||
|
|
||||||
|
// The SDK's useQuery only refetches when the ACTION changes, not the variables — so a filter
|
||||||
|
// change (same action, new { state }) wouldn't reload. Force a refetch when the filter changes.
|
||||||
|
const refetchInbox = q.refetch;
|
||||||
|
useEffect(() => { refetchInbox(); }, [state, refetchInbox]);
|
||||||
|
|
||||||
|
const items = useMemo<UiInboxItem[]>(() => {
|
||||||
|
const inboxItems = q.data ?? [];
|
||||||
|
const mailItems: UiInboxItem[] = showMail
|
||||||
|
? (mq.data ?? []).map((t) => ({
|
||||||
|
id: `mail:${t.threadId}`,
|
||||||
|
kind: "MAIL",
|
||||||
|
state: "OPEN" as InboxState,
|
||||||
|
title: t.subject || "(no subject)",
|
||||||
|
...(t.lastMessage ? { summary: t.lastMessage } : {}),
|
||||||
|
priority: t.unread > 0 ? "HIGH" : "LOW",
|
||||||
|
threadId: t.threadId,
|
||||||
|
createdAt: t.lastAt ?? "",
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
// Newest first; mail and inbox items interleave by time.
|
||||||
|
return [...mailItems, ...inboxItems].sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
|
||||||
|
}, [q.data, mq.data, showMail]);
|
||||||
|
|
||||||
const transition = useCallback(async (id: string, next: InboxState) => {
|
const transition = useCallback(async (id: string, next: InboxState) => {
|
||||||
await sdk.command("crm.inbox.transition", { id, state: next });
|
await sdk.command("crm.inbox.transition", { id, state: next });
|
||||||
q.refetch();
|
q.refetch();
|
||||||
}, [sdk, q]);
|
}, [sdk, q]);
|
||||||
return { live: true, loading: q.loading, error: q.error?.message ?? null, items: q.data ?? [], transition, refetch: q.refetch };
|
|
||||||
|
return {
|
||||||
|
live: true,
|
||||||
|
loading: q.loading || (showMail && mq.loading),
|
||||||
|
// Don't let a mail-list hiccup blank the whole inbox — surface only the inbox error.
|
||||||
|
error: q.error?.message ?? null,
|
||||||
|
items,
|
||||||
|
transition,
|
||||||
|
refetch: () => { q.refetch(); mq.refetch(); },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const MOCK_ITEMS: UiInboxItem[] = [
|
const MOCK_ITEMS: UiInboxItem[] = [
|
||||||
|
|||||||
+14
-1
@@ -14,12 +14,25 @@ export function isImage(mime?: string | null): boolean {
|
|||||||
return !!mime && mime.startsWith("image/");
|
return !!mime && mime.startsWith("image/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Some types (notably .md) have no OS-registered MIME, so the browser reports an empty file.type.
|
||||||
|
// Fall back to the extension for the text types IIOS allows, else a generic binary.
|
||||||
|
const EXT_MIME: Record<string, string> = {
|
||||||
|
md: "text/markdown", markdown: "text/markdown",
|
||||||
|
html: "text/html", htm: "text/html",
|
||||||
|
txt: "text/plain", csv: "text/csv",
|
||||||
|
};
|
||||||
|
function mimeForFile(file: File): string {
|
||||||
|
if (file.type) return file.type;
|
||||||
|
const ext = file.name.toLowerCase().split(".").pop() ?? "";
|
||||||
|
return EXT_MIME[ext] ?? "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
/** Upload a File → { contentRef, mimeType, sizeBytes, filename }. Throws on oversize / failure. */
|
/** Upload a File → { contentRef, mimeType, sizeBytes, filename }. Throws on oversize / failure. */
|
||||||
export function useUploadAttachment() {
|
export function useUploadAttachment() {
|
||||||
const { sdk } = useAppShell();
|
const { sdk } = useAppShell();
|
||||||
return useCallback(async (file: File): Promise<UploadedAttachment> => {
|
return useCallback(async (file: File): Promise<UploadedAttachment> => {
|
||||||
if (file.size > MAX_ATTACHMENT_BYTES) throw new Error("File is too large (max 25 MB).");
|
if (file.size > MAX_ATTACHMENT_BYTES) throw new Error("File is too large (max 25 MB).");
|
||||||
const mime = file.type || "application/octet-stream";
|
const mime = mimeForFile(file);
|
||||||
const { objectKey, uploadUrl } = (await sdk.command("crm.media.presignUpload", { mime, sizeBytes: file.size })) as { objectKey: string; uploadUrl: string };
|
const { objectKey, uploadUrl } = (await sdk.command("crm.media.presignUpload", { mime, sizeBytes: file.size })) as { objectKey: string; uploadUrl: string };
|
||||||
const res = await fetch(uploadUrl, { method: "PUT", body: file });
|
const res = await fetch(uploadUrl, { method: "PUT", body: file });
|
||||||
if (!res.ok) throw new Error(`Upload failed (${res.status}).`);
|
if (!res.ok) throw new Error(`Upload failed (${res.status}).`);
|
||||||
|
|||||||
Reference in New Issue
Block a user