Files
message-inbox-web-frontend-sdk/apps/web/components/overlays/Modals.tsx
T
2026-07-17 21:48:37 +05:30

434 lines
23 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}