Files
message-inbox-web-frontend-sdk/apps/web/components/message/ChannelView.tsx
T
2026-07-17 21:48:37 +05:30

296 lines
12 KiB
TypeScript

"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>
);
}