first commit
This commit is contained in:
@@ -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[]>;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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>,
|
||||
});
|
||||
}
|
||||
@@ -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[] = ["👍", "🎉", "🔥", "❤️", "😂", "🙌", "👀", "✅", "🚀", "💯", "🙏", "👏"];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`)) : [],
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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: "🇰🇪" },
|
||||
];
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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());
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user