"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(null); const scrollRef = useRef(null); const searchParams = useSearchParams(); const jumpMsg = searchParams.get("msg"); const router = useRouter(); const pathname = usePathname(); const [chatLang, setChatLang] = useState(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= 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("[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 (
Select a conversation
); } return (
{channel.kind === "channel" && } {channel.kind === "private" && } {isDm && other && } {title} } subtitle={channel.topic ?? (other ? `${other.title} · ${other.presence}` : undefined)} > {!isDm && ( )} {!isDm && ( openModal("channel-members", channelId)}> )} {flags.huddles && ( startHuddle(channelId)}> )} {flags.huddles && ( startHuddle(channelId)} className="hidden sm:grid"> )} {flags.translation && (
setLangOpen((o) => !o)} className={chatLang ? "text-accent" : undefined}> {langOpen && ( { setChatLang(lang); setLangOpen(false); }} onClose={() => setLangOpen(false)} /> )}
)} openModal("channel-details", channelId)}>
{/* whole-conversation translation banner */} {chatLang && (
Conversation translated to {chatLang}
)} {/* pinned bar */} {flags.pins && pinned.length > 0 && (
{pinned.length} pinned · {pinned[0].body}
)} {/* messages */}
{grouped.map((g) => (
{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 ( 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)} /> ); })}
))}
{/* typing indicator — fixed just above the composer */}
{flags.typingIndicator && typing.length > 0 && }
{ await send(body, opts); }} onStartHuddle={() => startHuddle(channelId)} />
); } function DayDivider({ label }: { label: string }) { return (
{label}
); } 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.
{typers.map((u, i) => ( {u.name} {i < typers.length - 1 ? ", " : ""} ))} {" "} {typers.length === 1 ? "is" : "are"} typing…
); } function ChannelIntro({ channelName, isDm }: { channelName: string; isDm: boolean }) { return (
{channelName.slice(0, 1).toUpperCase()}

{isDm ? channelName : `#${channelName}`}

{isDm ? `This is the beginning of your direct message history with ${channelName}.` : `This is the very beginning of the #${channelName} channel. Say hello 👋`}

); }