"use client";
import { useEffect, useRef, useState } from "react";
import clsx from "clsx";
import {
useFeatureFlags,
useSdk,
type Message,
type RichBlock,
} from "@lynkd/messaging-inbox-sdk";
import { useUi } from "@/lib/ui-state";
import { Avatar } from "@/components/ui/primitives";
import { RichText } from "@/lib/rich-text";
import { clockTime, relativeTime } from "@/lib/format";
import { EmojiPicker } from "./EmojiPicker";
import { LanguageMenu } from "./LanguageMenu";
import {
SmilePlus, MessageSquare, Pin, Bookmark, MoreHorizontal,
FileText, ImageIcon, Clock, Link2, Copy, Forward, Trash2, Pencil, Eye, Download,
Sparkles, Languages, RefreshCw,
} from "lucide-react";
export function MessageItem({
message,
grouped,
highlighted,
autoTranslateTo,
onReact,
onOpenThread,
onPin,
onSave,
onEdit,
onDelete,
}: {
message: Message;
grouped?: boolean;
highlighted?: boolean;
autoTranslateTo?: string | null;
onReact?: (emoji: string) => void;
onOpenThread?: () => void;
onPin?: () => void;
onSave?: () => void;
onEdit?: (body: string) => void;
onDelete?: () => void;
}) {
const { userById, me, client } = useSdk();
const { openProfile, toast, openAi } = useUi();
const flags = useFeatureFlags();
const author = userById(message.authorId);
const [hover, setHover] = useState(false);
const [emojiOpen, setEmojiOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [langOpen, setLangOpen] = useState(false);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(message.body);
const [translated, setTranslated] = useState<{ lang: string; text: string } | null>(null);
const [translating, setTranslating] = useState(false);
const mine = message.authorId === me?.id;
const toolbarVisible = hover || emojiOpen || menuOpen;
const copy = async (text: string, label: string) => {
try {
await navigator.clipboard?.writeText(text);
toast(`${label} copied to clipboard`, "success");
} catch {
toast("Couldn't access the clipboard", "danger");
}
};
const saveEdit = () => {
const v = draft.trim();
if (v && v !== message.body) onEdit?.(v);
setEditing(false);
};
const doTranslate = async (lang: string) => {
setLangOpen(false);
setTranslating(true);
try {
const res = await client.translate(message.body, lang);
if (res.ok && res.text) setTranslated({ lang, text: res.text });
else toast(res.error || "Translation failed", "danger");
} catch (e) {
toast(`Translation failed: ${(e as Error).message}`, "danger");
} finally {
setTranslating(false);
}
};
// whole-conversation translate: react to the channel-level language toggle
useEffect(() => {
if (autoTranslateTo) void doTranslate(autoTranslateTo);
else if (autoTranslateTo === null) setTranslated(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoTranslateTo]);
if (!author) return null;
return (
setHover(true)}
onMouseLeave={() => setHover(false)}
>
{!grouped ? (
) : (
{clockTime(message.ts)}
)}
{!grouped && (
{author.isBot &&
app}
{clockTime(message.ts)}
{message.scheduledFor && (
scheduled · {relativeTime(message.scheduledFor)}
)}
{message.isPinned &&
}
)}
{editing ? (
) : (
{message.editedTs && (edited)}
)}
{translating && (
Translating…
)}
{translated && (
Translated · {translated.lang}
)}
{message.blocks?.map((b, i) =>
)}
{message.attachments?.map((a) => (
{a.kind === "image" && a.url ? (
) : (
{a.kind === "video" ? : }
)}
))}
{flags.reactions && !!message.reactions?.length && (
{message.reactions.map((r) => {
const reacted = me ? r.userIds.includes(me.id) : false;
return (
);
})}
)}
{message.threadRootId && (
)}
{flags.threads && !!message.replyCount && (
)}
{toolbarVisible && !editing && (
{flags.reactions && (
{ setEmojiOpen((o) => !o); setMenuOpen(false); }}>
{emojiOpen && (
{ onReact?.(e); setEmojiOpen(false); }} onClose={() => setEmojiOpen(false)} />
)}
)}
{flags.threads &&
}
copy(message.body, "Message")}>
{flags.translation && (
{ setLangOpen((o) => !o); setEmojiOpen(false); setMenuOpen(false); }}>
{langOpen &&
setLangOpen(false)} />}
)}
{flags.aiAssist &&
openAi({ context: message.body })}>}
{flags.pins &&
}
{flags.savedItems &&
}
{mine && onEdit &&
{ setDraft(message.body); setEditing(true); setMenuOpen(false); }}>}
{ setMenuOpen((o) => !o); setEmojiOpen(false); }}>
{menuOpen && (
setMenuOpen(false)}
onCopyLink={() => { copy(`${location.origin}/c/${message.channelId}?msg=${message.id}`, "Link"); setMenuOpen(false); }}
onCopyText={() => { copy(message.body, "Text"); setMenuOpen(false); }}
onPin={() => { onPin?.(); setMenuOpen(false); }}
onSave={() => { onSave?.(); setMenuOpen(false); }}
onThread={() => { onOpenThread?.(); setMenuOpen(false); }}
onEdit={() => { setDraft(message.body); setEditing(true); setMenuOpen(false); }}
onDelete={() => { onDelete?.(); setMenuOpen(false); }}
/>
)}
)}
);
}
function ReactAdder({ onReact }: { onReact?: (e: string) => void }) {
const [open, setOpen] = useState(false);
return (
{open && { onReact?.(e); setOpen(false); }} onClose={() => setOpen(false)} />}
);
}
function MoreMenu({
mine, isPinned, isSaved, canDelete, canEdit, onClose, onCopyLink, onCopyText, onPin, onSave, onThread, onEdit, onDelete,
}: {
mine: boolean; isPinned?: boolean; isSaved?: boolean; canDelete: boolean; canEdit: boolean; onClose: () => void;
onCopyLink: () => void; onCopyText: () => void; onPin: () => void; onSave: () => void; onThread: () => void; onEdit: () => void; onDelete: () => void;
}) {
const ref = useRef(null);
useEffect(() => {
const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) onClose(); };
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); onClose(); } };
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
}, [onClose]);
const item = "flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-[13px] hover:bg-hover";
return (
{canEdit &&
}
{canDelete && <>
>}
);
}
function ActBtn({ children, title, active, onClick }: { children: React.ReactNode; title: string; active?: boolean; onClick?: () => void }) {
return (
);
}
function Block({ block }: { block: RichBlock }) {
if (block.type === "callout")
return {block.text}
;
if (block.type === "bullet")
return {block.items?.map((it, i) => - {it}
)}
;
if (block.type === "code")
return {block.text}
;
if (block.type === "quote")
return {block.text}
;
return null;
}