449 lines
22 KiB
TypeScript
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> `);
|
|
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> `);
|
|
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, "&").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() },
|
|
];
|
|
}
|