feat: update workspace channel components (header, sidebar, thread-panel, composer, etc)

This commit is contained in:
zhenyi 2026-05-30 15:08:01 +08:00
parent 04798b5adb
commit b6a4bd0210
7 changed files with 240 additions and 141 deletions

View File

@ -1,45 +1,37 @@
import { Hash, MessageSquare, Settings, Users } from "lucide-react"; import { Hash, Settings, Users } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { Room } from "./channel-sidebar"; import type { Room } from "./channel-sidebar";
import type { Thread } from "./thread-sidebar";
type Props = { type Props = {
room: Room; room: Room;
threads: Thread[];
onToggleThreads: () => void;
onToggleSettings: () => void; onToggleSettings: () => void;
memberCount?: number; memberCount?: number;
}; };
export function ChannelHeader({ export function ChannelHeader({
room, room,
threads,
onToggleThreads,
onToggleSettings, onToggleSettings,
memberCount, memberCount,
}: Props) { }: Props) {
const activeThreads = threads.filter((t) => !t.archived && !t.locked).length;
return ( return (
<header className="flex h-[52px] shrink-0 items-center gap-3 border-b border-border/50 bg-background/70 px-5 backdrop-blur-md"> <header className="flex h-[56px] shrink-0 items-center gap-3 border-b border-border/40 bg-card/60 px-5 backdrop-blur-md">
<div className="flex min-w-0 items-center gap-2.5"> <div className="flex min-w-0 items-center gap-2.5">
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-gradient-to-br from-primary/10 to-primary/[0.03] ring-1 ring-primary/[0.08]"> <span className="grid size-9 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-primary/15 to-primary/[0.04] ring-1 ring-primary/[0.12] shadow-sm">
<Hash className="size-3.5 text-primary/60" /> <Hash className="size-4 text-primary/70" />
</span> </span>
<div className="flex min-w-0 items-baseline gap-2"> <div className="flex min-w-0 items-baseline gap-2">
<h3 className="truncate text-[15px] font-semibold text-foreground tracking-tight"> <h3 className="truncate text-[16px] font-bold text-foreground tracking-tight">
{room.name} {room.name}
</h3> </h3>
{room.topic && ( {room.topic && (
<Tooltip> <Tooltip>
<TooltipTrigger className="hidden sm:block"> <TooltipTrigger className="hidden sm:block">
<span className="max-w-[200px] cursor-default truncate text-[13px] text-muted-foreground/40"> <span className="max-w-[200px] cursor-default truncate text-[13px] text-muted-foreground/35">
{room.topic} {room.topic}
</span> </span>
</TooltipTrigger> </TooltipTrigger>
@ -56,44 +48,17 @@ export function ChannelHeader({
<div className="ml-auto flex items-center gap-0.5"> <div className="ml-auto flex items-center gap-0.5">
{typeof memberCount === "number" && memberCount > 0 && ( {typeof memberCount === "number" && memberCount > 0 && (
<span className="flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-[12px] text-muted-foreground/60"> <span className="flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-[12px] text-muted-foreground/50 hover:bg-accent/40 hover:text-muted-foreground/70 transition-colors cursor-default">
<Users className="size-3.5" /> <Users className="size-3.5" />
<span className="tabular-nums">{memberCount}</span> <span className="tabular-nums font-medium">{memberCount}</span>
</span> </span>
)} )}
<Tooltip>
<TooltipTrigger>
<Button
aria-label="Toggle threads panel"
className={cn(
"relative size-8 cursor-pointer rounded-lg transition-all duration-150",
activeThreads > 0
? "text-foreground hover:bg-accent/50"
: "text-muted-foreground/50 hover:bg-accent/40 hover:text-foreground",
)}
onClick={onToggleThreads}
size="icon"
variant="ghost"
>
<MessageSquare className="size-[18px]" />
{activeThreads > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex size-[18px] items-center justify-center rounded-full bg-primary text-[10px] font-semibold text-primary-foreground ring-[2.5px] ring-background shadow-sm">
{activeThreads}
</span>
)}
</Button>
</TooltipTrigger>
<TooltipContent className="text-xs">
Threads{activeThreads > 0 ? ` (${activeThreads} active)` : ""}
</TooltipContent>
</Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<Button <Button
aria-label="Channel settings" aria-label="Channel settings"
className="size-8 cursor-pointer rounded-lg text-muted-foreground/50 transition-all duration-150 hover:bg-accent/40 hover:text-foreground" className="size-8 cursor-pointer rounded-lg text-muted-foreground/40 transition-all duration-150 hover:bg-accent/40 hover:text-foreground"
onClick={onToggleSettings} onClick={onToggleSettings}
size="icon" size="icon"
variant="ghost" variant="ghost"

View File

@ -53,17 +53,17 @@ function RoomLink({
return ( return (
<Link <Link
className={cn( className={cn(
"group flex h-8 items-center gap-2 rounded-md px-2 text-[13px] transition-all duration-150", "group flex h-8 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-all duration-150",
active active
? "bg-primary/[0.08] font-medium text-foreground" ? "bg-primary/[0.08] font-semibold text-foreground shadow-[inset_0_0_0_1px_rgba(var(--color-primary),0.08)]"
: "text-muted-foreground/80 hover:bg-accent/[0.4] hover:text-foreground", : "text-muted-foreground/70 hover:bg-accent/50 hover:text-foreground",
)} )}
to={`/${projectName}/channel/${room.id}`} to={`/${projectName}/channel/${room.id}`}
> >
<span <span
className={cn( className={cn(
"shrink-0 transition-colors duration-150", "shrink-0 transition-colors duration-150",
active && "text-primary/70", active ? "text-primary/70" : "text-muted-foreground/50 group-hover:text-muted-foreground/70",
)} )}
> >
<RoomIcon type={room.room_type} /> <RoomIcon type={room.room_type} />

View File

@ -1,5 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import ThreadSidebar from "./thread-sidebar";
import ThreadPane from "./thread-pane"; import ThreadPane from "./thread-pane";
import type { Thread } from "./thread-sidebar"; import type { Thread } from "./thread-sidebar";
@ -7,17 +6,51 @@ type Props = {
open: boolean; open: boolean;
threads: Thread[]; threads: Thread[];
roomId: string; roomId: string;
selectedThreadId?: string | null;
initialSeq?: number;
onClose: () => void; onClose: () => void;
}; };
export function ChannelThreadPanel({ open, threads, roomId, onClose }: Props) { export function ChannelThreadPanel({
open,
threads,
roomId,
selectedThreadId,
initialSeq = 0,
onClose,
}: Props) {
const [selectedThread, setSelectedThread] = useState<Thread | null>(null); const [selectedThread, setSelectedThread] = useState<Thread | null>(null);
const prevOpen = useRef(false);
// Auto-select the thread when selectedThreadId is provided
useEffect(() => {
if (selectedThreadId) {
const thread = threads.find((t) => t.id === selectedThreadId);
if (thread) {
setSelectedThread(thread);
} else if (initialSeq > 0) {
setSelectedThread({
id: selectedThreadId,
room: roomId,
seq: 0,
parent_seq: initialSeq,
title: "",
created_by: { id: "", display_name: "", username: "" },
archived: false,
locked: false,
last_message_at: null,
created_at: new Date().toISOString(),
});
}
}
}, [selectedThreadId, threads, roomId, initialSeq]);
// Reset selection when panel closes // Reset selection when panel closes
useEffect(() => { useEffect(() => {
if (!open) { if (!open && prevOpen.current) {
setSelectedThread(null); setSelectedThread(null);
} }
prevOpen.current = open;
}, [open]); }, [open]);
if (!open) return null; if (!open) return null;
@ -26,31 +59,16 @@ export function ChannelThreadPanel({ open, threads, roomId, onClose }: Props) {
<> <>
{/* Backdrop */} {/* Backdrop */}
<div <div
className="absolute inset-0 z-20 bg-background/[0.03] backdrop-blur-[2px]" className="absolute inset-0 z-20 bg-background/50"
onClick={onClose} onClick={onClose}
/> />
{/* Thread Sidebar (list) */} {/* Thread Pane (detail) — slides from right */}
<div <div
className={` className={`
absolute inset-y-0 right-0 z-30 w-80 border-l border-border/30 bg-card shadow-2xl absolute inset-y-0 right-0 z-40 w-[min(460px,88vw)] border-l border-border/30 bg-card shadow-2xl
transition-transform duration-300 ease-out transition-[transform,opacity] duration-300 ease-out
${selectedThread ? "translate-x-full" : "translate-x-0"} ${selectedThread ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"}
`}
>
<ThreadSidebar
onClose={onClose}
onSelect={setSelectedThread}
threads={threads}
/>
</div>
{/* Thread Pane (detail) */}
<div
className={`
absolute inset-y-0 right-0 z-40 w-96 border-l border-border/30 bg-card shadow-2xl
transition-transform duration-300 ease-out
${selectedThread ? "translate-x-0" : "translate-x-full"}
`} `}
> >
{selectedThread && ( {selectedThread && (

View File

@ -108,16 +108,16 @@ export default function MessageComposer({
<div <div
className={cn( className={cn(
"relative rounded-xl border bg-card shadow-sm transition-all duration-200", "relative rounded-2xl border bg-card/80 shadow-sm transition-all duration-200",
"focus-within:border-primary/20 focus-within:shadow-md focus-within:ring-1 focus-within:ring-primary/[0.06]", "focus-within:border-primary/20 focus-within:shadow-md focus-within:ring-1 focus-within:ring-primary/[0.06] focus-within:bg-card",
replyTarget ? "border-primary/[0.12]" : "border-border/50", replyTarget ? "border-primary/[0.12]" : "border-border/40",
)} )}
> >
<Textarea <Textarea
aria-label={`Message #${roomName}`} aria-label={`Message #${roomName}`}
className={cn( className={cn(
"min-h-[48px] max-h-48 resize-none border-0 bg-transparent px-4 py-3 pr-28 text-sm leading-relaxed shadow-none focus-visible:ring-0", "min-h-[52px] max-h-48 resize-none border-0 bg-transparent px-5 py-3.5 pr-28 text-[13px] leading-relaxed shadow-none focus-visible:ring-0",
"placeholder:text-muted-foreground/30", "placeholder:text-muted-foreground/35",
)} )}
disabled={disabled || sending} disabled={disabled || sending}
onChange={(e) => handleInput(e.target.value)} onChange={(e) => handleInput(e.target.value)}
@ -125,7 +125,7 @@ export default function MessageComposer({
placeholder={ placeholder={
replyTarget replyTarget
? `Reply to ${replyAuthorName}` ? `Reply to ${replyAuthorName}`
: `Message #${roomName}` : `Send a message in #${roomName}`
} }
ref={textareaRef} ref={textareaRef}
value={content} value={content}

View File

@ -1,5 +1,7 @@
import { useState } from "react"; import { useCallback, useState } from "react";
import { useParams } from "react-router"; import { useParams } from "react-router";
import { api } from "@/client";
import { MessageSquare } from "lucide-react";
import { useChannelState } from "./use-channel-state"; import { useChannelState } from "./use-channel-state";
import { ChannelHeader } from "./channel-header"; import { ChannelHeader } from "./channel-header";
import { ChannelThreadPanel } from "./channel-thread-panel"; import { ChannelThreadPanel } from "./channel-thread-panel";
@ -10,26 +12,62 @@ export default function ChannelPage() {
const { roomId } = useParams(); const { roomId } = useParams();
const { state, actions } = useChannelState(roomId); const { state, actions } = useChannelState(roomId);
const [showThreads, setShowThreads] = useState(false); const [showThreads, setShowThreads] = useState(false);
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
const [activeThreadSeq, setActiveThreadSeq] = useState<number>(0);
const [showRoomSettings, setShowRoomSettings] = useState(false); const [showRoomSettings, setShowRoomSettings] = useState(false);
const handleStartThread = useCallback(
async (_messageId: string, seq: number) => {
if (!roomId) return;
try {
const res = await api.post(`/api/v1/ws/rooms/${roomId}/threads`, {
parent: seq,
});
const payload = res.data as Record<string, unknown> | undefined;
const eventData = payload?.data as Record<string, unknown> | undefined;
const threadId = eventData?.id as string | undefined;
if (threadId) {
setActiveThreadId(threadId);
setShowThreads(true);
}
} catch {
// ignore websocket will sync threads
}
},
[roomId],
);
const handleViewThread = useCallback((threadId: string, seq: number) => {
setActiveThreadId(threadId);
setActiveThreadSeq(seq);
setShowThreads(true);
}, []);
const closeThreadPanel = useCallback(() => {
setShowThreads(false);
setTimeout(() => {
setActiveThreadId(null);
setActiveThreadSeq(0);
}, 320);
}, []);
return ( return (
<div className="relative flex h-full min-w-0 flex-col overflow-hidden"> <div className="relative flex h-full min-w-0 flex-col overflow-hidden">
{state.currentRoom ? ( {state.currentRoom ? (
<ChannelHeader <ChannelHeader
memberCount={undefined} memberCount={undefined}
onToggleSettings={() => setShowRoomSettings(true)} onToggleSettings={() => setShowRoomSettings(true)}
onToggleThreads={() => setShowThreads((v) => !v)}
room={state.currentRoom} room={state.currentRoom}
threads={state.threads}
/> />
) : ( ) : (
<div className="flex h-[52px] shrink-0 items-center border-b border-border/40 px-4"> <div className="flex h-[52px] shrink-0 items-center border-b border-border/40 px-4">
{state.loadingRooms ? ( {state.loadingRooms ? (
<span className="text-sm text-muted-foreground/40">Loading</span> <span className="text-sm text-muted-foreground/40">Loading</span>
) : ( ) : (
<span className="text-sm text-muted-foreground/40"> <div className="flex items-center gap-2 text-muted-foreground/40">
Select a channel <MessageSquare className="size-4" />
</span> <span className="text-sm">Select a channel to start chatting</span>
</div>
)} )}
</div> </div>
)} )}
@ -45,19 +83,23 @@ export default function ChannelPage() {
onPinToggle={actions.handlePinToggle} onPinToggle={actions.handlePinToggle}
onReactionToggle={actions.handleReactionToggle} onReactionToggle={actions.handleReactionToggle}
onSend={actions.handleSend} onSend={actions.handleSend}
onStartThread={actions.handleStartThread} onStartThread={handleStartThread}
onViewThread={handleViewThread}
onTyping={actions.handleTyping} onTyping={actions.handleTyping}
roomId={roomId ?? ""} roomId={roomId ?? ""}
roomName={state.currentRoom?.name ?? ""} roomName={state.currentRoom?.name ?? ""}
streamingMessages={state.streamingMessages} streamingMessages={state.streamingMessages}
threads={state.threads}
typingText={state.typingText} typingText={state.typingText}
/> />
{roomId && ( {roomId && (
<ChannelThreadPanel <ChannelThreadPanel
onClose={() => setShowThreads(false)} initialSeq={activeThreadSeq}
onClose={closeThreadPanel}
open={showThreads} open={showThreads}
roomId={roomId} roomId={roomId}
selectedThreadId={activeThreadId}
threads={state.threads} threads={state.threads}
/> />
)} )}

View File

@ -25,6 +25,7 @@ import {
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import type { MessageNewService } from "@/socket"; import type { MessageNewService } from "@/socket";
import type { Thread } from "./thread-sidebar";
export function formatTime(iso: string) { export function formatTime(iso: string) {
const d = new Date(iso); const d = new Date(iso);
@ -47,12 +48,12 @@ export function formatDate(iso: string) {
export function DateDivider({ date }: { date: string }) { export function DateDivider({ date }: { date: string }) {
return ( return (
<div className="flex items-center gap-3 px-4 py-4"> <div className="flex items-center gap-3 px-6 py-5">
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-border/50 to-transparent" /> <div className="h-px flex-1 bg-gradient-to-r from-transparent via-border/30 to-border/30" />
<span className="shrink-0 rounded-full bg-muted/40 px-3 py-0.5 text-[11px] font-medium text-muted-foreground/50"> <span className="shrink-0 rounded-lg bg-muted/30 px-3 py-1 text-[11px] font-semibold text-muted-foreground/40 tracking-wide uppercase border border-border/20">
{date} {date}
</span> </span>
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-border/50 to-transparent" /> <div className="h-px flex-1 bg-gradient-to-l from-transparent via-border/30 to-border/30" />
</div> </div>
); );
} }
@ -69,7 +70,9 @@ type Props = {
onDelete?: (messageId: string) => void; onDelete?: (messageId: string) => void;
onEdit?: (messageId: string, content: string) => void; onEdit?: (messageId: string, content: string) => void;
onStartThread?: (messageId: string, seq: number) => void; onStartThread?: (messageId: string, seq: number) => void;
onViewThread?: (threadId: string, seq: number) => void;
onReactionToggle?: (messageId: string, emoji: string, add: boolean) => void; onReactionToggle?: (messageId: string, emoji: string, add: boolean) => void;
threads?: Thread[];
}; };
export default function MessageItem({ export default function MessageItem({
@ -84,7 +87,9 @@ export default function MessageItem({
onDelete, onDelete,
onEdit, onEdit,
onStartThread, onStartThread,
onViewThread,
onReactionToggle, onReactionToggle,
threads,
}: Props) { }: Props) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [editContent, setEditContent] = useState(message.content); const [editContent, setEditContent] = useState(message.content);
@ -99,6 +104,8 @@ export default function MessageItem({
| boolean | boolean
| undefined; | undefined;
const threadForMessage = threads?.find((t) => t.parent_seq === message.seq);
// Look up the replied-to message from the local message list // Look up the replied-to message from the local message list
const repliedMessage = message.in_reply_to const repliedMessage = message.in_reply_to
? messages?.find((m) => m.id === message.in_reply_to) ? messages?.find((m) => m.id === message.in_reply_to)
@ -176,27 +183,27 @@ export default function MessageItem({
className={cn( className={cn(
"group relative flex gap-3 transition-colors duration-150", "group relative flex gap-3 transition-colors duration-150",
isCompact isCompact
? "items-start px-4 py-[1px] hover:bg-accent/[0.03]" ? "items-start px-4 py-0 hover:bg-black/[0.025]"
: "items-start px-4 py-1.5 hover:bg-accent/[0.03]", : "items-start px-4 py-1 hover:bg-black/[0.03]",
)} )}
> >
{isCompact ? ( {isCompact ? (
<div className="w-9 shrink-0 pt-0.5 text-center"> <div className="w-10 shrink-0 text-center">
<span className="text-[10px] text-muted-foreground/0 transition-opacity duration-150 group-hover:text-muted-foreground/40"> <span className="mt-1 inline-block text-[10px] text-muted-foreground/0 transition-opacity duration-150 group-hover:text-muted-foreground/50">
{formatTime(message.send_at)} {formatTime(message.send_at)}
</span> </span>
</div> </div>
) : ( ) : (
<div <div
className={cn( className={cn(
"grid size-9 shrink-0 place-items-center rounded-full bg-gradient-to-br text-sm font-semibold text-white shadow-sm", "grid size-10 shrink-0 place-items-center rounded-[10px] bg-gradient-to-br text-[13px] font-bold text-white shadow-sm ring-1 ring-white/10",
colorClass, colorClass,
)} )}
> >
{message.sender.avatar_url ? ( {message.sender.avatar_url ? (
<img <img
alt={name} alt={name}
className="size-full rounded-full object-cover" className="size-full rounded-[10px] object-cover"
src={message.sender.avatar_url} src={message.sender.avatar_url}
/> />
) : ( ) : (
@ -207,20 +214,20 @@ export default function MessageItem({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{!isCompact && showHeader && ( {!isCompact && showHeader && (
<div className="flex items-baseline gap-2"> <div className="flex items-center gap-2">
<span className="cursor-pointer text-sm font-semibold text-foreground transition-colors hover:text-primary"> <span className="cursor-pointer text-[15px] font-bold text-foreground transition-colors hover:text-primary">
{name} {name}
</span> </span>
<span className="text-[11px] text-muted-foreground/40"> <span className="text-[11px] font-medium text-muted-foreground/35">
{formatTime(message.send_at)} {formatTime(message.send_at)}
</span> </span>
{message.sender_type !== "user" && ( {message.sender_type !== "user" && (
<span className="rounded-full bg-primary/[0.06] px-1.5 py-[1px] text-[10px] font-medium text-primary/70"> <span className="rounded-full border border-primary/10 bg-primary/[0.05] px-1.5 py-[1px] text-[10px] font-semibold uppercase tracking-wide text-primary/60">
{message.sender_type} {message.sender_type}
</span> </span>
)} )}
{isPinned && ( {isPinned && (
<span className="text-amber-500/70" title="Pinned"> <span className="text-amber-500/60" title="Pinned">
<Pin className="size-3" /> <Pin className="size-3" />
</span> </span>
)} )}
@ -231,8 +238,8 @@ export default function MessageItem({
{message.in_reply_to && ( {message.in_reply_to && (
<div <div
className={cn( className={cn(
"mb-0.5 flex items-center gap-1.5 rounded rounded-r-md border-l-2 border-primary/30 bg-muted/[0.15] px-2 py-0.5 text-left", "mb-1 flex items-center gap-1.5 rounded-md border border-border/20 bg-muted/[0.08] px-2.5 py-1 text-left",
repliedMessage && "cursor-pointer transition-colors hover:bg-muted/[0.3]", repliedMessage && "cursor-pointer transition-colors hover:bg-muted/[0.15] hover:border-border/30",
)} )}
onClick={() => { onClick={() => {
if (repliedMessage) onReply?.(repliedMessage); if (repliedMessage) onReply?.(repliedMessage);
@ -240,9 +247,9 @@ export default function MessageItem({
title={replyPreview ? `Replying to: ${replyPreview}` : "Replying to a message"} title={replyPreview ? `Replying to: ${replyPreview}` : "Replying to a message"}
role={repliedMessage ? "button" : undefined} role={repliedMessage ? "button" : undefined}
> >
<CornerDownRight className="size-3 shrink-0 text-primary/40" /> <CornerDownRight className="size-3 shrink-0 text-primary/50" />
{replyAuthor ? ( {replyAuthor ? (
<span className="truncate text-[11px] font-medium text-primary/60"> <span className="truncate text-[11px] font-semibold text-primary/60">
{replyAuthor} {replyAuthor}
</span> </span>
) : ( ) : (
@ -251,7 +258,7 @@ export default function MessageItem({
</span> </span>
)} )}
{replyPreview && ( {replyPreview && (
<span className="truncate text-[11px] text-muted-foreground/35"> <span className="truncate text-[11px] text-muted-foreground/40">
{replyPreview} {replyPreview}
</span> </span>
)} )}
@ -262,7 +269,7 @@ export default function MessageItem({
<div className="mt-1 space-y-2"> <div className="mt-1 space-y-2">
<Textarea <Textarea
autoFocus autoFocus
className="min-h-[60px] resize-none rounded-lg text-sm" className="min-h-[56px] resize-none rounded-lg border-border/60 bg-muted/30 text-sm focus-visible:ring-1 focus-visible:ring-primary/20"
disabled={saving} disabled={saving}
onBlur={handleSaveEdit} onBlur={handleSaveEdit}
onChange={(e) => setEditContent(e.target.value)} onChange={(e) => setEditContent(e.target.value)}
@ -274,38 +281,49 @@ export default function MessageItem({
</div> </div>
</div> </div>
) : message.content_type === "text" || !message.content_type ? ( ) : message.content_type === "text" || !message.content_type ? (
<p className="whitespace-pre-wrap break-words text-[14px] leading-[1.6] text-foreground/90"> <p className="whitespace-pre-wrap break-words text-[13px] leading-[1.55] text-foreground/85">
{message.content} {message.content}
</p> </p>
) : ( ) : (
<p className="whitespace-pre-wrap break-words text-[14px] text-foreground/90"> <p className="whitespace-pre-wrap break-words text-[13px] text-foreground/85">
<span className="rounded bg-muted/50 px-1.5 py-[1px] text-[10px] font-medium uppercase text-muted-foreground/60"> <span className="inline-flex items-center gap-1 rounded bg-muted/40 px-1.5 py-[1px] text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">
{message.content_type} {message.content_type}
</span>{" "} </span>{" "}
{message.content} {message.content}
</p> </p>
)} )}
{threadForMessage && !message.thread && (
<button
className="mt-1.5 flex items-center gap-1.5 rounded-lg border border-border/20 bg-muted/[0.05] px-2.5 py-1 text-[12px] font-medium text-primary/60 transition-all duration-150 hover:bg-primary/[0.05] hover:text-primary hover:border-primary/15 hover:shadow-sm"
onClick={() => onViewThread?.(threadForMessage.id, message.seq)}
type="button"
>
<MessageSquarePlus className="size-3.5" />
<span>View thread</span>
</button>
)}
{message.reactions && message.reactions.length > 0 && ( {message.reactions && message.reactions.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1"> <div className="mt-1.5 flex flex-wrap gap-1.5">
{message.reactions.map((r) => ( {message.reactions.map((r) => (
<button <button
className={cn( className={cn(
"inline-flex cursor-pointer items-center gap-1 rounded-full border px-2 py-[1px] text-xs transition-all duration-150", "inline-flex cursor-pointer items-center gap-1 rounded-lg border px-2 py-[2px] text-xs transition-all duration-150",
r.reacted_by_me r.reacted_by_me
? "border-primary/20 bg-primary/[0.06] text-primary hover:bg-primary/[0.1]" ? "border-primary/15 bg-primary/[0.06] text-primary hover:bg-primary/[0.1]"
: "border-border/40 bg-muted/30 text-muted-foreground/70 hover:border-border/60 hover:bg-accent/40", : "border-border/30 bg-muted/20 text-muted-foreground/60 hover:border-border/50 hover:bg-muted/40",
)} )}
key={r.emoji} key={r.emoji}
onClick={() => handleReaction(r.emoji)} onClick={() => handleReaction(r.emoji)}
title={r.reacted_by_me ? "Click to remove" : "Click to add"} title={r.reacted_by_me ? "Click to remove" : "Click to add"}
> >
<span>{r.emoji}</span> <span className="text-[13px]">{r.emoji}</span>
<span className="text-[11px] tabular-nums">{r.count}</span> <span className="text-[10px] font-semibold tabular-nums">{r.count}</span>
</button> </button>
))} ))}
<ReactionPicker onSelect={(emoji) => handleReaction(emoji)}> <ReactionPicker onSelect={(emoji) => handleReaction(emoji)}>
<button className="inline-flex size-6 cursor-pointer items-center justify-center rounded-full border border-dashed border-border/40 text-muted-foreground/30 opacity-0 transition-all duration-150 hover:border-primary/30 hover:text-primary/60 group-hover:opacity-100"> <button className="inline-flex size-6 cursor-pointer items-center justify-center rounded-lg border border-dashed border-border/30 text-muted-foreground/25 opacity-0 transition-all duration-150 hover:border-primary/25 hover:text-primary/50 group-hover:opacity-100">
<SmilePlus className="size-3" /> <SmilePlus className="size-3" />
</button> </button>
</ReactionPicker> </ReactionPicker>
@ -313,10 +331,10 @@ export default function MessageItem({
)} )}
</div> </div>
<div className="absolute -top-3 right-3 z-10 flex items-center gap-[1px] rounded-lg border border-border/40 bg-background/95 px-0.5 py-0.5 shadow-md backdrop-blur-sm opacity-0 transition-all duration-150 group-hover:opacity-100"> <div className="absolute right-2 top-0 z-10 flex items-center gap-[1px] rounded-lg border border-border/20 bg-card/95 px-1 py-1 shadow-md backdrop-blur-sm opacity-0 transition-all duration-150 group-hover:opacity-100">
<ReactionPicker onSelect={(emoji) => handleReaction(emoji)}> <ReactionPicker onSelect={(emoji) => handleReaction(emoji)}>
<Button <Button
className="size-7 cursor-pointer rounded-md" className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50"
size="icon" size="icon"
title="Add reaction" title="Add reaction"
variant="ghost" variant="ghost"
@ -326,7 +344,7 @@ export default function MessageItem({
</ReactionPicker> </ReactionPicker>
<Button <Button
className="size-7 cursor-pointer rounded-md" className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50"
onClick={() => onReply?.(message)} onClick={() => onReply?.(message)}
size="icon" size="icon"
title="Reply" title="Reply"
@ -336,10 +354,14 @@ export default function MessageItem({
</Button> </Button>
<Button <Button
className="size-7 cursor-pointer rounded-md" className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50"
onClick={() => onStartThread?.(message.id, message.seq)} onClick={() =>
threadForMessage
? onViewThread?.(threadForMessage.id, message.seq)
: onStartThread?.(message.id, message.seq)
}
size="icon" size="icon"
title="Start thread" title={threadForMessage ? "View thread" : "Start thread"}
variant="ghost" variant="ghost"
> >
<MessageSquarePlus className="size-3.5" /> <MessageSquarePlus className="size-3.5" />
@ -348,7 +370,7 @@ export default function MessageItem({
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<Button <Button
className="size-7 cursor-pointer rounded-md" className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50"
size="icon" size="icon"
title="More" title="More"
variant="ghost" variant="ghost"
@ -419,10 +441,14 @@ export default function MessageItem({
<DropdownMenuItem <DropdownMenuItem
className="cursor-pointer text-[13px]" className="cursor-pointer text-[13px]"
onClick={() => onStartThread?.(message.id, message.seq)} onClick={() =>
threadForMessage
? onViewThread?.(threadForMessage.id, message.seq)
: onStartThread?.(message.id, message.seq)
}
> >
<MessageSquarePlus className="mr-2 size-4" /> <MessageSquarePlus className="mr-2 size-4" />
Start thread {threadForMessage ? "View thread" : "Start thread"}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@ -80,6 +80,44 @@ export function useChannelState(roomId: string | undefined) {
const lastSeq = useRef(0); const lastSeq = useRef(0);
const prevRoomId = useRef<string | undefined>(undefined); const prevRoomId = useRef<string | undefined>(undefined);
// Load threads for current room
const { data: threadsResponse } = useQuery({
queryKey: ["threads", roomId],
queryFn: async () => {
const response = await api.get(
`/api/v1/ws/rooms/${roomId}/threads`,
);
// REST returns WsOutEvent::ThreadList { type, data: { threads } }
const payload = response.data as Record<string, unknown> | undefined;
const inner = payload?.data as Record<string, unknown> | undefined;
const list = (inner?.threads ?? []) as Array<Record<string, unknown>>;
// Normalize: room comes back as RoomInfo { id, name } object, need string UUID
return list.map((t) => ({
...t,
room:
typeof t.room === "object" && t.room !== null
? ((t.room as Record<string, unknown>).id as string)
: t.room,
})) as Thread[];
},
enabled: Boolean(roomId),
retry: false,
staleTime: 5000,
});
// Merge fetched threads with websocket events
useEffect(() => {
if (threadsResponse && threadsResponse.length > 0) {
setThreads((prev) => {
const existingIds = new Set(prev.map((t) => t.id));
const newThreads = threadsResponse.filter(
(t: Thread) => !existingIds.has(t.id),
);
return newThreads.length > 0 ? [...prev, ...newThreads] : prev;
});
}
}, [threadsResponse]);
const { data: channelData, isLoading: loadingRooms } = useQuery({ const { data: channelData, isLoading: loadingRooms } = useQuery({
queryKey: ["channel", "rooms"], queryKey: ["channel", "rooms"],
queryFn: async () => { queryFn: async () => {
@ -113,16 +151,19 @@ export function useChannelState(roomId: string | undefined) {
const result = (response.data as Record<string, unknown>)?.data as { messages?: MessageNewService[] } | undefined; const result = (response.data as Record<string, unknown>)?.data as { messages?: MessageNewService[] } | undefined;
if (result?.messages) { if (result?.messages) {
const msgs = result.messages as MessageNewService[]; const allMsgs = result.messages as MessageNewService[];
messageCache.persistBatch(msgs).catch(() => {}); // Persist all messages (including thread) to cache
messageCache.persistBatch(allMsgs).catch(() => {});
// Only show non-thread messages in main channel view
const channelMsgs = allMsgs.filter((m) => !m.thread);
if (beforeSeq) { if (beforeSeq) {
setMessages((prev) => [...msgs, ...prev]); setMessages((prev) => [...channelMsgs, ...prev]);
setHasMore(msgs.length >= 50); setHasMore(allMsgs.length >= 50);
} else { } else {
setMessages(msgs); setMessages(channelMsgs);
setHasMore(msgs.length >= 50); setHasMore(allMsgs.length >= 50);
if (msgs.length > 0) { if (allMsgs.length > 0) {
lastSeq.current = msgs[msgs.length - 1].seq; lastSeq.current = allMsgs[allMsgs.length - 1].seq;
} }
} }
} }
@ -167,10 +208,13 @@ export function useChannelState(roomId: string | undefined) {
const msg = event.data as MessageNewService; const msg = event.data as MessageNewService;
messageCache.persistMessage(msg).catch(() => { messageCache.persistMessage(msg).catch(() => {
}); });
setMessages((prev) => { // Only add to main channel if message is NOT in a thread
if (prev.some((m) => m.id === msg.id)) return prev; if (!msg.thread) {
return [...prev, msg]; setMessages((prev) => {
}); if (prev.some((m) => m.id === msg.id)) return prev;
return [...prev, msg];
});
}
if (msg.seq > lastSeq.current) lastSeq.current = msg.seq; if (msg.seq > lastSeq.current) lastSeq.current = msg.seq;
break; break;
} }
@ -373,7 +417,8 @@ export function useChannelState(roomId: string | undefined) {
{ {
id: tc.id, id: tc.id,
room: tc.room.id, room: tc.room.id,
seq: tc.parent, seq: 0,
parent_seq: tc.parent,
title: "", title: "",
created_by: tc.created_by, created_by: tc.created_by,
archived: false, archived: false,
@ -518,11 +563,14 @@ export function useChannelState(roomId: string | undefined) {
try { try {
const body: Record<string, unknown> = { content, content_type: "text" }; const body: Record<string, unknown> = { content, content_type: "text" };
if (inReplyTo) body.in_reply_to = inReplyTo; if (inReplyTo) body.in_reply_to = inReplyTo;
const res = await api.post<{ data?: MessageNewService }>( const res = await api.post(
`/api/v1/ws/rooms/${roomId}/messages`, `/api/v1/ws/rooms/${roomId}/messages`,
body, body,
); );
const msg = res.data?.data; // res.data = WsOutEvent::MessageNew { type, room, data: MessageNewService }
const msg = (res.data as Record<string, unknown>)?.data as
| MessageNewService
| undefined;
if (msg) { if (msg) {
messageCache.persistMessage(msg).catch(() => {}); messageCache.persistMessage(msg).catch(() => {});
setMessages((prev) => { setMessages((prev) => {