Files
2026-07-17 21:48:37 +05:30

449 lines
22 KiB
TypeScript

"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() },
];
}