first commit
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams, useRouter, usePathname } from "next/navigation";
|
||||
import {
|
||||
useChannel,
|
||||
useFeatureFlags,
|
||||
useMessages,
|
||||
useSdk,
|
||||
useTyping,
|
||||
type Message,
|
||||
} from "@lynkd/messaging-inbox-sdk";
|
||||
import { useUi } from "@/lib/ui-state";
|
||||
import { Header } from "@/components/nav/Header";
|
||||
import { MessageItem } from "./MessageItem";
|
||||
import { Composer } from "./Composer";
|
||||
import { LanguageMenu } from "./LanguageMenu";
|
||||
import { Avatar, IconButton } from "@/components/ui/primitives";
|
||||
import { groupByDay } from "@/lib/format";
|
||||
import { Hash, Lock, Users, Video, Phone, Pin, Info, UserPlus, Languages } from "lucide-react";
|
||||
|
||||
export function ChannelView({ channelId }: { channelId: string }) {
|
||||
const channel = useChannel(channelId);
|
||||
const { messages, pinned, send, react, pin, save, edit, remove } = useMessages(channelId);
|
||||
const { userById, me, client, refresh } = useSdk();
|
||||
const flags = useFeatureFlags();
|
||||
const { openThread, startHuddle, openModal } = useUi();
|
||||
const typing = useTyping(channelId);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const searchParams = useSearchParams();
|
||||
const jumpMsg = searchParams.get("msg");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [chatLang, setChatLang] = useState<string | null>(null);
|
||||
const [langOpen, setLangOpen] = useState(false);
|
||||
|
||||
// reset the whole-chat translation when switching channels
|
||||
useEffect(() => { setChatLang(null); setLangOpen(false); }, [channelId]);
|
||||
|
||||
// auto-scroll on channel change, and on new messages only if already near the bottom
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [channelId]);
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 140;
|
||||
if (nearBottom) bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [messages.length]);
|
||||
|
||||
// mark the channel read on open (clears the sidebar unread badge)
|
||||
useEffect(() => {
|
||||
if (!channelId) return;
|
||||
let alive = true;
|
||||
client.markRead(channelId).then(() => alive && refresh()).catch(() => {});
|
||||
return () => { alive = false; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [channelId]);
|
||||
|
||||
// deep-link jump: /c/:id?msg=<id> scrolls to + flashes that message. Keyed on
|
||||
// jumpMsg so jumping to a message in the channel you're already viewing (from
|
||||
// search / a notification) still re-fires. It runs ONCE per jump: as soon as
|
||||
// the target resolves (or we give up), the ?msg param is stripped so later
|
||||
// sends / incoming messages don't re-yank the viewport back to the old message.
|
||||
// gate on "messages have loaded" (a boolean) rather than the raw count, so
|
||||
// that later sends / incoming messages growing the array never retrigger the jump.
|
||||
const hasMessages = messages.length > 0;
|
||||
useEffect(() => {
|
||||
if (!hasMessages || !jumpMsg) return;
|
||||
let tries = 0;
|
||||
const clearParam = () => router.replace(pathname, { scroll: false });
|
||||
const iv = setInterval(() => {
|
||||
const el = document.getElementById(`msg-${jumpMsg}`);
|
||||
if (el) {
|
||||
clearInterval(iv);
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
// flash via a WAAPI animation on an inner wrapper (survives row re-renders)
|
||||
const flash = el.querySelector<HTMLElement>("[data-mid-body]");
|
||||
const target = flash ?? el;
|
||||
target.animate(
|
||||
[{ backgroundColor: "rgba(253,169,19,0.22)" }, { backgroundColor: "rgba(253,169,19,0.22)", offset: 0.7 }, { backgroundColor: "rgba(253,169,19,0)" }],
|
||||
{ duration: 2400, easing: "ease" },
|
||||
);
|
||||
clearParam();
|
||||
} else if (++tries > 30) {
|
||||
clearInterval(iv);
|
||||
clearParam(); // target isn't in this view — stop retrying on every new message
|
||||
}
|
||||
}, 80);
|
||||
return () => clearInterval(iv);
|
||||
}, [hasMessages, channelId, jumpMsg, router, pathname]);
|
||||
|
||||
const isDm = channel?.kind === "dm" || channel?.kind === "group_dm";
|
||||
const other =
|
||||
channel?.kind === "dm" ? userById(channel.memberIds.find((id) => id !== me?.id) ?? "") : undefined;
|
||||
const title = channel ? channel.name : "…";
|
||||
|
||||
const grouped = useMemo(() => groupByDay(messages), [messages]);
|
||||
|
||||
if (!channel) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center text-muted">Select a conversation</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Header
|
||||
eyebrow={isDm ? "Direct message" : channel.external ? "External · Slack Connect" : channel.kind === "private" ? "Private channel" : "Channel"}
|
||||
title={
|
||||
<span className="flex items-center gap-1.5">
|
||||
{channel.kind === "channel" && <Hash size={16} className="text-dim" />}
|
||||
{channel.kind === "private" && <Lock size={14} className="text-dim" />}
|
||||
{isDm && other && <Avatar user={other} size={20} rounded="6px" />}
|
||||
{title}
|
||||
</span>
|
||||
}
|
||||
subtitle={channel.topic ?? (other ? `${other.title} · ${other.presence}` : undefined)}
|
||||
>
|
||||
{!isDm && (
|
||||
<button
|
||||
onClick={() => openModal("channel-members", channelId)}
|
||||
title="View members"
|
||||
className="mr-1 hidden items-center gap-1.5 rounded-control border border-border bg-panel px-2 py-1.5 text-[12px] text-muted transition-colors hover:bg-hover hover:text-ink xl:flex"
|
||||
>
|
||||
<Users size={14} />
|
||||
<span className="flex -space-x-1.5">
|
||||
{channel.memberIds.slice(0, 4).map((id) => {
|
||||
const u = userById(id);
|
||||
return u ? <Avatar key={id} user={u} size={20} showPresence={false} rounded="999px" /> : null;
|
||||
})}
|
||||
</span>
|
||||
<span className="font-semibold text-ink">{channel.memberIds.length}</span>
|
||||
</button>
|
||||
)}
|
||||
{!isDm && (
|
||||
<IconButton label="Add people" onClick={() => openModal("channel-members", channelId)}>
|
||||
<UserPlus size={18} />
|
||||
</IconButton>
|
||||
)}
|
||||
{flags.huddles && (
|
||||
<IconButton label="Start huddle" onClick={() => startHuddle(channelId)}>
|
||||
<Video size={18} />
|
||||
</IconButton>
|
||||
)}
|
||||
{flags.huddles && (
|
||||
<IconButton label="Start a call" onClick={() => startHuddle(channelId)} className="hidden sm:grid">
|
||||
<Phone size={18} />
|
||||
</IconButton>
|
||||
)}
|
||||
{flags.translation && (
|
||||
<div className="relative">
|
||||
<IconButton label="Translate conversation" onClick={() => setLangOpen((o) => !o)} className={chatLang ? "text-accent" : undefined}>
|
||||
<Languages size={18} />
|
||||
</IconButton>
|
||||
{langOpen && (
|
||||
<LanguageMenu
|
||||
align="right"
|
||||
onPick={(lang) => { setChatLang(lang); setLangOpen(false); }}
|
||||
onClose={() => setLangOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<IconButton label="Channel details" onClick={() => openModal("channel-details", channelId)}>
|
||||
<Info size={18} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
{/* whole-conversation translation banner */}
|
||||
{chatLang && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-info/5 px-4 py-1.5 text-[12.5px]">
|
||||
<Languages size={13} className="shrink-0 text-info" />
|
||||
<span className="text-muted">Conversation translated to <b className="text-ink">{chatLang}</b></span>
|
||||
<button onClick={() => setChatLang(null)} className="ml-auto font-semibold text-info hover:underline">Show originals</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* pinned bar */}
|
||||
{flags.pins && pinned.length > 0 && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-accent-soft/40 px-4 py-1.5 text-[12.5px]">
|
||||
<Pin size={13} className="shrink-0 text-accent" />
|
||||
<span className="shrink-0 text-muted">{pinned.length} pinned</span>
|
||||
<span className="min-w-0 flex-1 truncate text-ink/80">· {pinned[0].body}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* messages */}
|
||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
|
||||
<ChannelIntro channelName={title} isDm={!!isDm} />
|
||||
{grouped.map((g) => (
|
||||
<div key={g.day}>
|
||||
<DayDivider label={g.day} />
|
||||
{g.items.map((m, i) => {
|
||||
const prev = g.items[i - 1];
|
||||
const groupedMsg =
|
||||
!!prev && prev.authorId === m.authorId && m.ts - prev.ts < 5 * 60_000 && !m.systemEvent;
|
||||
return (
|
||||
<MessageItem
|
||||
key={m.id}
|
||||
message={m}
|
||||
grouped={groupedMsg}
|
||||
autoTranslateTo={chatLang}
|
||||
onReact={(e) => react(m.id, e)}
|
||||
onOpenThread={() => openThread(channelId, m.threadRootId ?? m.id)}
|
||||
onPin={() => pin(m.id)}
|
||||
onSave={() => save(m.id)}
|
||||
onEdit={(body) => edit(m.id, body)}
|
||||
onDelete={() => remove(m.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} className="h-4" />
|
||||
</div>
|
||||
|
||||
{/* typing indicator — fixed just above the composer */}
|
||||
<div className="h-6 shrink-0">
|
||||
{flags.typingIndicator && typing.length > 0 && <TypingIndicator userIds={typing} userById={userById} />}
|
||||
</div>
|
||||
|
||||
<Composer
|
||||
placeholder={`Message ${channel.kind === "channel" ? "#" + title : title}`}
|
||||
onSend={async (body, opts) => { await send(body, opts); }}
|
||||
onStartHuddle={() => startHuddle(channelId)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DayDivider({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="sticky top-0 z-10 my-2 flex items-center gap-3 px-4">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="rounded-pill border border-border bg-surface px-3 py-0.5 text-[11px] font-semibold text-muted">
|
||||
{label}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypingIndicator({
|
||||
userIds,
|
||||
userById,
|
||||
}: {
|
||||
userIds: string[];
|
||||
userById: (id: string) => { name: string; avatarColor?: string } | undefined;
|
||||
}) {
|
||||
const typers = userIds.map((id) => userById(id)).filter(Boolean) as { name: string; avatarColor?: string }[];
|
||||
if (!typers.length) return null;
|
||||
return (
|
||||
// left inset matches the editor's text (composer px-4 + editor px-3.5) so the
|
||||
// loader + text line up under the editor rather than hugging the sidebar edge.
|
||||
<div className="flex items-center gap-2 py-1 pl-[30px] pr-4 text-[12px] text-muted">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<span className="h-1.5 w-1.5 animate-blink rounded-full bg-accent" style={{ animationDelay: "0ms" }} />
|
||||
<span className="h-1.5 w-1.5 animate-blink rounded-full bg-accent" style={{ animationDelay: "160ms" }} />
|
||||
<span className="h-1.5 w-1.5 animate-blink rounded-full bg-accent" style={{ animationDelay: "320ms" }} />
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{typers.map((u, i) => (
|
||||
<span key={i}>
|
||||
<span className="font-semibold" style={{ color: u.avatarColor ?? "var(--c-accent)" }}>{u.name}</span>
|
||||
{i < typers.length - 1 ? ", " : ""}
|
||||
</span>
|
||||
))}
|
||||
{" "}
|
||||
{typers.length === 1 ? "is" : "are"} typing…
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelIntro({ channelName, isDm }: { channelName: string; isDm: boolean }) {
|
||||
return (
|
||||
<div className="px-4 pb-1 pt-6">
|
||||
<div className="brand-mark mb-3 grid h-12 w-12 place-items-center rounded-card text-lg font-black text-black/80">
|
||||
{channelName.slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<h2 className="text-[20px] font-black">
|
||||
{isDm ? channelName : `#${channelName}`}
|
||||
</h2>
|
||||
<p className="mt-1 max-w-lg text-[13.5px] text-muted">
|
||||
{isDm
|
||||
? `This is the beginning of your direct message history with ${channelName}.`
|
||||
: `This is the very beginning of the #${channelName} channel. Say hello 👋`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
"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() },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { EMOJIS, FREQUENT, searchEmojis, type EmojiCategory } from "@/lib/emoji";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
const CATS: EmojiCategory[] = ["Smileys", "Gestures", "Activity", "Animals", "Food", "Travel", "Objects", "Symbols"];
|
||||
|
||||
export function EmojiPicker({
|
||||
onPick,
|
||||
onClose,
|
||||
align = "left",
|
||||
}: {
|
||||
onPick: (emoji: string) => void;
|
||||
onClose: () => void;
|
||||
align?: "left" | "right";
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [q, setQ] = useState("");
|
||||
const [pos, setPos] = useState<{ v: "up" | "down"; h: "left" | "right" }>({ v: "up", h: align });
|
||||
|
||||
// auto-place: flip up/down + left/right based on available space (tooltip-style)
|
||||
useLayoutEffect(() => {
|
||||
const wrap = (ref.current?.offsetParent as HTMLElement | null) ?? ref.current;
|
||||
const r = wrap?.getBoundingClientRect();
|
||||
if (!r) return;
|
||||
const H = 300, W = 300;
|
||||
const v = r.top < H + 12 && window.innerHeight - r.bottom > r.top ? "down" : "up";
|
||||
const h = window.innerWidth - r.left < W ? "right" : "left";
|
||||
setPos({ v, h });
|
||||
}, []);
|
||||
|
||||
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(); } };
|
||||
const t = setTimeout(() => document.addEventListener("mousedown", onDoc), 0);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => { clearTimeout(t); document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
|
||||
}, [onClose]);
|
||||
|
||||
const results = useMemo(() => (q ? searchEmojis(q, 64) : null), [q]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`absolute z-40 w-[300px] rounded-control border border-border bg-elevated p-2 shadow-pop animate-slide-up ${pos.v === "up" ? "bottom-9" : "top-9"} ${pos.h === "right" ? "right-0" : "left-0"}`}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2 rounded-control border border-border bg-panel px-2">
|
||||
<Search size={14} className="text-muted" />
|
||||
<input
|
||||
autoFocus
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search emoji…"
|
||||
className="w-full bg-transparent py-1.5 text-[13px] outline-none placeholder:text-dim"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[240px] overflow-y-auto pr-0.5">
|
||||
{results ? (
|
||||
<Grid list={results.map((e) => e.e)} onPick={onPick} />
|
||||
) : (
|
||||
<>
|
||||
<CatLabel>Frequently used</CatLabel>
|
||||
<Grid list={FREQUENT} onPick={onPick} />
|
||||
{CATS.map((c) => (
|
||||
<div key={c}>
|
||||
<CatLabel>{c}</CatLabel>
|
||||
<Grid list={EMOJIS.filter((e) => e.c === c).map((e) => e.e)} onPick={onPick} />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{results && results.length === 0 && <div className="py-6 text-center text-[12.5px] text-muted">No emoji found</div>}
|
||||
</div>
|
||||
<div className="mt-1 border-t border-border px-1 pt-1 text-[10.5px] text-dim">
|
||||
Tip: type <code className="text-accent">:tick:</code> in a message for ✅
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CatLabel({ children }: { children: React.ReactNode }) {
|
||||
return <div className="px-1 pb-0.5 pt-1.5 text-[10px] font-bold uppercase tracking-wide text-dim">{children}</div>;
|
||||
}
|
||||
|
||||
function Grid({ list, onPick }: { list: string[]; onPick: (e: string) => void }) {
|
||||
return (
|
||||
<div className="grid grid-cols-8 gap-0.5">
|
||||
{list.map((e, i) => (
|
||||
<button key={e + i} onClick={() => onPick(e)} className="grid h-8 w-8 place-items-center rounded-md text-lg hover:bg-hover" title={e}>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { LANGUAGES } from "@/lib/languages";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Searchable language dropdown used by the per-message translator and the
|
||||
* composer's translate-before-send flow. Positions itself above or below the
|
||||
* trigger depending on available space (like a tooltip).
|
||||
*/
|
||||
export function LanguageMenu({
|
||||
onPick,
|
||||
onClose,
|
||||
align = "left",
|
||||
}: {
|
||||
onPick: (langName: string) => void;
|
||||
onClose: () => void;
|
||||
align?: "left" | "right";
|
||||
}) {
|
||||
const [q, setQ] = useState("");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [vpos, setVpos] = useState<"up" | "down">("down");
|
||||
|
||||
useEffect(() => {
|
||||
const parent = ref.current?.offsetParent as HTMLElement | null;
|
||||
const r = (parent ?? ref.current)?.getBoundingClientRect();
|
||||
if (r) {
|
||||
const H = 320;
|
||||
setVpos(r.bottom + H > window.innerHeight && r.top > H ? "up" : "down");
|
||||
}
|
||||
}, []);
|
||||
|
||||
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 list = LANGUAGES.filter(
|
||||
(l) => l.name.toLowerCase().includes(q.toLowerCase()) || l.native.toLowerCase().includes(q.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`absolute z-50 w-64 overflow-hidden rounded-control border border-border bg-elevated shadow-pop animate-slide-up ${vpos === "up" ? "bottom-9" : "top-9"} ${align === "right" ? "right-0" : "left-0"}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-2.5 py-2">
|
||||
<Search size={14} className="text-dim" />
|
||||
<input
|
||||
autoFocus
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search language…"
|
||||
className="w-full bg-transparent text-[13px] outline-none placeholder:text-dim"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto py-1">
|
||||
{list.map((l) => (
|
||||
<button
|
||||
key={l.name}
|
||||
onClick={() => onPick(l.name)}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-[13px] hover:bg-hover"
|
||||
>
|
||||
<span className="text-base">{l.flag}</span>
|
||||
<span className="font-medium">{l.name}</span>
|
||||
<span className="ml-auto text-[11.5px] text-dim">{l.native}</span>
|
||||
</button>
|
||||
))}
|
||||
{list.length === 0 && <div className="px-3 py-4 text-center text-[12.5px] text-muted">No match.</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
"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;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useThread, useChannel, useSdk, notifyChannelChanged } from "@lynkd/messaging-inbox-sdk";
|
||||
import { useUi } from "@/lib/ui-state";
|
||||
import { MessageItem } from "./MessageItem";
|
||||
import { Composer } from "./Composer";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
export function ThreadPanel() {
|
||||
const { thread, closeThread, toast } = useUi();
|
||||
const { client } = useSdk();
|
||||
const channel = useChannel(thread?.channelId ?? null);
|
||||
const { parent, replies, reply, react, pin, save, edit, remove } = useThread(
|
||||
thread?.channelId ?? null,
|
||||
thread?.parentId ?? null,
|
||||
);
|
||||
|
||||
if (!thread) return null;
|
||||
const channelLabel = channel ? (channel.kind === "channel" ? `#${channel.name}` : channel.name) : "channel";
|
||||
|
||||
return (
|
||||
<aside
|
||||
role="dialog"
|
||||
aria-label="Thread"
|
||||
className="fixed inset-0 z-40 flex w-full flex-col border-l border-border bg-surface md:static md:z-auto md:w-[400px] md:max-w-[400px] md:shrink-0 animate-fade-in"
|
||||
>
|
||||
<div className="flex h-[60px] items-center justify-between border-b border-border px-4">
|
||||
<div>
|
||||
<div className="text-[15px] font-bold">Thread</div>
|
||||
<div className="text-[12px] text-muted">
|
||||
{channel ? (channel.kind === "channel" ? `#${channel.name}` : channel.name) : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={closeThread} className="focus-ring grid h-8 w-8 place-items-center rounded-control text-muted hover:bg-hover hover:text-ink" aria-label="Close thread">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pb-2">
|
||||
{parent && (
|
||||
<MessageItem
|
||||
message={parent}
|
||||
onReact={(e) => react(parent.id, e)}
|
||||
onPin={() => pin(parent.id)}
|
||||
onSave={() => save(parent.id)}
|
||||
onEdit={(b) => edit(parent.id, b)}
|
||||
onDelete={() => { remove(parent.id); closeThread(); }}
|
||||
/>
|
||||
)}
|
||||
<div className="my-1 flex items-center gap-3 px-4">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-[11px] font-semibold text-muted">
|
||||
{replies.length} {replies.length === 1 ? "reply" : "replies"}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
{replies.map((m) => (
|
||||
<MessageItem
|
||||
key={m.id}
|
||||
message={m}
|
||||
onReact={(e) => react(m.id, e)}
|
||||
onPin={() => pin(m.id)}
|
||||
onSave={() => save(m.id)}
|
||||
onEdit={(b) => edit(m.id, b)}
|
||||
onDelete={() => remove(m.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Composer
|
||||
showAlsoSend
|
||||
channelName={channelLabel}
|
||||
placeholder="Reply…"
|
||||
onSend={async (body, opts) => {
|
||||
await reply(body, { attachments: opts?.attachments });
|
||||
if (opts?.alsoSendToChannel && thread) {
|
||||
await client.sendMessage(thread.channelId, body, { attachments: opts?.attachments, threadRootId: thread.parentId });
|
||||
notifyChannelChanged(thread.channelId);
|
||||
toast(`Also sent to ${channelLabel}`, "success");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user