first commit
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@lynkd/messaging-inbox-sdk",
|
||||
"version": "0.1.0",
|
||||
"description": "React SDK for Slack-grade messaging, inbox and support surfaces over a BFF. Headless hooks + providers + theming + feature flags.",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./styles.css": "./src/styles.css"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "echo \"(lint stub)\""
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import type {
|
||||
Bootstrap,
|
||||
Channel,
|
||||
InboxItem,
|
||||
InboxState,
|
||||
Message,
|
||||
Notification,
|
||||
SearchResult,
|
||||
Ticket,
|
||||
TicketState,
|
||||
AgentAvailability,
|
||||
ID,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Thin typed client for the BFF. The UI/SDK never talks to a backend directly;
|
||||
* it always goes through the host app's BFF (Next route handlers here). Swapping
|
||||
* the BFF to proxy the real IIOS service requires no changes to this client.
|
||||
*/
|
||||
export class BffClient {
|
||||
constructor(private baseUrl: string = "") {}
|
||||
|
||||
private url(path: string): string {
|
||||
const base = this.baseUrl.replace(/\/$/, "");
|
||||
return `${base}/api/bff${path}`;
|
||||
}
|
||||
|
||||
private async req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(this.url(path), {
|
||||
headers: { "content-type": "application/json" },
|
||||
cache: "no-store",
|
||||
...init,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`BFF ${res.status} ${path}: ${text}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like req(), but returns the parsed JSON body even on non-2xx responses
|
||||
* instead of throwing. Used for endpoints (AI/translate) that carry a
|
||||
* structured `{ ok:false, error }` payload we want to show the user
|
||||
* gracefully (e.g. a 429 quota message).
|
||||
*/
|
||||
private async reqSoft<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
try {
|
||||
const res = await fetch(this.url(path), {
|
||||
headers: { "content-type": "application/json" },
|
||||
cache: "no-store",
|
||||
...init,
|
||||
});
|
||||
const json = await res.json().catch(() => null);
|
||||
if (json) return json as T;
|
||||
return { ok: false, error: `Request failed (${res.status}).` } as unknown as T;
|
||||
} catch (e) {
|
||||
return { ok: false, error: `Could not reach the server: ${(e as Error).message}` } as unknown as T;
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap() {
|
||||
return this.req<Bootstrap>("/bootstrap");
|
||||
}
|
||||
|
||||
listChannels() {
|
||||
return this.req<{ channels: Channel[] }>("/channels");
|
||||
}
|
||||
|
||||
createChannel(input: { name: string; kind?: string; topic?: string; memberIds?: ID[] }) {
|
||||
return this.req<{ channel: Channel }>("/channels", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
addMember(channelId: ID, userId: ID) {
|
||||
return this.req<{ channel: Channel }>(`/channels/${channelId}/members`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
}
|
||||
|
||||
removeMember(channelId: ID, userId: ID) {
|
||||
return this.req<{ channel: Channel }>(`/channels/${channelId}/members`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
}
|
||||
|
||||
markRead(channelId: ID) {
|
||||
return this.req<{ channel: Channel }>(`/channels/${channelId}/read`, { method: "POST" });
|
||||
}
|
||||
|
||||
setStatus(statusText?: string, statusEmoji?: string, clearAt?: number) {
|
||||
return this.req<{ user: import("./types").User }>("/me/status", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ statusText, statusEmoji, clearAt }),
|
||||
});
|
||||
}
|
||||
|
||||
updateMe(patch: { name?: string; title?: string; avatarColor?: string; avatarUrl?: string | null; presence?: string }) {
|
||||
return this.req<{ user: import("./types").User }>("/me/profile", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
editMessage(channelId: ID, messageId: ID, body: string) {
|
||||
return this.req<{ message: Message }>(`/channels/${channelId}/messages/${messageId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
}
|
||||
|
||||
deleteMessage(channelId: ID, messageId: ID) {
|
||||
return this.req<{ ok: boolean }>(`/channels/${channelId}/messages/${messageId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
savedMessages() {
|
||||
return this.req<{ messages: Message[] }>("/saved");
|
||||
}
|
||||
|
||||
threadParents() {
|
||||
return this.req<{ messages: Message[] }>("/threads");
|
||||
}
|
||||
|
||||
getMessages(channelId: ID) {
|
||||
return this.req<{ messages: Message[] }>(`/channels/${channelId}/messages`);
|
||||
}
|
||||
|
||||
sendMessage(
|
||||
channelId: ID,
|
||||
body: string,
|
||||
opts?: { parentId?: ID; scheduledFor?: number; attachments?: import("./types").Attachment[]; threadRootId?: ID | null },
|
||||
) {
|
||||
return this.req<{ message: Message }>(`/channels/${channelId}/messages`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body, ...opts }),
|
||||
});
|
||||
}
|
||||
|
||||
getThread(channelId: ID, parentId: ID) {
|
||||
return this.req<{ parent: Message; replies: Message[] }>(
|
||||
`/channels/${channelId}/threads/${parentId}`,
|
||||
);
|
||||
}
|
||||
|
||||
toggleReaction(channelId: ID, messageId: ID, emoji: string) {
|
||||
return this.req<{ message: Message }>(
|
||||
`/channels/${channelId}/messages/${messageId}/reactions`,
|
||||
{ method: "POST", body: JSON.stringify({ emoji }) },
|
||||
);
|
||||
}
|
||||
|
||||
togglePin(channelId: ID, messageId: ID) {
|
||||
return this.req<{ message: Message }>(
|
||||
`/channels/${channelId}/messages/${messageId}/pin`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
toggleSave(channelId: ID, messageId: ID) {
|
||||
return this.req<{ message: Message }>(
|
||||
`/channels/${channelId}/messages/${messageId}/save`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
listInbox(state?: InboxState) {
|
||||
const q = state ? `?state=${state}` : "";
|
||||
return this.req<{ items: InboxItem[] }>(`/inbox${q}`);
|
||||
}
|
||||
|
||||
patchInbox(id: ID, state: InboxState) {
|
||||
return this.req<{ item: InboxItem }>(`/inbox/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ state }),
|
||||
});
|
||||
}
|
||||
|
||||
listTickets(scope?: "mine" | "assigned" | "all") {
|
||||
const q = scope ? `?scope=${scope}` : "";
|
||||
return this.req<{ tickets: Ticket[] }>(`/support/tickets${q}`);
|
||||
}
|
||||
|
||||
getTicket(id: ID) {
|
||||
return this.req<{ ticket: Ticket }>(`/support/tickets/${id}`);
|
||||
}
|
||||
|
||||
patchTicket(id: ID, state: TicketState) {
|
||||
return this.req<{ ticket: Ticket }>(`/support/tickets/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ state }),
|
||||
});
|
||||
}
|
||||
|
||||
replyTicket(id: ID, body: string) {
|
||||
return this.req<{ ticket: Ticket }>(`/support/tickets/${id}/reply`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
}
|
||||
|
||||
availability() {
|
||||
return this.req<{ agents: AgentAvailability[] }>(`/support/availability`);
|
||||
}
|
||||
|
||||
search(q: string) {
|
||||
return this.req<{ results: SearchResult[] }>(`/search?q=${encodeURIComponent(q)}`);
|
||||
}
|
||||
|
||||
notifications() {
|
||||
return this.req<{ notifications: Notification[] }>(`/notifications`);
|
||||
}
|
||||
|
||||
/** Ask the AI assistant. `context` is optional message text to reason about. */
|
||||
ai(prompt: string, context?: string) {
|
||||
return this.reqSoft<{ ok: boolean; text?: string; error?: string; retryable?: boolean }>("/ai", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ prompt, context }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Translate text into a target language (e.g. "Spanish", "Japanese"). */
|
||||
translate(text: string, targetLang: string) {
|
||||
return this.reqSoft<{ ok: boolean; text?: string; error?: string; retryable?: boolean }>("/translate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text, targetLang }),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Central configuration surface for the SDK.
|
||||
*
|
||||
* Everything a host app wants to toggle or restyle lives here:
|
||||
* - feature flags (turn whole surfaces / behaviours on and off)
|
||||
* - theme tokens (accent, surfaces, radius, gradient, font)
|
||||
*
|
||||
* Values are resolved with this precedence (last wins):
|
||||
* built-in defaults -> env (NEXT_PUBLIC_*) -> props passed to the Provider
|
||||
*
|
||||
* Defaults are tuned to match the LynkedUp Pro dashboard (dark, amber accent).
|
||||
*/
|
||||
|
||||
export interface FeatureFlags {
|
||||
// top-level surfaces
|
||||
messaging: boolean;
|
||||
inbox: boolean;
|
||||
support: boolean;
|
||||
activity: boolean;
|
||||
// messaging sub-features
|
||||
threads: boolean;
|
||||
reactions: boolean;
|
||||
mentions: boolean;
|
||||
pins: boolean;
|
||||
savedItems: boolean;
|
||||
fileUpload: boolean;
|
||||
richComposer: boolean;
|
||||
slashCommands: boolean;
|
||||
scheduledSend: boolean;
|
||||
drafts: boolean;
|
||||
huddles: boolean;
|
||||
presence: boolean;
|
||||
typingIndicator: boolean;
|
||||
readReceipts: boolean;
|
||||
emojiPicker: boolean;
|
||||
channelBrowser: boolean;
|
||||
directMessages: boolean;
|
||||
groupDms: boolean;
|
||||
// support sub-features
|
||||
supportTickets: boolean;
|
||||
supportEscalate: boolean;
|
||||
supportCallback: boolean;
|
||||
supportAvailability: boolean;
|
||||
// global
|
||||
search: boolean;
|
||||
commandPalette: boolean;
|
||||
notifications: boolean;
|
||||
themeToggle: boolean;
|
||||
themingPanel: boolean;
|
||||
workspaceSwitcher: boolean;
|
||||
aiAssist: boolean;
|
||||
translation: boolean;
|
||||
}
|
||||
|
||||
export const defaultFlags: FeatureFlags = {
|
||||
messaging: true,
|
||||
inbox: true,
|
||||
support: true,
|
||||
activity: true,
|
||||
threads: true,
|
||||
reactions: true,
|
||||
mentions: true,
|
||||
pins: true,
|
||||
savedItems: true,
|
||||
fileUpload: true,
|
||||
richComposer: true,
|
||||
slashCommands: true,
|
||||
scheduledSend: true,
|
||||
drafts: true,
|
||||
huddles: true,
|
||||
presence: true,
|
||||
typingIndicator: true,
|
||||
readReceipts: true,
|
||||
emojiPicker: true,
|
||||
channelBrowser: true,
|
||||
directMessages: true,
|
||||
groupDms: true,
|
||||
supportTickets: true,
|
||||
supportEscalate: true,
|
||||
supportCallback: true,
|
||||
supportAvailability: true,
|
||||
search: true,
|
||||
commandPalette: true,
|
||||
notifications: true,
|
||||
themeToggle: true,
|
||||
themingPanel: true,
|
||||
workspaceSwitcher: true,
|
||||
aiAssist: true,
|
||||
translation: true,
|
||||
};
|
||||
|
||||
/** A single set of design tokens (applies to one color scheme). */
|
||||
export interface ThemeTokens {
|
||||
bg: string;
|
||||
surface: string;
|
||||
panel: string;
|
||||
elevated: string;
|
||||
hover: string;
|
||||
border: string;
|
||||
borderStrong: string;
|
||||
ink: string;
|
||||
muted: string;
|
||||
dim: string;
|
||||
accent: string;
|
||||
accentFg: string;
|
||||
accentSoft: string;
|
||||
success: string;
|
||||
warning: string;
|
||||
danger: string;
|
||||
info: string;
|
||||
}
|
||||
|
||||
export interface ThemeConfig {
|
||||
/** initial color scheme */
|
||||
mode: "light" | "dark" | "system";
|
||||
/** brand accent — overrides accent in both schemes when set */
|
||||
accent?: string;
|
||||
accentFg?: string;
|
||||
/** radii */
|
||||
radiusCard: string;
|
||||
radiusControl: string;
|
||||
radiusPill: string;
|
||||
/** typography */
|
||||
fontSans: string;
|
||||
fontMono: string;
|
||||
/** optional decorative gradient used on the brand mark / hero accents */
|
||||
gradient: string;
|
||||
/** per-scheme token overrides */
|
||||
light: ThemeTokens;
|
||||
dark: ThemeTokens;
|
||||
}
|
||||
|
||||
/** Dark tokens — sampled from LynkedUp Pro (accent #FDA913). */
|
||||
export const darkTokens: ThemeTokens = {
|
||||
bg: "#060608",
|
||||
surface: "#08080b",
|
||||
panel: "#0e0e13",
|
||||
elevated: "#15151c",
|
||||
hover: "rgba(255,255,255,0.055)",
|
||||
border: "rgba(255,255,255,0.08)",
|
||||
borderStrong: "rgba(255,255,255,0.14)",
|
||||
ink: "#ededf1",
|
||||
muted: "#8c8c94",
|
||||
dim: "#63636b",
|
||||
accent: "#FDA913",
|
||||
accentFg: "#1a1205",
|
||||
accentSoft: "rgba(253,169,19,0.12)",
|
||||
success: "#3ecf8e",
|
||||
warning: "#f5b544",
|
||||
danger: "#f2555a",
|
||||
info: "#5b9dff",
|
||||
};
|
||||
|
||||
/** Light tokens — the same design language, inverted, amber preserved. */
|
||||
export const lightTokens: ThemeTokens = {
|
||||
bg: "#f6f7f9",
|
||||
surface: "#ffffff",
|
||||
panel: "#ffffff",
|
||||
elevated: "#ffffff",
|
||||
hover: "rgba(9,10,14,0.045)",
|
||||
border: "rgba(9,10,14,0.10)",
|
||||
borderStrong: "rgba(9,10,14,0.16)",
|
||||
ink: "#14151a",
|
||||
muted: "#5c6069",
|
||||
dim: "#8b909a",
|
||||
accent: "#e8920a",
|
||||
accentFg: "#ffffff",
|
||||
accentSoft: "rgba(232,146,10,0.12)",
|
||||
success: "#12915c",
|
||||
warning: "#b3730a",
|
||||
danger: "#d0353b",
|
||||
info: "#2f6fe0",
|
||||
};
|
||||
|
||||
export const defaultTheme: ThemeConfig = {
|
||||
mode: "dark",
|
||||
radiusCard: "20px",
|
||||
radiusControl: "10px",
|
||||
radiusPill: "999px",
|
||||
fontSans: 'Inter, system-ui, -apple-system, "Segoe UI", sans-serif',
|
||||
fontMono: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
|
||||
gradient: "linear-gradient(135deg, #FDA913 0%, #ff7a3d 55%, #ff4d6d 100%)",
|
||||
light: lightTokens,
|
||||
dark: darkTokens,
|
||||
};
|
||||
|
||||
export interface SdkConfig {
|
||||
/** base URL of the BFF (empty string = same-origin /api/bff) */
|
||||
bffBaseUrl: string;
|
||||
flags: FeatureFlags;
|
||||
theme: ThemeConfig;
|
||||
/** poll interval used by the mock realtime layer */
|
||||
pollMs: number;
|
||||
brandName: string;
|
||||
}
|
||||
|
||||
/* --------------------------- env resolution --------------------------- */
|
||||
|
||||
function envBool(v: string | undefined): boolean | undefined {
|
||||
if (v == null) return undefined;
|
||||
const s = v.trim().toLowerCase();
|
||||
if (["1", "true", "on", "yes"].includes(s)) return true;
|
||||
if (["0", "false", "off", "no"].includes(s)) return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads NEXT_PUBLIC_FEATURE_<UPPER_SNAKE> for every flag.
|
||||
* e.g. huddles -> NEXT_PUBLIC_FEATURE_HUDDLES=false
|
||||
*/
|
||||
export function flagsFromEnv(
|
||||
env: Record<string, string | undefined>,
|
||||
): Partial<FeatureFlags> {
|
||||
const out: Partial<FeatureFlags> = {};
|
||||
for (const key of Object.keys(defaultFlags) as (keyof FeatureFlags)[]) {
|
||||
const envKey =
|
||||
"NEXT_PUBLIC_FEATURE_" + key.replace(/[A-Z]/g, (m) => "_" + m).toUpperCase();
|
||||
const val = envBool(env[envKey]);
|
||||
if (val !== undefined) out[key] = val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function themeFromEnv(
|
||||
env: Record<string, string | undefined>,
|
||||
): Partial<ThemeConfig> {
|
||||
const out: Partial<ThemeConfig> = {};
|
||||
const mode = env.NEXT_PUBLIC_THEME_MODE as ThemeConfig["mode"] | undefined;
|
||||
if (mode) out.mode = mode;
|
||||
if (env.NEXT_PUBLIC_THEME_ACCENT) out.accent = env.NEXT_PUBLIC_THEME_ACCENT;
|
||||
if (env.NEXT_PUBLIC_THEME_ACCENT_FG) out.accentFg = env.NEXT_PUBLIC_THEME_ACCENT_FG;
|
||||
if (env.NEXT_PUBLIC_THEME_RADIUS_CARD) out.radiusCard = env.NEXT_PUBLIC_THEME_RADIUS_CARD;
|
||||
if (env.NEXT_PUBLIC_THEME_RADIUS_CONTROL) out.radiusControl = env.NEXT_PUBLIC_THEME_RADIUS_CONTROL;
|
||||
if (env.NEXT_PUBLIC_THEME_RADIUS_PILL) out.radiusPill = env.NEXT_PUBLIC_THEME_RADIUS_PILL;
|
||||
if (env.NEXT_PUBLIC_THEME_FONT_SANS) out.fontSans = env.NEXT_PUBLIC_THEME_FONT_SANS;
|
||||
if (env.NEXT_PUBLIC_THEME_FONT_MONO) out.fontMono = env.NEXT_PUBLIC_THEME_FONT_MONO;
|
||||
if (env.NEXT_PUBLIC_THEME_GRADIENT) out.gradient = env.NEXT_PUBLIC_THEME_GRADIENT;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function resolveConfig(opts: {
|
||||
env?: Record<string, string | undefined>;
|
||||
flags?: Partial<FeatureFlags>;
|
||||
theme?: Partial<ThemeConfig>;
|
||||
bffBaseUrl?: string;
|
||||
brandName?: string;
|
||||
pollMs?: number;
|
||||
} = {}): SdkConfig {
|
||||
const env = opts.env ?? {};
|
||||
const theme: ThemeConfig = { ...defaultTheme, ...themeFromEnv(env), ...opts.theme };
|
||||
// let a single accent env/prop override both schemes
|
||||
if (theme.accent) {
|
||||
theme.dark = { ...theme.dark, accent: theme.accent, accentSoft: withAlpha(theme.accent, 0.12) };
|
||||
theme.light = { ...theme.light, accent: theme.accent, accentSoft: withAlpha(theme.accent, 0.12) };
|
||||
}
|
||||
// accentFg can be set independently of accent
|
||||
if (theme.accentFg) {
|
||||
theme.dark = { ...theme.dark, accentFg: theme.accentFg };
|
||||
theme.light = { ...theme.light, accentFg: theme.accentFg };
|
||||
}
|
||||
return {
|
||||
bffBaseUrl:
|
||||
opts.bffBaseUrl ?? env.NEXT_PUBLIC_BFF_BASE_URL ?? "",
|
||||
brandName: opts.brandName ?? env.NEXT_PUBLIC_BRAND_NAME ?? "LynkedUp Pro",
|
||||
pollMs: opts.pollMs ?? Number(env.NEXT_PUBLIC_POLL_MS ?? 4000),
|
||||
flags: { ...defaultFlags, ...flagsFromEnv(env), ...opts.flags },
|
||||
theme,
|
||||
};
|
||||
}
|
||||
|
||||
/** Return `color` at a given alpha. Handles #rgb/#rrggbb, and falls back to
|
||||
* color-mix for rgb()/hsl()/named colors so a soft tint is never fully opaque. */
|
||||
export function withAlpha(color: string, alpha: number): string {
|
||||
const c = color.trim();
|
||||
const hex6 = /^#?([0-9a-f]{6})$/i.exec(c);
|
||||
const hex3 = /^#?([0-9a-f]{3})$/i.exec(c);
|
||||
let r: number | undefined, g: number | undefined, b: number | undefined;
|
||||
if (hex6) {
|
||||
const int = parseInt(hex6[1], 16);
|
||||
r = (int >> 16) & 255;
|
||||
g = (int >> 8) & 255;
|
||||
b = int & 255;
|
||||
} else if (hex3) {
|
||||
r = parseInt(hex3[1][0] + hex3[1][0], 16);
|
||||
g = parseInt(hex3[1][1] + hex3[1][1], 16);
|
||||
b = parseInt(hex3[1][2] + hex3[1][2], 16);
|
||||
}
|
||||
if (r !== undefined) return `rgba(${r},${g},${b},${alpha})`;
|
||||
// rgb()/hsl()/named — use color-mix (supported in modern browsers)
|
||||
return `color-mix(in srgb, ${c} ${Math.round(alpha * 100)}%, transparent)`;
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSdk } from "./provider";
|
||||
import type {
|
||||
Channel,
|
||||
InboxItem,
|
||||
InboxState,
|
||||
Message,
|
||||
Notification,
|
||||
SearchResult,
|
||||
Ticket,
|
||||
TicketState,
|
||||
AgentAvailability,
|
||||
ID,
|
||||
} from "./types";
|
||||
|
||||
/* --------------------- cross-view mutation bus ------------------------ */
|
||||
// Lets separate useMessages/useThread instances stay in sync without a refresh
|
||||
// (e.g. deleting in a thread reflects in the channel; "also send to channel").
|
||||
type ChannelListener = (channelId: string) => void;
|
||||
const channelListeners = new Set<ChannelListener>();
|
||||
export function notifyChannelChanged(channelId: string) {
|
||||
channelListeners.forEach((l) => l(channelId));
|
||||
}
|
||||
|
||||
/* ------------------------------ channels ------------------------------ */
|
||||
|
||||
export function useChannels() {
|
||||
const { channels, me } = useSdk();
|
||||
return useMemo(() => {
|
||||
const starred = channels.filter((c) => c.isStarred);
|
||||
const chans = channels.filter((c) => (c.kind === "channel" || c.kind === "private") && !c.isStarred);
|
||||
const dms = channels.filter((c) => (c.kind === "dm" || c.kind === "group_dm") && !c.isStarred);
|
||||
const totalUnread = channels.reduce((n, c) => n + (c.unreadCount ?? 0), 0);
|
||||
const totalMentions = channels.reduce((n, c) => n + (c.mentionCount ?? 0), 0);
|
||||
return { all: channels, starred, channels: chans, dms, me, totalUnread, totalMentions };
|
||||
}, [channels, me]);
|
||||
}
|
||||
|
||||
/* ------------------------------ messages ------------------------------ */
|
||||
|
||||
export function useMessages(channelId: ID | null) {
|
||||
const { client, config } = useSdk();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!channelId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
client
|
||||
.getMessages(channelId)
|
||||
.then((r) => setMessages(r.messages))
|
||||
.catch((e) => setError(String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [client, channelId]);
|
||||
|
||||
useEffect(() => {
|
||||
setMessages([]);
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// stay in sync when another view mutates this channel (thread reply, delete, also-send)
|
||||
useEffect(() => {
|
||||
if (!channelId) return;
|
||||
const l: ChannelListener = (cid) => { if (cid === channelId) load(); };
|
||||
channelListeners.add(l);
|
||||
return () => { channelListeners.delete(l); };
|
||||
}, [channelId, load]);
|
||||
|
||||
const send = useCallback(
|
||||
async (body: string, opts?: { parentId?: ID; scheduledFor?: number; attachments?: import("./types").Attachment[] }) => {
|
||||
if (!channelId || (!body.trim() && !opts?.attachments?.length)) return;
|
||||
const r = await client.sendMessage(channelId, body, opts);
|
||||
setMessages((prev) => [...prev, r.message]);
|
||||
notifyChannelChanged(channelId);
|
||||
return r.message;
|
||||
},
|
||||
[client, channelId],
|
||||
);
|
||||
|
||||
const react = useCallback(
|
||||
async (messageId: ID, emoji: string) => {
|
||||
if (!channelId) return;
|
||||
const r = await client.toggleReaction(channelId, messageId, emoji);
|
||||
setMessages((prev) => prev.map((m) => (m.id === messageId ? r.message : m)));
|
||||
},
|
||||
[client, channelId],
|
||||
);
|
||||
|
||||
const pin = useCallback(
|
||||
async (messageId: ID) => {
|
||||
if (!channelId) return;
|
||||
const r = await client.togglePin(channelId, messageId);
|
||||
setMessages((prev) => prev.map((m) => (m.id === messageId ? r.message : m)));
|
||||
},
|
||||
[client, channelId],
|
||||
);
|
||||
|
||||
const save = useCallback(
|
||||
async (messageId: ID) => {
|
||||
if (!channelId) return;
|
||||
const r = await client.toggleSave(channelId, messageId);
|
||||
setMessages((prev) => prev.map((m) => (m.id === messageId ? r.message : m)));
|
||||
},
|
||||
[client, channelId],
|
||||
);
|
||||
|
||||
const edit = useCallback(
|
||||
async (messageId: ID, body: string) => {
|
||||
if (!channelId) return;
|
||||
const r = await client.editMessage(channelId, messageId, body);
|
||||
setMessages((prev) => prev.map((m) => (m.id === messageId ? r.message : m)));
|
||||
notifyChannelChanged(channelId);
|
||||
},
|
||||
[client, channelId],
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
async (messageId: ID) => {
|
||||
if (!channelId) return;
|
||||
await client.deleteMessage(channelId, messageId);
|
||||
setMessages((prev) => prev.filter((m) => m.id !== messageId));
|
||||
notifyChannelChanged(channelId);
|
||||
},
|
||||
[client, channelId],
|
||||
);
|
||||
|
||||
const pinned = useMemo(() => messages.filter((m) => m.isPinned), [messages]);
|
||||
|
||||
return { messages, pinned, loading, error, send, react, pin, save, edit, remove, reload: load, pollMs: config.pollMs };
|
||||
}
|
||||
|
||||
export function useSavedMessages() {
|
||||
const { client } = useSdk();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
client.savedMessages().then((r) => alive && setMessages(r.messages)).finally(() => alive && setLoading(false));
|
||||
return () => { alive = false; };
|
||||
}, [client]);
|
||||
return { messages, loading };
|
||||
}
|
||||
|
||||
export function useThreadList() {
|
||||
const { client } = useSdk();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
client.threadParents().then((r) => alive && setMessages(r.messages)).finally(() => alive && setLoading(false));
|
||||
return () => { alive = false; };
|
||||
}, [client]);
|
||||
return { messages, loading };
|
||||
}
|
||||
|
||||
export function useThread(channelId: ID | null, parentId: ID | null) {
|
||||
const { client } = useSdk();
|
||||
const [parent, setParent] = useState<Message | null>(null);
|
||||
const [replies, setReplies] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!channelId || !parentId) return;
|
||||
setLoading(true);
|
||||
client
|
||||
.getThread(channelId, parentId)
|
||||
.then((r) => {
|
||||
setParent(r.parent);
|
||||
setReplies(r.replies);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [client, channelId, parentId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const reply = useCallback(
|
||||
async (body: string, opts?: { attachments?: import("./types").Attachment[] }) => {
|
||||
if (!channelId || !parentId) return;
|
||||
const r = await client.sendMessage(channelId, body, { parentId, attachments: opts?.attachments });
|
||||
setReplies((prev) => [...prev, r.message]);
|
||||
if (channelId) notifyChannelChanged(channelId);
|
||||
},
|
||||
[client, channelId, parentId],
|
||||
);
|
||||
|
||||
const editReply = useCallback(
|
||||
async (messageId: ID, body: string) => {
|
||||
if (!channelId) return;
|
||||
const r = await client.editMessage(channelId, messageId, body);
|
||||
const upd = r.message;
|
||||
setParent((p) => (p && p.id === messageId ? upd : p));
|
||||
setReplies((prev) => prev.map((m) => (m.id === messageId ? upd : m)));
|
||||
notifyChannelChanged(channelId);
|
||||
},
|
||||
[client, channelId],
|
||||
);
|
||||
|
||||
const removeReply = useCallback(
|
||||
async (messageId: ID) => {
|
||||
if (!channelId) return;
|
||||
await client.deleteMessage(channelId, messageId);
|
||||
setReplies((prev) => prev.filter((m) => m.id !== messageId));
|
||||
notifyChannelChanged(channelId);
|
||||
},
|
||||
[client, channelId],
|
||||
);
|
||||
|
||||
const apply = useCallback((updated: Message) => {
|
||||
setParent((p) => (p && p.id === updated.id ? updated : p));
|
||||
setReplies((prev) => prev.map((m) => (m.id === updated.id ? updated : m)));
|
||||
}, []);
|
||||
|
||||
const react = useCallback(
|
||||
async (messageId: ID, emoji: string) => {
|
||||
if (!channelId) return;
|
||||
const r = await client.toggleReaction(channelId, messageId, emoji);
|
||||
apply(r.message);
|
||||
},
|
||||
[client, channelId, apply],
|
||||
);
|
||||
|
||||
const pin = useCallback(
|
||||
async (messageId: ID) => {
|
||||
if (!channelId) return;
|
||||
const r = await client.togglePin(channelId, messageId);
|
||||
apply(r.message);
|
||||
},
|
||||
[client, channelId, apply],
|
||||
);
|
||||
|
||||
const save = useCallback(
|
||||
async (messageId: ID) => {
|
||||
if (!channelId) return;
|
||||
const r = await client.toggleSave(channelId, messageId);
|
||||
apply(r.message);
|
||||
},
|
||||
[client, channelId, apply],
|
||||
);
|
||||
|
||||
return { parent, replies, loading, reply, react, pin, save, edit: editReply, remove: removeReply, reload: load };
|
||||
}
|
||||
|
||||
/** Deterministic mock "who is typing" — only updates state when the set changes. */
|
||||
export function useTyping(channelId: ID | null): ID[] {
|
||||
const { channelById, me } = useSdk();
|
||||
const [typing, setTyping] = useState<ID[]>([]);
|
||||
useEffect(() => {
|
||||
setTyping([]);
|
||||
if (!channelId) return;
|
||||
const compute = (): ID[] => {
|
||||
const ch = channelById(channelId);
|
||||
if (!ch) return [];
|
||||
const others = ch.memberIds.filter((id) => id !== me?.id);
|
||||
// Show as a continuous burst (~6s typing, ~3s pause) rather than a quick
|
||||
// flicker, so it reads like someone actually typing.
|
||||
const on = Date.now() % 9000 < 6000;
|
||||
return on && others.length ? [others[0]] : [];
|
||||
};
|
||||
const tick = () =>
|
||||
setTyping((prev) => {
|
||||
const next = compute();
|
||||
return prev.length === next.length && prev.every((v, i) => v === next[i]) ? prev : next;
|
||||
});
|
||||
tick();
|
||||
const t = setInterval(tick, 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [channelId, channelById, me?.id]);
|
||||
return typing;
|
||||
}
|
||||
|
||||
/* ------------------------------- inbox -------------------------------- */
|
||||
|
||||
export function useInbox(initialState: InboxState = "OPEN") {
|
||||
const { client } = useSdk();
|
||||
const [state, setState] = useState<InboxState>(initialState);
|
||||
const [items, setItems] = useState<InboxItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
client
|
||||
.listInbox(state)
|
||||
.then((r) => setItems(r.items))
|
||||
.finally(() => setLoading(false));
|
||||
}, [client, state]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const patch = useCallback(
|
||||
async (id: ID, next: InboxState) => {
|
||||
await client.patchInbox(id, next);
|
||||
setItems((prev) => prev.filter((i) => i.id !== id));
|
||||
},
|
||||
[client],
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
loading,
|
||||
state,
|
||||
setState,
|
||||
reload: load,
|
||||
markDone: (id: ID) => patch(id, "DONE"),
|
||||
snooze: (id: ID) => patch(id, "SNOOZED"),
|
||||
archive: (id: ID) => patch(id, "ARCHIVED"),
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------ support ------------------------------- */
|
||||
|
||||
export function useTickets(scope: "mine" | "assigned" | "all" = "all") {
|
||||
const { client } = useSdk();
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
client
|
||||
.listTickets(scope)
|
||||
.then((r) => setTickets(r.tickets))
|
||||
.finally(() => setLoading(false));
|
||||
}, [client, scope]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const patch = useCallback(
|
||||
async (id: ID, next: TicketState) => {
|
||||
const r = await client.patchTicket(id, next);
|
||||
setTickets((prev) => prev.map((t) => (t.id === id ? r.ticket : t)));
|
||||
return r.ticket;
|
||||
},
|
||||
[client],
|
||||
);
|
||||
|
||||
const reply = useCallback(
|
||||
async (id: ID, body: string) => {
|
||||
const r = await client.replyTicket(id, body);
|
||||
setTickets((prev) => prev.map((t) => (t.id === id ? r.ticket : t)));
|
||||
return r.ticket;
|
||||
},
|
||||
[client],
|
||||
);
|
||||
|
||||
return { tickets, loading, reload: load, patch, reply };
|
||||
}
|
||||
|
||||
export function useTicket(id: ID | null) {
|
||||
const { client } = useSdk();
|
||||
const [ticket, setTicket] = useState<Ticket | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
client
|
||||
.getTicket(id)
|
||||
.then((r) => setTicket(r.ticket))
|
||||
.finally(() => setLoading(false));
|
||||
}, [client, id]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const reply = useCallback(
|
||||
async (body: string) => {
|
||||
if (!id) return;
|
||||
const r = await client.replyTicket(id, body);
|
||||
setTicket(r.ticket);
|
||||
},
|
||||
[client, id],
|
||||
);
|
||||
|
||||
const setState = useCallback(
|
||||
async (next: TicketState) => {
|
||||
if (!id) return;
|
||||
const r = await client.patchTicket(id, next);
|
||||
setTicket(r.ticket);
|
||||
},
|
||||
[client, id],
|
||||
);
|
||||
|
||||
return { ticket, loading, reply, setState, reload: load };
|
||||
}
|
||||
|
||||
export function useAvailability() {
|
||||
const { client } = useSdk();
|
||||
const [agents, setAgents] = useState<AgentAvailability[]>([]);
|
||||
useEffect(() => {
|
||||
client.availability().then((r) => setAgents(r.agents)).catch(() => {});
|
||||
}, [client]);
|
||||
return agents;
|
||||
}
|
||||
|
||||
/* --------------------------- search / notifs -------------------------- */
|
||||
|
||||
export function useSearch() {
|
||||
const { client } = useSdk();
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
if (!query.trim()) {
|
||||
setResults([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
timer.current = setTimeout(() => {
|
||||
client
|
||||
.search(query)
|
||||
.then((r) => setResults(r.results))
|
||||
.finally(() => setLoading(false));
|
||||
}, 160);
|
||||
return () => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
};
|
||||
}, [query, client]);
|
||||
|
||||
return { query, setQuery, results, loading };
|
||||
}
|
||||
|
||||
export function useNotifications() {
|
||||
const { client } = useSdk();
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
useEffect(() => {
|
||||
client.notifications().then((r) => setNotifications(r.notifications)).catch(() => {});
|
||||
}, [client]);
|
||||
const unread = notifications.filter((n) => !n.read).length;
|
||||
return { notifications, unread };
|
||||
}
|
||||
|
||||
/* ------------------------------ helpers ------------------------------- */
|
||||
|
||||
export function useChannel(channelId: ID | null): Channel | undefined {
|
||||
const { channelById } = useSdk();
|
||||
return channelId ? channelById(channelId) : undefined;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Public API of @lynkd/messaging-inbox-sdk
|
||||
|
||||
export * from "./types";
|
||||
export * from "./config";
|
||||
export * from "./theme";
|
||||
export { BffClient } from "./client";
|
||||
export {
|
||||
MessagingInboxProvider,
|
||||
useSdk,
|
||||
useFeatureFlags,
|
||||
useFeature,
|
||||
useTheme,
|
||||
type ProviderProps,
|
||||
} from "./provider";
|
||||
export {
|
||||
useChannels,
|
||||
useChannel,
|
||||
useMessages,
|
||||
useSavedMessages,
|
||||
useThreadList,
|
||||
useThread,
|
||||
useTyping,
|
||||
notifyChannelChanged,
|
||||
useInbox,
|
||||
useTickets,
|
||||
useTicket,
|
||||
useAvailability,
|
||||
useSearch,
|
||||
useNotifications,
|
||||
} from "./hooks";
|
||||
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { BffClient } from "./client";
|
||||
import {
|
||||
defaultFlags,
|
||||
defaultTheme,
|
||||
resolveConfig,
|
||||
withAlpha,
|
||||
type FeatureFlags,
|
||||
type SdkConfig,
|
||||
type ThemeConfig,
|
||||
} from "./config";
|
||||
import { applyTheme, resolveScheme } from "./theme";
|
||||
import type { Bootstrap, Channel, User } from "./types";
|
||||
|
||||
type Scheme = "light" | "dark";
|
||||
|
||||
interface SdkContextValue {
|
||||
config: SdkConfig;
|
||||
flags: FeatureFlags;
|
||||
client: BffClient;
|
||||
brandName: string;
|
||||
// bootstrap data
|
||||
bootstrap: Bootstrap | null;
|
||||
loading: boolean;
|
||||
me: User | null;
|
||||
users: User[];
|
||||
channels: Channel[];
|
||||
userById: (id: string) => User | undefined;
|
||||
channelById: (id: string) => Channel | undefined;
|
||||
refresh: () => void;
|
||||
// theme
|
||||
scheme: Scheme;
|
||||
setScheme: (s: Scheme) => void;
|
||||
toggleScheme: () => void;
|
||||
theme: ThemeConfig;
|
||||
setThemeOverrides: (patch: Partial<ThemeConfig> & { accent?: string }) => void;
|
||||
resetTheme: () => void;
|
||||
}
|
||||
|
||||
const SdkContext = createContext<SdkContextValue | null>(null);
|
||||
|
||||
const STORAGE_SCHEME = "lynkd.scheme";
|
||||
const STORAGE_THEME = "lynkd.theme";
|
||||
|
||||
export interface ProviderProps {
|
||||
/** Fully-resolved config (recommended: resolve on the server & pass down). */
|
||||
config?: SdkConfig;
|
||||
/** …or resolve inline from these overrides. */
|
||||
flags?: Partial<FeatureFlags>;
|
||||
theme?: Partial<ThemeConfig>;
|
||||
bffBaseUrl?: string;
|
||||
brandName?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MessagingInboxProvider(props: ProviderProps) {
|
||||
const config = useMemo<SdkConfig>(
|
||||
() =>
|
||||
props.config ??
|
||||
resolveConfig({
|
||||
flags: props.flags,
|
||||
theme: props.theme,
|
||||
bffBaseUrl: props.bffBaseUrl,
|
||||
brandName: props.brandName,
|
||||
}),
|
||||
[props.config, props.flags, props.theme, props.bffBaseUrl, props.brandName],
|
||||
);
|
||||
|
||||
const client = useMemo(() => new BffClient(config.bffBaseUrl), [config.bffBaseUrl]);
|
||||
|
||||
// ---- theme + scheme state ----
|
||||
const [theme, setTheme] = useState<ThemeConfig>(config.theme);
|
||||
const [scheme, setSchemeState] = useState<Scheme>(() => resolveScheme(config.theme.mode));
|
||||
|
||||
// hydrate persisted prefs
|
||||
useEffect(() => {
|
||||
try {
|
||||
const savedScheme = localStorage.getItem(STORAGE_SCHEME) as Scheme | null;
|
||||
if (savedScheme === "light" || savedScheme === "dark") setSchemeState(savedScheme);
|
||||
const savedTheme = localStorage.getItem(STORAGE_THEME);
|
||||
if (savedTheme) setTheme((t) => ({ ...t, ...JSON.parse(savedTheme) }));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// apply theme vars to <html>
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
applyTheme(document.documentElement, theme, scheme);
|
||||
}, [theme, scheme]);
|
||||
|
||||
const setScheme = useCallback((s: Scheme) => {
|
||||
setSchemeState(s);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_SCHEME, s);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleScheme = useCallback(
|
||||
() => setScheme(scheme === "dark" ? "light" : "dark"),
|
||||
[scheme, setScheme],
|
||||
);
|
||||
|
||||
const setThemeOverrides = useCallback(
|
||||
(patch: Partial<ThemeConfig> & { accent?: string }) => {
|
||||
setTheme((prev) => {
|
||||
const next: ThemeConfig = { ...prev, ...patch };
|
||||
if (patch.accent) {
|
||||
next.dark = { ...prev.dark, accent: patch.accent, accentSoft: withAlpha(patch.accent, 0.12) };
|
||||
next.light = { ...prev.light, accent: patch.accent, accentSoft: withAlpha(patch.accent, 0.12) };
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(STORAGE_THEME, JSON.stringify(next));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const resetTheme = useCallback(() => {
|
||||
setTheme(config.theme);
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_THEME);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [config.theme]);
|
||||
|
||||
// ---- bootstrap data ----
|
||||
const [bootstrap, setBootstrap] = useState<Bootstrap | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tick, setTick] = useState(0);
|
||||
const firstLoad = useRef(true);
|
||||
/** Re-fetch bootstrap silently (no boot skeleton) — used after mutations. */
|
||||
const refresh = useCallback(() => setTick((t) => t + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
// Only show the full-screen skeleton on the very first load; later refreshes
|
||||
// (e.g. after mark-read / create-channel) must not flash the skeleton.
|
||||
if (firstLoad.current) setLoading(true);
|
||||
client
|
||||
.bootstrap()
|
||||
.then((b) => {
|
||||
if (alive) setBootstrap(b);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (alive) {
|
||||
setLoading(false);
|
||||
firstLoad.current = false;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [client, tick]);
|
||||
|
||||
const userMap = useMemo(
|
||||
() => new Map((bootstrap?.users ?? []).map((u) => [u.id, u] as const)),
|
||||
[bootstrap?.users],
|
||||
);
|
||||
const channelMap = useMemo(
|
||||
() => new Map((bootstrap?.channels ?? []).map((c) => [c.id, c] as const)),
|
||||
[bootstrap?.channels],
|
||||
);
|
||||
const userById = useCallback((id: string) => userMap.get(id), [userMap]);
|
||||
const channelById = useCallback((id: string) => channelMap.get(id), [channelMap]);
|
||||
|
||||
const value = useMemo<SdkContextValue>(
|
||||
() => ({
|
||||
config,
|
||||
flags: config.flags,
|
||||
client,
|
||||
brandName: config.brandName,
|
||||
bootstrap,
|
||||
loading,
|
||||
me: bootstrap?.me ?? null,
|
||||
users: bootstrap?.users ?? [],
|
||||
channels: bootstrap?.channels ?? [],
|
||||
userById,
|
||||
channelById,
|
||||
refresh,
|
||||
scheme,
|
||||
setScheme,
|
||||
toggleScheme,
|
||||
theme,
|
||||
setThemeOverrides,
|
||||
resetTheme,
|
||||
}),
|
||||
[config, client, bootstrap, loading, userById, channelById, refresh, scheme, setScheme, toggleScheme, theme, setThemeOverrides, resetTheme],
|
||||
);
|
||||
|
||||
return <SdkContext.Provider value={value}>{props.children}</SdkContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSdk(): SdkContextValue {
|
||||
const ctx = useContext(SdkContext);
|
||||
if (!ctx) throw new Error("useSdk must be used within <MessagingInboxProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useFeatureFlags(): FeatureFlags {
|
||||
return useSdk().flags;
|
||||
}
|
||||
|
||||
export function useFeature(name: keyof FeatureFlags): boolean {
|
||||
return useSdk().flags[name];
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const {
|
||||
scheme,
|
||||
setScheme,
|
||||
toggleScheme,
|
||||
theme,
|
||||
setThemeOverrides,
|
||||
resetTheme,
|
||||
} = useSdk();
|
||||
return { scheme, setScheme, toggleScheme, theme, setThemeOverrides, resetTheme };
|
||||
}
|
||||
|
||||
export { defaultFlags, defaultTheme };
|
||||
@@ -0,0 +1,5 @@
|
||||
/* Optional base layer for host apps that don't use Tailwind.
|
||||
The demo app uses Tailwind, so this is intentionally minimal. */
|
||||
:root {
|
||||
--lynkd-focus: 0 0 0 2px var(--c-accent-soft, rgba(253, 169, 19, 0.3));
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ThemeConfig, ThemeTokens } from "./config";
|
||||
|
||||
/** Turns a ThemeConfig + active scheme into the CSS variables Tailwind reads. */
|
||||
export function tokensToCssVars(
|
||||
theme: ThemeConfig,
|
||||
scheme: "light" | "dark",
|
||||
): Record<string, string> {
|
||||
const t: ThemeTokens = scheme === "dark" ? theme.dark : theme.light;
|
||||
return {
|
||||
"--c-bg": t.bg,
|
||||
"--c-surface": t.surface,
|
||||
"--c-panel": t.panel,
|
||||
"--c-elevated": t.elevated,
|
||||
"--c-hover": t.hover,
|
||||
"--c-border": t.border,
|
||||
"--c-border-strong": t.borderStrong,
|
||||
"--c-ink": t.ink,
|
||||
"--c-muted": t.muted,
|
||||
"--c-dim": t.dim,
|
||||
"--c-accent": t.accent,
|
||||
"--c-accent-fg": t.accentFg,
|
||||
"--c-accent-soft": t.accentSoft,
|
||||
"--c-success": t.success,
|
||||
"--c-warning": t.warning,
|
||||
"--c-danger": t.danger,
|
||||
"--c-info": t.info,
|
||||
"--r-card": theme.radiusCard,
|
||||
"--r-control": theme.radiusControl,
|
||||
"--r-pill": theme.radiusPill,
|
||||
"--font-sans": theme.fontSans,
|
||||
"--font-mono": theme.fontMono,
|
||||
"--brand-gradient": theme.gradient,
|
||||
};
|
||||
}
|
||||
|
||||
/** Imperatively apply theme vars + the `dark` class to <html>. */
|
||||
export function applyTheme(
|
||||
el: HTMLElement,
|
||||
theme: ThemeConfig,
|
||||
scheme: "light" | "dark",
|
||||
): void {
|
||||
const vars = tokensToCssVars(theme, scheme);
|
||||
for (const [k, v] of Object.entries(vars)) el.style.setProperty(k, v);
|
||||
el.classList.toggle("dark", scheme === "dark");
|
||||
el.style.colorScheme = scheme;
|
||||
}
|
||||
|
||||
/** Resolve a "system" mode against the OS preference. */
|
||||
export function resolveScheme(mode: ThemeConfig["mode"]): "light" | "dark" {
|
||||
if (mode === "light" || mode === "dark") return mode;
|
||||
if (typeof window !== "undefined" && window.matchMedia) {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
return "dark";
|
||||
}
|
||||
|
||||
/** SSR-safe inline style string for the initial <html> render (prevents flash). */
|
||||
export function themeStyleString(
|
||||
theme: ThemeConfig,
|
||||
scheme: "light" | "dark",
|
||||
): string {
|
||||
return Object.entries(tokensToCssVars(theme, scheme))
|
||||
.map(([k, v]) => `${k}:${v}`)
|
||||
.join(";");
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Domain model for the messaging + inbox + support SDK.
|
||||
* Mirrors the IIOS kernel vocabulary (Interaction / Message / Thread / InboxItem /
|
||||
* Ticket) but stays UI-friendly. The BFF maps these to whatever backend is wired
|
||||
* (mock data today, the IIOS service tomorrow).
|
||||
*/
|
||||
|
||||
export type ID = string;
|
||||
|
||||
export type PresenceState = "active" | "away" | "dnd" | "offline";
|
||||
|
||||
export interface User {
|
||||
id: ID;
|
||||
name: string;
|
||||
handle: string;
|
||||
avatarText: string; // initials shown in the avatar chip
|
||||
avatarColor?: string;
|
||||
title?: string;
|
||||
presence: PresenceState;
|
||||
statusEmoji?: string;
|
||||
statusText?: string;
|
||||
isBot?: boolean;
|
||||
timezone?: string;
|
||||
localTime?: string;
|
||||
email?: string;
|
||||
pronouns?: string;
|
||||
avatarUrl?: string; // when set, shown instead of initials
|
||||
statusClearAt?: number; // epoch ms after which the status auto-clears
|
||||
}
|
||||
|
||||
export type ChannelKind = "channel" | "private" | "dm" | "group_dm";
|
||||
|
||||
export interface Channel {
|
||||
id: ID;
|
||||
kind: ChannelKind;
|
||||
name: string; // "#general" without the hash for channels; display name for DMs
|
||||
topic?: string;
|
||||
purpose?: string;
|
||||
memberIds: ID[];
|
||||
isStarred?: boolean;
|
||||
isMuted?: boolean;
|
||||
unreadCount?: number;
|
||||
mentionCount?: number;
|
||||
sectionId?: string; // sidebar section grouping
|
||||
lastActivityTs?: number;
|
||||
external?: boolean; // Slack Connect style
|
||||
}
|
||||
|
||||
export interface SidebarSection {
|
||||
id: string;
|
||||
label: string;
|
||||
collapsible?: boolean;
|
||||
defaultCollapsed?: boolean;
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
emoji: string;
|
||||
userIds: ID[];
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: ID;
|
||||
kind: "image" | "file" | "video" | "audio" | "link";
|
||||
name: string;
|
||||
sizeLabel?: string;
|
||||
mime?: string;
|
||||
previewColor?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface RichBlock {
|
||||
type: "text" | "code" | "quote" | "bullet" | "divider" | "callout";
|
||||
text?: string;
|
||||
items?: string[];
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: ID;
|
||||
channelId: ID;
|
||||
authorId: ID;
|
||||
ts: number;
|
||||
/** rendered/markdown text */
|
||||
body: string;
|
||||
blocks?: RichBlock[];
|
||||
reactions?: Reaction[];
|
||||
attachments?: Attachment[];
|
||||
editedTs?: number;
|
||||
isPinned?: boolean;
|
||||
isSaved?: boolean;
|
||||
parentId?: ID | null; // set when this is a thread reply
|
||||
replyCount?: number;
|
||||
replyUserIds?: ID[];
|
||||
lastReplyTs?: number;
|
||||
mentions?: ID[];
|
||||
systemEvent?: string; // e.g. "joined the channel"
|
||||
deliveryState?: "sending" | "sent" | "failed";
|
||||
scheduledFor?: number;
|
||||
threadRootId?: ID | null; // set on a channel message that was "also sent" from a thread
|
||||
}
|
||||
|
||||
export type InboxKind =
|
||||
| "NEEDS_REPLY"
|
||||
| "MENTION"
|
||||
| "REACTION"
|
||||
| "THREAD_REPLY"
|
||||
| "SAVED"
|
||||
| "APP"
|
||||
| "SUPPORT_UPDATE"
|
||||
| "MEETING_FOLLOWUP";
|
||||
|
||||
export type InboxState = "OPEN" | "SNOOZED" | "DONE" | "ARCHIVED";
|
||||
|
||||
export interface InboxItem {
|
||||
id: ID;
|
||||
kind: InboxKind;
|
||||
state: InboxState;
|
||||
title: string;
|
||||
preview: string;
|
||||
channelId?: ID;
|
||||
messageId?: ID;
|
||||
actorId?: ID;
|
||||
ts: number;
|
||||
dueAt?: number;
|
||||
priority?: "low" | "normal" | "high" | "urgent";
|
||||
}
|
||||
|
||||
export type TicketState = "NEW" | "OPEN" | "PENDING" | "RESOLVED" | "CLOSED";
|
||||
export type TicketPriority = "LOW" | "NORMAL" | "HIGH" | "URGENT";
|
||||
|
||||
export interface Ticket {
|
||||
id: ID;
|
||||
ref: string; // human ref like TCK-1042
|
||||
subject: string;
|
||||
requesterId: ID;
|
||||
assigneeId?: ID;
|
||||
state: TicketState;
|
||||
priority: TicketPriority;
|
||||
channelId?: ID; // linked conversation
|
||||
category?: string;
|
||||
createdTs: number;
|
||||
updatedTs: number;
|
||||
firstResponseDueTs?: number;
|
||||
slaBreached?: boolean;
|
||||
tags?: string[];
|
||||
messages?: Message[];
|
||||
csat?: number;
|
||||
}
|
||||
|
||||
export interface CallbackRequest {
|
||||
id: ID;
|
||||
ticketId?: ID;
|
||||
requesterId: ID;
|
||||
preferChannel: "PHONE" | "ZOOM" | "IN_APP";
|
||||
preferredTime?: string;
|
||||
notes?: string;
|
||||
state: "REQUESTED" | "SCHEDULED" | "DONE";
|
||||
}
|
||||
|
||||
export interface AgentAvailability {
|
||||
userId: ID;
|
||||
online: boolean;
|
||||
activeCount: number;
|
||||
maxActive: number;
|
||||
queue: string;
|
||||
}
|
||||
|
||||
export interface Workspace {
|
||||
id: ID;
|
||||
name: string;
|
||||
initials: string;
|
||||
accent?: string;
|
||||
plan?: string;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: ID;
|
||||
type: "message" | "channel" | "person" | "file" | "ticket";
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
channelId?: ID;
|
||||
ts?: number;
|
||||
parentId?: ID; // for message hits that are thread replies → open the thread, not a main-list jump
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: ID;
|
||||
kind: InboxKind | "SYSTEM";
|
||||
title: string;
|
||||
body: string;
|
||||
ts: number;
|
||||
read?: boolean;
|
||||
channelId?: ID;
|
||||
messageId?: ID; // when set, clicking deep-links to (and flashes) this message
|
||||
}
|
||||
|
||||
/** The full bootstrap payload the BFF hands the client. */
|
||||
export interface Bootstrap {
|
||||
me: User;
|
||||
workspace: Workspace;
|
||||
workspaces: Workspace[];
|
||||
users: User[];
|
||||
sections: SidebarSection[];
|
||||
channels: Channel[];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user