274 lines
8.8 KiB
TypeScript
274 lines
8.8 KiB
TypeScript
"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>
|
|
);
|
|
}
|