"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; onStartHuddle?: () => void; compact?: boolean; channelName?: string; showAlsoSend?: boolean; }) { const flags = useFeatureFlags(); const { toast, openAi } = useUi(); const { users, channels, client } = useSdk(); const editorRef = useRef(null); const fileRef = useRef(null); const scheduleRef = useRef(null); const [empty, setEmpty] = useState(true); const [emojiOpen, setEmojiOpen] = useState(false); const [scheduleOpen, setScheduleOpen] = useState(false); const [attachments, setAttachments] = useState([]); 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, `${escapeHtml(text)} `); refresh(); }; const makeLink = () => { focusEditor(); const sel = window.getSelection(); if (sel && !sel.isCollapsed) document.execCommand("createLink", false, "https://"); else document.execCommand("insertHTML", false, `link `); 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: })); } 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: })); } // emoji return searchEmojis(q, 8).map((e) => ({ key: e.n, label: `:${e.n}:`, insert: e.e, icon: {e.e} })); }, [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 (
{/* translate-before-send preview */} {preview && (
Translated to {preview.lang}
Preview
{preview.text}
Original: {preview.original}
)} {/* autocomplete popover */} {ac && suggestions.length > 0 && (
{ac.trigger === "@" ? "People" : ac.trigger === "#" ? "Channels" : "Emoji"}
{suggestions.map((s, i) => ( ))}
)} {flags.richComposer && !compact && (
)}
{empty && {placeholder}}
{ 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" />
{/* attachment previews */} {attachments.length > 0 && (
{attachments.map((a) => (
{a.kind === "image" && a.url ? ( // eslint-disable-next-line @next/next/no-img-element {a.name} ) : ( )}
{a.name}
{a.sizeLabel}
))}
)}
{flags.fileUpload && ( <> { onFiles(e.target.files); e.target.value = ""; }} /> )} {flags.mentions && } {flags.emojiPicker && (
{emojiOpen && { insertText(e); setEmojiOpen(false); }} onClose={() => setEmojiOpen(false)} />}
)} {flags.slashCommands && } {flags.translation && (
{translateOpen && setTranslateOpen(false)} />}
)} {flags.huddles && onStartHuddle && } {flags.aiAssist && ( )}
{flags.scheduledSend && !compact && (
{scheduleOpen && (
{scheduleOptions.map((o) => ( ))}
)}
)}
Shift + Enter for a new line · Enter to send
{showAlsoSend && ( )}
); } function escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } 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() }, ]; }