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
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation";
import { useSdk } from "@lynkd/messaging-inbox-sdk";
import { useUi } from "@/lib/ui-state";
import clsx from "clsx";
import { WorkspaceRail } from "@/components/nav/WorkspaceRail";
import { Sidebar } from "@/components/nav/Sidebar";
import { ThreadPanel } from "@/components/message/ThreadPanel";
import { CommandPalette } from "@/components/overlays/CommandPalette";
import { NotificationsPanel } from "@/components/overlays/NotificationsPanel";
import { ProfileDrawer } from "@/components/overlays/ProfileDrawer";
import { ThemingPanel } from "@/components/overlays/ThemingPanel";
import { HuddleBar } from "@/components/overlays/HuddleBar";
import { Modals } from "@/components/overlays/Modals";
import { AiPanel } from "@/components/overlays/AiPanel";
import { Toaster } from "@/components/overlays/Toaster";
export function AppShell({ children }: { children: React.ReactNode }) {
const { loading } = useSdk();
const { thread, sidebarOpen, setSidebarOpen, sidebarCollapsed } = useUi();
const pathname = usePathname();
// close the mobile drawer whenever the route changes
useEffect(() => {
setSidebarOpen(false);
}, [pathname, setSidebarOpen]);
return (
<div className="flex h-[100dvh] w-full overflow-hidden bg-bg text-ink">
<WorkspaceRail />
<div
role={sidebarOpen ? "dialog" : undefined}
aria-modal={sidebarOpen ? true : undefined}
aria-hidden={!sidebarOpen ? true : undefined}
className={clsx(
"fixed inset-y-0 left-0 z-40 transition-transform md:static md:z-auto md:translate-x-0 md:!pointer-events-auto",
sidebarOpen ? "translate-x-0" : "pointer-events-none -translate-x-full md:pointer-events-auto",
sidebarCollapsed && "md:hidden",
)}
>
<Sidebar />
</div>
{sidebarOpen && <div className="fixed inset-0 z-30 bg-black/50 md:hidden" onClick={() => setSidebarOpen(false)} />}
<main className="flex min-w-0 flex-1">{loading ? <BootSkeleton /> : children}</main>
{thread && <ThreadPanel />}
<CommandPalette />
<NotificationsPanel />
<ProfileDrawer />
<ThemingPanel />
<HuddleBar />
<Modals />
<AiPanel />
<Toaster />
</div>
);
}
function BootSkeleton() {
return (
<div className="flex flex-1 items-center justify-center">
<div className="flex flex-col items-center gap-3 text-muted">
<div className="brand-mark h-10 w-10 animate-pulse rounded-card" />
<span className="text-[13px]">Loading workspace</span>
</div>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { Header } from "@/components/nav/Header";
import { useRouter } from "next/navigation";
export function Placeholder({
eyebrow,
title,
subtitle,
body,
icon,
}: {
eyebrow: string;
title: string;
subtitle?: string;
body: string;
icon: React.ReactNode;
}) {
const router = useRouter();
return (
<div className="flex min-w-0 flex-1 flex-col">
<Header eyebrow={eyebrow} title={title} subtitle={subtitle} />
<div className="flex min-h-0 flex-1 items-center justify-center overflow-y-auto p-6">
<div className="card max-w-lg p-8 text-center">
<div className="brand-mark mx-auto mb-4 grid h-14 w-14 place-items-center rounded-card text-black/80">
{icon}
</div>
<h3 className="text-[18px] font-black">{title}</h3>
<p className="mx-auto mt-2 max-w-sm text-[13.5px] text-muted">{body}</p>
<div className="mt-5 flex justify-center gap-2">
<button
onClick={() => router.push("/c/c_general")}
className="focus-ring rounded-control bg-accent px-4 py-2 text-[13px] font-semibold text-accent-fg"
>
Go to #general
</button>
<button
onClick={() => router.push("/inbox")}
className="focus-ring rounded-control border border-border px-4 py-2 text-[13px] font-semibold hover:bg-hover"
>
Open Inbox
</button>
</div>
</div>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
"use client";
import { MessagingInboxProvider, type SdkConfig } from "@lynkd/messaging-inbox-sdk";
import { UiStateProvider } from "@/lib/ui-state";
export function Providers({
config,
children,
}: {
config: SdkConfig;
children: React.ReactNode;
}) {
return (
<MessagingInboxProvider config={config}>
<UiStateProvider>{children}</UiStateProvider>
</MessagingInboxProvider>
);
}
+183
View File
@@ -0,0 +1,183 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useInbox, useSdk, type InboxItem, type InboxKind, type InboxState } from "@lynkd/messaging-inbox-sdk";
import { Header } from "@/components/nav/Header";
import { Avatar, Chip } from "@/components/ui/primitives";
import { relativeTime } from "@/lib/format";
import clsx from "clsx";
import {
AtSign,
Heart,
MessageSquare,
Bookmark,
LifeBuoy,
CalendarClock,
Bot,
Reply,
Check,
Clock,
Archive,
ArrowRight,
} from "lucide-react";
const kindMeta: Record<InboxKind, { icon: typeof AtSign; label: string; tone: string }> = {
NEEDS_REPLY: { icon: Reply, label: "Needs reply", tone: "text-accent" },
MENTION: { icon: AtSign, label: "Mention", tone: "text-info" },
REACTION: { icon: Heart, label: "Reaction", tone: "text-danger" },
THREAD_REPLY: { icon: MessageSquare, label: "Thread", tone: "text-info" },
SAVED: { icon: Bookmark, label: "Saved", tone: "text-warning" },
APP: { icon: Bot, label: "App", tone: "text-muted" },
SUPPORT_UPDATE: { icon: LifeBuoy, label: "Support", tone: "text-success" },
MEETING_FOLLOWUP: { icon: CalendarClock, label: "Follow-up", tone: "text-info" },
};
const STATES: InboxState[] = ["OPEN", "SNOOZED", "DONE", "ARCHIVED"];
export function InboxView() {
const { items, state, setState, loading, markDone, snooze, archive } = useInbox("OPEN");
const [filter, setFilter] = useState<InboxKind | "ALL">("ALL");
const shown = items.filter((i) => filter === "ALL" || i.kind === filter);
const kinds: (InboxKind | "ALL")[] = ["ALL", "NEEDS_REPLY", "MENTION", "REACTION", "THREAD_REPLY", "SUPPORT_UPDATE", "SAVED"];
return (
<div className="flex min-w-0 flex-1 flex-col">
<Header eyebrow="Workspace" title="Inbox" subtitle="Everything that needs your attention, in one queue" />
{/* state tabs */}
<div className="flex shrink-0 items-center gap-1 border-b border-border px-4 py-2">
{STATES.map((s) => (
<button
key={s}
onClick={() => setState(s)}
className={clsx(
"shrink-0 rounded-control px-3 py-1.5 text-[13px] font-semibold capitalize transition-colors",
state === s ? "bg-accent-soft text-accent" : "text-muted hover:bg-hover hover:text-ink",
)}
>
{s.toLowerCase()}
</button>
))}
<div className="ml-auto flex min-w-0 gap-1 overflow-x-auto no-scrollbar">
{kinds.map((k) => (
<button
key={k}
onClick={() => setFilter(k)}
className={clsx(
"shrink-0 whitespace-nowrap rounded-pill border px-2.5 py-1 text-[11.5px] font-medium transition-colors",
filter === k ? "border-accent text-accent" : "border-border text-muted hover:text-ink",
)}
>
{k === "ALL" ? "All" : kindMeta[k].label}
</button>
))}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{loading && <div className="py-10 text-center text-muted">Loading</div>}
{!loading && shown.length === 0 && (
<div className="flex flex-col items-center gap-2 py-20 text-muted">
<Check size={36} className="text-success" />
<div className="text-[15px] font-semibold text-ink">You're all caught up</div>
<div className="text-[13px]">Nothing in {state.toLowerCase()}.</div>
</div>
)}
<div className="mx-auto max-w-3xl space-y-2">
{shown.map((item) => (
<InboxRow
key={item.id}
item={item}
onDone={() => markDone(item.id)}
onSnooze={() => snooze(item.id)}
onArchive={() => archive(item.id)}
/>
))}
</div>
</div>
</div>
);
}
function InboxRow({
item,
onDone,
onSnooze,
onArchive,
}: {
item: InboxItem;
onDone: () => void;
onSnooze: () => void;
onArchive: () => void;
}) {
const { userById } = useSdk();
const router = useRouter();
const meta = kindMeta[item.kind];
const actor = item.actorId ? userById(item.actorId) : undefined;
return (
<div className="group flex items-start gap-3 rounded-card border border-border bg-panel p-3.5 transition-colors hover:border-border-strong">
<span className={clsx("mt-0.5 grid h-9 w-9 shrink-0 place-items-center rounded-control bg-hover", meta.tone)}>
<meta.icon size={17} />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="min-w-0 truncate text-[14px] font-semibold">{item.title}</span>
{item.priority === "urgent" && <Chip tone="danger">urgent</Chip>}
{item.priority === "high" && <Chip tone="warning">high</Chip>}
<span className="ml-auto shrink-0 text-[11px] text-dim">{relativeTime(item.ts)}</span>
</div>
<div className="mt-0.5 truncate text-[13px] text-muted">{item.preview}</div>
<div className="mt-2 flex items-center gap-2">
{actor && (
<span className="flex items-center gap-1.5 text-[11px] text-dim">
<Avatar user={actor} size={16} showPresence={false} rounded="5px" /> {actor.name}
</span>
)}
<Chip>{meta.label}</Chip>
{item.dueAt && (
<span className="flex items-center gap-1 text-[11px] text-info">
<Clock size={11} /> due {relativeTime(item.dueAt)}
</span>
)}
</div>
</div>
{/* actions — always visible on touch, hover/focus-reveal on desktop */}
<div className="flex items-center gap-1 opacity-100 transition-opacity md:pointer-events-none md:opacity-0 md:group-hover:pointer-events-auto md:group-hover:opacity-100 md:group-focus-within:pointer-events-auto md:group-focus-within:opacity-100">
<RowBtn title="Open" icon={ArrowRight} onClick={() => (item.channelId ? router.push(`/c/${item.channelId}`) : router.push("/support"))} />
<RowBtn title="Snooze" icon={Clock} onClick={onSnooze} />
<RowBtn title="Archive" icon={Archive} onClick={onArchive} />
<RowBtn title="Mark done" icon={Check} accent onClick={onDone} />
</div>
</div>
);
}
function RowBtn({
title,
icon: Icon,
onClick,
accent,
}: {
title: string;
icon: typeof Check;
onClick: () => void;
accent?: boolean;
}) {
return (
<button
title={title}
onClick={onClick}
className={clsx(
"grid h-8 w-8 place-items-center rounded-control border border-border transition-colors hover:bg-hover",
accent ? "text-accent hover:border-accent" : "text-muted hover:text-ink",
)}
>
<Icon size={15} />
</button>
);
}
+295
View File
@@ -0,0 +1,295 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
import {
useChannel,
useFeatureFlags,
useMessages,
useSdk,
useTyping,
type Message,
} from "@lynkd/messaging-inbox-sdk";
import { useUi } from "@/lib/ui-state";
import { Header } from "@/components/nav/Header";
import { MessageItem } from "./MessageItem";
import { Composer } from "./Composer";
import { LanguageMenu } from "./LanguageMenu";
import { Avatar, IconButton } from "@/components/ui/primitives";
import { groupByDay } from "@/lib/format";
import { Hash, Lock, Users, Video, Phone, Pin, Info, UserPlus, Languages } from "lucide-react";
export function ChannelView({ channelId }: { channelId: string }) {
const channel = useChannel(channelId);
const { messages, pinned, send, react, pin, save, edit, remove } = useMessages(channelId);
const { userById, me, client, refresh } = useSdk();
const flags = useFeatureFlags();
const { openThread, startHuddle, openModal } = useUi();
const typing = useTyping(channelId);
const bottomRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const searchParams = useSearchParams();
const jumpMsg = searchParams.get("msg");
const router = useRouter();
const pathname = usePathname();
const [chatLang, setChatLang] = useState<string | null>(null);
const [langOpen, setLangOpen] = useState(false);
// reset the whole-chat translation when switching channels
useEffect(() => { setChatLang(null); setLangOpen(false); }, [channelId]);
// auto-scroll on channel change, and on new messages only if already near the bottom
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "auto" });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [channelId]);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 140;
if (nearBottom) bottomRef.current?.scrollIntoView({ behavior: "smooth" });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messages.length]);
// mark the channel read on open (clears the sidebar unread badge)
useEffect(() => {
if (!channelId) return;
let alive = true;
client.markRead(channelId).then(() => alive && refresh()).catch(() => {});
return () => { alive = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [channelId]);
// deep-link jump: /c/:id?msg=<id> scrolls to + flashes that message. Keyed on
// jumpMsg so jumping to a message in the channel you're already viewing (from
// search / a notification) still re-fires. It runs ONCE per jump: as soon as
// the target resolves (or we give up), the ?msg param is stripped so later
// sends / incoming messages don't re-yank the viewport back to the old message.
// gate on "messages have loaded" (a boolean) rather than the raw count, so
// that later sends / incoming messages growing the array never retrigger the jump.
const hasMessages = messages.length > 0;
useEffect(() => {
if (!hasMessages || !jumpMsg) return;
let tries = 0;
const clearParam = () => router.replace(pathname, { scroll: false });
const iv = setInterval(() => {
const el = document.getElementById(`msg-${jumpMsg}`);
if (el) {
clearInterval(iv);
el.scrollIntoView({ behavior: "smooth", block: "center" });
// flash via a WAAPI animation on an inner wrapper (survives row re-renders)
const flash = el.querySelector<HTMLElement>("[data-mid-body]");
const target = flash ?? el;
target.animate(
[{ backgroundColor: "rgba(253,169,19,0.22)" }, { backgroundColor: "rgba(253,169,19,0.22)", offset: 0.7 }, { backgroundColor: "rgba(253,169,19,0)" }],
{ duration: 2400, easing: "ease" },
);
clearParam();
} else if (++tries > 30) {
clearInterval(iv);
clearParam(); // target isn't in this view — stop retrying on every new message
}
}, 80);
return () => clearInterval(iv);
}, [hasMessages, channelId, jumpMsg, router, pathname]);
const isDm = channel?.kind === "dm" || channel?.kind === "group_dm";
const other =
channel?.kind === "dm" ? userById(channel.memberIds.find((id) => id !== me?.id) ?? "") : undefined;
const title = channel ? channel.name : "…";
const grouped = useMemo(() => groupByDay(messages), [messages]);
if (!channel) {
return (
<div className="flex flex-1 items-center justify-center text-muted">Select a conversation</div>
);
}
return (
<div className="flex min-w-0 flex-1 flex-col">
<Header
eyebrow={isDm ? "Direct message" : channel.external ? "External · Slack Connect" : channel.kind === "private" ? "Private channel" : "Channel"}
title={
<span className="flex items-center gap-1.5">
{channel.kind === "channel" && <Hash size={16} className="text-dim" />}
{channel.kind === "private" && <Lock size={14} className="text-dim" />}
{isDm && other && <Avatar user={other} size={20} rounded="6px" />}
{title}
</span>
}
subtitle={channel.topic ?? (other ? `${other.title} · ${other.presence}` : undefined)}
>
{!isDm && (
<button
onClick={() => openModal("channel-members", channelId)}
title="View members"
className="mr-1 hidden items-center gap-1.5 rounded-control border border-border bg-panel px-2 py-1.5 text-[12px] text-muted transition-colors hover:bg-hover hover:text-ink xl:flex"
>
<Users size={14} />
<span className="flex -space-x-1.5">
{channel.memberIds.slice(0, 4).map((id) => {
const u = userById(id);
return u ? <Avatar key={id} user={u} size={20} showPresence={false} rounded="999px" /> : null;
})}
</span>
<span className="font-semibold text-ink">{channel.memberIds.length}</span>
</button>
)}
{!isDm && (
<IconButton label="Add people" onClick={() => openModal("channel-members", channelId)}>
<UserPlus size={18} />
</IconButton>
)}
{flags.huddles && (
<IconButton label="Start huddle" onClick={() => startHuddle(channelId)}>
<Video size={18} />
</IconButton>
)}
{flags.huddles && (
<IconButton label="Start a call" onClick={() => startHuddle(channelId)} className="hidden sm:grid">
<Phone size={18} />
</IconButton>
)}
{flags.translation && (
<div className="relative">
<IconButton label="Translate conversation" onClick={() => setLangOpen((o) => !o)} className={chatLang ? "text-accent" : undefined}>
<Languages size={18} />
</IconButton>
{langOpen && (
<LanguageMenu
align="right"
onPick={(lang) => { setChatLang(lang); setLangOpen(false); }}
onClose={() => setLangOpen(false)}
/>
)}
</div>
)}
<IconButton label="Channel details" onClick={() => openModal("channel-details", channelId)}>
<Info size={18} />
</IconButton>
</Header>
{/* whole-conversation translation banner */}
{chatLang && (
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-info/5 px-4 py-1.5 text-[12.5px]">
<Languages size={13} className="shrink-0 text-info" />
<span className="text-muted">Conversation translated to <b className="text-ink">{chatLang}</b></span>
<button onClick={() => setChatLang(null)} className="ml-auto font-semibold text-info hover:underline">Show originals</button>
</div>
)}
{/* pinned bar */}
{flags.pins && pinned.length > 0 && (
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-accent-soft/40 px-4 py-1.5 text-[12.5px]">
<Pin size={13} className="shrink-0 text-accent" />
<span className="shrink-0 text-muted">{pinned.length} pinned</span>
<span className="min-w-0 flex-1 truncate text-ink/80">· {pinned[0].body}</span>
</div>
)}
{/* messages */}
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
<ChannelIntro channelName={title} isDm={!!isDm} />
{grouped.map((g) => (
<div key={g.day}>
<DayDivider label={g.day} />
{g.items.map((m, i) => {
const prev = g.items[i - 1];
const groupedMsg =
!!prev && prev.authorId === m.authorId && m.ts - prev.ts < 5 * 60_000 && !m.systemEvent;
return (
<MessageItem
key={m.id}
message={m}
grouped={groupedMsg}
autoTranslateTo={chatLang}
onReact={(e) => react(m.id, e)}
onOpenThread={() => openThread(channelId, m.threadRootId ?? m.id)}
onPin={() => pin(m.id)}
onSave={() => save(m.id)}
onEdit={(body) => edit(m.id, body)}
onDelete={() => remove(m.id)}
/>
);
})}
</div>
))}
<div ref={bottomRef} className="h-4" />
</div>
{/* typing indicator — fixed just above the composer */}
<div className="h-6 shrink-0">
{flags.typingIndicator && typing.length > 0 && <TypingIndicator userIds={typing} userById={userById} />}
</div>
<Composer
placeholder={`Message ${channel.kind === "channel" ? "#" + title : title}`}
onSend={async (body, opts) => { await send(body, opts); }}
onStartHuddle={() => startHuddle(channelId)}
/>
</div>
);
}
function DayDivider({ label }: { label: string }) {
return (
<div className="sticky top-0 z-10 my-2 flex items-center gap-3 px-4">
<div className="h-px flex-1 bg-border" />
<span className="rounded-pill border border-border bg-surface px-3 py-0.5 text-[11px] font-semibold text-muted">
{label}
</span>
<div className="h-px flex-1 bg-border" />
</div>
);
}
function TypingIndicator({
userIds,
userById,
}: {
userIds: string[];
userById: (id: string) => { name: string; avatarColor?: string } | undefined;
}) {
const typers = userIds.map((id) => userById(id)).filter(Boolean) as { name: string; avatarColor?: string }[];
if (!typers.length) return null;
return (
// left inset matches the editor's text (composer px-4 + editor px-3.5) so the
// loader + text line up under the editor rather than hugging the sidebar edge.
<div className="flex items-center gap-2 py-1 pl-[30px] pr-4 text-[12px] text-muted">
<span className="flex items-center gap-0.5">
<span className="h-1.5 w-1.5 animate-blink rounded-full bg-accent" style={{ animationDelay: "0ms" }} />
<span className="h-1.5 w-1.5 animate-blink rounded-full bg-accent" style={{ animationDelay: "160ms" }} />
<span className="h-1.5 w-1.5 animate-blink rounded-full bg-accent" style={{ animationDelay: "320ms" }} />
</span>
<span className="truncate">
{typers.map((u, i) => (
<span key={i}>
<span className="font-semibold" style={{ color: u.avatarColor ?? "var(--c-accent)" }}>{u.name}</span>
{i < typers.length - 1 ? ", " : ""}
</span>
))}
{" "}
{typers.length === 1 ? "is" : "are"} typing
</span>
</div>
);
}
function ChannelIntro({ channelName, isDm }: { channelName: string; isDm: boolean }) {
return (
<div className="px-4 pb-1 pt-6">
<div className="brand-mark mb-3 grid h-12 w-12 place-items-center rounded-card text-lg font-black text-black/80">
{channelName.slice(0, 1).toUpperCase()}
</div>
<h2 className="text-[20px] font-black">
{isDm ? channelName : `#${channelName}`}
</h2>
<p className="mt-1 max-w-lg text-[13.5px] text-muted">
{isDm
? `This is the beginning of your direct message history with ${channelName}.`
: `This is the very beginning of the #${channelName} channel. Say hello 👋`}
</p>
</div>
);
}
+448
View File
@@ -0,0 +1,448 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import clsx from "clsx";
import { useFeatureFlags, useSdk, type Attachment } from "@lynkd/messaging-inbox-sdk";
import { useUi } from "@/lib/ui-state";
import { EmojiPicker } from "./EmojiPicker";
import { LanguageMenu } from "./LanguageMenu";
import { Avatar } from "@/components/ui/primitives";
import { replaceShortcodes, searchEmojis } from "@/lib/emoji";
import {
Bold, Italic, Strikethrough, Code, Link2, List, ListOrdered,
Paperclip, Smile, AtSign, Slash, Send, Video, Clock, Hash, X, FileText,
Languages, RefreshCw, Check, Sparkles,
} from "lucide-react";
export interface SendOpts {
scheduledFor?: number;
attachments?: Attachment[];
alsoSendToChannel?: boolean;
}
/** Serialize the contentEditable DOM into markdown, then expand :shortcodes:. */
function serialize(root: HTMLElement): string {
const walk = (node: ChildNode): string => {
if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? "";
if (node.nodeType !== Node.ELEMENT_NODE) return "";
const el = node as HTMLElement;
const tag = el.tagName.toLowerCase();
const inner = () => Array.from(el.childNodes).map(walk).join("");
switch (tag) {
case "br": return "\n";
case "b": case "strong": return `**${inner()}**`;
case "i": case "em": return `_${inner()}_`;
case "s": case "strike": case "del": return `~~${inner()}~~`;
case "code": return "`" + inner() + "`";
case "a": return `[${inner()}](${el.getAttribute("href") || "https://"})`;
case "ul": return Array.from(el.children).map((li) => `\n• ${Array.from(li.childNodes).map(walk).join("")}`).join("");
case "ol": return Array.from(el.children).map((li, i) => `\n${i + 1}. ${Array.from(li.childNodes).map(walk).join("")}`).join("");
case "div": case "p": { const t = inner(); return t ? "\n" + t : ""; }
default: return inner();
}
};
return replaceShortcodes(Array.from(root.childNodes).map(walk).join("").replace(/\n{3,}/g, "\n\n").trim());
}
type Suggestion = { key: string; label: string; sub?: string; insert: string; icon?: React.ReactNode };
export function Composer({
placeholder,
onSend,
onStartHuddle,
compact,
channelName,
showAlsoSend,
}: {
placeholder: string;
onSend: (body: string, opts?: SendOpts) => void | Promise<void>;
onStartHuddle?: () => void;
compact?: boolean;
channelName?: string;
showAlsoSend?: boolean;
}) {
const flags = useFeatureFlags();
const { toast, openAi } = useUi();
const { users, channels, client } = useSdk();
const editorRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const scheduleRef = useRef<HTMLDivElement>(null);
const [empty, setEmpty] = useState(true);
const [emojiOpen, setEmojiOpen] = useState(false);
const [scheduleOpen, setScheduleOpen] = useState(false);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [alsoSend, setAlsoSend] = useState(false);
// translate-before-send
const [translateOpen, setTranslateOpen] = useState(false);
const [translating, setTranslating] = useState(false);
const [preview, setPreview] = useState<{ lang: string; original: string; text: string } | null>(null);
// autocomplete state
const [ac, setAc] = useState<{ trigger: string; query: string } | null>(null);
const [acIndex, setAcIndex] = useState(0);
const refresh = () => setEmpty(!(editorRef.current?.textContent?.trim()));
const focusEditor = () => editorRef.current?.focus();
const exec = (cmd: string, value?: string) => { focusEditor(); document.execCommand(cmd, false, value); refresh(); };
const wrapCode = () => {
focusEditor();
const sel = window.getSelection();
const text = sel && !sel.isCollapsed ? sel.toString() : "code";
document.execCommand("insertHTML", false, `<code>${escapeHtml(text)}</code>&nbsp;`);
refresh();
};
const makeLink = () => {
focusEditor();
const sel = window.getSelection();
if (sel && !sel.isCollapsed) document.execCommand("createLink", false, "https://");
else document.execCommand("insertHTML", false, `<a href="https://">link</a>&nbsp;`);
refresh();
};
const insertText = (str: string) => { focusEditor(); document.execCommand("insertText", false, str); refresh(); };
// ---- translate before send ----
const requestTranslation = async (lang: string) => {
setTranslateOpen(false);
const el = editorRef.current;
const text = el ? serialize(el) : "";
if (!text.trim()) { toast("Write a message first, then translate.", "default"); return; }
setTranslating(true);
try {
const res = await client.translate(text, lang);
if (res.ok && res.text) setPreview({ lang, original: text, text: res.text });
else toast(res.error || "Translation failed", "danger");
} catch (e) {
toast(`Translation failed: ${(e as Error).message}`, "danger");
} finally {
setTranslating(false);
}
};
const applyPreview = () => {
const el = editorRef.current;
if (!preview || !el) return;
el.innerText = preview.text; // replace draft with the translated text
setPreview(null);
refresh();
focusEditor();
};
// ---- autocomplete detection ----
const detectToken = () => {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) { setAc(null); return; }
const range = sel.getRangeAt(0);
const node = range.startContainer;
if (node.nodeType !== Node.TEXT_NODE) { setAc(null); return; }
const text = (node.textContent ?? "").slice(0, range.startOffset);
const m = /([@#:])([\w+-]*)$/.exec(text);
if (!m) { setAc(null); return; }
const idx = m.index;
if (idx > 0 && !/\s/.test(text[idx - 1])) { setAc(null); return; }
setAc({ trigger: m[1], query: m[2] });
setAcIndex(0);
};
const suggestions: Suggestion[] = useMemo(() => {
if (!ac) return [];
const q = ac.query.toLowerCase();
if (ac.trigger === "@") {
return users
.filter((u) => u.handle.toLowerCase().includes(q) || u.name.toLowerCase().includes(q))
.slice(0, 8)
.map((u) => ({ key: u.id, label: u.name, sub: `@${u.handle}`, insert: `@${u.handle} `, icon: <Avatar user={u} size={22} showPresence={false} rounded="6px" /> }));
}
if (ac.trigger === "#") {
return channels
.filter((c) => (c.kind === "channel" || c.kind === "private") && c.name.toLowerCase().includes(q))
.slice(0, 8)
.map((c) => ({ key: c.id, label: `#${c.name}`, sub: c.topic, insert: `#${c.name} `, icon: <Hash size={16} className="text-muted" /> }));
}
// emoji
return searchEmojis(q, 8).map((e) => ({ key: e.n, label: `:${e.n}:`, insert: e.e, icon: <span className="text-lg">{e.e}</span> }));
}, [ac, users, channels]);
const acceptSuggestion = (s: Suggestion) => {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
const node = range.startContainer;
const text = (node.textContent ?? "").slice(0, range.startOffset);
const m = /([@#:])([\w+-]*)$/.exec(text);
if (!m || node.nodeType !== Node.TEXT_NODE) return;
const start = m.index;
const r = document.createRange();
r.setStart(node, start);
r.setEnd(node, range.startOffset);
r.deleteContents();
const tn = document.createTextNode(s.insert);
r.insertNode(tn);
// caret after inserted
const after = document.createRange();
after.setStartAfter(tn);
after.collapse(true);
sel.removeAllRanges();
sel.addRange(after);
setAc(null);
refresh();
};
async function submit(scheduledFor?: number) {
const el = editorRef.current;
if (!el) return;
const body = serialize(el);
if (!body && attachments.length === 0) return;
el.innerHTML = "";
setEmpty(true);
const opts: SendOpts = {};
if (scheduledFor) opts.scheduledFor = scheduledFor;
if (attachments.length) opts.attachments = attachments;
if (showAlsoSend && alsoSend) opts.alsoSendToChannel = true;
setAttachments([]);
await onSend(body || "(attachment)", opts);
}
// files
const onFiles = (files: FileList | null) => {
if (!files) return;
Array.from(files).slice(0, 5).forEach((f) => {
const isImg = f.type.startsWith("image/");
const reader = new FileReader();
reader.onload = () => {
setAttachments((prev) => [...prev, {
id: `a_${Date.now()}_${Math.round(f.size)}_${prev.length}`,
kind: isImg ? "image" : f.type.startsWith("video/") ? "video" : "file",
name: f.name,
sizeLabel: humanSize(f.size),
mime: f.type,
url: String(reader.result),
}]);
};
reader.readAsDataURL(f);
});
};
useEffect(() => {
if (!scheduleOpen) return;
const onDoc = (e: MouseEvent) => { if (scheduleRef.current && !scheduleRef.current.contains(e.target as Node)) setScheduleOpen(false); };
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [scheduleOpen]);
const toolbarBtn = "grid h-7 w-7 place-items-center rounded-md text-muted transition-colors hover:bg-hover hover:text-ink";
const keepFocus = (e: React.MouseEvent) => e.preventDefault();
const scheduleOptions = buildScheduleOptions();
const inList = () => {
let n: Node | null | undefined = window.getSelection()?.anchorNode;
while (n && n !== editorRef.current) {
if (n.nodeType === 1 && /^(LI|UL|OL)$/.test((n as HTMLElement).tagName)) return true;
n = n.parentNode;
}
return false;
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (ac && suggestions.length) {
if (e.key === "ArrowDown") { e.preventDefault(); setAcIndex((i) => Math.min(i + 1, suggestions.length - 1)); return; }
if (e.key === "ArrowUp") { e.preventDefault(); setAcIndex((i) => Math.max(i - 1, 0)); return; }
if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); acceptSuggestion(suggestions[acIndex]); return; }
if (e.key === "Escape") { e.preventDefault(); setAc(null); return; }
}
// Tab inside a list = indent / Shift+Tab = outdent (nested sub-levels)
if (e.key === "Tab" && inList()) {
e.preventDefault();
document.execCommand(e.shiftKey ? "outdent" : "indent");
refresh();
return;
}
if (e.key === "Enter" && !(e.nativeEvent as any).isComposing) {
// Inside a list: Enter makes the next item natively (and exits on an empty
// item). Shift+Enter — which the composer hint trains people to use — must
// ALSO make the next bullet/number here, not a soft line break.
if (inList()) {
if (e.shiftKey) { e.preventDefault(); document.execCommand("insertParagraph"); }
refresh();
return;
}
if (!e.shiftKey) { e.preventDefault(); submit(); }
// plain Shift+Enter outside a list → default soft newline
}
};
return (
<div className="shrink-0 px-4 pb-4 pt-1">
<div className="relative rounded-card border border-border bg-panel focus-within:border-border-strong">
{/* translate-before-send preview */}
{preview && (
<div className="absolute bottom-[calc(100%+6px)] left-0 right-0 z-40 overflow-hidden rounded-control border border-border bg-elevated shadow-pop animate-slide-up">
<div className="flex items-center gap-2 border-b border-border px-3 py-2 text-[12px] font-semibold">
<Languages size={14} className="text-accent" /> Translated to {preview.lang}
<button onClick={() => setPreview(null)} className="ml-auto text-muted hover:text-ink"><X size={14} /></button>
</div>
<div className="max-h-40 overflow-y-auto px-3 py-2.5">
<div className="mb-1 text-[10.5px] font-bold uppercase tracking-wide text-dim">Preview</div>
<div className="whitespace-pre-wrap break-words text-[13.5px] text-ink/90">{preview.text}</div>
<div className="mt-2 border-t border-border pt-2 text-[11.5px] text-muted"><span className="text-dim">Original:</span> {preview.original}</div>
</div>
<div className="flex items-center justify-end gap-2 border-t border-border px-3 py-2">
<button onClick={() => setPreview(null)} className="rounded-control px-2.5 py-1 text-[12px] font-semibold text-muted hover:bg-hover">Cancel</button>
<button onClick={applyPreview} className="flex items-center gap-1.5 rounded-control bg-accent px-2.5 py-1 text-[12px] font-semibold text-accent-fg"><Check size={13} /> Use translation</button>
</div>
</div>
)}
{/* autocomplete popover */}
{ac && suggestions.length > 0 && (
<div className="absolute bottom-[calc(100%+6px)] left-0 z-40 w-72 overflow-hidden rounded-control border border-border bg-elevated shadow-pop animate-slide-up">
<div className="px-3 py-1.5 text-[10.5px] font-bold uppercase tracking-wide text-dim">
{ac.trigger === "@" ? "People" : ac.trigger === "#" ? "Channels" : "Emoji"}
</div>
<div className="max-h-56 overflow-y-auto pb-1">
{suggestions.map((s, i) => (
<button
key={s.key}
onMouseEnter={() => setAcIndex(i)}
onMouseDown={(e) => { e.preventDefault(); acceptSuggestion(s); }}
className={clsx("flex w-full items-center gap-2.5 px-3 py-1.5 text-left", i === acIndex ? "bg-hover" : "")}
>
<span className="grid h-6 w-6 shrink-0 place-items-center">{s.icon}</span>
<span className="min-w-0 flex-1"><span className="block truncate text-[13.5px]">{s.label}</span>{s.sub && <span className="block truncate text-[11.5px] text-muted">{s.sub}</span>}</span>
</button>
))}
</div>
</div>
)}
{flags.richComposer && !compact && (
<div className="flex flex-wrap items-center gap-0.5 border-b border-border px-2 py-1.5">
<button className={toolbarBtn} title="Bold (Ctrl+B)" onMouseDown={keepFocus} onClick={() => exec("bold")}><Bold size={15} /></button>
<button className={toolbarBtn} title="Italic (Ctrl+I)" onMouseDown={keepFocus} onClick={() => exec("italic")}><Italic size={15} /></button>
<button className={toolbarBtn} title="Strikethrough" onMouseDown={keepFocus} onClick={() => exec("strikeThrough")}><Strikethrough size={15} /></button>
<span className="mx-1 h-4 w-px bg-border" />
<button className={toolbarBtn} title="Code" onMouseDown={keepFocus} onClick={wrapCode}><Code size={15} /></button>
<button className={toolbarBtn} title="Link" onMouseDown={keepFocus} onClick={makeLink}><Link2 size={15} /></button>
<span className="mx-1 h-4 w-px bg-border" />
<button className={toolbarBtn} title="Bulleted list" onMouseDown={keepFocus} onClick={() => exec("insertUnorderedList")}><List size={15} /></button>
<button className={toolbarBtn} title="Numbered list" onMouseDown={keepFocus} onClick={() => exec("insertOrderedList")}><ListOrdered size={15} /></button>
</div>
)}
<div className="relative">
{empty && <span className="pointer-events-none absolute left-3.5 top-3 text-[14.5px] text-dim">{placeholder}</span>}
<div
ref={editorRef}
role="textbox"
aria-label={placeholder}
aria-multiline="true"
contentEditable
suppressContentEditableWarning
onInput={() => { refresh(); detectToken(); }}
onKeyUp={detectToken}
onClick={detectToken}
onKeyDown={onKeyDown}
className="lynkd-editor max-h-40 min-h-[44px] w-full overflow-y-auto whitespace-pre-wrap break-words px-3.5 py-3 text-[14.5px] leading-relaxed text-ink outline-none [&_a]:text-info [&_a]:underline [&_code]:rounded [&_code]:bg-hover [&_code]:px-1 [&_code]:font-mono [&_code]:text-accent [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-6 [&_ol]:pl-6"
/>
</div>
{/* attachment previews */}
{attachments.length > 0 && (
<div className="flex flex-wrap gap-2 px-3 pb-2">
{attachments.map((a) => (
<div key={a.id} className="relative flex items-center gap-2 rounded-control border border-border bg-surface p-1.5 pr-6">
{a.kind === "image" && a.url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={a.url} alt={a.name} className="h-9 w-9 rounded object-cover" />
) : (
<span className="grid h-9 w-9 place-items-center rounded bg-elevated text-muted"><FileText size={16} /></span>
)}
<div className="max-w-[140px]"><div className="truncate text-[12px] font-medium">{a.name}</div><div className="text-[10.5px] text-dim">{a.sizeLabel}</div></div>
<button onClick={() => setAttachments((prev) => prev.filter((x) => x.id !== a.id))} className="absolute right-1 top-1 grid h-4 w-4 place-items-center rounded-full bg-elevated text-muted hover:text-ink" title="Remove"><X size={11} /></button>
</div>
))}
</div>
)}
<div className="flex items-center gap-0.5 px-2 py-1.5">
{flags.fileUpload && (
<>
<button className={toolbarBtn} title="Attach a file" onClick={() => fileRef.current?.click()}><Paperclip size={16} /></button>
<input ref={fileRef} type="file" multiple className="hidden" onChange={(e) => { onFiles(e.target.files); e.target.value = ""; }} />
</>
)}
{flags.mentions && <button className={toolbarBtn} title="Mention someone" onMouseDown={keepFocus} onClick={() => insertText("@")}><AtSign size={16} /></button>}
{flags.emojiPicker && (
<div className="relative">
<button className={toolbarBtn} title="Emoji" onMouseDown={keepFocus} onClick={() => { setEmojiOpen((o) => !o); setScheduleOpen(false); }}><Smile size={16} /></button>
{emojiOpen && <EmojiPicker onPick={(e) => { insertText(e); setEmojiOpen(false); }} onClose={() => setEmojiOpen(false)} />}
</div>
)}
{flags.slashCommands && <button className={toolbarBtn} title="Slash command" onMouseDown={keepFocus} onClick={() => insertText("/")}><Slash size={16} /></button>}
{flags.translation && (
<div className="relative">
<button className={clsx(toolbarBtn, translating && "text-accent")} title="Translate before sending" onMouseDown={keepFocus} onClick={() => { setTranslateOpen((o) => !o); setEmojiOpen(false); setScheduleOpen(false); }}>
{translating ? <RefreshCw size={16} className="animate-spin" /> : <Languages size={16} />}
</button>
{translateOpen && <LanguageMenu onPick={requestTranslation} onClose={() => setTranslateOpen(false)} />}
</div>
)}
{flags.huddles && onStartHuddle && <button className={toolbarBtn} title="Start huddle" onClick={onStartHuddle}><Video size={16} /></button>}
{flags.aiAssist && (
<button
type="button"
onClick={() => openAi()}
title="Ask the AI assistant"
className="ml-1 flex items-center gap-1 rounded-control border border-accent/40 bg-accent-soft px-2 py-1 text-[12px] font-bold text-accent transition-colors hover:bg-accent hover:text-accent-fg"
>
<Sparkles size={14} /> AI
</button>
)}
<div className="ml-auto flex items-center gap-1">
{flags.scheduledSend && !compact && (
<div className="relative" ref={scheduleRef}>
<button className={clsx(toolbarBtn, "gap-1")} title="Schedule send" onClick={() => { setScheduleOpen((o) => !o); setEmojiOpen(false); }}><Clock size={16} /></button>
{scheduleOpen && (
<div className="absolute bottom-9 right-0 z-30 w-52 overflow-hidden rounded-control border border-border bg-elevated shadow-pop animate-slide-up">
{scheduleOptions.map((o) => (
<button key={o.label} onClick={() => { submit(o.at); setScheduleOpen(false); }} disabled={empty && attachments.length === 0} className="block w-full px-3 py-2 text-left text-[13px] hover:bg-hover disabled:opacity-40">{o.label}</button>
))}
</div>
)}
</div>
)}
<button onClick={() => submit()} disabled={empty && attachments.length === 0} className="focus-ring flex items-center gap-1.5 rounded-control bg-accent px-3 py-1.5 text-[13px] font-semibold text-accent-fg transition-opacity disabled:opacity-40">
<Send size={14} /> Send
</button>
</div>
</div>
</div>
<div className="flex items-center justify-between px-2 pt-1">
<div className="text-[11px] text-dim"><b>Shift + Enter</b> for a new line · <b>Enter</b> to send</div>
{showAlsoSend && (
<label className="flex cursor-pointer items-center gap-1.5 text-[11.5px] text-muted">
<input type="checkbox" checked={alsoSend} onChange={(e) => setAlsoSend(e.target.checked)} className="accent-[var(--c-accent)]" />
Also send to {channelName ? <b className="text-ink">{channelName}</b> : "channel"}
</label>
)}
</div>
</div>
);
}
function escapeHtml(s: string): string { return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
function humanSize(b: number): string { return b < 1024 ? `${b} B` : b < 1048576 ? `${(b / 1024).toFixed(0)} KB` : `${(b / 1048576).toFixed(1)} MB`; }
function buildScheduleOptions(): { label: string; at: number }[] {
const now = new Date();
const in30 = new Date(now.getTime() + 30 * 60_000);
const tomorrow9 = new Date(now); tomorrow9.setDate(now.getDate() + 1); tomorrow9.setHours(9, 0, 0, 0);
const nextMon = new Date(now); const d = ((8 - now.getDay()) % 7) || 7; nextMon.setDate(now.getDate() + d); nextMon.setHours(9, 0, 0, 0);
const fmt = (x: Date) => x.toLocaleString(undefined, { weekday: "short", hour: "numeric", minute: "2-digit" });
return [
{ label: `In 30 min · ${in30.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`, at: in30.getTime() },
{ label: `Tomorrow · ${fmt(tomorrow9)}`, at: tomorrow9.getTime() },
{ label: `Next Monday · ${fmt(nextMon)}`, at: nextMon.getTime() },
];
}
@@ -0,0 +1,96 @@
"use client";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { EMOJIS, FREQUENT, searchEmojis, type EmojiCategory } from "@/lib/emoji";
import { Search } from "lucide-react";
const CATS: EmojiCategory[] = ["Smileys", "Gestures", "Activity", "Animals", "Food", "Travel", "Objects", "Symbols"];
export function EmojiPicker({
onPick,
onClose,
align = "left",
}: {
onPick: (emoji: string) => void;
onClose: () => void;
align?: "left" | "right";
}) {
const ref = useRef<HTMLDivElement>(null);
const [q, setQ] = useState("");
const [pos, setPos] = useState<{ v: "up" | "down"; h: "left" | "right" }>({ v: "up", h: align });
// auto-place: flip up/down + left/right based on available space (tooltip-style)
useLayoutEffect(() => {
const wrap = (ref.current?.offsetParent as HTMLElement | null) ?? ref.current;
const r = wrap?.getBoundingClientRect();
if (!r) return;
const H = 300, W = 300;
const v = r.top < H + 12 && window.innerHeight - r.bottom > r.top ? "down" : "up";
const h = window.innerWidth - r.left < W ? "right" : "left";
setPos({ v, h });
}, []);
useEffect(() => {
const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) onClose(); };
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); onClose(); } };
const t = setTimeout(() => document.addEventListener("mousedown", onDoc), 0);
document.addEventListener("keydown", onKey);
return () => { clearTimeout(t); document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
}, [onClose]);
const results = useMemo(() => (q ? searchEmojis(q, 64) : null), [q]);
return (
<div
ref={ref}
className={`absolute z-40 w-[300px] rounded-control border border-border bg-elevated p-2 shadow-pop animate-slide-up ${pos.v === "up" ? "bottom-9" : "top-9"} ${pos.h === "right" ? "right-0" : "left-0"}`}
>
<div className="mb-2 flex items-center gap-2 rounded-control border border-border bg-panel px-2">
<Search size={14} className="text-muted" />
<input
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search emoji…"
className="w-full bg-transparent py-1.5 text-[13px] outline-none placeholder:text-dim"
/>
</div>
<div className="max-h-[240px] overflow-y-auto pr-0.5">
{results ? (
<Grid list={results.map((e) => e.e)} onPick={onPick} />
) : (
<>
<CatLabel>Frequently used</CatLabel>
<Grid list={FREQUENT} onPick={onPick} />
{CATS.map((c) => (
<div key={c}>
<CatLabel>{c}</CatLabel>
<Grid list={EMOJIS.filter((e) => e.c === c).map((e) => e.e)} onPick={onPick} />
</div>
))}
</>
)}
{results && results.length === 0 && <div className="py-6 text-center text-[12.5px] text-muted">No emoji found</div>}
</div>
<div className="mt-1 border-t border-border px-1 pt-1 text-[10.5px] text-dim">
Tip: type <code className="text-accent">:tick:</code> in a message for
</div>
</div>
);
}
function CatLabel({ children }: { children: React.ReactNode }) {
return <div className="px-1 pb-0.5 pt-1.5 text-[10px] font-bold uppercase tracking-wide text-dim">{children}</div>;
}
function Grid({ list, onPick }: { list: string[]; onPick: (e: string) => void }) {
return (
<div className="grid grid-cols-8 gap-0.5">
{list.map((e, i) => (
<button key={e + i} onClick={() => onPick(e)} className="grid h-8 w-8 place-items-center rounded-md text-lg hover:bg-hover" title={e}>
{e}
</button>
))}
</div>
);
}
@@ -0,0 +1,77 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { LANGUAGES } from "@/lib/languages";
import { Search } from "lucide-react";
/**
* Searchable language dropdown used by the per-message translator and the
* composer's translate-before-send flow. Positions itself above or below the
* trigger depending on available space (like a tooltip).
*/
export function LanguageMenu({
onPick,
onClose,
align = "left",
}: {
onPick: (langName: string) => void;
onClose: () => void;
align?: "left" | "right";
}) {
const [q, setQ] = useState("");
const ref = useRef<HTMLDivElement>(null);
const [vpos, setVpos] = useState<"up" | "down">("down");
useEffect(() => {
const parent = ref.current?.offsetParent as HTMLElement | null;
const r = (parent ?? ref.current)?.getBoundingClientRect();
if (r) {
const H = 320;
setVpos(r.bottom + H > window.innerHeight && r.top > H ? "up" : "down");
}
}, []);
useEffect(() => {
const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) onClose(); };
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); onClose(); } };
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
}, [onClose]);
const list = LANGUAGES.filter(
(l) => l.name.toLowerCase().includes(q.toLowerCase()) || l.native.toLowerCase().includes(q.toLowerCase()),
);
return (
<div
ref={ref}
className={`absolute z-50 w-64 overflow-hidden rounded-control border border-border bg-elevated shadow-pop animate-slide-up ${vpos === "up" ? "bottom-9" : "top-9"} ${align === "right" ? "right-0" : "left-0"}`}
>
<div className="flex items-center gap-2 border-b border-border px-2.5 py-2">
<Search size={14} className="text-dim" />
<input
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search language…"
className="w-full bg-transparent text-[13px] outline-none placeholder:text-dim"
/>
</div>
<div className="max-h-64 overflow-y-auto py-1">
{list.map((l) => (
<button
key={l.name}
onClick={() => onPick(l.name)}
className="flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-[13px] hover:bg-hover"
>
<span className="text-base">{l.flag}</span>
<span className="font-medium">{l.name}</span>
<span className="ml-auto text-[11.5px] text-dim">{l.native}</span>
</button>
))}
{list.length === 0 && <div className="px-3 py-4 text-center text-[12.5px] text-muted">No match.</div>}
</div>
</div>
);
}
+356
View File
@@ -0,0 +1,356 @@
"use client";
import { useEffect, useRef, useState } from "react";
import clsx from "clsx";
import {
useFeatureFlags,
useSdk,
type Message,
type RichBlock,
} from "@lynkd/messaging-inbox-sdk";
import { useUi } from "@/lib/ui-state";
import { Avatar } from "@/components/ui/primitives";
import { RichText } from "@/lib/rich-text";
import { clockTime, relativeTime } from "@/lib/format";
import { EmojiPicker } from "./EmojiPicker";
import { LanguageMenu } from "./LanguageMenu";
import {
SmilePlus, MessageSquare, Pin, Bookmark, MoreHorizontal,
FileText, ImageIcon, Clock, Link2, Copy, Forward, Trash2, Pencil, Eye, Download,
Sparkles, Languages, RefreshCw,
} from "lucide-react";
export function MessageItem({
message,
grouped,
highlighted,
autoTranslateTo,
onReact,
onOpenThread,
onPin,
onSave,
onEdit,
onDelete,
}: {
message: Message;
grouped?: boolean;
highlighted?: boolean;
autoTranslateTo?: string | null;
onReact?: (emoji: string) => void;
onOpenThread?: () => void;
onPin?: () => void;
onSave?: () => void;
onEdit?: (body: string) => void;
onDelete?: () => void;
}) {
const { userById, me, client } = useSdk();
const { openProfile, toast, openAi } = useUi();
const flags = useFeatureFlags();
const author = userById(message.authorId);
const [hover, setHover] = useState(false);
const [emojiOpen, setEmojiOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [langOpen, setLangOpen] = useState(false);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(message.body);
const [translated, setTranslated] = useState<{ lang: string; text: string } | null>(null);
const [translating, setTranslating] = useState(false);
const mine = message.authorId === me?.id;
const toolbarVisible = hover || emojiOpen || menuOpen;
const copy = async (text: string, label: string) => {
try {
await navigator.clipboard?.writeText(text);
toast(`${label} copied to clipboard`, "success");
} catch {
toast("Couldn't access the clipboard", "danger");
}
};
const saveEdit = () => {
const v = draft.trim();
if (v && v !== message.body) onEdit?.(v);
setEditing(false);
};
const doTranslate = async (lang: string) => {
setLangOpen(false);
setTranslating(true);
try {
const res = await client.translate(message.body, lang);
if (res.ok && res.text) setTranslated({ lang, text: res.text });
else toast(res.error || "Translation failed", "danger");
} catch (e) {
toast(`Translation failed: ${(e as Error).message}`, "danger");
} finally {
setTranslating(false);
}
};
// whole-conversation translate: react to the channel-level language toggle
useEffect(() => {
if (autoTranslateTo) void doTranslate(autoTranslateTo);
else if (autoTranslateTo === null) setTranslated(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoTranslateTo]);
if (!author) return null;
return (
<div
id={`msg-${message.id}`}
data-mid={message.id}
data-jump-highlight={highlighted ? "1" : undefined}
className={clsx("group relative flex scroll-mt-4 gap-3 px-4 py-0.5 row-hover transition-colors", grouped ? "mt-0.5" : "mt-3")}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
<div className="w-9 shrink-0 pt-0.5">
{!grouped ? (
<button onClick={() => openProfile(author.id)} className="focus-ring rounded-control" title={author.name}>
<Avatar user={author} size={36} showPresence={false} />
</button>
) : (
<span className="hidden pt-1 text-[10px] leading-4 text-dim group-hover:block">{clockTime(message.ts)}</span>
)}
</div>
<div data-mid-body className="min-w-0 flex-1">
{!grouped && (
<div className="flex items-baseline gap-2">
<button onClick={() => openProfile(author.id)} className="focus-ring text-[14px] font-bold hover:underline">
{author.name}
</button>
{author.isBot && <span className="rounded bg-hover px-1 text-[10px] font-bold uppercase text-muted">app</span>}
<span className="text-[11px] text-dim">{clockTime(message.ts)}</span>
{message.scheduledFor && (
<span className="flex items-center gap-1 text-[11px] text-warning">
<Clock size={11} /> scheduled · {relativeTime(message.scheduledFor)}
</span>
)}
{message.isPinned && <Pin size={12} className="text-accent" />}
</div>
)}
{editing ? (
<div className="mt-1 rounded-control border border-border bg-panel p-2 focus-within:border-border-strong">
<textarea
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); saveEdit(); }
if (e.key === "Escape") { e.preventDefault(); setEditing(false); setDraft(message.body); }
}}
rows={2}
className="max-h-40 w-full resize-none bg-transparent px-1.5 py-1 text-[14px] outline-none"
/>
<div className="flex items-center justify-end gap-2 pt-1">
<button onClick={() => { setEditing(false); setDraft(message.body); }} className="rounded-control px-2.5 py-1 text-[12px] font-semibold text-muted hover:bg-hover">Cancel</button>
<button onClick={saveEdit} className="rounded-control bg-accent px-2.5 py-1 text-[12px] font-semibold text-accent-fg">Save</button>
</div>
</div>
) : (
<div className="whitespace-pre-wrap break-words text-[14.5px] leading-relaxed text-ink/90">
<RichText text={message.body} />
{message.editedTs && <span className="ml-1 text-[11px] text-dim">(edited)</span>}
</div>
)}
{translating && (
<div className="mt-1.5 flex items-center gap-1.5 text-[12px] text-muted"><RefreshCw size={12} className="animate-spin" /> Translating</div>
)}
{translated && (
<div className="mt-1.5 rounded-control border-l-2 border-info bg-info/5 px-3 py-2">
<div className="mb-1 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wide text-dim">
<Languages size={12} /> Translated · {translated.lang}
<button onClick={() => setTranslated(null)} className="ml-auto font-semibold normal-case text-info hover:underline">Show original</button>
</div>
<div className="whitespace-pre-wrap break-words text-[14px] leading-relaxed text-ink/90"><RichText text={translated.text} /></div>
</div>
)}
{message.blocks?.map((b, i) => <Block key={i} block={b} />)}
{message.attachments?.map((a) => (
<div key={a.id} className="group/att mt-1.5 max-w-sm">
{a.kind === "image" && a.url ? (
<button onClick={() => window.open(a.url, "_blank")} className="block overflow-hidden rounded-control border border-border" title="Open image">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={a.url} alt={a.name} className="max-h-64 w-auto max-w-full object-cover" />
</button>
) : (
<div className="flex items-center gap-3 rounded-control border border-border bg-surface p-2.5">
<span className="grid h-10 w-10 place-items-center rounded-md text-muted" style={{ background: a.previewColor ?? "var(--c-elevated)" }}>
{a.kind === "video" ? <ImageIcon size={18} /> : <FileText size={18} />}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium">{a.name}</div>
<div className="text-[11px] text-dim">{a.sizeLabel}</div>
</div>
</div>
)}
<div className="mt-1 flex items-center gap-2 text-[11px] text-muted">
<span className="truncate">{a.name} · {a.sizeLabel}</span>
{a.url && (
<>
<a href={a.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-0.5 text-info hover:underline"><Eye size={12} /> Open</a>
<a href={a.url} download={a.name} className="inline-flex items-center gap-0.5 text-info hover:underline"><Download size={12} /> Download</a>
</>
)}
</div>
</div>
))}
{flags.reactions && !!message.reactions?.length && (
<div className="mt-1.5 flex flex-wrap items-center gap-1">
{message.reactions.map((r) => {
const reacted = me ? r.userIds.includes(me.id) : false;
return (
<button
key={r.emoji}
onClick={() => onReact?.(r.emoji)}
className={clsx(
"flex items-center gap-1 rounded-pill border px-2 py-0.5 text-[12px] transition-colors",
reacted ? "border-accent bg-accent-soft text-accent" : "border-border bg-surface text-muted hover:border-border-strong",
)}
>
<span>{r.emoji}</span>
<span className="font-semibold">{r.userIds.length}</span>
</button>
);
})}
<ReactAdder onReact={onReact} />
</div>
)}
{message.threadRootId && (
<button onClick={onOpenThread} className="mt-1 flex items-center gap-1.5 rounded-control border border-border bg-surface px-2 py-1 text-[12px] font-medium text-info hover:bg-hover">
<MessageSquare size={12} /> Replied to a thread <span className="text-dim"> open</span>
</button>
)}
{flags.threads && !!message.replyCount && (
<button
onClick={onOpenThread}
className="mt-1.5 flex items-center gap-2 rounded-control border border-transparent px-1.5 py-1 text-[12.5px] font-semibold text-info hover:border-border hover:bg-surface"
>
<span className="flex -space-x-1.5">
{(message.replyUserIds ?? []).slice(0, 3).map((id) => {
const u = userById(id);
return u ? <Avatar key={id} user={u} size={18} showPresence={false} rounded="6px" /> : null;
})}
</span>
{message.replyCount} {message.replyCount === 1 ? "reply" : "replies"}
{message.lastReplyTs && <span className="font-normal text-dim">· {relativeTime(message.lastReplyTs)}</span>}
</button>
)}
</div>
{toolbarVisible && !editing && (
<div className="absolute -top-3 right-4 z-20 flex items-center gap-0.5 rounded-control border border-border bg-elevated p-0.5 shadow-pop">
{flags.reactions && (
<div className="relative">
<ActBtn title="React" active={emojiOpen} onClick={() => { setEmojiOpen((o) => !o); setMenuOpen(false); }}><SmilePlus size={16} /></ActBtn>
{emojiOpen && (
<EmojiPicker align="right" onPick={(e) => { onReact?.(e); setEmojiOpen(false); }} onClose={() => setEmojiOpen(false)} />
)}
</div>
)}
{flags.threads && <ActBtn title="Reply in thread" onClick={onOpenThread}><MessageSquare size={16} /></ActBtn>}
<ActBtn title="Copy message · ⌘C" onClick={() => copy(message.body, "Message")}><Copy size={16} /></ActBtn>
{flags.translation && (
<div className="relative">
<ActBtn title="Translate message" active={langOpen} onClick={() => { setLangOpen((o) => !o); setEmojiOpen(false); setMenuOpen(false); }}><Languages size={16} /></ActBtn>
{langOpen && <LanguageMenu align="right" onPick={doTranslate} onClose={() => setLangOpen(false)} />}
</div>
)}
{flags.aiAssist && <ActBtn title="Ask AI about this message" onClick={() => openAi({ context: message.body })}><Sparkles size={16} /></ActBtn>}
{flags.pins && <ActBtn title={message.isPinned ? "Unpin" : "Pin"} active={message.isPinned} onClick={onPin}><Pin size={16} /></ActBtn>}
{flags.savedItems && <ActBtn title={message.isSaved ? "Remove from saved" : "Save"} active={message.isSaved} onClick={onSave}><Bookmark size={16} /></ActBtn>}
{mine && onEdit && <ActBtn title="Edit message" onClick={() => { setDraft(message.body); setEditing(true); setMenuOpen(false); }}><Pencil size={16} /></ActBtn>}
<div className="relative">
<ActBtn title="More actions" active={menuOpen} onClick={() => { setMenuOpen((o) => !o); setEmojiOpen(false); }}><MoreHorizontal size={16} /></ActBtn>
{menuOpen && (
<MoreMenu
mine={mine}
isPinned={message.isPinned}
isSaved={message.isSaved}
canDelete={mine && !!onDelete}
canEdit={mine && !!onEdit}
onClose={() => setMenuOpen(false)}
onCopyLink={() => { copy(`${location.origin}/c/${message.channelId}?msg=${message.id}`, "Link"); setMenuOpen(false); }}
onCopyText={() => { copy(message.body, "Text"); setMenuOpen(false); }}
onPin={() => { onPin?.(); setMenuOpen(false); }}
onSave={() => { onSave?.(); setMenuOpen(false); }}
onThread={() => { onOpenThread?.(); setMenuOpen(false); }}
onEdit={() => { setDraft(message.body); setEditing(true); setMenuOpen(false); }}
onDelete={() => { onDelete?.(); setMenuOpen(false); }}
/>
)}
</div>
</div>
)}
</div>
);
}
function ReactAdder({ onReact }: { onReact?: (e: string) => void }) {
const [open, setOpen] = useState(false);
return (
<div className="relative">
<button onClick={() => setOpen((o) => !o)} className="grid h-[26px] w-7 place-items-center rounded-pill border border-border text-muted hover:border-border-strong hover:text-ink" title="Add reaction">
<SmilePlus size={14} />
</button>
{open && <EmojiPicker onPick={(e) => { onReact?.(e); setOpen(false); }} onClose={() => setOpen(false)} />}
</div>
);
}
function MoreMenu({
mine, isPinned, isSaved, canDelete, canEdit, onClose, onCopyLink, onCopyText, onPin, onSave, onThread, onEdit, onDelete,
}: {
mine: boolean; isPinned?: boolean; isSaved?: boolean; canDelete: boolean; canEdit: boolean; onClose: () => void;
onCopyLink: () => void; onCopyText: () => void; onPin: () => void; onSave: () => void; onThread: () => void; onEdit: () => void; onDelete: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) onClose(); };
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); onClose(); } };
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
}, [onClose]);
const item = "flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-[13px] hover:bg-hover";
return (
<div ref={ref} className="absolute right-0 top-9 z-40 w-52 overflow-hidden rounded-control border border-border bg-elevated py-1 shadow-pop animate-slide-up">
<button className={item} onClick={onThread}><Forward size={14} /> Reply in thread</button>
{canEdit && <button className={item} onClick={onEdit}><Pencil size={14} /> Edit message</button>}
<button className={item} onClick={onCopyLink}><Link2 size={14} /> Copy link</button>
<button className={item} onClick={onCopyText}><Copy size={14} /> Copy text</button>
<button className={item} onClick={onPin}><Pin size={14} /> {isPinned ? "Unpin" : "Pin to channel"}</button>
<button className={item} onClick={onSave}><Bookmark size={14} /> {isSaved ? "Remove from saved" : "Save for later"}</button>
{canDelete && <><div className="my-1 h-px bg-border" /><button className={clsx(item, "text-danger")} onClick={onDelete}><Trash2 size={14} /> Delete message</button></>}
</div>
);
}
function ActBtn({ children, title, active, onClick }: { children: React.ReactNode; title: string; active?: boolean; onClick?: () => void }) {
return (
<button title={title} onClick={onClick} className={clsx("grid h-7 w-7 place-items-center rounded-md text-muted hover:bg-hover hover:text-ink", active && "bg-hover text-accent")}>
{children}
</button>
);
}
function Block({ block }: { block: RichBlock }) {
if (block.type === "callout")
return <div className="mt-1.5 rounded-control border-l-2 border-accent bg-accent-soft px-3 py-2 text-[13px] text-ink/90">{block.text}</div>;
if (block.type === "bullet")
return <ul className="mt-1 list-disc pl-5 text-[13.5px] text-ink/85">{block.items?.map((it, i) => <li key={i}>{it}</li>)}</ul>;
if (block.type === "code")
return <pre className="mt-1.5 overflow-x-auto rounded-control border border-border bg-surface p-3 font-mono text-[12.5px] text-ink/90"><code>{block.text}</code></pre>;
if (block.type === "quote")
return <blockquote className="mt-1 border-l-2 border-border pl-3 text-[13.5px] text-muted">{block.text}</blockquote>;
return null;
}
@@ -0,0 +1,85 @@
"use client";
import { useThread, useChannel, useSdk, notifyChannelChanged } from "@lynkd/messaging-inbox-sdk";
import { useUi } from "@/lib/ui-state";
import { MessageItem } from "./MessageItem";
import { Composer } from "./Composer";
import { X } from "lucide-react";
export function ThreadPanel() {
const { thread, closeThread, toast } = useUi();
const { client } = useSdk();
const channel = useChannel(thread?.channelId ?? null);
const { parent, replies, reply, react, pin, save, edit, remove } = useThread(
thread?.channelId ?? null,
thread?.parentId ?? null,
);
if (!thread) return null;
const channelLabel = channel ? (channel.kind === "channel" ? `#${channel.name}` : channel.name) : "channel";
return (
<aside
role="dialog"
aria-label="Thread"
className="fixed inset-0 z-40 flex w-full flex-col border-l border-border bg-surface md:static md:z-auto md:w-[400px] md:max-w-[400px] md:shrink-0 animate-fade-in"
>
<div className="flex h-[60px] items-center justify-between border-b border-border px-4">
<div>
<div className="text-[15px] font-bold">Thread</div>
<div className="text-[12px] text-muted">
{channel ? (channel.kind === "channel" ? `#${channel.name}` : channel.name) : ""}
</div>
</div>
<button onClick={closeThread} className="focus-ring grid h-8 w-8 place-items-center rounded-control text-muted hover:bg-hover hover:text-ink" aria-label="Close thread">
<X size={18} />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pb-2">
{parent && (
<MessageItem
message={parent}
onReact={(e) => react(parent.id, e)}
onPin={() => pin(parent.id)}
onSave={() => save(parent.id)}
onEdit={(b) => edit(parent.id, b)}
onDelete={() => { remove(parent.id); closeThread(); }}
/>
)}
<div className="my-1 flex items-center gap-3 px-4">
<div className="h-px flex-1 bg-border" />
<span className="text-[11px] font-semibold text-muted">
{replies.length} {replies.length === 1 ? "reply" : "replies"}
</span>
<div className="h-px flex-1 bg-border" />
</div>
{replies.map((m) => (
<MessageItem
key={m.id}
message={m}
onReact={(e) => react(m.id, e)}
onPin={() => pin(m.id)}
onSave={() => save(m.id)}
onEdit={(b) => edit(m.id, b)}
onDelete={() => remove(m.id)}
/>
))}
</div>
<Composer
showAlsoSend
channelName={channelLabel}
placeholder="Reply…"
onSend={async (body, opts) => {
await reply(body, { attachments: opts?.attachments });
if (opts?.alsoSendToChannel && thread) {
await client.sendMessage(thread.channelId, body, { attachments: opts?.attachments, threadRootId: thread.parentId });
notifyChannelChanged(thread.channelId);
toast(`Also sent to ${channelLabel}`, "success");
}
}}
/>
</aside>
);
}
+100
View File
@@ -0,0 +1,100 @@
"use client";
import { useEffect, useState } from "react";
import { useFeatureFlags, useSdk, useTheme, useNotifications } from "@lynkd/messaging-inbox-sdk";
import { useUi } from "@/lib/ui-state";
import { Avatar, IconButton } from "@/components/ui/primitives";
import { Search, Sun, Moon, Bell, ChevronDown, PanelLeft } from "lucide-react";
export function Header({
title,
subtitle,
eyebrow,
children,
}: {
title: React.ReactNode;
subtitle?: React.ReactNode;
eyebrow?: string;
children?: React.ReactNode;
}) {
const flags = useFeatureFlags();
const { me } = useSdk();
const { scheme, toggleScheme } = useTheme();
const { setPaletteOpen, setNotifOpen, openProfile, setSidebarOpen, sidebarCollapsed, toggleSidebarCollapsed } = useUi();
const { unread } = useNotifications();
const [mac, setMac] = useState(true);
useEffect(() => {
setMac(/mac|iphone|ipad/i.test(navigator.platform || navigator.userAgent));
}, []);
return (
<header className="flex h-[60px] shrink-0 items-center gap-3 border-b border-border bg-surface/80 px-4 backdrop-blur">
<button
className="focus-ring grid h-9 w-9 place-items-center rounded-control text-muted hover:bg-hover md:hidden"
onClick={() => setSidebarOpen(true)}
aria-label="Open sidebar"
>
<PanelLeft size={18} />
</button>
{sidebarCollapsed && (
<button
className="focus-ring hidden h-9 w-9 place-items-center rounded-control text-muted hover:bg-hover hover:text-ink md:grid"
onClick={toggleSidebarCollapsed}
aria-label="Expand sidebar"
title="Expand sidebar"
>
<PanelLeft size={18} />
</button>
)}
<div className="min-w-0 flex-1">
{eyebrow && <div className="text-[11px] font-semibold uppercase tracking-wide text-dim">{eyebrow}</div>}
<div className="flex items-center gap-2">
<h1 className="truncate text-[17px] font-bold leading-tight">{title}</h1>
</div>
{subtitle && <p className="truncate text-[12.5px] text-muted">{subtitle}</p>}
</div>
<div className="flex items-center gap-1.5">
{children}
{flags.search && (
<button
onClick={() => setPaletteOpen(true)}
className="focus-ring hidden items-center gap-2 rounded-control border border-border bg-panel px-3 py-2 text-[13px] text-muted transition-colors hover:bg-hover hover:text-ink sm:flex"
>
<Search size={15} />
<span>Search</span>
<kbd suppressHydrationWarning className="rounded bg-hover px-1.5 py-0.5 font-mono text-[10px] text-dim">{mac ? "⌘K" : "Ctrl K"}</kbd>
</button>
)}
{flags.themeToggle && (
<IconButton label="Toggle theme" onClick={toggleScheme}>
{scheme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
</IconButton>
)}
{flags.notifications && (
<IconButton label="Notifications" onClick={() => setNotifOpen(true)} badge={unread}>
<Bell size={18} />
</IconButton>
)}
{me && (
<button
onClick={() => openProfile(me.id)}
className="focus-ring ml-1 flex items-center gap-2 rounded-control p-1 pr-2 hover:bg-hover"
>
<Avatar user={me} size={32} showPresence={false} />
<span className="hidden text-left lg:block">
<span className="block text-[13px] font-semibold leading-tight">{me.name}</span>
<span className="block text-[11px] text-muted">{me.title}</span>
</span>
<ChevronDown size={15} className="hidden text-muted lg:block" />
</button>
)}
</div>
</header>
);
}
+273
View File
@@ -0,0 +1,273 @@
"use client";
import { useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import clsx from "clsx";
import {
useChannels,
useFeatureFlags,
useInbox,
useSdk,
type Channel,
} from "@lynkd/messaging-inbox-sdk";
import { useUi } from "@/lib/ui-state";
import { Avatar, CountBadge } from "@/components/ui/primitives";
import {
Hash,
Lock,
ChevronDown,
ChevronRight,
Plus,
MessageSquarePlus,
Send,
Bookmark,
MessagesSquare,
Inbox as InboxIcon,
Sliders,
Circle,
PanelLeftClose,
} from "lucide-react";
export function Sidebar() {
const { starred, channels, dms } = useChannels();
const { me, brandName, channelById } = useSdk();
const flags = useFeatureFlags();
const router = useRouter();
const pathname = usePathname();
const { openProfile, setThemingOpen, openModal, toggleSidebarCollapsed } = useUi();
const { items: openInbox } = useInbox("OPEN");
const quick = [
flags.threads && { icon: MessagesSquare, label: "Threads", href: "/threads" },
flags.inbox && { icon: InboxIcon, label: "Inbox", href: "/inbox", badge: openInbox.length },
flags.drafts && { icon: Send, label: "Drafts & sent", href: "/drafts" },
flags.savedItems && { icon: Bookmark, label: "Saved", href: "/saved" },
].filter(Boolean) as { icon: typeof Hash; label: string; href: string; badge?: number }[];
return (
<aside className="flex h-full w-[264px] shrink-0 flex-col border-r border-border bg-surface">
{/* workspace header */}
<div className="flex items-center gap-2 px-3.5 py-3">
<div className="brand-mark grid h-8 w-8 shrink-0 place-items-center rounded-[10px] text-sm font-black text-black/80">
{brandName.slice(0, 1)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-[15px] font-bold leading-tight">{brandName}</div>
<div className="flex items-center gap-1 text-[11px] text-muted">
<Circle size={7} className="fill-success text-success" /> Business+
</div>
</div>
<button
title="New message"
onClick={() => openModal("new-message")}
className="focus-ring grid h-8 w-8 shrink-0 place-items-center rounded-control border border-border bg-panel text-ink transition-colors hover:bg-hover"
>
<MessageSquarePlus size={16} />
</button>
<button
title="Collapse sidebar"
onClick={toggleSidebarCollapsed}
className="focus-ring hidden h-8 w-8 shrink-0 place-items-center rounded-control text-muted transition-colors hover:bg-hover hover:text-ink md:grid"
>
<PanelLeftClose size={16} />
</button>
</div>
{/* quick nav */}
<div className="px-2 pb-1">
{quick.map((q) => {
const active = pathname === q.href;
return (
<button
key={q.href}
onClick={() => router.push(q.href)}
className={clsx(
"focus-ring flex w-full items-center gap-2.5 rounded-control px-2.5 py-[7px] text-[14px] transition-colors",
active ? "bg-accent-soft font-semibold text-accent" : "text-muted hover:bg-hover hover:text-ink",
)}
>
<q.icon size={17} />
<span className="flex-1 text-left">{q.label}</span>
{q.badge ? <CountBadge count={q.badge} /> : null}
</button>
);
})}
</div>
<div className="mx-3 my-1.5 h-px bg-border" />
{/* channel lists */}
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{flags.messaging && starred.length > 0 && (
<ChannelGroup title="Starred" defaultOpen items={starred} activePath={pathname} onOpen={(c) => router.push(`/c/${c.id}`)} me={me?.id} channelById={channelById} />
)}
{flags.messaging && (
<ChannelGroup
title="Channels"
defaultOpen
items={channels}
activePath={pathname}
onOpen={(c) => router.push(`/c/${c.id}`)}
footer={
flags.channelBrowser ? (
<SidebarAction icon={Plus} label="Add channels" onClick={() => openModal("create-channel")} />
) : null
}
me={me?.id}
channelById={channelById}
/>
)}
{flags.directMessages && (
<ChannelGroup
title="Direct messages"
defaultOpen
items={dms}
activePath={pathname}
onOpen={(c) => router.push(`/c/${c.id}`)}
footer={<SidebarAction icon={Plus} label="Invite people" onClick={() => openModal("invite")} />}
me={me?.id}
channelById={channelById}
/>
)}
</div>
{/* user card footer */}
{me && (
<div className="flex items-center gap-2.5 border-t border-border px-3 py-2.5">
<button
className="focus-ring flex min-w-0 flex-1 items-center gap-2.5 rounded-control p-1 text-left hover:bg-hover"
onClick={() => openProfile(me.id)}
>
<Avatar user={me} size={34} />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-semibold">{me.name}</div>
<div className="truncate text-[11px] text-muted">
{me.statusEmoji} {me.statusText ?? "Active"}
</div>
</div>
</button>
{flags.themingPanel && (
<button
title="Theme & appearance"
onClick={() => setThemingOpen(true)}
className="focus-ring grid h-8 w-8 place-items-center rounded-control text-muted hover:bg-hover hover:text-ink"
>
<Sliders size={16} />
</button>
)}
</div>
)}
</aside>
);
}
function ChannelGroup({
title,
items,
activePath,
onOpen,
footer,
defaultOpen,
me,
channelById,
}: {
title: string;
items: Channel[];
activePath: string;
onOpen: (c: Channel) => void;
footer?: React.ReactNode;
defaultOpen?: boolean;
me?: string;
channelById: (id: string) => Channel | undefined;
}) {
const [open, setOpen] = useState(defaultOpen ?? true);
return (
<div className="mb-1.5">
<button
onClick={() => setOpen((o) => !o)}
className="focus-ring flex w-full items-center gap-1 rounded px-2 py-1 text-[12px] font-bold uppercase tracking-wide text-dim hover:text-muted"
>
{open ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{title}
</button>
{open && (
<div className="mt-0.5">
{items.map((c) => (
<ChannelRow key={c.id} channel={c} active={activePath === `/c/${c.id}`} onOpen={() => onOpen(c)} meId={me} />
))}
{footer}
</div>
)}
</div>
);
}
function ChannelRow({
channel,
active,
onOpen,
meId,
}: {
channel: Channel;
active: boolean;
onOpen: () => void;
meId?: string;
}) {
const { userById } = useSdk();
const unread = channel.unreadCount ?? 0;
const isDm = channel.kind === "dm";
const other = isDm ? channel.memberIds.find((id) => id !== meId) : undefined;
const otherUser = other ? userById(other) : undefined;
return (
<button
onClick={onOpen}
className={clsx(
"focus-ring group flex w-full items-center gap-2 rounded-control px-2.5 py-[6px] text-[14px] transition-colors",
active
? "bg-accent-soft font-semibold text-accent"
: unread
? "font-semibold text-ink hover:bg-hover"
: "text-muted hover:bg-hover hover:text-ink",
)}
>
<span className="grid w-4 shrink-0 place-items-center">
{channel.kind === "channel" && <Hash size={15} />}
{channel.kind === "private" && <Lock size={13} />}
{channel.kind === "group_dm" && <MessagesSquare size={14} />}
{isDm && otherUser && <Avatar user={otherUser} size={18} showPresence rounded="6px" />}
</span>
<span className="flex-1 truncate text-left">
{channel.name}
{channel.external && <span className="ml-1 text-[10px] text-dim"></span>}
</span>
{channel.mentionCount ? (
<CountBadge count={channel.mentionCount} tone="danger" />
) : unread ? (
<CountBadge count={unread} />
) : null}
</button>
);
}
function SidebarAction({
icon: Icon,
label,
onClick,
}: {
icon: typeof Hash;
label: string;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className="focus-ring flex w-full items-center gap-2 rounded-control px-2.5 py-[6px] text-[13px] text-muted transition-colors hover:bg-hover hover:text-ink"
>
<span className="grid h-[18px] w-[18px] place-items-center rounded bg-hover">
<Icon size={13} />
</span>
{label}
</button>
);
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
import { useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useSdk, useFeatureFlags } from "@lynkd/messaging-inbox-sdk";
import { useUi } from "@/lib/ui-state";
import { IconButton } from "@/components/ui/primitives";
import { Home, MessageSquare, Inbox as InboxIcon, Bell, LifeBuoy, MoreHorizontal, Plus } from "lucide-react";
import clsx from "clsx";
/** Slack-style far-left rail: workspace switcher + primary destinations. */
export function WorkspaceRail() {
const { bootstrap, brandName } = useSdk();
const flags = useFeatureFlags();
const router = useRouter();
const pathname = usePathname();
const { setNotifOpen, openModal, toast } = useUi();
const workspaces = bootstrap?.workspaces ?? [];
const [activeWs, setActiveWs] = useState<string | undefined>(bootstrap?.workspace?.id);
const nav = [
{ icon: Home, label: "Home", href: "/", match: (p: string) => p === "/" || p.startsWith("/c/") },
flags.messaging && { icon: MessageSquare, label: "DMs", href: "/dms", match: (p: string) => p.startsWith("/dms") },
flags.inbox && { icon: InboxIcon, label: "Inbox", href: "/inbox", match: (p: string) => p.startsWith("/inbox") },
flags.support && { icon: LifeBuoy, label: "Support", href: "/support", match: (p: string) => p.startsWith("/support") },
].filter(Boolean) as { icon: typeof Home; label: string; href: string; match: (p: string) => boolean }[];
const active = activeWs ?? bootstrap?.workspace?.id;
return (
<nav className="hidden w-[68px] shrink-0 flex-col items-center gap-1 border-r border-border bg-bg py-3 md:flex">
{flags.workspaceSwitcher && (
<div className="flex flex-col items-center gap-2 pb-2">
{workspaces.map((w) => (
<button
key={w.id}
title={w.name}
onClick={() => {
setActiveWs(w.id);
if (w.id !== bootstrap?.workspace?.id) toast(`Switched to ${w.name} (demo)`, "success");
}}
className={clsx(
"focus-ring grid h-11 w-11 place-items-center rounded-[14px] text-sm font-bold transition-all",
w.id === active ? "text-white ring-2 ring-accent" : "text-ink/80 hover:rounded-[12px]",
)}
style={{ background: w.id === active ? "var(--brand-gradient)" : "var(--c-elevated)" }}
>
{w.initials}
</button>
))}
<IconButton label="Add workspace" onClick={() => openModal("create-workspace")}>
<Plus size={18} />
</IconButton>
<div className="my-1 h-px w-8 bg-border" />
</div>
)}
<div className="flex min-h-0 flex-1 flex-col items-center gap-1 overflow-y-auto no-scrollbar">
{nav.map((n) => {
const isActive = n.match(pathname);
return (
<button
key={n.href}
title={n.label}
onClick={() => router.push(n.href)}
className={clsx(
"focus-ring flex h-12 w-12 flex-col items-center justify-center gap-0.5 rounded-control text-[10px] font-medium transition-colors",
isActive ? "bg-accent-soft text-accent" : "text-muted hover:bg-hover hover:text-ink",
)}
>
<n.icon size={20} />
<span>{n.label}</span>
</button>
);
})}
</div>
<div className="flex flex-col items-center gap-1">
{flags.notifications && (
<IconButton label="Notifications" onClick={() => setNotifOpen(true)}>
<Bell size={20} />
</IconButton>
)}
<IconButton label="Help & more" onClick={() => toast("Help center — coming soon", "default")}>
<MoreHorizontal size={20} />
</IconButton>
</div>
<span className="sr-only">{brandName}</span>
</nav>
);
}
+160
View File
@@ -0,0 +1,160 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useSdk } from "@lynkd/messaging-inbox-sdk";
import { useUi } from "@/lib/ui-state";
import { Sparkles, X, Copy, Send, RefreshCw, MessageSquareReply, FileText, WandSparkles, Languages } from "lucide-react";
interface Turn {
role: "user" | "ai";
text: string;
error?: boolean;
}
const QUICK = [
{ label: "Summarize message", icon: FileText, prompt: "Summarize the message above in one or two sentences." },
{ label: "What should I reply?", icon: MessageSquareReply, prompt: "Suggest a thoughtful reply to the message above. Write it in first person, ready to send." },
{ label: "Improve my writing", icon: WandSparkles, prompt: "Rewrite the message above to be clearer and more professional, keeping the meaning." },
{ label: "Explain simply", icon: Languages, prompt: "Explain the message above in simple, plain language." },
];
export function AiPanel() {
const { aiPanel, closeAi, toast } = useUi();
const { client } = useSdk();
const [context, setContext] = useState<string>("");
const [turns, setTurns] = useState<Turn[]>([]);
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
// seed from the message that opened the panel
useEffect(() => {
if (!aiPanel) return;
setContext(aiPanel.context ?? "");
setTurns([]);
setInput("");
if (aiPanel.prompt) void ask(aiPanel.prompt, aiPanel.context ?? "");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [aiPanel]);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
}, [turns, busy]);
if (!aiPanel) return null;
async function ask(prompt: string, ctx: string) {
const p = prompt.trim();
if (!p || busy) return;
setInput("");
setTurns((t) => [...t, { role: "user", text: p }]);
setBusy(true);
try {
const res = await client.ai(p, ctx || undefined);
if (res.ok && res.text) {
setTurns((t) => [...t, { role: "ai", text: res.text as string }]);
} else {
setTurns((t) => [...t, { role: "ai", text: res.error || "The AI couldn't respond.", error: true }]);
}
} catch (e) {
setTurns((t) => [...t, { role: "ai", text: `Request failed: ${(e as Error).message}`, error: true }]);
} finally {
setBusy(false);
}
}
const copy = async (text: string) => {
try { await navigator.clipboard?.writeText(text); toast("Copied to clipboard", "success"); }
catch { toast("Couldn't access the clipboard", "danger"); }
};
return (
<div className="fixed inset-0 z-40 flex justify-end bg-black/40 animate-fade-in" onClick={closeAi}>
<div role="dialog" aria-modal="true" aria-label="AI assistant" className="flex h-full w-[min(420px,100vw)] flex-col border-l border-border bg-surface shadow-pop" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<div className="flex items-center gap-2">
<span className="grid h-7 w-7 place-items-center rounded-control bg-accent-soft text-accent"><Sparkles size={16} /></span>
<div>
<div className="text-[14px] font-bold leading-none">AI Assistant</div>
<div className="text-[11px] text-dim">Powered by Gemini</div>
</div>
</div>
<button onClick={closeAi} className="text-muted hover:text-ink"><X size={18} /></button>
</div>
<div ref={scrollRef} className="min-h-0 flex-1 space-y-3 overflow-y-auto p-4">
{context && (
<div className="rounded-control border border-border bg-panel p-3">
<div className="mb-1 text-[11px] font-bold uppercase tracking-wide text-dim">Message context</div>
<div className="max-h-32 overflow-y-auto whitespace-pre-wrap text-[13px] text-ink/80">{context}</div>
</div>
)}
{turns.length === 0 && !busy && (
<div className="pt-2 text-center text-[13px] text-muted">
{context ? "Pick a quick action below, or ask anything about this message." : "Ask me anything — summaries, replies, rewrites, and more."}
</div>
)}
{turns.map((t, i) => (
<div key={i} className={t.role === "user" ? "flex justify-end" : "flex justify-start"}>
<div className={
t.role === "user"
? "max-w-[85%] rounded-2xl rounded-br-sm bg-accent px-3 py-2 text-[13.5px] text-accent-fg"
: `max-w-[90%] rounded-2xl rounded-bl-sm border px-3 py-2 text-[13.5px] ${t.error ? "border-danger/40 bg-danger/10 text-danger" : "border-border bg-panel text-ink/90"}`
}>
<div className="whitespace-pre-wrap break-words">{t.text}</div>
{t.role === "ai" && !t.error && (
<button onClick={() => copy(t.text)} className="mt-1.5 flex items-center gap-1 text-[11px] font-semibold text-muted hover:text-ink">
<Copy size={11} /> Copy
</button>
)}
</div>
</div>
))}
{busy && (
<div className="flex items-center gap-2 text-[12.5px] text-muted">
<RefreshCw size={13} className="animate-spin" /> Thinking
</div>
)}
</div>
{/* quick actions */}
<div className="flex flex-wrap gap-1.5 border-t border-border px-3 pt-2.5">
{QUICK.map((a) => (
<button
key={a.label}
disabled={busy}
onClick={() => ask(a.prompt, context)}
className="inline-flex items-center gap-1.5 rounded-pill border border-border bg-panel px-2.5 py-1 text-[12px] font-semibold hover:bg-hover disabled:opacity-40"
>
<a.icon size={12} /> {a.label}
</button>
))}
</div>
<div className="p-3">
<div className="flex items-end gap-2 rounded-control border border-border bg-panel p-1.5 focus-within:border-border-strong">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); ask(input, context); } }}
rows={1}
placeholder="Ask the AI…"
className="max-h-28 min-h-[32px] w-full resize-none bg-transparent px-2 py-1.5 text-[13.5px] outline-none placeholder:text-dim"
/>
<button
disabled={busy || !input.trim()}
onClick={() => ask(input, context)}
className="grid h-8 w-8 shrink-0 place-items-center rounded-control bg-accent text-accent-fg disabled:opacity-40"
title="Send"
>
<Send size={15} />
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,98 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useSearch, type SearchResult } from "@lynkd/messaging-inbox-sdk";
import { useUi } from "@/lib/ui-state";
import { Search, Hash, User, MessageSquare, FileText, LifeBuoy, CornerDownLeft, Inbox } from "lucide-react";
import clsx from "clsx";
const icons: Record<SearchResult["type"], typeof Hash> = {
channel: Hash, person: User, message: MessageSquare, file: FileText, ticket: LifeBuoy,
};
type Item = { key: string; icon: typeof Hash; title: string; subtitle?: string; run: () => void };
export function CommandPalette() {
const { paletteOpen, setPaletteOpen, openProfile, openThread } = useUi();
const { query, setQuery, results, loading } = useSearch();
const router = useRouter();
const [active, setActive] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
const go = (r: SearchResult) => {
setPaletteOpen(false);
if (r.type === "person") openProfile(r.id);
else if (r.type === "ticket") router.push(`/support/${r.id}`);
else if (r.type === "message" && r.channelId) {
// a reply lives inside a thread (not the main list) → open the thread;
// otherwise deep-link so the channel view scrolls to + flashes it
if (r.parentId) { router.push(`/c/${r.channelId}`); openThread(r.channelId, r.parentId); }
else router.push(`/c/${r.channelId}?msg=${r.id}`);
}
else if (r.channelId) router.push(`/c/${r.channelId}`);
};
const items: Item[] = useMemo(() => {
if (!query) {
return [
{ key: "q1", icon: Hash, title: "Jump to #general", run: () => { setPaletteOpen(false); router.push("/c/c_general"); } },
{ key: "q2", icon: Inbox, title: "Open Inbox", run: () => { setPaletteOpen(false); router.push("/inbox"); } },
{ key: "q3", icon: LifeBuoy, title: "Support Center", run: () => { setPaletteOpen(false); router.push("/support"); } },
];
}
if (loading) return [];
return results.map((r) => ({ key: `${r.type}-${r.id}`, icon: icons[r.type], title: r.title, subtitle: r.subtitle, run: () => go(r) }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, loading, results]);
useEffect(() => { setActive(0); }, [query, results]);
if (!paletteOpen) return null;
const onKey = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(a + 1, items.length - 1)); }
else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); }
else if (e.key === "Enter") { e.preventDefault(); items[active]?.run(); }
};
return (
<div className="fixed inset-0 z-[60] flex items-start justify-center bg-black/50 p-4 pt-[12vh] backdrop-blur-sm animate-fade-in" onClick={() => setPaletteOpen(false)}>
<div role="dialog" aria-modal="true" aria-label="Search" className="w-full max-w-xl overflow-hidden rounded-card border border-border bg-elevated shadow-pop" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-3 border-b border-border px-4">
<Search size={18} className="text-muted" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKey}
placeholder="Search messages, channels, people, tickets…"
className="w-full bg-transparent py-4 text-[15px] outline-none placeholder:text-dim"
/>
<kbd className="rounded bg-hover px-1.5 py-0.5 font-mono text-[10px] text-dim">esc</kbd>
</div>
<div ref={listRef} className="max-h-[52vh] overflow-y-auto p-2">
{!query && <div className="px-2 py-1 text-[11px] font-bold uppercase text-dim">Quick actions</div>}
{query && loading && <div className="px-3 py-6 text-center text-[13px] text-muted">Searching</div>}
{query && !loading && items.length === 0 && <div className="px-3 py-6 text-center text-[13px] text-muted">No results for {query}</div>}
{items.map((it, i) => (
<button
key={it.key}
onMouseEnter={() => setActive(i)}
onClick={it.run}
className={clsx("flex w-full items-center gap-3 rounded-control px-3 py-2 text-left", i === active ? "bg-hover" : "hover:bg-hover")}
>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-control bg-hover text-muted"><it.icon size={16} /></span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[14px] text-ink">{it.title}</span>
{it.subtitle && <span className="block truncate text-[12px] text-muted">{it.subtitle}</span>}
</span>
{i === active && <CornerDownLeft size={14} className="shrink-0 text-dim" />}
</button>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,74 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useSdk, useChannel } from "@lynkd/messaging-inbox-sdk";
import { useUi } from "@/lib/ui-state";
import { Avatar } from "@/components/ui/primitives";
import { Mic, MicOff, Video, VideoOff, MonitorUp, PhoneOff } from "lucide-react";
import clsx from "clsx";
export function HuddleBar() {
const { huddleChannelId, endHuddle } = useUi();
const channel = useChannel(huddleChannelId ?? null);
const { userById } = useSdk();
const [muted, setMuted] = useState(false);
const [video, setVideo] = useState(false);
const [sharing, setSharing] = useState(false);
const [seconds, setSeconds] = useState(0);
const startRef = useRef(0);
useEffect(() => {
if (!huddleChannelId) return;
setSeconds(0);
setMuted(false);
setVideo(false);
setSharing(false);
startRef.current = Date.now();
const t = setInterval(() => setSeconds(Math.floor((Date.now() - startRef.current) / 1000)), 1000);
return () => clearInterval(t);
}, [huddleChannelId]);
if (!huddleChannelId || !channel) return null;
const members = channel.memberIds.slice(0, 4).map((id) => userById(id)).filter(Boolean);
const total = channel.memberIds.length;
const mmss = `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`;
return (
<div className="fixed bottom-4 left-1/2 z-50 w-[min(440px,92vw)] -translate-x-1/2 rounded-card border border-border bg-elevated p-3 shadow-pop animate-slide-up">
<div className="flex items-center gap-3">
<span className="relative flex h-9 w-9 items-center justify-center rounded-control bg-success/20 text-success">
<Video size={18} />
<span className="absolute inset-0 animate-ping rounded-control border border-success/40" />
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[13.5px] font-bold">Huddle · {channel.kind === "channel" ? `#${channel.name}` : channel.name}</div>
<div className="text-[12px] text-muted">{total} {total === 1 ? "person" : "people"} · {mmss}</div>
</div>
<div className="flex -space-x-2">
{members.map((u) => (u ? <Avatar key={u.id} user={u} size={26} showPresence={false} /> : null))}
</div>
</div>
<div className="mt-3 flex items-center justify-center gap-2">
<HuddleBtn icon={muted ? MicOff : Mic} label={muted ? "Unmute" : "Mute"} active={muted} onClick={() => setMuted((m) => !m)} />
<HuddleBtn icon={video ? Video : VideoOff} label={video ? "Stop video" : "Start video"} active={video} onClick={() => setVideo((v) => !v)} />
<HuddleBtn icon={MonitorUp} label={sharing ? "Stop sharing" : "Share screen"} active={sharing} onClick={() => setSharing((s) => !s)} />
<button onClick={endHuddle} className="focus-ring flex items-center gap-2 rounded-control bg-danger px-4 py-2 text-[13px] font-semibold text-white">
<PhoneOff size={15} /> Leave
</button>
</div>
</div>
);
}
function HuddleBtn({ icon: Icon, label, onClick, active }: { icon: typeof Mic; label: string; onClick: () => void; active?: boolean }) {
return (
<button
title={label}
onClick={onClick}
className={clsx("focus-ring grid h-9 w-11 place-items-center rounded-control border transition-colors", active ? "border-accent bg-accent-soft text-accent" : "border-border bg-panel text-ink hover:bg-hover")}
>
<Icon size={16} />
</button>
);
}
+433
View File
@@ -0,0 +1,433 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useSdk, useChannel } from "@lynkd/messaging-inbox-sdk";
import { useUi } from "@/lib/ui-state";
import { Modal, Field, inputCls, PrimaryBtn, GhostBtn } from "@/components/ui/Modal";
import { Avatar } from "@/components/ui/primitives";
import { EmojiPicker } from "@/components/message/EmojiPicker";
import { Hash, Lock, Smile, Upload, Trash2 } from "lucide-react";
import clsx from "clsx";
const AVATAR_COLORS = ["#FDA913", "#5b9dff", "#3ecf8e", "#bb98ff", "#f2555a", "#ff7a3d", "#54e7ff", "#f5b544", "#8c8c94"];
export function Modals() {
const { modal } = useUi();
if (!modal) return null;
return (
<>
{modal === "create-channel" && <CreateChannelModal />}
{modal === "new-message" && <NewMessageModal />}
{modal === "invite" && <InviteModal />}
{modal === "create-workspace" && <CreateWorkspaceModal />}
{modal === "callback" && <CallbackModal />}
{modal === "channel-details" && <ChannelDetailsModal />}
{modal === "channel-members" && <ChannelMembersModal />}
{modal === "set-status" && <StatusModal />}
{modal === "edit-profile" && <EditProfileModal />}
</>
);
}
function ChannelMembersModal() {
const { modalArg, closeModal, toast } = useUi();
const { channelById, users, client, refresh, me } = useSdk();
const channel = useChannel(modalArg ?? null);
const [q, setQ] = useState("");
const [busy, setBusy] = useState(false);
if (!channel) return <Modal title="Members" onClose={closeModal}><p className="text-[13px] text-muted">No channel selected.</p></Modal>;
const memberIds = channelById(channel.id)?.memberIds ?? channel.memberIds;
const members = memberIds.map((id) => users.find((u) => u.id === id)).filter(Boolean) as typeof users;
const candidates = users.filter((u) => !memberIds.includes(u.id) && (u.name.toLowerCase().includes(q.toLowerCase()) || !q));
const add = async (userId: string, name: string) => { setBusy(true); await client.addMember(channel.id, userId); refresh(); setBusy(false); toast(`Added ${name} to #${channel.name}`, "success"); };
const remove = async (userId: string, name: string) => { setBusy(true); await client.removeMember(channel.id, userId); refresh(); setBusy(false); toast(`Removed ${name}`, "default"); };
return (
<Modal title={`Members of ${channel.kind === "channel" ? "#" + channel.name : channel.name}`} subtitle={`${members.length} member${members.length === 1 ? "" : "s"}`} onClose={closeModal} wide>
<div className="mb-4">
<div className="mb-2 text-[12px] font-bold uppercase text-dim">In this channel</div>
<div className="max-h-56 space-y-1 overflow-y-auto">
{members.map((u) => (
<div key={u.id} className="flex items-center gap-3 rounded-control px-2 py-1.5 hover:bg-hover">
<Avatar user={u} size={30} />
<div className="min-w-0 flex-1"><div className="truncate text-[13.5px] font-medium">{u.name}{u.id === me?.id ? " (you)" : ""}</div><div className="truncate text-[11.5px] text-muted">{u.title}</div></div>
{u.id !== me?.id && <button disabled={busy} onClick={() => remove(u.id, u.name)} className="rounded-control px-2 py-1 text-[12px] font-semibold text-danger hover:bg-hover disabled:opacity-40">Remove</button>}
</div>
))}
</div>
</div>
<div>
<div className="mb-2 text-[12px] font-bold uppercase text-dim">Add people</div>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people to add…" className={inputCls} />
<div className="mt-2 max-h-52 space-y-1 overflow-y-auto">
{candidates.map((u) => (
<div key={u.id} className="flex items-center gap-3 rounded-control px-2 py-1.5 hover:bg-hover">
<Avatar user={u} size={30} />
<div className="min-w-0 flex-1"><div className="truncate text-[13.5px] font-medium">{u.name}</div><div className="truncate text-[11.5px] text-muted">{u.title}</div></div>
<button disabled={busy} onClick={() => add(u.id, u.name)} className="rounded-control border border-border px-2.5 py-1 text-[12px] font-semibold hover:bg-hover disabled:opacity-40">Add</button>
</div>
))}
{candidates.length === 0 && <div className="py-4 text-center text-[12.5px] text-muted">Everyone's already here.</div>}
</div>
</div>
</Modal>
);
}
const CLEAR_OPTIONS: { label: string; ms: number | null }[] = [
{ label: "Don't clear", ms: null },
{ label: "30 minutes", ms: 30 * 60_000 },
{ label: "1 hour", ms: 60 * 60_000 },
{ label: "4 hours", ms: 4 * 60 * 60_000 },
{ label: "Today", ms: -1 }, // end of today
];
function StatusModal() {
const { closeModal, toast } = useUi();
const { me, client, refresh } = useSdk();
const [emoji, setEmoji] = useState(me?.statusEmoji ?? "");
const [text, setText] = useState(me?.statusText ?? "");
const [pickerOpen, setPickerOpen] = useState(false);
const [clearIdx, setClearIdx] = useState(0);
const [customAt, setCustomAt] = useState("");
const presets = [
{ e: "🏗", t: "On site" }, { e: "📅", t: "In a meeting" }, { e: "🎧", t: "Focusing" },
{ e: "🌴", t: "On vacation" }, { e: "🤒", t: "Out sick" }, { e: "🏠", t: "Working remotely" },
{ e: "🍽", t: "Out for lunch" }, { e: "🚗", t: "Commuting" },
];
const computeClearAt = (): number | undefined => {
if (customAt) { const t = Date.parse(customAt); return Number.isNaN(t) ? undefined : t; }
const opt = CLEAR_OPTIONS[clearIdx];
if (opt.ms === null) return undefined;
if (opt.ms === -1) { const d = new Date(); d.setHours(23, 59, 0, 0); return d.getTime(); }
return Date.now() + opt.ms;
};
const save = async () => { await client.setStatus(text.trim() || undefined, emoji || undefined, computeClearAt()); refresh(); closeModal(); toast("Status updated", "success"); };
const clear = async () => { await client.setStatus(undefined, undefined); refresh(); closeModal(); toast("Status cleared", "default"); };
return (
<Modal title="Set a status" onClose={closeModal} footer={<><GhostBtn onClick={clear}>Clear</GhostBtn><PrimaryBtn onClick={save}>Save</PrimaryBtn></>}>
<div className="relative mb-3 flex items-center gap-2 rounded-control border border-border bg-panel pl-1.5 pr-3">
<button onClick={() => setPickerOpen((o) => !o)} className="grid h-9 w-9 place-items-center rounded-control text-lg hover:bg-hover" title="Pick an emoji">
{emoji || <Smile size={18} className="text-muted" />}
</button>
{pickerOpen && <EmojiPicker onPick={(e) => { setEmoji(e); setPickerOpen(false); }} onClose={() => setPickerOpen(false)} />}
<input autoFocus value={text} onChange={(e) => setText(e.target.value)} placeholder="What's your status?" className="w-full bg-transparent py-2 text-[14px] outline-none placeholder:text-dim" onKeyDown={(e) => e.key === "Enter" && save()} />
</div>
<div className="mb-4 grid grid-cols-2 gap-2">
{presets.map((p) => (
<button key={p.t} onClick={() => { setEmoji(p.e); setText(p.t); }} className="flex items-center gap-2 rounded-control border border-border px-3 py-2 text-left text-[13px] hover:bg-hover">
<span className="text-base">{p.e}</span> {p.t}
</button>
))}
</div>
<Field label="Clear after">
<div className="flex flex-wrap gap-1.5">
{CLEAR_OPTIONS.map((o, i) => (
<button key={o.label} onClick={() => { setClearIdx(i); setCustomAt(""); }} className={clsx("rounded-pill border px-2.5 py-1 text-[12px] font-medium", !customAt && clearIdx === i ? "border-accent text-accent" : "border-border text-muted hover:text-ink")}>{o.label}</button>
))}
</div>
<input type="datetime-local" value={customAt} onChange={(e) => setCustomAt(e.target.value)} className={clsx(inputCls, "mt-2")} title="Custom clear time" />
</Field>
</Modal>
);
}
function EditProfileModal() {
const { closeModal, toast } = useUi();
const { me, client, refresh } = useSdk();
const fileRef = useRef<HTMLInputElement>(null);
const [name, setName] = useState(me?.name ?? "");
const [title, setTitle] = useState(me?.title ?? "");
const [color, setColor] = useState(me?.avatarColor ?? AVATAR_COLORS[0]);
const [avatarUrl, setAvatarUrl] = useState<string | null>(me?.avatarUrl ?? null);
const onFile = (f: File | undefined) => {
if (!f) return;
const reader = new FileReader();
reader.onload = () => setAvatarUrl(String(reader.result));
reader.readAsDataURL(f);
};
const save = async () => {
await client.updateMe({ name: name.trim() || undefined, title: title.trim() || undefined, avatarColor: color, avatarUrl: avatarUrl });
refresh();
closeModal();
toast("Profile updated", "success");
};
const preview = { avatarText: (name || "?").slice(0, 2).toUpperCase(), avatarColor: color, presence: (me?.presence ?? "active") as any, name, avatarUrl: avatarUrl ?? undefined };
return (
<Modal title="Edit profile" onClose={closeModal} footer={<><GhostBtn onClick={closeModal}>Cancel</GhostBtn><PrimaryBtn onClick={save}>Save changes</PrimaryBtn></>}>
<div className="mb-4 flex items-center gap-4">
<Avatar user={preview} size={72} showPresence={false} rounded="20px" />
<div className="flex flex-col gap-2">
<button onClick={() => fileRef.current?.click()} className="focus-ring flex items-center gap-2 rounded-control border border-border px-3 py-1.5 text-[13px] font-semibold hover:bg-hover"><Upload size={14} /> Upload image</button>
{avatarUrl && <button onClick={() => setAvatarUrl(null)} className="flex items-center gap-1.5 text-[12px] text-danger hover:underline"><Trash2 size={12} /> Remove image</button>}
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={(e) => { onFile(e.target.files?.[0]); e.target.value = ""; }} />
</div>
</div>
{!avatarUrl && (
<Field label="Avatar color">
<div className="flex flex-wrap gap-2">
{AVATAR_COLORS.map((c) => (
<button key={c} onClick={() => setColor(c)} className="h-8 w-8 rounded-full ring-2 ring-transparent transition-all hover:scale-110" style={{ background: c, boxShadow: color === c ? "0 0 0 2px var(--c-bg), 0 0 0 4px " + c : undefined }} />
))}
</div>
</Field>
)}
<Field label="Display name"><input value={name} onChange={(e) => setName(e.target.value)} className={inputCls} /></Field>
<Field label="Title"><input value={title} onChange={(e) => setTitle(e.target.value)} className={inputCls} /></Field>
</Modal>
);
}
function CreateChannelModal() {
const { client, refresh, users, me } = useSdk();
const { closeModal, toast } = useUi();
const router = useRouter();
const [name, setName] = useState("");
const [topic, setTopic] = useState("");
const [priv, setPriv] = useState(false);
const [busy, setBusy] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const [q, setQ] = useState("");
const others = users.filter((u) => u.id !== me?.id);
const candidates = others.filter((u) => u.name.toLowerCase().includes(q.toLowerCase()) || u.handle.toLowerCase().includes(q.toLowerCase()));
const toggle = (id: string) => setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
async function create() {
const clean = name.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
if (!clean) return;
setBusy(true);
// Public channels include the whole team automatically; private channels
// include only the members you explicitly select.
const memberIds = priv ? selected : others.map((u) => u.id);
const { channel } = await client.createChannel({ name: clean, topic: topic.trim() || undefined, kind: priv ? "private" : "channel", memberIds });
refresh();
closeModal();
toast(`Created #${channel.name}`, "success");
router.push(`/c/${channel.id}`);
}
return (
<Modal
title="Create a channel"
subtitle="Channels are where your team communicates."
onClose={closeModal}
footer={<><GhostBtn onClick={closeModal}>Cancel</GhostBtn><PrimaryBtn onClick={create} disabled={!name.trim() || busy || (priv && selected.length === 0)}>Create channel</PrimaryBtn></>}
>
<Field label="Name">
<div className="flex items-center gap-2 rounded-control border border-border bg-panel px-3">
{priv ? <Lock size={15} className="text-muted" /> : <Hash size={15} className="text-muted" />}
<input autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. marketing" className="w-full bg-transparent py-2 text-[14px] outline-none placeholder:text-dim" onKeyDown={(e) => e.key === "Enter" && create()} />
</div>
</Field>
<Field label="Description (optional)">
<input value={topic} onChange={(e) => setTopic(e.target.value)} placeholder="What's this channel about?" className={inputCls} />
</Field>
<label className="flex items-center gap-2.5 rounded-control border border-border bg-panel px-3 py-2.5 text-[13px]">
<input type="checkbox" checked={priv} onChange={(e) => setPriv(e.target.checked)} className="accent-[var(--c-accent)]" />
<span><b>Make private</b> — only invited members can view</span>
</label>
{priv ? (
<div className="mt-3">
<div className="mb-1.5 text-[12px] font-bold uppercase text-dim">Add members · {selected.length} selected</div>
{selected.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{selected.map((id) => {
const u = users.find((x) => x.id === id);
if (!u) return null;
return (
<span key={id} className="inline-flex items-center gap-1.5 rounded-pill bg-accent-soft py-1 pl-1.5 pr-2 text-[12px] font-medium text-accent">
<Avatar user={u} size={16} showPresence={false} /> {u.name}
<button onClick={() => toggle(id)} className="text-accent/70 hover:text-accent">✕</button>
</span>
);
})}
</div>
)}
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people to add" className={inputCls} />
<div className="mt-2 max-h-44 space-y-1 overflow-y-auto">
{candidates.map((u) => (
<button key={u.id} onClick={() => toggle(u.id)} className="flex w-full items-center gap-3 rounded-control px-2 py-1.5 text-left hover:bg-hover">
<input type="checkbox" readOnly checked={selected.includes(u.id)} className="accent-[var(--c-accent)]" />
<Avatar user={u} size={28} />
<div className="min-w-0 flex-1"><div className="truncate text-[13.5px] font-medium">{u.name}</div><div className="truncate text-[11.5px] text-muted">{u.title}</div></div>
</button>
))}
{candidates.length === 0 && <div className="py-4 text-center text-[12.5px] text-muted">No people match “{q}”.</div>}
</div>
</div>
) : (
<p className="mt-3 flex items-center gap-2 rounded-control border border-border bg-panel px-3 py-2 text-[12.5px] text-muted">
<Hash size={14} className="text-accent" /> Everyone on the team ({others.length}) will be added automatically.
</p>
)}
</Modal>
);
}
function NewMessageModal() {
const { users, me, channels, client, refresh } = useSdk();
const { closeModal, toast } = useUi();
const router = useRouter();
const [q, setQ] = useState("");
const people = users.filter((u) => u.id !== me?.id && u.name.toLowerCase().includes(q.toLowerCase()));
async function openDm(userId: string, name: string) {
const existing = channels.find((c) => c.kind === "dm" && c.memberIds.includes(userId));
closeModal();
if (existing) { router.push(`/c/${existing.id}`); return; }
const { channel } = await client.createChannel({ name, kind: "dm", memberIds: [userId] });
refresh();
toast(`Started a conversation with ${name}`, "success");
router.push(`/c/${channel.id}`);
}
return (
<Modal title="New message" subtitle="Start a direct message" onClose={closeModal}>
<input autoFocus value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people" className={inputCls} />
<div className="mt-3 max-h-72 space-y-1 overflow-y-auto">
{people.map((u) => (
<button key={u.id} onClick={() => openDm(u.id, u.name)} className="flex w-full items-center gap-3 rounded-control px-2 py-2 text-left hover:bg-hover">
<Avatar user={u} size={34} />
<div className="min-w-0">
<div className="truncate text-[14px] font-medium">{u.name}</div>
<div className="truncate text-[12px] text-muted">{u.title}</div>
</div>
</button>
))}
{people.length === 0 && <div className="py-6 text-center text-[13px] text-muted">No people match “{q}”.</div>}
</div>
</Modal>
);
}
function InviteModal() {
const { closeModal, toast } = useUi();
const [chips, setChips] = useState<string[]>([]);
const [draft, setDraft] = useState("");
const isEmail = (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
const commit = (raw: string) => {
const v = raw.trim().replace(/,$/, "").trim();
if (!v) return;
if (!isEmail(v)) { toast(`“${v}” doesn't look like an email`, "default"); return; }
setChips((c) => (c.includes(v) ? c : [...c, v]));
setDraft("");
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "," || e.key === "Enter" || e.key === " ") { e.preventDefault(); commit(draft); }
else if (e.key === "Backspace" && !draft && chips.length) { setChips((c) => c.slice(0, -1)); }
};
const total = chips.length + (isEmail(draft.trim()) ? 1 : 0);
return (
<Modal
title="Invite people"
subtitle="Invite teammates to LynkedUp Pro"
onClose={closeModal}
footer={<><GhostBtn onClick={closeModal}>Cancel</GhostBtn><PrimaryBtn disabled={total === 0} onClick={() => { const all = [...chips, ...(isEmail(draft.trim()) ? [draft.trim()] : [])]; closeModal(); toast(`${all.length} invitation${all.length === 1 ? "" : "s"} sent (demo)`, "success"); }}>Send invites</PrimaryBtn></>}
>
<Field label="Email addresses">
<div className="flex flex-wrap items-center gap-1.5 rounded-control border border-border bg-panel px-2 py-2">
{chips.map((email) => (
<span key={email} className="inline-flex items-center gap-1.5 rounded-pill bg-accent-soft py-1 pl-2.5 pr-1.5 text-[12.5px] font-medium text-accent">
{email}
<button onClick={() => setChips((c) => c.filter((x) => x !== email))} className="grid h-4 w-4 place-items-center rounded-full text-accent/70 hover:bg-accent/20 hover:text-accent">✕</button>
</span>
))}
<input
autoFocus
value={draft}
onChange={(e) => { const v = e.target.value; if (v.endsWith(",")) commit(v); else setDraft(v); }}
onKeyDown={onKeyDown}
onBlur={() => commit(draft)}
placeholder={chips.length ? "" : "name@company.com, another@company.com"}
className="min-w-[160px] flex-1 bg-transparent py-1 text-[14px] outline-none placeholder:text-dim"
/>
</div>
</Field>
<p className="text-[12px] text-muted">Type an email and press <b>,</b> or <b>Enter</b> to add it as a chip.</p>
</Modal>
);
}
function CreateWorkspaceModal() {
const { closeModal, toast } = useUi();
const [name, setName] = useState("");
return (
<Modal
title="Create a workspace"
subtitle="Spin up a new workspace"
onClose={closeModal}
footer={<><GhostBtn onClick={closeModal}>Cancel</GhostBtn><PrimaryBtn disabled={!name.trim()} onClick={() => { closeModal(); toast(`Workspace “${name}” created (demo)`, "success"); }}>Create</PrimaryBtn></>}
>
<Field label="Workspace name"><input autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Acme Field Ops" className={inputCls} /></Field>
</Modal>
);
}
function CallbackModal() {
const { closeModal, toast } = useUi();
const [channel, setChannel] = useState("PHONE");
const [time, setTime] = useState("");
return (
<Modal
title="Request a callback"
subtitle="We'll schedule a call with the customer"
onClose={closeModal}
footer={<><GhostBtn onClick={closeModal}>Cancel</GhostBtn><PrimaryBtn onClick={() => { closeModal(); toast("Callback requested scheduled follow-up created", "success"); }}>Request callback</PrimaryBtn></>}
>
<Field label="Preferred channel">
<div className="grid grid-cols-3 gap-2">
{["PHONE", "ZOOM", "IN_APP"].map((c) => (
<button key={c} onClick={() => setChannel(c)} className={clsx("rounded-control border py-2 text-[12.5px] font-semibold", channel === c ? "border-accent bg-accent-soft text-accent" : "border-border hover:bg-hover")}>
{c.replace("_", "-")}
</button>
))}
</div>
</Field>
<Field label="Preferred time (optional)"><input value={time} onChange={(e) => setTime(e.target.value)} placeholder="e.g. Tomorrow 24pm CT" className={inputCls} /></Field>
</Modal>
);
}
function ChannelDetailsModal() {
const { modalArg, closeModal, openModal } = useUi();
const { userById } = useSdk();
const channel = useChannel(modalArg ?? null);
if (!channel) { return <Modal title="Details" onClose={closeModal}><p className="text-[13px] text-muted">No channel selected.</p></Modal>; }
return (
<Modal title={channel.kind === "channel" ? `#${channel.name}` : channel.name} subtitle={channel.topic} onClose={closeModal} wide>
<div className="space-y-4">
<div className="rounded-control border border-border bg-panel p-3">
<div className="text-[11px] font-semibold uppercase text-dim">Topic</div>
<div className="mt-1 text-[13.5px]">{channel.topic || "No topic set"}</div>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<span className="text-[12px] font-semibold uppercase text-dim">Members · {channel.memberIds.length}</span>
<button onClick={() => openModal("channel-members", channel.id)} className="focus-ring rounded-control border border-border px-2 py-1 text-[12px] font-semibold hover:bg-hover">+ Add people</button>
</div>
<div className="grid grid-cols-2 gap-2">
{channel.memberIds.map((id) => {
const u = userById(id);
return u ? (
<div key={id} className="flex items-center gap-2 rounded-control border border-border bg-panel px-2.5 py-2">
<Avatar user={u} size={28} />
<div className="min-w-0"><div className="truncate text-[13px] font-medium">{u.name}</div><div className="truncate text-[11px] text-muted">{u.title}</div></div>
</div>
) : null;
})}
</div>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,58 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useNotifications } from "@lynkd/messaging-inbox-sdk";
import { useUi } from "@/lib/ui-state";
import { relativeTime } from "@/lib/format";
import { AtSign, Heart, LifeBuoy, Bell, MessageSquare, X, CheckCheck } from "lucide-react";
const kindIcon: Record<string, typeof Bell> = {
MENTION: AtSign, REACTION: Heart, SUPPORT_UPDATE: LifeBuoy, THREAD_REPLY: MessageSquare, SYSTEM: Bell,
};
export function NotificationsPanel() {
const { notifOpen, setNotifOpen } = useUi();
const { notifications } = useNotifications();
const router = useRouter();
const [readIds, setReadIds] = useState<Set<string>>(new Set());
if (!notifOpen) return null;
const isRead = (id: string, base?: boolean) => base || readIds.has(id);
const markAll = () => setReadIds(new Set(notifications.map((n) => n.id)));
return (
<div className="fixed inset-0 z-40" onClick={() => setNotifOpen(false)}>
<div className="absolute right-3 top-[58px] w-[min(360px,calc(100vw-1.5rem))] overflow-hidden rounded-card border border-border bg-elevated shadow-pop animate-slide-up" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<span className="text-[15px] font-bold">Notifications</span>
<div className="flex items-center gap-1">
<button onClick={markAll} title="Mark all as read" className="flex items-center gap-1 rounded-control px-2 py-1 text-[11.5px] font-semibold text-muted hover:bg-hover hover:text-ink"><CheckCheck size={14} /> Mark all read</button>
<button onClick={() => setNotifOpen(false)} className="text-muted hover:text-ink"><X size={16} /></button>
</div>
</div>
<div className="max-h-[60vh] overflow-y-auto">
{notifications.map((n) => {
const Icon = kindIcon[n.kind] ?? Bell;
const read = isRead(n.id, n.read);
return (
<button
key={n.id}
onClick={() => { setReadIds((s) => new Set(s).add(n.id)); setNotifOpen(false); if (n.channelId) router.push(`/c/${n.channelId}${n.messageId ? `?msg=${n.messageId}` : ""}`); else router.push("/inbox"); }}
className="flex w-full items-start gap-3 border-b border-border px-4 py-3 text-left hover:bg-hover"
>
<span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-control bg-accent-soft text-accent"><Icon size={15} /></span>
<span className="min-w-0 flex-1">
<span className="text-[13px]"><b className="text-ink">{n.title}</b> <span className="text-muted">{n.body}</span></span>
<span className="mt-0.5 block text-[11px] text-dim">{relativeTime(n.ts)}</span>
</span>
{!read && <span className="mt-1 h-2 w-2 shrink-0 rounded-full bg-accent" />}
</button>
);
})}
</div>
<button onClick={() => { setNotifOpen(false); router.push("/inbox"); }} className="w-full py-2.5 text-center text-[13px] font-semibold text-accent hover:bg-hover">Open Inbox </button>
</div>
</div>
);
}
@@ -0,0 +1,109 @@
"use client";
import { useRouter } from "next/navigation";
import { useSdk } from "@lynkd/messaging-inbox-sdk";
import { useUi } from "@/lib/ui-state";
import { Avatar, Chip } from "@/components/ui/primitives";
import { X, MessageSquare, Phone, Clock, Mail, AtSign, Smile, Pencil } from "lucide-react";
export function ProfileDrawer() {
const { profileUserId, closeProfile, startHuddle, toast, openModal } = useUi();
const { userById, me, channels, client, refresh } = useSdk();
const router = useRouter();
if (!profileUserId) return null;
const user = userById(profileUserId);
if (!user) return null;
const isMe = user.id === me?.id;
async function openDm() {
if (!user) return;
const existing = channels.find((c) => c.kind === "dm" && c.memberIds.includes(user.id));
closeProfile();
if (existing) { router.push(`/c/${existing.id}`); return; }
const { channel } = await client.createChannel({ name: user.name, kind: "dm", memberIds: [user.id] });
refresh();
router.push(`/c/${channel.id}`);
}
async function huddle() {
if (!user) return;
const existing = channels.find((c) => c.kind === "dm" && c.memberIds.includes(user.id));
closeProfile();
if (existing) startHuddle(existing.id);
else toast(`Starting a huddle with ${user.name}`, "success");
}
return (
<div className="fixed inset-0 z-40 flex justify-end bg-black/40 animate-fade-in" onClick={closeProfile}>
<div role="dialog" aria-modal="true" aria-label="Profile" className="flex h-full w-[min(380px,100vw)] flex-col border-l border-border bg-surface shadow-pop" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<span className="text-[15px] font-bold">Profile</span>
<button onClick={closeProfile} className="text-muted hover:text-ink"><X size={18} /></button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-5">
<div className="flex flex-col items-center text-center">
<Avatar user={user} size={96} showPresence={false} rounded="24px" />
<h2 className="mt-3 text-[20px] font-black">{user.name}</h2>
<div className="text-[13px] text-muted">{user.title}</div>
{user.statusText && <div className="mt-2 rounded-pill bg-hover px-3 py-1 text-[12.5px]">{user.statusEmoji} {user.statusText}</div>}
<div className="mt-2"><Chip tone={user.presence === "active" ? "success" : user.presence === "dnd" ? "danger" : "neutral"}>{user.presence}</Chip></div>
</div>
{!isMe && (
<div className="mt-5 grid grid-cols-2 gap-2">
<button onClick={openDm} className="focus-ring flex items-center justify-center gap-2 rounded-control bg-accent px-3 py-2 text-[13px] font-semibold text-accent-fg"><MessageSquare size={15} /> Message</button>
<button onClick={huddle} className="focus-ring flex items-center justify-center gap-2 rounded-control border border-border bg-panel px-3 py-2 text-[13px] font-semibold hover:bg-hover"><Phone size={15} /> Huddle</button>
</div>
)}
{isMe && (
<>
<div className="mt-5">
<div className="mb-1.5 text-[11px] font-bold uppercase tracking-wide text-dim">Availability</div>
<div className="grid grid-cols-3 gap-2">
{([
{ p: "active", label: "Active", dot: "bg-success" },
{ p: "away", label: "Away", dot: "bg-warning" },
{ p: "dnd", label: "Do not disturb", dot: "bg-danger" },
] as const).map((o) => (
<button
key={o.p}
onClick={async () => { await client.updateMe({ presence: o.p }); refresh(); }}
className={`focus-ring flex items-center justify-center gap-1.5 rounded-control border px-2 py-2 text-[12px] font-semibold ${user.presence === o.p ? "border-accent bg-accent-soft text-accent" : "border-border hover:bg-hover"}`}
>
<span className={`h-2 w-2 rounded-full ${o.dot}`} /> {o.label}
</button>
))}
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<button onClick={() => { closeProfile(); openModal("set-status"); }} className="focus-ring flex items-center justify-center gap-2 rounded-control border border-border bg-panel px-3 py-2 text-[13px] font-semibold hover:bg-hover">
<Smile size={15} /> {user.statusText ? "Status" : "Set status"}
</button>
<button onClick={() => { closeProfile(); openModal("edit-profile"); }} className="focus-ring flex items-center justify-center gap-2 rounded-control border border-border bg-panel px-3 py-2 text-[13px] font-semibold hover:bg-hover">
<Pencil size={15} /> Edit profile
</button>
</div>
</>
)}
<div className="mt-6 space-y-3 text-[13px]">
<Field icon={AtSign} label="Handle" value={`@${user.handle}`} />
{user.email && <Field icon={Mail} label="Email" value={user.email} />}
{user.timezone && <Field icon={Clock} label="Local time" value={`${user.localTime ?? ""} · ${user.timezone}`} />}
{user.pronouns && <Field icon={AtSign} label="Pronouns" value={user.pronouns} />}
</div>
</div>
</div>
</div>
);
}
function Field({ icon: Icon, label, value }: { icon: typeof Mail; label: string; value: string }) {
return (
<div className="flex items-center gap-3 rounded-control border border-border bg-panel px-3 py-2.5">
<Icon size={16} className="text-muted" />
<div><div className="text-[11px] uppercase tracking-wide text-dim">{label}</div><div className="text-ink">{value}</div></div>
</div>
);
}
@@ -0,0 +1,125 @@
"use client";
import { useEffect, useState } from "react";
import { useTheme } from "@lynkd/messaging-inbox-sdk";
import { useUi } from "@/lib/ui-state";
import { X, RotateCcw, Sun, Moon, Monitor, Check } from "lucide-react";
import clsx from "clsx";
const ACCENTS = ["#FDA913", "#5b9dff", "#3ecf8e", "#bb98ff", "#f2555a", "#ff7a3d", "#54e7ff", "#f5b544"];
const RADII = [
{ label: "Sharp", card: "8px", control: "6px" },
{ label: "Default", card: "20px", control: "10px" },
{ label: "Round", card: "28px", control: "14px" },
];
const GRADIENTS = [
"linear-gradient(135deg, #FDA913 0%, #ff7a3d 55%, #ff4d6d 100%)",
"linear-gradient(135deg, #5b9dff 0%, #bb98ff 100%)",
"linear-gradient(135deg, #3ecf8e 0%, #54e7ff 100%)",
"linear-gradient(135deg, #f2555a 0%, #ff7a3d 100%)",
];
type Mode = "light" | "dark" | "system";
export function ThemingPanel() {
const { themingOpen, setThemingOpen } = useUi();
const { theme, scheme, setScheme, setThemeOverrides, resetTheme } = useTheme();
const [mode, setMode] = useState<Mode>(scheme);
// when in "system" mode, follow OS changes live
useEffect(() => {
if (mode !== "system" || typeof window === "undefined") return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const apply = () => setScheme(mq.matches ? "dark" : "light");
apply();
mq.addEventListener("change", apply);
return () => mq.removeEventListener("change", apply);
}, [mode, setScheme]);
if (!themingOpen) return null;
const activeAccent = scheme === "light" ? theme.light.accent : theme.dark.accent;
const pickMode = (m: Mode) => {
setMode(m);
if (m !== "system") setScheme(m);
};
return (
<div className="fixed inset-0 z-40 flex justify-end bg-black/40 animate-fade-in" onClick={() => setThemingOpen(false)}>
<div role="dialog" aria-modal="true" aria-label="Appearance" className="flex h-full w-[min(360px,100vw)] flex-col border-l border-border bg-surface shadow-pop" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<div>
<div className="text-[15px] font-bold">Appearance</div>
<div className="text-[12px] text-muted">Live theme persists to this browser</div>
</div>
<button onClick={() => setThemingOpen(false)} className="text-muted hover:text-ink"><X size={18} /></button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<Group title="Color mode">
<div className="grid grid-cols-3 gap-2">
{([{ v: "light", icon: Sun, label: "Light" }, { v: "dark", icon: Moon, label: "Dark" }, { v: "system", icon: Monitor, label: "System" }] as const).map((m) => (
<button key={m.v} onClick={() => pickMode(m.v)} className={clsx("flex flex-col items-center gap-1.5 rounded-control border py-3 text-[12px]", mode === m.v ? "border-accent bg-accent-soft text-accent" : "border-border hover:bg-hover")}>
<m.icon size={18} />
{m.label}
</button>
))}
</div>
</Group>
<Group title="Accent color">
<div className="flex flex-wrap gap-2">
{ACCENTS.map((c) => (
<button key={c} onClick={() => setThemeOverrides({ accent: c })} className="grid h-9 w-9 place-items-center rounded-full transition-all hover:scale-110" style={{ background: c, boxShadow: activeAccent === c ? "0 0 0 2px var(--c-bg), 0 0 0 4px " + c : undefined }}>
{activeAccent === c && <Check size={16} className="text-black/70" />}
</button>
))}
<label className="flex items-center gap-1.5 rounded-control border border-border px-2 text-[12px] text-muted">
Hex
<input type="color" value={/^#[0-9a-f]{6}$/i.test(activeAccent) ? activeAccent : "#FDA913"} onChange={(e) => setThemeOverrides({ accent: e.target.value })} className="h-7 w-8 cursor-pointer border-0 bg-transparent" />
</label>
</div>
</Group>
<Group title="Corner radius">
<div className="grid grid-cols-3 gap-2">
{RADII.map((r) => (
<button key={r.label} onClick={() => setThemeOverrides({ radiusCard: r.card, radiusControl: r.control })} className={clsx("border py-3 text-[12px]", theme.radiusCard === r.card ? "border-accent bg-accent-soft text-accent" : "border-border hover:bg-hover")} style={{ borderRadius: r.control }}>
{r.label}
</button>
))}
</div>
</Group>
<Group title="Brand gradient">
<div className="flex gap-2">
{GRADIENTS.map((g) => (
<button key={g} onClick={() => setThemeOverrides({ gradient: g })} className={clsx("h-10 flex-1 rounded-control ring-2 ring-offset-2 ring-offset-surface", theme.gradient === g ? "ring-accent" : "ring-transparent")} style={{ background: g }} />
))}
</div>
</Group>
<div className="rounded-control border border-border bg-panel p-3 text-[12px] text-muted">
All tokens here are also settable at build time via <code className="text-accent">NEXT_PUBLIC_THEME_*</code> env vars. See <code className="text-accent">docs/THEMING.md</code>.
</div>
</div>
<div className="border-t border-border p-3">
<button onClick={() => { resetTheme(); setMode("dark"); }} className="focus-ring flex w-full items-center justify-center gap-2 rounded-control border border-border py-2.5 text-[13px] font-semibold hover:bg-hover">
<RotateCcw size={15} /> Reset to defaults
</button>
</div>
</div>
</div>
);
}
function Group({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-5">
<div className="mb-2 text-[12px] font-bold uppercase tracking-wide text-dim">{title}</div>
{children}
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { useUi } from "@/lib/ui-state";
import { CheckCircle2, AlertTriangle, Info, X } from "lucide-react";
import clsx from "clsx";
export function Toaster() {
const { toasts, dismissToast } = useUi();
if (!toasts.length) return null;
return (
<div className="fixed bottom-4 right-4 z-[70] flex flex-col gap-2">
{toasts.map((t) => {
const Icon = t.tone === "success" ? CheckCircle2 : t.tone === "danger" ? AlertTriangle : Info;
return (
<div
key={t.id}
role="status"
className={clsx(
"flex items-center gap-2.5 rounded-control border bg-elevated px-3.5 py-2.5 text-[13px] shadow-pop animate-slide-up",
t.tone === "success" ? "border-success/40" : t.tone === "danger" ? "border-danger/40" : "border-border",
)}
>
<Icon size={16} className={clsx(t.tone === "success" ? "text-success" : t.tone === "danger" ? "text-danger" : "text-info")} />
<span className="max-w-xs">{t.message}</span>
<button onClick={() => dismissToast(t.id)} className="ml-1 text-muted hover:text-ink"><X size={14} /></button>
</div>
);
})}
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { useRouter } from "next/navigation";
import { useSavedMessages, useSdk } from "@lynkd/messaging-inbox-sdk";
import { Header } from "@/components/nav/Header";
import { Avatar } from "@/components/ui/primitives";
import { RichText } from "@/lib/rich-text";
import { relativeTime } from "@/lib/format";
import { Bookmark, Hash } from "lucide-react";
export function SavedView() {
const { messages, loading } = useSavedMessages();
const { userById, channelById } = useSdk();
const router = useRouter();
return (
<div className="flex min-w-0 flex-1 flex-col">
<Header eyebrow="Messaging" title="Saved items" subtitle="Messages you bookmarked for later" />
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{loading && <div className="py-10 text-center text-muted">Loading</div>}
{!loading && messages.length === 0 && (
<div className="flex flex-col items-center gap-2 py-20 text-muted">
<Bookmark size={34} className="text-dim" />
<div className="text-[15px] font-semibold text-ink">No saved items yet</div>
<div className="text-[13px]">Hover a message and hit the bookmark icon to save it here.</div>
</div>
)}
<div className="mx-auto max-w-3xl space-y-2">
{messages.map((m) => {
const author = userById(m.authorId);
const ch = m.channelId ? channelById(m.channelId) : undefined;
return (
<button
key={m.id}
onClick={() => ch && router.push(`/c/${ch.id}`)}
className="flex w-full items-start gap-3 rounded-card border border-border bg-panel p-3.5 text-left transition-colors hover:border-border-strong"
>
{author && <Avatar user={author} size={34} showPresence={false} />}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-[12.5px]">
<b>{author?.name}</b>
{ch && <span className="flex items-center gap-0.5 text-muted"><Hash size={11} /> {ch.name}</span>}
<span className="ml-auto text-[11px] text-dim">{relativeTime(m.ts)}</span>
</div>
<div className="mt-0.5 line-clamp-2 text-[13.5px] text-ink/85"><RichText text={m.body} /></div>
</div>
<Bookmark size={15} className="mt-1 shrink-0 fill-accent text-accent" />
</button>
);
})}
</div>
</div>
</div>
);
}
+264
View File
@@ -0,0 +1,264 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
useTickets,
useAvailability,
useSdk,
useFeatureFlags,
type Ticket,
type TicketState,
type TicketPriority,
} from "@lynkd/messaging-inbox-sdk";
import { useUi } from "@/lib/ui-state";
import { Header } from "@/components/nav/Header";
import { Avatar, Chip } from "@/components/ui/primitives";
import { RichText } from "@/lib/rich-text";
import { clockTime, relativeTime } from "@/lib/format";
import clsx from "clsx";
import {
LifeBuoy, AlertTriangle, Circle, Send, ArrowUpCircle, CheckCircle2,
PhoneCall, Tag, Clock, Search, ChevronLeft,
} from "lucide-react";
const stateTone: Record<TicketState, Parameters<typeof Chip>[0]["tone"]> = {
NEW: "info", OPEN: "accent", PENDING: "warning", RESOLVED: "success", CLOSED: "neutral",
};
const priTone: Record<TicketPriority, Parameters<typeof Chip>[0]["tone"]> = {
LOW: "neutral", NORMAL: "info", HIGH: "warning", URGENT: "danger",
};
export function SupportView({ initialTicketId }: { initialTicketId?: string }) {
const { tickets, loading, patch, reply } = useTickets("all");
const { toast, openModal } = useUi();
const [selected, setSelected] = useState<string | null>(initialTicketId ?? null);
const [scope, setScope] = useState<TicketState | "ALL">("ALL");
const [q, setQ] = useState("");
useEffect(() => {
if (initialTicketId) setSelected(initialTicketId);
}, [initialTicketId]);
// Desktop shows both panes, so default-select the first ticket. On mobile,
// don't auto-reselect — that would defeat the "Back to list" button.
useEffect(() => {
if (!selected && tickets.length && typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches) {
setSelected(tickets[0].id);
}
}, [tickets, selected]);
const filtered = useMemo(
() => tickets.filter((t) =>
(scope === "ALL" || t.state === scope) &&
(!q || t.subject.toLowerCase().includes(q.toLowerCase()) || t.ref.toLowerCase().includes(q.toLowerCase()))),
[tickets, scope, q],
);
const selectedTicket = tickets.find((t) => t.id === selected) ?? null;
const open = tickets.filter((t) => t.state === "OPEN" || t.state === "NEW").length;
const breached = tickets.filter((t) => t.slaBreached).length;
const resolved7d = tickets.filter((t) => (t.state === "RESOLVED" || t.state === "CLOSED")).length;
return (
<div className="flex min-w-0 flex-1 flex-col">
<Header eyebrow="Workspace" title="Support Center" subtitle="Tickets, escalations, callbacks and agent availability">
<AvailabilityPill />
</Header>
<div className="grid grid-cols-2 gap-3 border-b border-border p-4 sm:grid-cols-4">
<Stat label="Open tickets" value={open} icon={LifeBuoy} tone="accent" />
<Stat label="SLA breached" value={breached} icon={AlertTriangle} tone="danger" />
<Stat label="Resolved / closed" value={resolved7d} icon={CheckCircle2} tone="success" />
<Stat label="Avg. first response" value="18m" icon={Clock} tone="info" />
</div>
<div className="flex min-h-0 flex-1">
{/* list */}
<div className={clsx("w-full shrink-0 flex-col border-r border-border md:flex md:w-[340px] lg:w-[380px]", selected ? "hidden md:flex" : "flex")}>
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<div className="flex flex-1 items-center gap-2 rounded-control border border-border bg-panel px-2.5 py-1.5">
<Search size={14} className="text-muted" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search tickets" className="w-full bg-transparent text-[13px] outline-none placeholder:text-dim" />
</div>
</div>
<div className="flex gap-1 overflow-x-auto no-scrollbar border-b border-border px-3 py-2">
{(["ALL", "NEW", "OPEN", "PENDING", "RESOLVED", "CLOSED"] as const).map((s) => (
<button key={s} onClick={() => setScope(s)} className={clsx("whitespace-nowrap rounded-pill border px-2.5 py-1 text-[11.5px] font-semibold capitalize", scope === s ? "border-accent text-accent" : "border-border text-muted hover:text-ink")}>
{s.toLowerCase()}
</button>
))}
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{loading && <div className="p-6 text-center text-muted">Loading</div>}
{filtered.map((t) => <TicketRow key={t.id} ticket={t} active={t.id === selected} onClick={() => setSelected(t.id)} />)}
{!loading && filtered.length === 0 && <div className="p-6 text-center text-[13px] text-muted">No tickets match.</div>}
</div>
</div>
{/* detail */}
<div className={clsx("min-w-0 flex-1", selected ? "flex" : "hidden md:flex")}>
{selectedTicket ? (
<TicketDetail
key={selectedTicket.id}
ticket={selectedTicket}
onBack={() => setSelected(null)}
onSetState={(s) => patch(selectedTicket.id, s)}
onReply={(b) => reply(selectedTicket.id, b)}
onEscalate={() => { patch(selectedTicket.id, "OPEN"); toast(`${selectedTicket.ref} escalated to Tier 2`, "success"); }}
onCallback={() => openModal("callback")}
/>
) : <EmptyDetail />}
</div>
</div>
</div>
);
}
function TicketRow({ ticket, active, onClick }: { ticket: Ticket; active: boolean; onClick: () => void }) {
const { userById } = useSdk();
const requester = userById(ticket.requesterId);
return (
<button onClick={onClick} className={clsx("flex w-full flex-col gap-1.5 border-b border-border px-3.5 py-3 text-left transition-colors", active ? "bg-accent-soft/50" : "hover:bg-hover")}>
<div className="flex items-center gap-2">
<span className="font-mono text-[11px] text-dim">{ticket.ref}</span>
<Chip tone={priTone[ticket.priority]}>{ticket.priority}</Chip>
{ticket.slaBreached && <Chip tone="danger">SLA</Chip>}
<span className="ml-auto text-[11px] text-dim">{relativeTime(ticket.updatedTs)}</span>
</div>
<div className="truncate text-[14px] font-semibold">{ticket.subject}</div>
<div className="flex items-center gap-2 text-[12px] text-muted">
{requester && <Avatar user={requester} size={16} showPresence={false} rounded="5px" />}
<span className="min-w-0 truncate">{requester?.name}</span>
<Chip tone={stateTone[ticket.state]}>{ticket.state}</Chip>
</div>
</button>
);
}
function TicketDetail({
ticket, onBack, onSetState, onReply, onEscalate, onCallback,
}: {
ticket: Ticket;
onBack: () => void;
onSetState: (s: TicketState) => void;
onReply: (body: string) => void;
onEscalate: () => void;
onCallback: () => void;
}) {
const { userById } = useSdk();
const flags = useFeatureFlags();
const [draft, setDraft] = useState("");
const requester = userById(ticket.requesterId);
const assignee = ticket.assigneeId ? userById(ticket.assigneeId) : undefined;
const send = () => { if (draft.trim()) { onReply(draft.trim()); setDraft(""); } };
return (
<div className="flex h-full min-w-0 flex-col">
<div className="shrink-0 border-b border-border p-4">
<div className="flex items-center gap-2">
<button onClick={onBack} className="focus-ring -ml-1 mr-1 grid h-7 w-7 place-items-center rounded-control text-muted hover:bg-hover md:hidden" aria-label="Back to list"><ChevronLeft size={18} /></button>
<span className="font-mono text-[12px] text-dim">{ticket.ref}</span>
<Chip tone={stateTone[ticket.state]}>{ticket.state}</Chip>
<Chip tone={priTone[ticket.priority]}>{ticket.priority}</Chip>
{ticket.slaBreached && <Chip tone="danger"><AlertTriangle size={11} /> SLA breached</Chip>}
</div>
<h2 className="mt-2 text-[18px] font-black">{ticket.subject}</h2>
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] text-muted">
{requester && <span className="flex items-center gap-1.5"><Avatar user={requester} size={18} showPresence={false} rounded="5px" /> {requester.name}</span>}
{assignee && <span>· Assigned to <b className="text-ink">{assignee.name}</b></span>}
{ticket.category && <span className="flex items-center gap-1"><Tag size={12} /> {ticket.category}</span>}
<span>· opened {relativeTime(ticket.createdTs)}</span>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{flags.supportEscalate && <ActionBtn icon={ArrowUpCircle} label="Escalate" onClick={onEscalate} />}
<ActionBtn icon={CheckCircle2} label="Resolve" accent onClick={() => onSetState("RESOLVED")} />
<ActionBtn icon={Circle} label="Pending" onClick={() => onSetState("PENDING")} />
{flags.supportCallback && <ActionBtn icon={PhoneCall} label="Request callback" onClick={onCallback} />}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<div className="mx-auto max-w-2xl space-y-4">
{ticket.messages?.map((m) => {
const author = userById(m.authorId);
const mine = m.authorId === "u_me";
return (
<div key={m.id} className={clsx("flex gap-3", mine && "flex-row-reverse")}>
{author && <Avatar user={author} size={32} showPresence={false} />}
<div className={clsx("max-w-[80%] rounded-card border border-border p-3", mine ? "bg-accent-soft" : "bg-panel")}>
<div className="mb-1 flex items-center gap-2 text-[12px]"><b>{author?.name}</b><span className="text-dim">{clockTime(m.ts)}</span></div>
<div className="text-[14px] leading-relaxed text-ink/90"><RichText text={m.body} /></div>
</div>
</div>
);
})}
</div>
</div>
{ticket.state !== "CLOSED" && (
<div className="shrink-0 border-t border-border p-3">
<div className="flex items-end gap-2 rounded-card border border-border bg-panel p-2 focus-within:border-border-strong">
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && !(e.nativeEvent as any).isComposing) { e.preventDefault(); send(); } }}
rows={2}
placeholder={`Reply to ${requester?.name ?? "customer"}`}
className="max-h-32 flex-1 resize-none bg-transparent px-2 py-1.5 text-[14px] outline-none placeholder:text-dim"
/>
<button onClick={send} disabled={!draft.trim()} className="focus-ring mb-1 mr-1 flex items-center gap-1.5 rounded-control bg-accent px-3 py-2 text-[13px] font-semibold text-accent-fg disabled:opacity-40">
<Send size={14} /> Reply
</button>
</div>
</div>
)}
</div>
);
}
function AvailabilityPill() {
const flags = useFeatureFlags();
const { toast } = useUi();
const agents = useAvailability();
const [online, setOnline] = useState(true);
if (!flags.supportAvailability) return null;
const onlineCount = agents.filter((a) => a.online).length + (online ? 1 : 0);
return (
<button
onClick={() => { setOnline((o) => { const n = !o; toast(n ? "You're online for support" : "You're offline", n ? "success" : "default"); return n; }); }}
className="mr-1 flex items-center gap-2 rounded-control border border-border bg-panel px-3 py-1.5 text-[12.5px] font-semibold hover:bg-hover"
title="Toggle your availability"
>
<span className={clsx("h-2.5 w-2.5 rounded-full", online ? "bg-success" : "bg-dim")} />
{online ? "Online" : "Offline"}
<span className="text-muted">· {onlineCount} agents</span>
</button>
);
}
function Stat({ label, value, icon: Icon, tone }: { label: string; value: React.ReactNode; icon: typeof LifeBuoy; tone: string }) {
const toneBg: Record<string, string> = { accent: "var(--c-accent-soft)", danger: "rgba(242,85,90,0.12)", success: "rgba(62,207,142,0.12)", info: "rgba(91,157,255,0.12)" };
const toneFg: Record<string, string> = { accent: "var(--c-accent)", danger: "var(--c-danger)", success: "var(--c-success)", info: "var(--c-info)" };
return (
<div className="flex items-center gap-3 rounded-card border border-border bg-panel p-3">
<span className="grid h-10 w-10 place-items-center rounded-control" style={{ background: toneBg[tone], color: toneFg[tone] }}><Icon size={18} /></span>
<div><div className="text-[20px] font-black leading-none">{value}</div><div className="mt-1 text-[11.5px] text-muted">{label}</div></div>
</div>
);
}
function ActionBtn({ icon: Icon, label, onClick, accent }: { icon: typeof Send; label: string; onClick: () => void; accent?: boolean }) {
return (
<button onClick={onClick} className={clsx("focus-ring flex items-center gap-1.5 rounded-control border px-3 py-1.5 text-[12.5px] font-semibold transition-colors", accent ? "border-accent bg-accent text-accent-fg" : "border-border hover:bg-hover")}>
<Icon size={14} /> {label}
</button>
);
}
function EmptyDetail() {
return (
<div className="flex h-full w-full items-center justify-center text-muted">
<div className="text-center"><LifeBuoy size={36} className="mx-auto mb-2 text-dim" /><div className="text-[14px]">Select a ticket to view the conversation</div></div>
</div>
);
}
@@ -0,0 +1,71 @@
"use client";
import { useRouter } from "next/navigation";
import { useThreadList, useSdk } from "@lynkd/messaging-inbox-sdk";
import { useUi } from "@/lib/ui-state";
import { Header } from "@/components/nav/Header";
import { Avatar } from "@/components/ui/primitives";
import { RichText } from "@/lib/rich-text";
import { relativeTime } from "@/lib/format";
import { MessagesSquare, Hash } from "lucide-react";
export function ThreadsView() {
const { messages, loading } = useThreadList();
const { userById, channelById } = useSdk();
const { openThread } = useUi();
const router = useRouter();
const open = (channelId: string, parentId: string) => {
router.push(`/c/${channelId}`);
openThread(channelId, parentId);
};
return (
<div className="flex min-w-0 flex-1 flex-col">
<Header eyebrow="Messaging" title="Threads" subtitle="Conversations you're following" />
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{loading && <div className="py-10 text-center text-muted">Loading</div>}
{!loading && messages.length === 0 && (
<div className="flex flex-col items-center gap-2 py-20 text-muted">
<MessagesSquare size={34} className="text-dim" />
<div className="text-[15px] font-semibold text-ink">No threads yet</div>
<div className="text-[13px]">Reply in a thread on any message and it shows up here.</div>
</div>
)}
<div className="mx-auto max-w-3xl space-y-2">
{messages.map((m) => {
const author = userById(m.authorId);
const ch = m.channelId ? channelById(m.channelId) : undefined;
return (
<button
key={m.id}
onClick={() => ch && open(ch.id, m.id)}
className="flex w-full items-start gap-3 rounded-card border border-border bg-panel p-3.5 text-left transition-colors hover:border-border-strong"
>
{author && <Avatar user={author} size={34} showPresence={false} />}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-[12.5px]">
<b>{author?.name}</b>
{ch && <span className="flex items-center gap-0.5 text-muted"><Hash size={11} /> {ch.name}</span>}
<span className="ml-auto text-[11px] text-dim">{relativeTime(m.ts)}</span>
</div>
<div className="mt-0.5 line-clamp-2 text-[13.5px] text-ink/85"><RichText text={m.body} /></div>
<div className="mt-1.5 flex items-center gap-2 text-[12px] font-semibold text-info">
<span className="flex -space-x-1.5">
{(m.replyUserIds ?? []).slice(0, 3).map((id) => {
const u = userById(id);
return u ? <Avatar key={id} user={u} size={16} showPresence={false} rounded="5px" /> : null;
})}
</span>
{m.replyCount} {m.replyCount === 1 ? "reply" : "replies"}
{m.lastReplyTs && <span className="font-normal text-dim">· last {relativeTime(m.lastReplyTs)}</span>}
</div>
</div>
</button>
);
})}
</div>
</div>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { X } from "lucide-react";
export function Modal({
title,
subtitle,
onClose,
children,
footer,
wide,
}: {
title: string;
subtitle?: string;
onClose: () => void;
children: React.ReactNode;
footer?: React.ReactNode;
wide?: boolean;
}) {
return (
<div
className="fixed inset-0 z-[60] grid place-items-center bg-black/50 p-4 backdrop-blur-sm animate-fade-in"
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(e) => e.stopPropagation()}
className={`flex max-h-[90dvh] w-full ${wide ? "max-w-xl" : "max-w-md"} flex-col overflow-hidden rounded-card border border-border bg-elevated shadow-pop animate-slide-up`}
>
<div className="flex shrink-0 items-start justify-between gap-3 border-b border-border px-5 py-4">
<div className="min-w-0">
<h2 className="truncate text-[16px] font-bold">{title}</h2>
{subtitle && <p className="mt-0.5 truncate text-[12.5px] text-muted">{subtitle}</p>}
</div>
<button onClick={onClose} className="focus-ring -mr-1 grid h-8 w-8 shrink-0 place-items-center rounded-control text-muted hover:bg-hover hover:text-ink" aria-label="Close">
<X size={18} />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">{children}</div>
{footer && <div className="flex shrink-0 justify-end gap-2 border-t border-border px-5 py-3">{footer}</div>}
</div>
</div>
);
}
export function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="mb-3 block">
<span className="mb-1 block text-[12px] font-semibold text-muted">{label}</span>
{children}
</label>
);
}
export const inputCls =
"w-full rounded-control border border-border bg-panel px-3 py-2 text-[14px] outline-none focus:border-border-strong placeholder:text-dim";
export function PrimaryBtn({ children, onClick, disabled, type = "button" }: { children: React.ReactNode; onClick?: () => void; disabled?: boolean; type?: "button" | "submit" }) {
return (
<button type={type} onClick={onClick} disabled={disabled} className="focus-ring rounded-control bg-accent px-4 py-2 text-[13px] font-semibold text-accent-fg disabled:opacity-40">
{children}
</button>
);
}
export function GhostBtn({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) {
return (
<button onClick={onClick} className="focus-ring rounded-control border border-border px-4 py-2 text-[13px] font-semibold hover:bg-hover">
{children}
</button>
);
}
+186
View File
@@ -0,0 +1,186 @@
"use client";
import clsx from "clsx";
import type { PresenceState, User } from "@lynkd/messaging-inbox-sdk";
/* ------------------------------ presence ------------------------------ */
const presenceColor: Record<PresenceState, string> = {
active: "var(--c-success)",
away: "var(--c-warning)",
dnd: "var(--c-danger)",
offline: "var(--c-dim)",
};
export function PresenceDot({
state,
size = 10,
ring = "var(--c-surface)",
}: {
state: PresenceState;
size?: number;
ring?: string;
}) {
const filled = state === "active" || state === "dnd";
return (
<span
title={state}
style={{
width: size,
height: size,
boxShadow: `0 0 0 2px ${ring}`,
background: filled ? presenceColor[state] : "transparent",
border: filled ? "none" : `2px solid ${presenceColor[state]}`,
}}
className="inline-block rounded-full"
/>
);
}
/* ------------------------------- avatar ------------------------------- */
export function Avatar({
user,
size = 36,
showPresence = true,
rounded = "var(--r-control)",
}: {
user: Pick<User, "avatarText" | "avatarColor" | "presence" | "name"> & { avatarUrl?: string };
size?: number;
showPresence?: boolean;
rounded?: string;
}) {
return (
<span className="relative inline-flex shrink-0" style={{ width: size, height: size }}>
{user.avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={user.avatarUrl}
alt={user.name}
className="h-full w-full object-cover"
style={{ borderRadius: rounded }}
/>
) : (
<span
className="flex h-full w-full items-center justify-center font-semibold text-white"
style={{
background: user.avatarColor ?? "var(--c-elevated)",
borderRadius: rounded,
fontSize: size * 0.4,
}}
>
{user.avatarText}
</span>
)}
{showPresence && (
<span className="absolute -bottom-0.5 -right-0.5">
<PresenceDot state={user.presence} size={Math.max(9, size * 0.28)} />
</span>
)}
</span>
);
}
/* ------------------------------- badges ------------------------------- */
export function CountBadge({ count, tone = "accent" }: { count: number; tone?: "accent" | "danger" }) {
if (!count) return null;
return (
<span
className={clsx(
"inline-flex min-w-[18px] items-center justify-center rounded-full px-1.5 text-[11px] font-bold leading-[18px]",
tone === "accent" ? "bg-accent text-accent-fg" : "bg-danger text-white",
)}
>
{count > 99 ? "99+" : count}
</span>
);
}
export function Chip({
children,
tone = "neutral",
className,
}: {
children: React.ReactNode;
tone?: "neutral" | "accent" | "success" | "warning" | "danger" | "info";
className?: string;
}) {
const tones: Record<string, string> = {
neutral: "bg-hover text-muted",
accent: "bg-accent-soft text-accent",
success: "text-success",
warning: "text-warning",
danger: "text-danger",
info: "text-info",
};
const bg: Record<string, string> = {
success: "rgba(62,207,142,0.12)",
warning: "rgba(245,181,68,0.12)",
danger: "rgba(242,85,90,0.12)",
info: "rgba(91,157,255,0.12)",
neutral: "",
accent: "",
};
return (
<span
className={clsx(
"inline-flex items-center gap-1 rounded-pill px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide",
tones[tone],
className,
)}
style={bg[tone] ? { background: bg[tone] } : undefined}
>
{children}
</span>
);
}
/* ---------------------------- icon button ----------------------------- */
export function IconButton({
children,
label,
active,
onClick,
className,
badge,
}: {
children: React.ReactNode;
label: string;
active?: boolean;
onClick?: () => void;
className?: string;
badge?: number;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className={clsx(
"focus-ring relative grid h-9 w-9 place-items-center rounded-control text-muted transition-colors hover:bg-hover hover:text-ink",
active && "bg-hover text-ink",
className,
)}
>
{children}
{!!badge && (
<span className="absolute -right-0.5 -top-0.5">
<CountBadge count={badge} tone="danger" />
</span>
)}
</button>
);
}
/* ------------------------------ tooltip-ish --------------------------- */
export function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div className="px-3 pb-1 pt-3 text-[11px] font-bold uppercase tracking-[0.08em] text-dim">
{children}
</div>
);
}