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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user