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
+51
View File
@@ -0,0 +1,51 @@
import type {
AgentAvailability,
Bootstrap,
Channel,
InboxItem,
InboxState,
Message,
Notification,
SearchResult,
Ticket,
TicketState,
User,
} from "@lynkd/messaging-inbox-sdk";
/**
* The contract every BFF handler talks to. This is the ONE seam between the UI
* and a real backend. Two implementations:
* - MockBackend (default) — the in-memory store
* - IiosBackend — proxies the real IIOS service
* Selected by BFF_BACKEND ("mock" | "iios"). All methods are async so a network
* backend drops in without changing the routes.
*/
export interface Backend {
bootstrap(): Promise<Bootstrap>;
listChannels(): Promise<Channel[]>;
createChannel(input: { name: string; kind?: string; topic?: string; memberIds?: string[] }): Promise<Channel>;
addMember(channelId: string, userId: string): Promise<Channel | null>;
removeMember(channelId: string, userId: string): Promise<Channel | null>;
markRead(channelId: string): Promise<Channel | null>;
setStatus(text: string | undefined, emoji: string | undefined, clearAt?: number): Promise<User>;
updateMe(patch: { name?: string; title?: string; avatarColor?: string; avatarUrl?: string | null; presence?: string }): Promise<User>;
channelMessages(channelId: string): Promise<Message[]>;
thread(channelId: string, parentId: string): Promise<{ parent: Message | null; replies: Message[] }>;
send(channelId: string, body: string, opts?: { parentId?: string; scheduledFor?: number; attachments?: import("@lynkd/messaging-inbox-sdk").Attachment[]; threadRootId?: string | null }): Promise<Message>;
editMessage(channelId: string, messageId: string, body: string): Promise<Message | null>;
deleteMessage(channelId: string, messageId: string): Promise<{ ok: boolean }>;
react(channelId: string, messageId: string, emoji: string): Promise<Message | null>;
pin(channelId: string, messageId: string): Promise<Message | null>;
save(channelId: string, messageId: string): Promise<Message | null>;
savedMessages(): Promise<Message[]>;
threadParents(): Promise<Message[]>;
listInbox(state?: InboxState): Promise<InboxItem[]>;
patchInbox(id: string, state: InboxState): Promise<InboxItem | null>;
listTickets(scope?: string): Promise<Ticket[]>;
getTicket(id: string): Promise<Ticket | null>;
patchTicket(id: string, state: TicketState): Promise<Ticket | null>;
replyTicket(id: string, body: string): Promise<Ticket | null>;
availability(): Promise<AgentAvailability[]>;
search(q: string): Promise<SearchResult[]>;
notifications(): Promise<Notification[]>;
}
+104
View File
@@ -0,0 +1,104 @@
import type { Backend } from "./Backend";
import { mockBackend } from "./mock";
import { iios } from "@/lib/iios/client";
import { toInboxItem, toMessage, toTicket } from "@/lib/iios/map";
import manifest from "@/lib/iios/seed-manifest.json";
import type { Bootstrap, InboxState, Message, TicketState } from "@lynkd/messaging-inbox-sdk";
// If the IIOS DB has been seeded (scripts/seed-iios.mjs), a manifest maps the real
// IIOS thread ids to a channel directory so the sidebar shows live data. Otherwise
// bootstrap/channels fall back to the mock store (IIOS has no channel-list endpoint).
const seeded = (manifest as any)?.seeded === true;
const seededBootstrap = () => manifest as unknown as Bootstrap;
/**
* IIOS-backed implementation. Endpoints that map cleanly to the IIOS API are wired
* for real (messages, threads, inbox, tickets). Concepts IIOS doesn't expose 1:1 yet
* (bootstrap/users/channels, reactions, search, notifications, availability) delegate
* to the mock backend so the UI keeps working — replace these as IIOS grows portal/
* directory endpoints. See docs/IIOS_INTEGRATION.md.
*/
export const iiosBackend: Backend = {
// --- bootstrap/channels: from the seed manifest if present, else mock ---
bootstrap: () => (seeded ? Promise.resolve(seededBootstrap()) : mockBackend.bootstrap()),
listChannels: () => (seeded ? Promise.resolve(seededBootstrap().channels) : mockBackend.listChannels()),
createChannel: (input) => mockBackend.createChannel(input),
addMember: (channelId, userId) => mockBackend.addMember(channelId, userId),
removeMember: (channelId, userId) => mockBackend.removeMember(channelId, userId),
markRead: (channelId) => mockBackend.markRead(channelId),
setStatus: (text, emoji, clearAt) => mockBackend.setStatus(text, emoji, clearAt),
updateMe: (patch) => mockBackend.updateMe(patch),
availability: () => mockBackend.availability(),
notifications: () => mockBackend.notifications(),
// IIOS has no reaction/pin/save/edit/delete endpoints — keep local behavior
react: (channelId, messageId, emoji) => mockBackend.react(channelId, messageId, emoji),
pin: (channelId, messageId) => mockBackend.pin(channelId, messageId),
save: (channelId, messageId) => mockBackend.save(channelId, messageId),
editMessage: (channelId, messageId, body) => mockBackend.editMessage(channelId, messageId, body),
deleteMessage: (channelId, messageId) => mockBackend.deleteMessage(channelId, messageId),
savedMessages: () => mockBackend.savedMessages(),
threadParents: () => mockBackend.threadParents(),
// IIOS has no cross-entity search endpoint documented — use local search
search: (q) => mockBackend.search(q),
// --- wired to the real IIOS service ---
async channelMessages(channelId) {
const r = await iios.getThreadMessages(channelId);
const list = Array.isArray(r) ? r : (r.messages ?? []);
return list.filter((m: any) => !(m.parentInteractionId ?? m.parentId)).map((m: any) => toMessage(m, channelId));
},
async thread(channelId, parentId) {
const r = await iios.getThreadMessages(channelId);
const list: Message[] = (Array.isArray(r) ? r : r.messages ?? []).map((m: any) => toMessage(m, channelId));
return {
parent: list.find((m) => m.id === parentId) ?? null,
replies: list.filter((m) => m.parentId === parentId),
};
},
async send(channelId, body, opts) {
const idem = `web-${channelId}-${body.length}-${Math.round((opts?.scheduledFor ?? 0) / 1000)}`;
const r = await iios.sendMessage(channelId, body, idem);
return toMessage(r.message ?? r, channelId);
},
async listInbox(state) {
const r = await iios.listInbox(state);
const list = Array.isArray(r) ? r : r.items ?? [];
return list.map(toInboxItem);
},
async patchInbox(id, state: InboxState) {
const r = await iios.patchInbox(id, state);
return toInboxItem(r.item ?? r);
},
async listTickets(scope) {
const r = await iios.listTickets(scope);
const list = Array.isArray(r) ? r : r.tickets ?? [];
return list.map(toTicket);
},
async getTicket(id) {
// IIOS lists tickets; fetch and find (swap for a GET /tickets/:id when available)
const r = await iios.listTickets("all");
const list = Array.isArray(r) ? r : r.tickets ?? [];
const found = list.find((t: any) => String(t.id) === id || String(t.ref) === id);
return found ? toTicket(found) : null;
},
async patchTicket(id, state: TicketState) {
const r = await iios.patchTicket(id, state);
return toTicket(r.ticket ?? r);
},
async replyTicket(id, body) {
// A ticket reply is a message on the ticket's linked thread.
const t = await this.getTicket(id);
if (t?.channelId) await iios.sendMessage(t.channelId, body);
return this.getTicket(id);
},
};
+15
View File
@@ -0,0 +1,15 @@
import type { Backend } from "./Backend";
import { mockBackend } from "./mock";
import { iiosBackend } from "./iios";
/**
* Choose the BFF backend from env. Default "mock" (in-memory store). Set
* BFF_BACKEND=iios (+ IIOS_URL etc.) to proxy the real IIOS service.
* Importing the IIOS backend runs no network calls until a method is invoked
* (the token is minted lazily), so the mock demo never needs IIOS present.
*/
export function getBackend(): Backend {
return (process.env.BFF_BACKEND ?? "mock").toLowerCase() === "iios" ? iiosBackend : mockBackend;
}
export type { Backend };
+87
View File
@@ -0,0 +1,87 @@
import type { Backend } from "./Backend";
import { store } from "@/lib/mock/store";
/** The default backend: the in-memory mock store, wrapped as async. */
export const mockBackend: Backend = {
async bootstrap() {
return store.bootstrap();
},
async listChannels() {
return store.channels;
},
async createChannel(input) {
return store.createChannel(input);
},
async addMember(channelId, userId) {
return store.addMember(channelId, userId);
},
async removeMember(channelId, userId) {
return store.removeMember(channelId, userId);
},
async markRead(channelId) {
return store.markRead(channelId);
},
async setStatus(text, emoji, clearAt) {
return store.setStatus(text, emoji, clearAt);
},
async updateMe(patch) {
return store.updateMe(patch);
},
async channelMessages(channelId) {
return store.channelMessages(channelId);
},
async thread(channelId, parentId) {
return store.thread(channelId, parentId);
},
async send(channelId, body, opts) {
return store.send(channelId, body, opts);
},
async editMessage(_channelId, messageId, body) {
return store.editMessage(messageId, body);
},
async deleteMessage(_channelId, messageId) {
return { ok: store.deleteMessage(messageId) };
},
async savedMessages() {
return store.savedMessages();
},
async threadParents() {
return store.threadParents();
},
async react(_channelId, messageId, emoji) {
return store.react(messageId, emoji);
},
async pin(_channelId, messageId) {
return store.pin(messageId);
},
async save(_channelId, messageId) {
return store.save(messageId);
},
async listInbox(state) {
return store.listInbox(state);
},
async patchInbox(id, state) {
return store.patchInbox(id, state);
},
async listTickets(scope) {
return store.listTickets(scope);
},
async getTicket(id) {
return store.getTicket(id);
},
async patchTicket(id, state) {
return store.patchTicket(id, state);
},
async replyTicket(id, body) {
return store.replyTicket(id, body);
},
async availability() {
return store.availability;
},
async search(q) {
return store.search(q);
},
async notifications() {
return store.notifications;
},
};
+12
View File
@@ -0,0 +1,12 @@
import { resolveConfig, type SdkConfig } from "@lynkd/messaging-inbox-sdk";
/**
* Resolve the SDK config on the server from process.env, so feature flags and
* theme are decided by NEXT_PUBLIC_* env at request time and handed to the
* client as plain JSON (no dynamic env access in the browser bundle).
*/
export function getServerConfig(): SdkConfig {
return resolveConfig({
env: process.env as Record<string, string | undefined>,
});
}
+109
View File
@@ -0,0 +1,109 @@
/** A generous, categorized emoji set with shortcodes + search keywords.
* Powers the picker, composer autocomplete, reactions, and `:shortcode:` parsing. */
export interface Emoji {
e: string; // the emoji
n: string; // canonical shortcode name (no colons)
k?: string; // extra search keywords
c: EmojiCategory;
}
export type EmojiCategory =
| "Frequent" | "Smileys" | "Gestures" | "People" | "Animals"
| "Food" | "Activity" | "Travel" | "Objects" | "Symbols" | "Flags";
export const EMOJI_CATEGORIES: EmojiCategory[] = [
"Frequent", "Smileys", "Gestures", "People", "Animals", "Food", "Activity", "Travel", "Objects", "Symbols", "Flags",
];
export const EMOJIS: Emoji[] = [
// Smileys
{ e: "😀", n: "grinning", c: "Smileys" }, { e: "😃", n: "smiley", c: "Smileys" }, { e: "😄", n: "smile", c: "Smileys" },
{ e: "😁", n: "grin", c: "Smileys" }, { e: "😆", n: "laughing", k: "haha", c: "Smileys" }, { e: "😅", n: "sweat_smile", c: "Smileys" },
{ e: "🤣", n: "rofl", k: "lol laugh", c: "Smileys" }, { e: "😂", n: "joy", k: "lol cry laugh", c: "Smileys" }, { e: "🙂", n: "slightly_smiling", c: "Smileys" },
{ e: "🙃", n: "upside_down", c: "Smileys" }, { e: "😉", n: "wink", c: "Smileys" }, { e: "😊", n: "blush", c: "Smileys" },
{ e: "😇", n: "innocent", k: "angel", c: "Smileys" }, { e: "🥰", n: "smiling_hearts", k: "love", c: "Smileys" }, { e: "😍", n: "heart_eyes", k: "love", c: "Smileys" },
{ e: "😘", n: "kissing_heart", c: "Smileys" }, { e: "😜", n: "stuck_out_tongue_wink", c: "Smileys" }, { e: "🤪", n: "zany", c: "Smileys" },
{ e: "🤨", n: "raised_eyebrow", k: "skeptic", c: "Smileys" }, { e: "🧐", n: "monocle", c: "Smileys" }, { e: "🤓", n: "nerd", c: "Smileys" },
{ e: "😎", n: "sunglasses", k: "cool", c: "Smileys" }, { e: "🥳", n: "partying", k: "celebrate party", c: "Smileys" }, { e: "😏", n: "smirk", c: "Smileys" },
{ e: "😒", n: "unamused", c: "Smileys" }, { e: "😞", n: "disappointed", c: "Smileys" }, { e: "😔", n: "pensive", c: "Smileys" },
{ e: "😟", n: "worried", c: "Smileys" }, { e: "😢", n: "cry", k: "sad tear", c: "Smileys" }, { e: "😭", n: "sob", k: "cry sad", c: "Smileys" },
{ e: "😤", n: "huff", c: "Smileys" }, { e: "😠", n: "angry", c: "Smileys" }, { e: "😡", n: "rage", k: "mad angry", c: "Smileys" },
{ e: "🤬", n: "cursing", c: "Smileys" }, { e: "🤯", n: "mind_blown", k: "explode", c: "Smileys" }, { e: "😳", n: "flushed", c: "Smileys" },
{ e: "🥵", n: "hot", c: "Smileys" }, { e: "🥶", n: "cold", c: "Smileys" }, { e: "😱", n: "scream", k: "shock", c: "Smileys" },
{ e: "😨", n: "fearful", c: "Smileys" }, { e: "😰", n: "anxious", c: "Smileys" }, { e: "🤔", n: "thinking", k: "hmm", c: "Smileys" },
{ e: "🤫", n: "shush", c: "Smileys" }, { e: "🤐", n: "zipper_mouth", c: "Smileys" }, { e: "😴", n: "sleeping", k: "zzz tired", c: "Smileys" },
{ e: "🥱", n: "yawn", k: "tired bored", c: "Smileys" }, { e: "🤒", n: "sick", k: "ill", c: "Smileys" }, { e: "🤕", n: "hurt", c: "Smileys" },
{ e: "🤮", n: "vomit", c: "Smileys" }, { e: "😷", n: "mask", c: "Smileys" }, { e: "🤗", n: "hug", c: "Smileys" },
{ e: "🤭", n: "hand_over_mouth", c: "Smileys" }, { e: "😬", n: "grimace", c: "Smileys" }, { e: "🙄", n: "eye_roll", c: "Smileys" },
// Gestures
{ e: "👍", n: "+1", k: "thumbsup like yes", c: "Gestures" }, { e: "👎", n: "-1", k: "thumbsdown no", c: "Gestures" }, { e: "👏", n: "clap", k: "applause", c: "Gestures" },
{ e: "🙌", n: "raised_hands", k: "hooray celebrate", c: "Gestures" }, { e: "👋", n: "wave", k: "hi hello bye", c: "Gestures" }, { e: "🤝", n: "handshake", k: "deal", c: "Gestures" },
{ e: "🙏", n: "pray", k: "please thanks", c: "Gestures" }, { e: "✌️", n: "victory", k: "peace", c: "Gestures" }, { e: "🤞", n: "crossed_fingers", k: "luck", c: "Gestures" },
{ e: "👌", n: "ok_hand", c: "Gestures" }, { e: "🤙", n: "call_me", c: "Gestures" }, { e: "💪", n: "muscle", k: "strong flex", c: "Gestures" },
{ e: "👊", n: "fist_bump", c: "Gestures" }, { e: "✊", n: "raised_fist", c: "Gestures" }, { e: "🤟", n: "love_you", c: "Gestures" },
{ e: "👉", n: "point_right", c: "Gestures" }, { e: "👈", n: "point_left", c: "Gestures" }, { e: "👆", n: "point_up", c: "Gestures" },
{ e: "🫡", n: "salute", c: "Gestures" }, { e: "🫶", n: "heart_hands", c: "Gestures" }, { e: "🤌", n: "pinched", c: "Gestures" },
// People / Activity
{ e: "🎉", n: "tada", k: "party celebrate hooray", c: "Activity" }, { e: "🎊", n: "confetti", c: "Activity" }, { e: "🥳", n: "party", c: "Activity" },
{ e: "🏆", n: "trophy", k: "win", c: "Activity" }, { e: "🥇", n: "1st_place", c: "Activity" }, { e: "⚽", n: "soccer", c: "Activity" },
{ e: "🏀", n: "basketball", c: "Activity" }, { e: "🎯", n: "dart", k: "target bullseye", c: "Activity" }, { e: "🎮", n: "video_game", c: "Activity" },
{ e: "🎸", n: "guitar", c: "Activity" }, { e: "🎧", n: "headphones", k: "music", c: "Activity" }, { e: "🚀", n: "rocket", k: "ship launch fast", c: "Travel" },
// Animals
{ e: "🐶", n: "dog", c: "Animals" }, { e: "🐱", n: "cat", c: "Animals" }, { e: "🦄", n: "unicorn", c: "Animals" },
{ e: "🐝", n: "bee", c: "Animals" }, { e: "🦋", n: "butterfly", c: "Animals" }, { e: "🐢", n: "turtle", c: "Animals" },
{ e: "🐼", n: "panda", c: "Animals" }, { e: "🦁", n: "lion", c: "Animals" }, { e: "🐧", n: "penguin", c: "Animals" },
// Food
{ e: "🍕", n: "pizza", c: "Food" }, { e: "🍔", n: "burger", c: "Food" }, { e: "🌮", n: "taco", c: "Food" },
{ e: "🍣", n: "sushi", c: "Food" }, { e: "🍜", n: "ramen", k: "noodles", c: "Food" }, { e: "☕", n: "coffee", c: "Food" },
{ e: "🍺", n: "beer", c: "Food" }, { e: "🥂", n: "clink", k: "cheers", c: "Food" }, { e: "🎂", n: "cake", k: "birthday", c: "Food" },
{ e: "🍩", n: "donut", c: "Food" }, { e: "🍎", n: "apple", c: "Food" }, { e: "🥑", n: "avocado", c: "Food" },
// Travel / weather
{ e: "🌍", n: "earth", c: "Travel" }, { e: "✈️", n: "airplane", c: "Travel" }, { e: "🚗", n: "car", c: "Travel" },
{ e: "🏠", n: "house", k: "home", c: "Travel" }, { e: "🏗️", n: "construction", k: "site build", c: "Travel" }, { e: "🌩️", n: "storm", k: "thunder weather", c: "Travel" },
{ e: "☀️", n: "sun", c: "Travel" }, { e: "🌈", n: "rainbow", c: "Travel" }, { e: "🔥", n: "fire", k: "lit hot flame", c: "Objects" },
// Objects
{ e: "💡", n: "bulb", k: "idea", c: "Objects" }, { e: "📌", n: "pin", c: "Objects" }, { e: "📎", n: "paperclip", c: "Objects" },
{ e: "📈", n: "chart_up", k: "growth", c: "Objects" }, { e: "📉", n: "chart_down", c: "Objects" }, { e: "📅", n: "calendar", c: "Objects" },
{ e: "📐", n: "ruler", c: "Objects" }, { e: "🛠️", n: "tools", c: "Objects" }, { e: "🔧", n: "wrench", c: "Objects" },
{ e: "💰", n: "money", c: "Objects" }, { e: "📱", n: "phone", c: "Objects" }, { e: "💻", n: "laptop", c: "Objects" },
{ e: "⚙️", n: "gear", k: "settings", c: "Objects" }, { e: "🔒", n: "lock", c: "Objects" }, { e: "🔑", n: "key", c: "Objects" },
{ e: "🎁", n: "gift", c: "Objects" }, { e: "🤖", n: "robot", k: "bot ai", c: "Objects" }, { e: "⚡", n: "zap", k: "fast bolt", c: "Objects" },
// Symbols
{ e: "❤️", n: "heart", k: "love red", c: "Symbols" }, { e: "🧡", n: "orange_heart", c: "Symbols" }, { e: "💛", n: "yellow_heart", c: "Symbols" },
{ e: "💚", n: "green_heart", c: "Symbols" }, { e: "💙", n: "blue_heart", c: "Symbols" }, { e: "💜", n: "purple_heart", c: "Symbols" },
{ e: "🖤", n: "black_heart", c: "Symbols" }, { e: "💯", n: "100", k: "hundred perfect", c: "Symbols" }, { e: "✅", n: "white_check_mark", k: "tick done yes check", c: "Symbols" },
{ e: "☑️", n: "check", k: "tick done", c: "Symbols" }, { e: "✔️", n: "heavy_check", k: "tick", c: "Symbols" }, { e: "❌", n: "x", k: "cross no wrong", c: "Symbols" },
{ e: "⭐", n: "star", c: "Symbols" }, { e: "🌟", n: "glowing_star", c: "Symbols" }, { e: "✨", n: "sparkles", c: "Symbols" },
{ e: "❗", n: "exclamation", c: "Symbols" }, { e: "❓", n: "question", c: "Symbols" }, { e: "⚠️", n: "warning", c: "Symbols" },
{ e: "👀", n: "eyes", k: "look watch", c: "Symbols" }, { e: "🎈", n: "balloon", c: "Symbols" }, { e: "💥", n: "boom", c: "Symbols" },
];
// name → emoji map for :shortcode: parsing
const NAME_MAP: Record<string, string> = (() => {
const m: Record<string, string> = {};
for (const em of EMOJIS) m[em.n] = em.e;
// common aliases
m["thumbsup"] = "👍"; m["thumbsdown"] = "👎"; m["heart"] = "❤️"; m["fire"] = "🔥";
m["check"] = "✅"; m["tick"] = "✅"; m["done"] = "✅"; m["rocket"] = "🚀"; m["party"] = "🎉";
m["smile"] = "😄"; m["laugh"] = "😂"; m["ok"] = "👌"; m["eyes"] = "👀"; m["100"] = "💯";
return m;
})();
/** Replace :shortcode: tokens in a string with their emoji. */
export function replaceShortcodes(text: string): string {
return text.replace(/:([a-z0-9_+-]{1,30}):/gi, (whole, name) => NAME_MAP[name.toLowerCase()] ?? whole);
}
export function shortcodeToEmoji(name: string): string | undefined {
return NAME_MAP[name.toLowerCase()];
}
/** Fuzzy search across name + keywords. */
export function searchEmojis(query: string, limit = 40): Emoji[] {
const q = query.toLowerCase().trim();
if (!q) return EMOJIS.slice(0, limit);
return EMOJIS.filter((e) => e.n.includes(q) || (e.k && e.k.includes(q))).slice(0, limit);
}
export const FREQUENT: string[] = ["👍", "🎉", "🔥", "❤️", "😂", "🙌", "👀", "✅", "🚀", "💯", "🙏", "👏"];
+40
View File
@@ -0,0 +1,40 @@
export function relativeTime(ts: number): string {
const diff = Date.now() - ts;
const s = Math.round(diff / 1000);
if (s < 45) return "just now";
const m = Math.round(s / 60);
if (m < 60) return `${m}m`;
const h = Math.round(m / 60);
if (h < 24) return `${h}h`;
const d = Math.round(h / 24);
if (d < 7) return `${d}d`;
return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
export function clockTime(ts: number): string {
return new Date(ts).toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
});
}
export function dayLabel(ts: number): string {
const d = new Date(ts);
const today = new Date();
const yst = new Date();
yst.setDate(today.getDate() - 1);
if (d.toDateString() === today.toDateString()) return "Today";
if (d.toDateString() === yst.toDateString()) return "Yesterday";
return d.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" });
}
export function groupByDay<T extends { ts: number }>(items: T[]): { day: string; items: T[] }[] {
const out: { day: string; items: T[] }[] = [];
for (const it of items) {
const label = dayLabel(it.ts);
const last = out[out.length - 1];
if (last && last.day === label) last.items.push(it);
else out.push({ day: label, items: [it] });
}
return out;
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Server-only Gemini client. Powers the AI assistant + translation features.
*
* SECURITY: GEMINI_API_KEY is read from the server environment and NEVER exposed
* to the client bundle (no NEXT_PUBLIC_ prefix). All calls go through BFF routes
* so the key stays on the server. This module must only be imported by route
* handlers / server code.
*/
const ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/models";
export type GeminiResult =
| { ok: true; text: string }
| { ok: false; status: number; error: string; retryable: boolean };
/**
* Single-shot text generation. Returns a discriminated result so callers can
* surface a graceful message (e.g. on 429 quota-exhausted) instead of throwing.
*/
export async function geminiGenerate(prompt: string, opts?: { system?: string; temperature?: number }): Promise<GeminiResult> {
const key = process.env.GEMINI_API_KEY;
const model = process.env.GEMINI_MODEL || "gemini-2.0-flash";
if (!key) {
return { ok: false, status: 0, error: "GEMINI_API_KEY is not configured on the server.", retryable: false };
}
const body: Record<string, unknown> = {
contents: [{ role: "user", parts: [{ text: prompt }] }],
generationConfig: { temperature: opts?.temperature ?? 0.4 },
};
if (opts?.system) {
body.systemInstruction = { parts: [{ text: opts.system }] };
}
let res: Response;
try {
res = await fetch(`${ENDPOINT}/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(key)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
cache: "no-store",
});
} catch (e) {
return { ok: false, status: 0, error: `Could not reach Gemini: ${(e as Error).message}`, retryable: true };
}
if (!res.ok) {
const detail = await res.text().catch(() => "");
const retryable = res.status === 429 || res.status >= 500;
let msg = `Gemini error ${res.status}`;
if (res.status === 429) msg = "The AI is rate-limited right now (quota exhausted). Please try again shortly.";
else if (res.status === 403) msg = "The AI request was rejected (check the API key / permissions).";
return { ok: false, status: res.status, error: msg, retryable };
}
const json = await res.json().catch(() => null);
const text: string | undefined = json?.candidates?.[0]?.content?.parts?.map((p: any) => p?.text).filter(Boolean).join("\n");
if (!text) {
// Blocked (safety) or empty candidate.
const reason = json?.candidates?.[0]?.finishReason || json?.promptFeedback?.blockReason;
return { ok: false, status: 200, error: reason ? `The AI returned no text (${reason}).` : "The AI returned an empty response.", retryable: false };
}
return { ok: true, text: text.trim() };
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Server-side client for the real IIOS service (see D:\iios\docs\IIOS_API_AND_SDK_GUIDE.md).
* Runs only in BFF route handlers — never shipped to the browser. Auth uses a dev token
* minted from /v1/dev/token (requires the IIOS service running with IIOS_DEV_TOKENS=1);
* in production swap mintToken() for a real Session-Broker exchange.
*/
const IIOS_URL = process.env.IIOS_URL ?? "http://localhost:3200";
const APP_ID = process.env.IIOS_APP_ID ?? "portal-demo";
const USER_ID = process.env.IIOS_USER_ID ?? "james";
const ORG_ID = process.env.IIOS_ORG_ID ?? "org_A";
let cachedToken: { token: string; exp: number } | null = null;
async function mintToken(): Promise<string> {
// reuse for ~100 min (IIOS dev tokens live 2h)
if (cachedToken && cachedToken.exp > Date.now()) return cachedToken.token;
const res = await fetch(`${IIOS_URL}/v1/dev/token`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ appId: APP_ID, userId: USER_ID, orgId: ORG_ID }),
cache: "no-store",
});
if (!res.ok) throw new Error(`IIOS token mint failed: ${res.status} ${await res.text().catch(() => "")}`);
const { token } = (await res.json()) as { token: string };
cachedToken = { token, exp: Date.now() + 100 * 60_000 };
return token;
}
async function req<T>(path: string, init?: RequestInit & { idempotencyKey?: string }): Promise<T> {
const token = await mintToken();
const headers: Record<string, string> = {
"content-type": "application/json",
authorization: `Bearer ${token}`,
...(init?.headers as Record<string, string> | undefined),
};
if (init?.idempotencyKey) headers["idempotency-key"] = init.idempotencyKey;
const res = await fetch(`${IIOS_URL}${path}`, { ...init, headers, cache: "no-store" });
if (!res.ok) throw new Error(`IIOS ${res.status} ${path}: ${await res.text().catch(() => "")}`);
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export const iios = {
info: { url: IIOS_URL, appId: APP_ID, userId: USER_ID, orgId: ORG_ID },
health: () => req<{ status: string; db: boolean }>("/health"),
getThreadMessages: (threadId: string) => req<any>(`/v1/threads/${threadId}/messages`),
sendMessage: (threadId: string, content: string, idem?: string) =>
req<any>(`/v1/threads/${threadId}/messages`, { method: "POST", body: JSON.stringify({ content }), idempotencyKey: idem }),
listInbox: (state?: string) => req<any>(`/v1/inbox/items${state ? `?state=${state}` : ""}`),
patchInbox: (id: string, state: string, reason?: string) =>
req<any>(`/v1/inbox/items/${id}`, { method: "PATCH", body: JSON.stringify({ state, reason }) }),
listTickets: (scope?: string) => req<any>(`/v1/support/tickets${scope ? `?scope=${scope}` : ""}`),
patchTicket: (id: string, state: string, reason?: string) =>
req<any>(`/v1/support/tickets/${id}`, { method: "PATCH", body: JSON.stringify({ state, reason }) }),
createTicket: (subject: string, priority?: string, threadId?: string) =>
req<any>(`/v1/support/tickets`, { method: "POST", body: JSON.stringify({ subject, priority, threadId }) }),
escalate: (threadId: string, subject?: string) =>
req<any>(`/v1/support/escalate`, { method: "POST", body: JSON.stringify({ threadId, subject }) }),
};
export async function iiosReachable(): Promise<boolean> {
try {
const h = await iios.health();
return !!h?.status;
} catch {
return false;
}
}
+72
View File
@@ -0,0 +1,72 @@
import type { InboxItem, InboxKind, InboxState, Message, Ticket, TicketPriority, TicketState } from "@lynkd/messaging-inbox-sdk";
/**
* Map IIOS API shapes → this app's SDK types. Written defensively because the
* exact field names live in the IIOS codebase; adjust as you wire real responses.
* (Keep the UI contract = SDK types; do all translation here in the BFF.)
*/
const ts = (v: any): number => {
if (typeof v === "number") return v;
if (typeof v === "string") { const t = Date.parse(v); if (!Number.isNaN(t)) return t; }
return Date.now();
};
export function toMessage(m: any, channelId: string): Message {
const text =
m.content ??
m.body ??
(Array.isArray(m.parts) ? m.parts.find((p: any) => (p.kind ?? p.partType) === "TEXT")?.bodyText : undefined) ??
"";
return {
id: String(m.id ?? m.messageId ?? m.interactionId),
channelId,
authorId: String(m.senderActorId ?? m.actorId ?? m.authorId ?? m.source?.externalId ?? "unknown"),
ts: ts(m.sentAt ?? m.occurredAt ?? m.createdAt ?? m.ts),
body: String(text),
parentId: m.parentInteractionId ?? m.parentId ?? null,
reactions: [],
deliveryState: "sent",
};
}
const inboxKind = (k: any): InboxKind => {
const up = String(k ?? "").toUpperCase();
const known: InboxKind[] = ["NEEDS_REPLY", "MENTION", "REACTION", "THREAD_REPLY", "SAVED", "APP", "SUPPORT_UPDATE", "MEETING_FOLLOWUP"];
return (known.includes(up as InboxKind) ? up : "NEEDS_REPLY") as InboxKind;
};
export function toInboxItem(i: any): InboxItem {
return {
id: String(i.id),
kind: inboxKind(i.kind),
state: (String(i.state ?? "OPEN").toUpperCase() as InboxState),
title: String(i.title ?? i.subject ?? "Inbox item"),
preview: String(i.preview ?? i.snippet ?? ""),
channelId: i.conversationId ?? i.threadId ?? undefined,
messageId: i.messageId ?? undefined,
actorId: i.actorId ?? i.createdByActorId ?? undefined,
ts: ts(i.createdAt ?? i.ts),
dueAt: i.dueAt ? ts(i.dueAt) : undefined,
priority: i.priority ? (String(i.priority).toLowerCase() as InboxItem["priority"]) : "normal",
};
}
export function toTicket(t: any): Ticket {
return {
id: String(t.id),
ref: String(t.ref ?? t.id),
subject: String(t.subject ?? ""),
requesterId: String(t.requesterActorId ?? t.requesterId ?? "unknown"),
assigneeId: t.assigneeActorId ?? t.assigneeId ?? undefined,
state: (String(t.state ?? "NEW").toUpperCase() as TicketState),
priority: (String(t.priority ?? "NORMAL").toUpperCase() as TicketPriority),
channelId: t.threadId ?? t.conversationId ?? undefined,
category: t.klass ?? t.category ?? undefined,
createdTs: ts(t.createdAt),
updatedTs: ts(t.updatedAt ?? t.createdAt),
slaBreached: !!t.slaBreached,
tags: t.tags ?? undefined,
messages: Array.isArray(t.messages) ? t.messages.map((m: any) => toMessage(m, `t_${t.id}`)) : [],
};
}
+186
View File
@@ -0,0 +1,186 @@
{
"seeded": true,
"generatedAt": "2026-07-12T07:10:38.714Z",
"workspace": {
"id": "ws_lynkd",
"name": "LynkedUp Pro",
"initials": "LP",
"accent": "#FDA913",
"plan": "Business+"
},
"workspaces": [
{
"id": "ws_lynkd",
"name": "LynkedUp Pro",
"initials": "LP",
"accent": "#FDA913",
"plan": "Business+"
}
],
"me": {
"id": "cmrgtgjsd0004vcsgilt81q5t",
"name": "James Carter",
"handle": "james",
"avatarText": "JC",
"avatarColor": "#FDA913",
"title": "Property Owner",
"presence": "active"
},
"users": [
{
"id": "cmrhgf55k000wvcsg3r0v75yt",
"name": "Ava Mitchell",
"handle": "ava",
"avatarText": "AM",
"avatarColor": "#5b9dff",
"title": "Ops Lead",
"presence": "active"
},
{
"id": "cmrhgf56a0018vcsgmfp78o4e",
"name": "Sara Khan",
"handle": "sara",
"avatarText": "SK",
"avatarColor": "#f5b544",
"title": "Account Manager",
"presence": "active"
},
{
"id": "cmrgtgjsd0004vcsgilt81q5t",
"name": "James Carter",
"handle": "james",
"avatarText": "JC",
"avatarColor": "#FDA913",
"title": "Property Owner",
"presence": "active"
},
{
"id": "cmrhgf56z001mvcsgni08yw4s",
"name": "Noah Bennett",
"handle": "noah",
"avatarText": "NB",
"avatarColor": "#3ecf8e",
"title": "Field Supervisor",
"presence": "away"
},
{
"id": "cmrhgf57s0022vcsgd8cjjuwq",
"name": "Mia Torres",
"handle": "mia",
"avatarText": "MT",
"avatarColor": "#bb98ff",
"title": "Estimator",
"presence": "active"
},
{
"id": "cmrhgf58d002gvcsga37m9tzz",
"name": "Zoe Nguyen",
"handle": "zoe",
"avatarText": "ZN",
"avatarColor": "#f2555a",
"title": "Customer Success",
"presence": "active"
}
],
"sections": [
{
"id": "starred",
"label": "Starred"
},
{
"id": "channels",
"label": "Channels"
},
{
"id": "dms",
"label": "Direct messages"
}
],
"channels": [
{
"id": "cmrhgf55s0010vcsgm2fl4wnw",
"kind": "channel",
"name": "general",
"topic": "Company-wide announcements and work-based matters",
"memberIds": [
"cmrhgf55k000wvcsg3r0v75yt",
"cmrhgf56a0018vcsgmfp78o4e",
"cmrgtgjsd0004vcsgilt81q5t",
"cmrhgf56z001mvcsgni08yw4s",
"cmrhgf57s0022vcsgd8cjjuwq",
"cmrhgf58d002gvcsga37m9tzz"
],
"isStarred": true,
"sectionId": "starred",
"unreadCount": 0
},
{
"id": "cmrhgf57i001uvcsg83xtxj23",
"kind": "channel",
"name": "field-ops",
"topic": "Crews, schedules, and on-site coordination",
"memberIds": [
"cmrhgf55k000wvcsg3r0v75yt",
"cmrhgf56a0018vcsgmfp78o4e",
"cmrgtgjsd0004vcsgilt81q5t",
"cmrhgf56z001mvcsgni08yw4s",
"cmrhgf57s0022vcsgd8cjjuwq",
"cmrhgf58d002gvcsga37m9tzz"
],
"isStarred": true,
"sectionId": "starred",
"unreadCount": 0
},
{
"id": "cmrhgf58e002ivcsg0lsskcjr",
"kind": "channel",
"name": "leads-pipeline",
"topic": "New leads and verification queue",
"memberIds": [
"cmrhgf55k000wvcsg3r0v75yt",
"cmrhgf56a0018vcsgmfp78o4e",
"cmrgtgjsd0004vcsgilt81q5t",
"cmrhgf56z001mvcsgni08yw4s",
"cmrhgf57s0022vcsgd8cjjuwq",
"cmrhgf58d002gvcsga37m9tzz"
],
"isStarred": false,
"sectionId": "channels",
"unreadCount": 0
},
{
"id": "cmrhgf590002wvcsg322e4w53",
"kind": "channel",
"name": "estimates",
"topic": "ProCanvas estimates & approvals",
"memberIds": [
"cmrhgf55k000wvcsg3r0v75yt",
"cmrhgf56a0018vcsgmfp78o4e",
"cmrgtgjsd0004vcsgilt81q5t",
"cmrhgf56z001mvcsgni08yw4s",
"cmrhgf57s0022vcsgd8cjjuwq",
"cmrhgf58d002gvcsga37m9tzz"
],
"isStarred": false,
"sectionId": "channels",
"unreadCount": 0
},
{
"id": "cmrhgf59k003avcsgw3lvm6mt",
"kind": "channel",
"name": "random",
"topic": "Non-work banter",
"memberIds": [
"cmrhgf55k000wvcsg3r0v75yt",
"cmrhgf56a0018vcsgmfp78o4e",
"cmrgtgjsd0004vcsgilt81q5t",
"cmrhgf56z001mvcsgni08yw4s",
"cmrhgf57s0022vcsgd8cjjuwq",
"cmrhgf58d002gvcsga37m9tzz"
],
"isStarred": false,
"sectionId": "channels",
"unreadCount": 0
}
]
}
+44
View File
@@ -0,0 +1,44 @@
/** Languages offered in the translation dropdowns (per-message + composer). */
export interface Language {
name: string;
native: string;
flag: string;
}
export const LANGUAGES: Language[] = [
{ name: "English", native: "English", flag: "🇬🇧" },
{ name: "Spanish", native: "Español", flag: "🇪🇸" },
{ name: "French", native: "Français", flag: "🇫🇷" },
{ name: "German", native: "Deutsch", flag: "🇩🇪" },
{ name: "Italian", native: "Italiano", flag: "🇮🇹" },
{ name: "Portuguese", native: "Português", flag: "🇵🇹" },
{ name: "Dutch", native: "Nederlands", flag: "🇳🇱" },
{ name: "Russian", native: "Русский", flag: "🇷🇺" },
{ name: "Arabic", native: "العربية", flag: "🇸🇦" },
{ name: "Hindi", native: "हिन्दी", flag: "🇮🇳" },
{ name: "Bengali", native: "বাংলা", flag: "🇧🇩" },
{ name: "Urdu", native: "اردو", flag: "🇵🇰" },
{ name: "Gujarati", native: "ગુજરાતી", flag: "🇮🇳" },
{ name: "Chinese (Simplified)", native: "简体中文", flag: "🇨🇳" },
{ name: "Chinese (Traditional)", native: "繁體中文", flag: "🇹🇼" },
{ name: "Japanese", native: "日本語", flag: "🇯🇵" },
{ name: "Korean", native: "한국어", flag: "🇰🇷" },
{ name: "Vietnamese", native: "Tiếng Việt", flag: "🇻🇳" },
{ name: "Thai", native: "ไทย", flag: "🇹🇭" },
{ name: "Indonesian", native: "Bahasa Indonesia", flag: "🇮🇩" },
{ name: "Turkish", native: "Türkçe", flag: "🇹🇷" },
{ name: "Polish", native: "Polski", flag: "🇵🇱" },
{ name: "Ukrainian", native: "Українська", flag: "🇺🇦" },
{ name: "Greek", native: "Ελληνικά", flag: "🇬🇷" },
{ name: "Hebrew", native: "עברית", flag: "🇮🇱" },
{ name: "Swedish", native: "Svenska", flag: "🇸🇪" },
{ name: "Norwegian", native: "Norsk", flag: "🇳🇴" },
{ name: "Danish", native: "Dansk", flag: "🇩🇰" },
{ name: "Finnish", native: "Suomi", flag: "🇫🇮" },
{ name: "Czech", native: "Čeština", flag: "🇨🇿" },
{ name: "Romanian", native: "Română", flag: "🇷🇴" },
{ name: "Hungarian", native: "Magyar", flag: "🇭🇺" },
{ name: "Filipino", native: "Filipino", flag: "🇵🇭" },
{ name: "Malay", native: "Bahasa Melayu", flag: "🇲🇾" },
{ name: "Swahili", native: "Kiswahili", flag: "🇰🇪" },
];
+218
View File
@@ -0,0 +1,218 @@
import type {
AgentAvailability,
Bootstrap,
Channel,
InboxItem,
Message,
Notification,
SidebarSection,
Ticket,
User,
Workspace,
} from "@lynkd/messaging-inbox-sdk";
const now = Date.now();
const min = 60_000;
const hr = 60 * min;
const day = 24 * hr;
/* ------------------------------- users -------------------------------- */
export const users: User[] = [
{ id: "u_me", name: "James Carter", handle: "james", avatarText: "JC", avatarColor: "#FDA913", title: "Property Owner", presence: "active", statusEmoji: "🏗️", statusText: "On site — Territory 4", timezone: "America/Chicago", localTime: "9:41 AM", email: "james@lynkedup.pro", pronouns: "he/him" },
{ id: "u_ava", name: "Ava Mitchell", handle: "ava", avatarText: "AM", avatarColor: "#5b9dff", title: "Ops Lead", presence: "active", statusText: "In a meeting", timezone: "America/New_York" },
{ id: "u_noah", name: "Noah Bennett", handle: "noah", avatarText: "NB", avatarColor: "#3ecf8e", title: "Field Supervisor", presence: "away", timezone: "America/Chicago" },
{ id: "u_mia", name: "Mia Torres", handle: "mia", avatarText: "MT", avatarColor: "#bb98ff", title: "Estimator", presence: "active", statusEmoji: "📐", timezone: "America/Denver" },
{ id: "u_liam", name: "Liam Foster", handle: "liam", avatarText: "LF", avatarColor: "#ff9f66", title: "Dispatch", presence: "dnd", statusText: "Heads-down", timezone: "America/Chicago" },
{ id: "u_zoe", name: "Zoe Nguyen", handle: "zoe", avatarText: "ZN", avatarColor: "#f2555a", title: "Customer Success", presence: "active", timezone: "America/Los_Angeles" },
{ id: "u_ethan", name: "Ethan Cole", handle: "ethan", avatarText: "EC", avatarColor: "#54e7ff", title: "Subcontractor", presence: "offline", timezone: "America/Chicago" },
{ id: "u_sara", name: "Sara Khan", handle: "sara", avatarText: "SK", avatarColor: "#f5b544", title: "Account Manager", presence: "active", timezone: "America/New_York" },
{ id: "u_bot", name: "LynkBot", handle: "lynkbot", avatarText: "LB", avatarColor: "#8c8c94", title: "Automation", presence: "active", isBot: true },
];
export const me = users[0];
export const workspace: Workspace = {
id: "ws_lynkd",
name: "LynkedUp Pro",
initials: "LP",
accent: "#FDA913",
plan: "Business+",
};
export const workspaces: Workspace[] = [
workspace,
{ id: "ws_storm", name: "Storm Response", initials: "SR", accent: "#5b9dff", plan: "Pro" },
{ id: "ws_hq", name: "LynkedUp HQ", initials: "HQ", accent: "#3ecf8e", plan: "Enterprise" },
];
/* ------------------------------ sections ------------------------------ */
export const sections: SidebarSection[] = [
{ id: "starred", label: "Starred", collapsible: true },
{ id: "channels", label: "Channels", collapsible: true },
{ id: "dms", label: "Direct messages", collapsible: true },
];
/* ------------------------------ channels ------------------------------ */
export const channels: Channel[] = [
{ id: "c_general", kind: "channel", name: "general", topic: "Company-wide announcements and work-based matters", memberIds: ["u_me", "u_ava", "u_noah", "u_mia", "u_liam", "u_zoe", "u_sara", "u_bot"], isStarred: true, sectionId: "starred", unreadCount: 3, mentionCount: 1, lastActivityTs: now - 4 * min },
{ id: "c_field", kind: "channel", name: "field-ops", topic: "Crews, schedules, and on-site coordination", memberIds: ["u_me", "u_noah", "u_liam", "u_ethan"], isStarred: true, sectionId: "starred", unreadCount: 7, lastActivityTs: now - 12 * min },
{ id: "c_leads", kind: "channel", name: "leads-pipeline", topic: "New leads and verification queue", memberIds: ["u_me", "u_ava", "u_sara", "u_zoe"], sectionId: "channels", unreadCount: 0, lastActivityTs: now - 2 * hr },
{ id: "c_estimates", kind: "channel", name: "estimates", topic: "ProCanvas estimates & approvals", memberIds: ["u_me", "u_mia", "u_ava"], sectionId: "channels", unreadCount: 2, lastActivityTs: now - 40 * min },
{ id: "c_storm", kind: "channel", name: "storm-intel", topic: "Weather events & rapid dispatch", memberIds: ["u_me", "u_liam", "u_noah", "u_bot"], sectionId: "channels", unreadCount: 0, lastActivityTs: now - 5 * hr },
{ id: "c_random", kind: "channel", name: "random", topic: "Non-work banter", memberIds: ["u_me", "u_ava", "u_noah", "u_mia", "u_zoe"], sectionId: "channels", isMuted: true, unreadCount: 0, lastActivityTs: now - day },
{ id: "c_partners", kind: "channel", name: "acme-partners", topic: "Shared with Acme Roofing", memberIds: ["u_me", "u_sara", "u_ethan"], sectionId: "channels", external: true, unreadCount: 1, lastActivityTs: now - 3 * hr },
{ id: "c_private", kind: "private", name: "leadership", topic: "Leadership only", memberIds: ["u_me", "u_ava", "u_sara"], sectionId: "channels", unreadCount: 0, lastActivityTs: now - 6 * hr },
// DMs
{ id: "d_ava", kind: "dm", name: "Ava Mitchell", memberIds: ["u_me", "u_ava"], sectionId: "dms", unreadCount: 0, mentionCount: 0, lastActivityTs: now - 20 * min },
{ id: "d_liam", kind: "dm", name: "Liam Foster", memberIds: ["u_me", "u_liam"], sectionId: "dms", unreadCount: 2, mentionCount: 2, lastActivityTs: now - 8 * min },
{ id: "d_bot", kind: "dm", name: "LynkBot", memberIds: ["u_me", "u_bot"], sectionId: "dms", unreadCount: 0, lastActivityTs: now - hr },
{ id: "g_crew", kind: "group_dm", name: "Noah, Mia, Zoe", memberIds: ["u_me", "u_noah", "u_mia", "u_zoe"], sectionId: "dms", unreadCount: 0, lastActivityTs: now - 90 * min },
];
/* ------------------------------ messages ------------------------------ */
let seq = 1;
function m(
channelId: string,
authorId: string,
minutesAgo: number,
body: string,
extra: Partial<Message> = {},
): Message {
return {
id: `m_${seq++}`,
channelId,
authorId,
ts: now - minutesAgo * min,
body,
reactions: [],
deliveryState: "sent",
...extra,
};
}
export const messages: Message[] = [
// #general
m("c_general", "u_ava", 190, "Morning team 👋 Reminder: Q3 territory review is Thursday 10am CT. Agenda in the canvas."),
m("c_general", "u_sara", 180, "Thanks Ava. I'll add the Acme renewal numbers.", { reactions: [{ emoji: "🙌", userIds: ["u_ava", "u_me"] }] }),
m("c_general", "u_bot", 120, "Deploy `web@1.9.2` shipped to production. 0 errors in the last hour.", { systemEvent: undefined, reactions: [{ emoji: "🚀", userIds: ["u_me", "u_noah", "u_mia"] }] }),
m("c_general", "u_ava", 45, "@james can you approve the new estimate template before the review?", { mentions: ["u_me"], replyCount: 2, replyUserIds: ["u_me", "u_mia"], lastReplyTs: now - 30 * min }),
m("c_general", "u_me", 30, "On it — reviewing now. Looks solid.", { parentId: "m_4" }),
m("c_general", "u_mia", 29, "Added the line-item breakdown you asked for 📐", { parentId: "m_4" }),
m("c_general", "u_noah", 4, "Crew 2 wrapped the Henderson job early. Photos uploaded.", { attachments: [{ id: "a1", kind: "image", name: "henderson-roof.jpg", sizeLabel: "2.4 MB", previewColor: "#2d3a2f" }], reactions: [{ emoji: "🔥", userIds: ["u_me"] }, { emoji: "👏", userIds: ["u_ava", "u_sara"] }] }),
// #field-ops
m("c_field", "u_liam", 300, "Dispatch board for today is set. 6 jobs, 3 crews."),
m("c_field", "u_noah", 240, "Copy. Crew 1 starting at the Willow St property."),
m("c_field", "u_ethan", 60, "Running 20 min late to the 2pm — traffic on I-35.", { reactions: [{ emoji: "👍", userIds: ["u_liam"] }] }),
m("c_field", "u_liam", 40, "No problem, pushed the window. Customer notified.", { blocks: [{ type: "callout", text: "SLA: customer must be notified within 15 min of any delay." }] }),
m("c_field", "u_noah", 12, "Weather alert incoming for Sector 4 — see #storm-intel.", { mentions: [], reactions: [] }),
// #estimates
m("c_estimates", "u_mia", 120, "Uploaded 3 new estimates for review.", { attachments: [{ id: "a2", kind: "file", name: "estimate-4471.pdf", sizeLabel: "180 KB", previewColor: "#3a2f2d" }] }),
m("c_estimates", "u_ava", 40, "Approved 4471 and 4472. 4473 needs a material adjustment.", { reactions: [{ emoji: "✅", userIds: ["u_mia"] }] }),
// #leads-pipeline
m("c_leads", "u_zoe", 200, "12 new leads from the storm campaign overnight 🌩️"),
m("c_leads", "u_sara", 150, "Verified 8 of them. 4 flagged for manual review.", { blocks: [{ type: "bullet", items: ["Verified: 8", "Manual review: 4", "Duplicate: 0"] }] }),
// #storm-intel
m("c_storm", "u_bot", 300, "⚠️ NWS: Severe thunderstorm watch for Sector 4 until 6pm CT. Hail up to 1in possible."),
m("c_storm", "u_liam", 290, "Rerouting crews away from Sector 4. Safety first."),
// #acme-partners (external)
m("c_partners", "u_ethan", 180, "Hi team — Acme here. We can take the overflow inspections this week.", { reactions: [{ emoji: "🤝", userIds: ["u_me", "u_sara"] }] }),
// DM with Liam
m("d_liam", "u_liam", 12, "Hey James — can you sign off on the overtime for crew 2?"),
m("d_liam", "u_liam", 8, "@james ^ need it before payroll closes at 5.", { mentions: ["u_me"] }),
// DM with Ava
m("d_ava", "u_ava", 60, "Sending the review deck over in a few."),
m("d_ava", "u_me", 20, "Perfect, thanks!"),
// DM with bot
m("d_bot", "u_bot", 60, "Your daily digest: 3 mentions, 2 tickets awaiting reply, 1 estimate pending approval.", { blocks: [{ type: "bullet", items: ["3 mentions across #general, #field-ops", "2 support tickets need a reply", "Estimate 4473 pending approval"] }] }),
// group DM
m("g_crew", "u_zoe", 100, "Team lunch Friday? 🍕"),
m("g_crew", "u_mia", 95, "I'm in!", { reactions: [{ emoji: "🍕", userIds: ["u_zoe", "u_noah"] }] }),
];
/* ------------------------------- inbox -------------------------------- */
export const inbox: InboxItem[] = [
{ id: "i1", kind: "MENTION", state: "OPEN", title: "Liam Foster mentioned you", preview: "@james ^ need it before payroll closes at 5.", channelId: "d_liam", messageId: "m_21", actorId: "u_liam", ts: now - 8 * min, priority: "high" },
{ id: "i2", kind: "NEEDS_REPLY", state: "OPEN", title: "Ava is waiting on approval", preview: "can you approve the new estimate template before the review?", channelId: "c_general", messageId: "m_4", actorId: "u_ava", ts: now - 45 * min, priority: "normal" },
{ id: "i3", kind: "SUPPORT_UPDATE", state: "OPEN", title: "Ticket TCK-1042 needs a reply", preview: "Customer replied: 'Still seeing the sync issue on mobile.'", actorId: "u_zoe", ts: now - 25 * min, priority: "urgent" },
{ id: "i4", kind: "REACTION", state: "OPEN", title: "Noah reacted 🔥 to your message", preview: "Crew 2 wrapped the Henderson job early.", channelId: "c_general", actorId: "u_noah", ts: now - 3 * min, priority: "low" },
{ id: "i5", kind: "THREAD_REPLY", state: "OPEN", title: "New reply in a thread you're in", preview: "Mia: Added the line-item breakdown you asked for", channelId: "c_general", messageId: "m_4", actorId: "u_mia", ts: now - 29 * min, priority: "normal" },
{ id: "i6", kind: "MEETING_FOLLOWUP", state: "OPEN", title: "Follow-up: Q3 review action items", preview: "Send revised payment schedule to Acme", ts: now - 2 * hr, dueAt: now + day, priority: "normal" },
{ id: "i7", kind: "SAVED", state: "OPEN", title: "Saved for later", preview: "SLA: customer must be notified within 15 min of any delay.", channelId: "c_field", messageId: "m_11", ts: now - hr, priority: "low" },
{ id: "i8", kind: "APP", state: "OPEN", title: "LynkBot digest ready", preview: "Your daily digest: 3 mentions, 2 tickets awaiting reply.", channelId: "d_bot", actorId: "u_bot", ts: now - hr, priority: "low" },
];
/* ------------------------------ tickets ------------------------------- */
function tmsg(id: string, ticketId: string, authorId: string, minutesAgo: number, body: string): Message {
return { id, channelId: `t_${ticketId}`, authorId, ts: now - minutesAgo * min, body, deliveryState: "sent" };
}
export const tickets: Ticket[] = [
{
id: "TCK-1042", ref: "TCK-1042", subject: "Mobile app not syncing new leads", requesterId: "u_zoe", assigneeId: "u_me",
state: "OPEN", priority: "URGENT", channelId: "c_leads", category: "Sync / Data", createdTs: now - 3 * hr, updatedTs: now - 25 * min,
firstResponseDueTs: now - 10 * min, slaBreached: true, tags: ["mobile", "sync", "p1"],
messages: [
tmsg("tm1", "1042", "u_zoe", 180, "Customer reports new leads aren't appearing on the mobile app after the last update."),
tmsg("tm2", "1042", "u_me", 150, "Thanks — can you confirm the app version and device?"),
tmsg("tm3", "1042", "u_zoe", 25, "Still seeing the sync issue on mobile. iOS 18, app v1.9.1."),
],
},
{
id: "TCK-1041", ref: "TCK-1041", subject: "Estimate PDF export missing logo", requesterId: "u_mia", assigneeId: "u_me",
state: "PENDING", priority: "NORMAL", channelId: "c_estimates", category: "Estimates", createdTs: now - day, updatedTs: now - 4 * hr, tags: ["export", "branding"],
messages: [
tmsg("tm4", "1041", "u_mia", 1440, "Exported estimates are missing the company logo in the header."),
tmsg("tm5", "1041", "u_me", 300, "Reproduced. Fix queued for the next release. Marking pending."),
],
},
{
id: "TCK-1040", ref: "TCK-1040", subject: "Request: SMS notifications for dispatch", requesterId: "u_liam", state: "NEW", priority: "LOW", category: "Feature request", createdTs: now - 2 * hr, updatedTs: now - 2 * hr, tags: ["dispatch", "notifications"],
messages: [tmsg("tm6", "1040", "u_liam", 120, "Could we get SMS alerts when a job is reassigned?")],
},
{
id: "TCK-1039", ref: "TCK-1039", subject: "Billing — duplicate charge on invoice 8871", requesterId: "u_sara", assigneeId: "u_ava", state: "RESOLVED", priority: "HIGH", category: "Billing", createdTs: now - 3 * day, updatedTs: now - day, csat: 5, tags: ["billing"],
messages: [
tmsg("tm7", "1039", "u_sara", 4320, "Client was charged twice on invoice 8871."),
tmsg("tm8", "1039", "u_ava", 1500, "Refund processed. Confirmed with the client. Closing out."),
],
},
{
id: "TCK-1038", ref: "TCK-1038", subject: "Territory map tiles not loading", requesterId: "u_noah", assigneeId: "u_me", state: "CLOSED", priority: "NORMAL", category: "Maps", createdTs: now - 5 * day, updatedTs: now - 4 * day, csat: 4, tags: ["maps"],
messages: [tmsg("tm9", "1038", "u_noah", 7200, "Map tiles were blank on the territory view. Seems fixed now.")],
},
];
export const availability: AgentAvailability[] = [
{ userId: "u_me", online: true, activeCount: 2, maxActive: 5, queue: "Tier 1" },
{ userId: "u_ava", online: true, activeCount: 1, maxActive: 4, queue: "Billing" },
{ userId: "u_zoe", online: true, activeCount: 3, maxActive: 5, queue: "Success" },
{ userId: "u_sara", online: false, activeCount: 0, maxActive: 4, queue: "Accounts" },
];
/* --------------------------- notifications ---------------------------- */
export const notifications: Notification[] = [
{ id: "n1", kind: "MENTION", title: "Liam Foster", body: "mentioned you in a DM", ts: now - 8 * min, read: false, channelId: "d_liam", messageId: "m_21" },
{ id: "n2", kind: "SUPPORT_UPDATE", title: "TCK-1042", body: "SLA breached — first response overdue", ts: now - 10 * min, read: false },
{ id: "n3", kind: "REACTION", title: "Noah Bennett", body: "reacted 🔥 to your message", ts: now - 3 * min, read: false, channelId: "c_general", messageId: "m_7" },
{ id: "n4", kind: "SYSTEM", title: "LynkBot", body: "Deploy web@1.9.2 shipped to production", ts: now - 2 * hr, read: true },
];
export function bootstrapPayload(): Bootstrap {
return { me, workspace, workspaces, users, sections, channels };
}
+291
View File
@@ -0,0 +1,291 @@
import type {
InboxItem,
InboxState,
Message,
SearchResult,
Ticket,
TicketState,
} from "@lynkd/messaging-inbox-sdk";
import * as seed from "./data";
/**
* In-memory mutable store. Next.js dev/prod route handlers share one Node
* process, so a module singleton persists across requests for the session.
* This is the ONLY stateful piece of the "backend" — swap it for the IIOS
* client and the BFF routes stay identical.
*/
class MockStore {
users = clone(seed.users);
channels = clone(seed.channels);
messages = clone(seed.messages);
inbox = clone(seed.inbox);
tickets = clone(seed.tickets);
notifications = clone(seed.notifications);
availability = clone(seed.availability);
private idc = 10000;
me() {
return this.users.find((u) => u.id === seed.me.id) ?? this.users[0];
}
bootstrap() {
return {
me: this.me(),
workspace: seed.workspace,
workspaces: seed.workspaces,
users: this.users,
sections: seed.sections,
channels: this.channels,
};
}
addMember(channelId: string, userId: string) {
const c = this.channels.find((x) => x.id === channelId);
if (c && !c.memberIds.includes(userId)) c.memberIds.push(userId);
return c ?? null;
}
removeMember(channelId: string, userId: string) {
const c = this.channels.find((x) => x.id === channelId);
if (c) c.memberIds = c.memberIds.filter((id) => id !== userId);
return c ?? null;
}
markRead(channelId: string) {
const c = this.channels.find((x) => x.id === channelId);
if (c) {
c.unreadCount = 0;
c.mentionCount = 0;
}
return c ?? null;
}
editMessage(messageId: string, body: string): Message | null {
const m = this.messages.find((x) => x.id === messageId);
if (!m) return null;
m.body = body;
m.editedTs = Date.now();
return m;
}
deleteMessage(messageId: string): boolean {
const before = this.messages.length;
this.messages = this.messages.filter((m) => m.id !== messageId && m.parentId !== messageId);
return this.messages.length !== before;
}
savedMessages(): Message[] {
return this.messages.filter((m) => m.isSaved).sort((a, b) => b.ts - a.ts);
}
threadParents(): Message[] {
return this.messages.filter((m) => (m.replyCount ?? 0) > 0).sort((a, b) => (b.lastReplyTs ?? b.ts) - (a.lastReplyTs ?? a.ts));
}
setStatus(text: string | undefined, emoji: string | undefined, clearAt?: number) {
const me = this.me();
me.statusText = text;
me.statusEmoji = emoji;
me.statusClearAt = clearAt;
return me;
}
updateMe(patch: { name?: string; title?: string; avatarColor?: string; avatarUrl?: string | null; presence?: string }) {
const me = this.me();
if (patch.name !== undefined) me.name = patch.name;
if (patch.title !== undefined) me.title = patch.title;
if (patch.avatarColor !== undefined) me.avatarColor = patch.avatarColor;
if (patch.avatarUrl !== undefined) me.avatarUrl = patch.avatarUrl ?? undefined;
if (patch.presence !== undefined) me.presence = patch.presence as any;
return me;
}
channelMessages(channelId: string): Message[] {
return this.messages
.filter((m) => m.channelId === channelId && !m.parentId)
.sort((a, b) => a.ts - b.ts);
}
thread(channelId: string, parentId: string) {
const parent = this.messages.find((m) => m.id === parentId) ?? null;
const replies = this.messages
.filter((m) => m.parentId === parentId)
.sort((a, b) => a.ts - b.ts);
return { parent, replies };
}
send(
channelId: string,
body: string,
opts: { parentId?: string; scheduledFor?: number; attachments?: any[]; threadRootId?: string | null } = {},
): Message {
const msg: Message = {
id: `m_new_${this.idc++}`,
channelId,
authorId: seed.me.id,
ts: Date.now(),
body,
reactions: [],
deliveryState: "sent",
parentId: opts.parentId ?? null,
scheduledFor: opts.scheduledFor,
attachments: opts.attachments && opts.attachments.length ? opts.attachments : undefined,
threadRootId: opts.threadRootId ?? undefined,
};
this.messages.push(msg);
if (opts.parentId) {
const parent = this.messages.find((m) => m.id === opts.parentId);
if (parent) {
parent.replyCount = (parent.replyCount ?? 0) + 1;
parent.lastReplyTs = msg.ts;
parent.replyUserIds = Array.from(new Set([...(parent.replyUserIds ?? []), seed.me.id]));
}
}
const ch = this.channels.find((c) => c.id === channelId);
if (ch) ch.lastActivityTs = msg.ts;
return msg;
}
createChannel(input: { name: string; kind?: string; topic?: string; memberIds?: string[] }) {
const kind = (input.kind as any) || "channel";
const isDm = kind === "dm" || kind === "group_dm";
const id = `${isDm ? "d" : "c"}_new_${this.idc++}`;
const channel: any = {
id,
kind,
name: input.name.replace(/^#/, "").trim() || "new-channel",
topic: input.topic,
memberIds: Array.from(new Set([seed.me.id, ...(input.memberIds ?? [])])),
sectionId: isDm ? "dms" : "channels",
unreadCount: 0,
lastActivityTs: Date.now(),
};
this.channels.push(channel);
return channel;
}
react(messageId: string, emoji: string): Message | null {
const msg = this.messages.find((m) => m.id === messageId);
if (!msg) return null;
msg.reactions = msg.reactions ?? [];
const r = msg.reactions.find((x) => x.emoji === emoji);
const uid = seed.me.id;
if (r) {
if (r.userIds.includes(uid)) {
r.userIds = r.userIds.filter((id) => id !== uid);
if (r.userIds.length === 0) msg.reactions = msg.reactions.filter((x) => x.emoji !== emoji);
} else {
r.userIds.push(uid);
}
} else {
msg.reactions.push({ emoji, userIds: [uid] });
}
return msg;
}
pin(messageId: string): Message | null {
const msg = this.messages.find((m) => m.id === messageId);
if (!msg) return null;
msg.isPinned = !msg.isPinned;
return msg;
}
save(messageId: string): Message | null {
const msg = this.messages.find((m) => m.id === messageId);
if (!msg) return null;
msg.isSaved = !msg.isSaved;
return msg;
}
listInbox(state?: InboxState): InboxItem[] {
const s = state ?? "OPEN";
return this.inbox.filter((i) => i.state === s).sort((a, b) => b.ts - a.ts);
}
patchInbox(id: string, state: InboxState): InboxItem | null {
const item = this.inbox.find((i) => i.id === id);
if (!item) return null;
item.state = state;
return item;
}
listTickets(scope?: string): Ticket[] {
let list = this.tickets;
if (scope === "mine") list = list.filter((t) => t.requesterId === seed.me.id || t.assigneeId === seed.me.id);
if (scope === "assigned") list = list.filter((t) => t.assigneeId === seed.me.id);
return [...list].sort((a, b) => b.updatedTs - a.updatedTs);
}
getTicket(id: string): Ticket | null {
return this.tickets.find((t) => t.id === id) ?? null;
}
patchTicket(id: string, state: TicketState): Ticket | null {
const t = this.tickets.find((x) => x.id === id);
if (!t) return null;
t.state = state;
t.updatedTs = Date.now();
if (state === "RESOLVED" || state === "CLOSED") t.slaBreached = false;
return t;
}
replyTicket(id: string, body: string): Ticket | null {
const t = this.tickets.find((x) => x.id === id);
if (!t) return null;
t.messages = t.messages ?? [];
t.messages.push({
id: `tm_new_${this.idc++}`,
channelId: `t_${id}`,
authorId: seed.me.id,
ts: Date.now(),
body,
deliveryState: "sent",
});
t.updatedTs = Date.now();
if (t.state === "NEW") t.state = "OPEN";
t.slaBreached = false;
return t;
}
search(q: string): SearchResult[] {
const query = q.toLowerCase().trim();
if (!query) return [];
const out: SearchResult[] = [];
for (const c of this.channels) {
if (c.name.toLowerCase().includes(query) || c.topic?.toLowerCase().includes(query)) {
out.push({ id: c.id, type: "channel", title: c.kind === "channel" ? `#${c.name}` : c.name, subtitle: c.topic, channelId: c.id });
}
}
for (const u of this.users) {
if (u.name.toLowerCase().includes(query) || u.handle.includes(query)) {
out.push({ id: u.id, type: "person", title: u.name, subtitle: u.title });
}
}
for (const msg of this.messages) {
if (msg.body.toLowerCase().includes(query)) {
out.push({ id: msg.id, type: "message", title: msg.body.slice(0, 80), subtitle: `in ${this.channelLabel(msg.channelId)}`, channelId: msg.channelId, ts: msg.ts, parentId: msg.parentId ?? undefined });
}
}
for (const t of this.tickets) {
if (t.subject.toLowerCase().includes(query) || t.ref.toLowerCase().includes(query)) {
out.push({ id: t.id, type: "ticket", title: `${t.ref} · ${t.subject}`, subtitle: t.state });
}
}
return out.slice(0, 20);
}
private channelLabel(id: string): string {
const c = this.channels.find((x) => x.id === id);
if (!c) return "conversation";
return c.kind === "channel" || c.kind === "private" ? `#${c.name}` : c.name;
}
}
function clone<T>(v: T): T {
return JSON.parse(JSON.stringify(v));
}
// preserve the singleton across HMR reloads in dev (bump the key when the
// store's shape/methods change so HMR rebuilds it instead of reusing a stale one)
const g = globalThis as unknown as { __lynkdStore_v3?: MockStore };
export const store = g.__lynkdStore_v3 ?? (g.__lynkdStore_v3 = new MockStore());
+58
View File
@@ -0,0 +1,58 @@
"use client";
import React from "react";
import { shortcodeToEmoji } from "@/lib/emoji";
/**
* Small, safe inline renderer for message bodies:
* **bold** _italic_ ~~strike~~ `code` [label](url) @mention #channel :shortcode: bare-url
* Mentions/channels render as highlighted blue chips. No dangerouslySetInnerHTML.
*/
export function RichText({ text }: { text: string }) {
return <>{parseInline(text)}</>;
}
const TOKEN =
/(\*\*[^*]+\*\*|~~[^~]+~~|_[^_]+_|`[^`]+`|\[[^\]]+\]\(https?:\/\/[^)\s]+\)|:[a-z0-9_+-]{2,30}:|@[a-z0-9_.-]+|#[a-z0-9_-]+|https?:\/\/[^\s]+)/gi;
function parseInline(text: string): React.ReactNode[] {
const nodes: React.ReactNode[] = [];
let last = 0;
let m: RegExpExecArray | null;
let i = 0;
TOKEN.lastIndex = 0;
while ((m = TOKEN.exec(text)) !== null) {
if (m.index > last) nodes.push(text.slice(last, m.index));
const tok = m[0];
if (tok.startsWith("**")) {
nodes.push(<strong key={i++} className="font-semibold text-ink">{tok.slice(2, -2)}</strong>);
} else if (tok.startsWith("~~")) {
nodes.push(<span key={i++} className="line-through opacity-80">{tok.slice(2, -2)}</span>);
} else if (tok.startsWith("_")) {
nodes.push(<em key={i++}>{tok.slice(1, -1)}</em>);
} else if (tok.startsWith("`")) {
nodes.push(<code key={i++} className="rounded bg-hover px-1 py-0.5 font-mono text-[12.5px] text-accent">{tok.slice(1, -1)}</code>);
} else if (tok.startsWith("[")) {
const mm = /^\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)$/.exec(tok);
nodes.push(mm ? (
<a key={i++} href={mm[2]} target="_blank" rel="noreferrer" className="text-info underline underline-offset-2 hover:text-accent">{mm[1]}</a>
) : tok);
} else if (tok.startsWith(":") && tok.endsWith(":")) {
const emoji = shortcodeToEmoji(tok.slice(1, -1));
nodes.push(emoji ? <span key={i++}>{emoji}</span> : tok);
} else if (tok.startsWith("@")) {
nodes.push(
<span key={i++} className="rounded px-1 font-medium text-info" style={{ background: "rgba(91,157,255,0.14)" }}>{tok}</span>,
);
} else if (tok.startsWith("#")) {
nodes.push(
<span key={i++} className="rounded px-1 font-medium text-info" style={{ background: "rgba(91,157,255,0.14)" }}>{tok}</span>,
);
} else {
nodes.push(<a key={i++} href={tok} target="_blank" rel="noreferrer" className="text-info underline underline-offset-2 hover:text-accent">{tok}</a>);
}
last = m.index + tok.length;
}
if (last < text.length) nodes.push(text.slice(last));
return nodes;
}
+177
View File
@@ -0,0 +1,177 @@
"use client";
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
interface ThreadTarget {
channelId: string;
parentId: string;
}
export interface AiSeed {
context?: string;
prompt?: string;
}
export type ModalKind = "create-channel" | "new-message" | "invite" | "channel-details" | "channel-members" | "create-workspace" | "callback" | "set-status" | "edit-profile";
export interface Toast {
id: number;
message: string;
tone: "default" | "success" | "danger";
}
interface UiState {
thread: ThreadTarget | null;
openThread: (channelId: string, parentId: string) => void;
closeThread: () => void;
paletteOpen: boolean;
setPaletteOpen: (v: boolean) => void;
notifOpen: boolean;
setNotifOpen: (v: boolean) => void;
profileUserId: string | null;
openProfile: (id: string) => void;
closeProfile: () => void;
themingOpen: boolean;
setThemingOpen: (v: boolean) => void;
huddleChannelId: string | null;
startHuddle: (channelId: string) => void;
endHuddle: () => void;
aiPanel: AiSeed | null;
openAi: (seed?: AiSeed) => void;
closeAi: () => void;
sidebarOpen: boolean;
setSidebarOpen: (v: boolean) => void;
// desktop sidebar collapse
sidebarCollapsed: boolean;
toggleSidebarCollapsed: () => void;
// modals
modal: ModalKind | null;
modalArg?: string;
openModal: (m: ModalKind, arg?: string) => void;
closeModal: () => void;
// toasts
toasts: Toast[];
toast: (message: string, tone?: Toast["tone"]) => void;
dismissToast: (id: number) => void;
}
const Ctx = createContext<UiState | null>(null);
export function UiStateProvider({ children }: { children: React.ReactNode }) {
const [thread, setThread] = useState<ThreadTarget | null>(null);
const [paletteOpen, setPaletteOpen] = useState(false);
const [notifOpen, setNotifOpen] = useState(false);
const [profileUserId, setProfileUserId] = useState<string | null>(null);
const [themingOpen, setThemingOpen] = useState(false);
const [huddleChannelId, setHuddleChannelId] = useState<string | null>(null);
const [aiPanel, setAiPanel] = useState<AiSeed | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [modal, setModal] = useState<ModalKind | null>(null);
const [modalArg, setModalArg] = useState<string | undefined>(undefined);
const [toasts, setToasts] = useState<Toast[]>([]);
const toastId = useRef(1);
const openThread = useCallback((channelId: string, parentId: string) => setThread({ channelId, parentId }), []);
const closeThread = useCallback(() => setThread(null), []);
const openProfile = useCallback((id: string) => setProfileUserId(id), []);
const closeProfile = useCallback(() => setProfileUserId(null), []);
const startHuddle = useCallback((id: string) => setHuddleChannelId(id), []);
const endHuddle = useCallback(() => setHuddleChannelId(null), []);
const openAi = useCallback((seed?: AiSeed) => setAiPanel(seed ?? {}), []);
const closeAi = useCallback(() => setAiPanel(null), []);
const openModal = useCallback((m: ModalKind, arg?: string) => {
setModal(m);
setModalArg(arg);
}, []);
const closeModal = useCallback(() => {
setModal(null);
setModalArg(undefined);
}, []);
useEffect(() => {
try {
if (localStorage.getItem("lynkd.sidebarCollapsed") === "1") setSidebarCollapsed(true);
} catch {
/* ignore */
}
}, []);
const toggleSidebarCollapsed = useCallback(() => {
setSidebarCollapsed((v) => {
const next = !v;
try {
localStorage.setItem("lynkd.sidebarCollapsed", next ? "1" : "0");
} catch {
/* ignore */
}
return next;
});
}, []);
const dismissToast = useCallback((id: number) => setToasts((t) => t.filter((x) => x.id !== id)), []);
const toast = useCallback((message: string, tone: Toast["tone"] = "default") => {
const id = toastId.current++;
setToasts((t) => [...t, { id, message, tone }]);
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 3200);
}, []);
// ⌘/Ctrl+K opens palette; Escape closes the top-most open surface.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setPaletteOpen((v) => !v);
return;
}
if (e.key === "Escape") {
// close the most transient surface first
if (paletteOpen) return setPaletteOpen(false);
if (modal) return closeModal();
if (notifOpen) return setNotifOpen(false);
if (profileUserId) return setProfileUserId(null);
if (themingOpen) return setThemingOpen(false);
if (aiPanel) return setAiPanel(null);
if (thread) return setThread(null);
if (sidebarOpen) return setSidebarOpen(false);
if (huddleChannelId) return setHuddleChannelId(null);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [paletteOpen, modal, notifOpen, profileUserId, themingOpen, aiPanel, thread, sidebarOpen, huddleChannelId, closeModal]);
const value = useMemo<UiState>(
() => ({
thread, openThread, closeThread,
paletteOpen, setPaletteOpen,
notifOpen, setNotifOpen,
profileUserId, openProfile, closeProfile,
themingOpen, setThemingOpen,
huddleChannelId, startHuddle, endHuddle,
aiPanel, openAi, closeAi,
sidebarOpen, setSidebarOpen,
sidebarCollapsed, toggleSidebarCollapsed,
modal, modalArg, openModal, closeModal,
toasts, toast, dismissToast,
}),
[thread, openThread, closeThread, paletteOpen, notifOpen, profileUserId, openProfile, closeProfile, themingOpen, huddleChannelId, startHuddle, endHuddle, aiPanel, openAi, closeAi, sidebarOpen, sidebarCollapsed, toggleSidebarCollapsed, modal, modalArg, openModal, closeModal, toasts, toast, dismissToast],
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useUi(): UiState {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useUi must be used within <UiStateProvider>");
return ctx;
}