357 lines
17 KiB
TypeScript
357 lines
17 KiB
TypeScript
"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 (
|
|
<div
|
|
id={`msg-${message.id}`}
|
|
data-mid={message.id}
|
|
data-jump-highlight={highlighted ? "1" : undefined}
|
|
className={clsx("group relative flex scroll-mt-4 gap-3 px-4 py-0.5 row-hover transition-colors", grouped ? "mt-0.5" : "mt-3")}
|
|
onMouseEnter={() => setHover(true)}
|
|
onMouseLeave={() => setHover(false)}
|
|
>
|
|
<div className="w-9 shrink-0 pt-0.5">
|
|
{!grouped ? (
|
|
<button onClick={() => openProfile(author.id)} className="focus-ring rounded-control" title={author.name}>
|
|
<Avatar user={author} size={36} showPresence={false} />
|
|
</button>
|
|
) : (
|
|
<span className="hidden pt-1 text-[10px] leading-4 text-dim group-hover:block">{clockTime(message.ts)}</span>
|
|
)}
|
|
</div>
|
|
|
|
<div data-mid-body className="min-w-0 flex-1">
|
|
{!grouped && (
|
|
<div className="flex items-baseline gap-2">
|
|
<button onClick={() => openProfile(author.id)} className="focus-ring text-[14px] font-bold hover:underline">
|
|
{author.name}
|
|
</button>
|
|
{author.isBot && <span className="rounded bg-hover px-1 text-[10px] font-bold uppercase text-muted">app</span>}
|
|
<span className="text-[11px] text-dim">{clockTime(message.ts)}</span>
|
|
{message.scheduledFor && (
|
|
<span className="flex items-center gap-1 text-[11px] text-warning">
|
|
<Clock size={11} /> scheduled · {relativeTime(message.scheduledFor)}
|
|
</span>
|
|
)}
|
|
{message.isPinned && <Pin size={12} className="text-accent" />}
|
|
</div>
|
|
)}
|
|
|
|
{editing ? (
|
|
<div className="mt-1 rounded-control border border-border bg-panel p-2 focus-within:border-border-strong">
|
|
<textarea
|
|
autoFocus
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); saveEdit(); }
|
|
if (e.key === "Escape") { e.preventDefault(); setEditing(false); setDraft(message.body); }
|
|
}}
|
|
rows={2}
|
|
className="max-h-40 w-full resize-none bg-transparent px-1.5 py-1 text-[14px] outline-none"
|
|
/>
|
|
<div className="flex items-center justify-end gap-2 pt-1">
|
|
<button onClick={() => { setEditing(false); setDraft(message.body); }} className="rounded-control px-2.5 py-1 text-[12px] font-semibold text-muted hover:bg-hover">Cancel</button>
|
|
<button onClick={saveEdit} className="rounded-control bg-accent px-2.5 py-1 text-[12px] font-semibold text-accent-fg">Save</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="whitespace-pre-wrap break-words text-[14.5px] leading-relaxed text-ink/90">
|
|
<RichText text={message.body} />
|
|
{message.editedTs && <span className="ml-1 text-[11px] text-dim">(edited)</span>}
|
|
</div>
|
|
)}
|
|
|
|
{translating && (
|
|
<div className="mt-1.5 flex items-center gap-1.5 text-[12px] text-muted"><RefreshCw size={12} className="animate-spin" /> Translating…</div>
|
|
)}
|
|
{translated && (
|
|
<div className="mt-1.5 rounded-control border-l-2 border-info bg-info/5 px-3 py-2">
|
|
<div className="mb-1 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wide text-dim">
|
|
<Languages size={12} /> Translated · {translated.lang}
|
|
<button onClick={() => setTranslated(null)} className="ml-auto font-semibold normal-case text-info hover:underline">Show original</button>
|
|
</div>
|
|
<div className="whitespace-pre-wrap break-words text-[14px] leading-relaxed text-ink/90"><RichText text={translated.text} /></div>
|
|
</div>
|
|
)}
|
|
|
|
{message.blocks?.map((b, i) => <Block key={i} block={b} />)}
|
|
|
|
{message.attachments?.map((a) => (
|
|
<div key={a.id} className="group/att mt-1.5 max-w-sm">
|
|
{a.kind === "image" && a.url ? (
|
|
<button onClick={() => window.open(a.url, "_blank")} className="block overflow-hidden rounded-control border border-border" title="Open image">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img src={a.url} alt={a.name} className="max-h-64 w-auto max-w-full object-cover" />
|
|
</button>
|
|
) : (
|
|
<div className="flex items-center gap-3 rounded-control border border-border bg-surface p-2.5">
|
|
<span className="grid h-10 w-10 place-items-center rounded-md text-muted" style={{ background: a.previewColor ?? "var(--c-elevated)" }}>
|
|
{a.kind === "video" ? <ImageIcon size={18} /> : <FileText size={18} />}
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-[13px] font-medium">{a.name}</div>
|
|
<div className="text-[11px] text-dim">{a.sizeLabel}</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="mt-1 flex items-center gap-2 text-[11px] text-muted">
|
|
<span className="truncate">{a.name} · {a.sizeLabel}</span>
|
|
{a.url && (
|
|
<>
|
|
<a href={a.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-0.5 text-info hover:underline"><Eye size={12} /> Open</a>
|
|
<a href={a.url} download={a.name} className="inline-flex items-center gap-0.5 text-info hover:underline"><Download size={12} /> Download</a>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{flags.reactions && !!message.reactions?.length && (
|
|
<div className="mt-1.5 flex flex-wrap items-center gap-1">
|
|
{message.reactions.map((r) => {
|
|
const reacted = me ? r.userIds.includes(me.id) : false;
|
|
return (
|
|
<button
|
|
key={r.emoji}
|
|
onClick={() => onReact?.(r.emoji)}
|
|
className={clsx(
|
|
"flex items-center gap-1 rounded-pill border px-2 py-0.5 text-[12px] transition-colors",
|
|
reacted ? "border-accent bg-accent-soft text-accent" : "border-border bg-surface text-muted hover:border-border-strong",
|
|
)}
|
|
>
|
|
<span>{r.emoji}</span>
|
|
<span className="font-semibold">{r.userIds.length}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
<ReactAdder onReact={onReact} />
|
|
</div>
|
|
)}
|
|
|
|
{message.threadRootId && (
|
|
<button onClick={onOpenThread} className="mt-1 flex items-center gap-1.5 rounded-control border border-border bg-surface px-2 py-1 text-[12px] font-medium text-info hover:bg-hover">
|
|
<MessageSquare size={12} /> Replied to a thread <span className="text-dim">→ open</span>
|
|
</button>
|
|
)}
|
|
|
|
{flags.threads && !!message.replyCount && (
|
|
<button
|
|
onClick={onOpenThread}
|
|
className="mt-1.5 flex items-center gap-2 rounded-control border border-transparent px-1.5 py-1 text-[12.5px] font-semibold text-info hover:border-border hover:bg-surface"
|
|
>
|
|
<span className="flex -space-x-1.5">
|
|
{(message.replyUserIds ?? []).slice(0, 3).map((id) => {
|
|
const u = userById(id);
|
|
return u ? <Avatar key={id} user={u} size={18} showPresence={false} rounded="6px" /> : null;
|
|
})}
|
|
</span>
|
|
{message.replyCount} {message.replyCount === 1 ? "reply" : "replies"}
|
|
{message.lastReplyTs && <span className="font-normal text-dim">· {relativeTime(message.lastReplyTs)}</span>}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{toolbarVisible && !editing && (
|
|
<div className="absolute -top-3 right-4 z-20 flex items-center gap-0.5 rounded-control border border-border bg-elevated p-0.5 shadow-pop">
|
|
{flags.reactions && (
|
|
<div className="relative">
|
|
<ActBtn title="React" active={emojiOpen} onClick={() => { setEmojiOpen((o) => !o); setMenuOpen(false); }}><SmilePlus size={16} /></ActBtn>
|
|
{emojiOpen && (
|
|
<EmojiPicker align="right" onPick={(e) => { onReact?.(e); setEmojiOpen(false); }} onClose={() => setEmojiOpen(false)} />
|
|
)}
|
|
</div>
|
|
)}
|
|
{flags.threads && <ActBtn title="Reply in thread" onClick={onOpenThread}><MessageSquare size={16} /></ActBtn>}
|
|
<ActBtn title="Copy message · ⌘C" onClick={() => copy(message.body, "Message")}><Copy size={16} /></ActBtn>
|
|
{flags.translation && (
|
|
<div className="relative">
|
|
<ActBtn title="Translate message" active={langOpen} onClick={() => { setLangOpen((o) => !o); setEmojiOpen(false); setMenuOpen(false); }}><Languages size={16} /></ActBtn>
|
|
{langOpen && <LanguageMenu align="right" onPick={doTranslate} onClose={() => setLangOpen(false)} />}
|
|
</div>
|
|
)}
|
|
{flags.aiAssist && <ActBtn title="Ask AI about this message" onClick={() => openAi({ context: message.body })}><Sparkles size={16} /></ActBtn>}
|
|
{flags.pins && <ActBtn title={message.isPinned ? "Unpin" : "Pin"} active={message.isPinned} onClick={onPin}><Pin size={16} /></ActBtn>}
|
|
{flags.savedItems && <ActBtn title={message.isSaved ? "Remove from saved" : "Save"} active={message.isSaved} onClick={onSave}><Bookmark size={16} /></ActBtn>}
|
|
{mine && onEdit && <ActBtn title="Edit message" onClick={() => { setDraft(message.body); setEditing(true); setMenuOpen(false); }}><Pencil size={16} /></ActBtn>}
|
|
<div className="relative">
|
|
<ActBtn title="More actions" active={menuOpen} onClick={() => { setMenuOpen((o) => !o); setEmojiOpen(false); }}><MoreHorizontal size={16} /></ActBtn>
|
|
{menuOpen && (
|
|
<MoreMenu
|
|
mine={mine}
|
|
isPinned={message.isPinned}
|
|
isSaved={message.isSaved}
|
|
canDelete={mine && !!onDelete}
|
|
canEdit={mine && !!onEdit}
|
|
onClose={() => 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); }}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ReactAdder({ onReact }: { onReact?: (e: string) => void }) {
|
|
const [open, setOpen] = useState(false);
|
|
return (
|
|
<div className="relative">
|
|
<button onClick={() => setOpen((o) => !o)} className="grid h-[26px] w-7 place-items-center rounded-pill border border-border text-muted hover:border-border-strong hover:text-ink" title="Add reaction">
|
|
<SmilePlus size={14} />
|
|
</button>
|
|
{open && <EmojiPicker onPick={(e) => { onReact?.(e); setOpen(false); }} onClose={() => setOpen(false)} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<HTMLDivElement>(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 (
|
|
<div ref={ref} className="absolute right-0 top-9 z-40 w-52 overflow-hidden rounded-control border border-border bg-elevated py-1 shadow-pop animate-slide-up">
|
|
<button className={item} onClick={onThread}><Forward size={14} /> Reply in thread</button>
|
|
{canEdit && <button className={item} onClick={onEdit}><Pencil size={14} /> Edit message</button>}
|
|
<button className={item} onClick={onCopyLink}><Link2 size={14} /> Copy link</button>
|
|
<button className={item} onClick={onCopyText}><Copy size={14} /> Copy text</button>
|
|
<button className={item} onClick={onPin}><Pin size={14} /> {isPinned ? "Unpin" : "Pin to channel"}</button>
|
|
<button className={item} onClick={onSave}><Bookmark size={14} /> {isSaved ? "Remove from saved" : "Save for later"}</button>
|
|
{canDelete && <><div className="my-1 h-px bg-border" /><button className={clsx(item, "text-danger")} onClick={onDelete}><Trash2 size={14} /> Delete message</button></>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActBtn({ children, title, active, onClick }: { children: React.ReactNode; title: string; active?: boolean; onClick?: () => void }) {
|
|
return (
|
|
<button title={title} onClick={onClick} className={clsx("grid h-7 w-7 place-items-center rounded-md text-muted hover:bg-hover hover:text-ink", active && "bg-hover text-accent")}>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function Block({ block }: { block: RichBlock }) {
|
|
if (block.type === "callout")
|
|
return <div className="mt-1.5 rounded-control border-l-2 border-accent bg-accent-soft px-3 py-2 text-[13px] text-ink/90">{block.text}</div>;
|
|
if (block.type === "bullet")
|
|
return <ul className="mt-1 list-disc pl-5 text-[13.5px] text-ink/85">{block.items?.map((it, i) => <li key={i}>{it}</li>)}</ul>;
|
|
if (block.type === "code")
|
|
return <pre className="mt-1.5 overflow-x-auto rounded-control border border-border bg-surface p-3 font-mono text-[12.5px] text-ink/90"><code>{block.text}</code></pre>;
|
|
if (block.type === "quote")
|
|
return <blockquote className="mt-1 border-l-2 border-border pl-3 text-[13.5px] text-muted">{block.text}</blockquote>;
|
|
return null;
|
|
}
|