first commit

This commit is contained in:
2026-07-17 21:48:37 +05:30
commit f85599dac5
124 changed files with 10775 additions and 0 deletions
+27
View File
@@ -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"
}
}
+234
View File
@@ -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 }),
});
}
}
+291
View File
@@ -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)`;
}
+451
View File
@@ -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;
}
+30
View File
@@ -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));
}
+65
View File
@@ -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(";");
}
+205
View File
@@ -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"]
}