feat: update workspace channel components (header, sidebar, thread-panel, composer, etc)
This commit is contained in:
parent
04798b5adb
commit
b6a4bd0210
@ -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 {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Room } from "./channel-sidebar";
|
||||
import type { Thread } from "./thread-sidebar";
|
||||
|
||||
type Props = {
|
||||
room: Room;
|
||||
threads: Thread[];
|
||||
onToggleThreads: () => void;
|
||||
onToggleSettings: () => void;
|
||||
memberCount?: number;
|
||||
};
|
||||
|
||||
export function ChannelHeader({
|
||||
room,
|
||||
threads,
|
||||
onToggleThreads,
|
||||
onToggleSettings,
|
||||
memberCount,
|
||||
}: Props) {
|
||||
const activeThreads = threads.filter((t) => !t.archived && !t.locked).length;
|
||||
|
||||
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">
|
||||
<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]">
|
||||
<Hash className="size-3.5 text-primary/60" />
|
||||
<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-4 text-primary/70" />
|
||||
</span>
|
||||
<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}
|
||||
</h3>
|
||||
{room.topic && (
|
||||
<Tooltip>
|
||||
<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}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
@ -56,44 +48,17 @@ export function ChannelHeader({
|
||||
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
{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" />
|
||||
<span className="tabular-nums">{memberCount}</span>
|
||||
<span className="tabular-nums font-medium">{memberCount}</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>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
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}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
|
||||
@ -53,17 +53,17 @@ function RoomLink({
|
||||
return (
|
||||
<Link
|
||||
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
|
||||
? "bg-primary/[0.08] font-medium text-foreground"
|
||||
: "text-muted-foreground/80 hover:bg-accent/[0.4] hover: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/70 hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
to={`/${projectName}/channel/${room.id}`}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"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} />
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import ThreadSidebar from "./thread-sidebar";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import ThreadPane from "./thread-pane";
|
||||
import type { Thread } from "./thread-sidebar";
|
||||
|
||||
@ -7,17 +6,51 @@ type Props = {
|
||||
open: boolean;
|
||||
threads: Thread[];
|
||||
roomId: string;
|
||||
selectedThreadId?: string | null;
|
||||
initialSeq?: number;
|
||||
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 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
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
if (!open && prevOpen.current) {
|
||||
setSelectedThread(null);
|
||||
}
|
||||
prevOpen.current = open;
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
@ -26,31 +59,16 @@ export function ChannelThreadPanel({ open, threads, roomId, onClose }: Props) {
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<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}
|
||||
/>
|
||||
|
||||
{/* Thread Sidebar (list) */}
|
||||
{/* Thread Pane (detail) — slides from right */}
|
||||
<div
|
||||
className={`
|
||||
absolute inset-y-0 right-0 z-30 w-80 border-l border-border/30 bg-card shadow-2xl
|
||||
transition-transform duration-300 ease-out
|
||||
${selectedThread ? "translate-x-full" : "translate-x-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"}
|
||||
absolute inset-y-0 right-0 z-40 w-[min(460px,88vw)] border-l border-border/30 bg-card shadow-2xl
|
||||
transition-[transform,opacity] duration-300 ease-out
|
||||
${selectedThread ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"}
|
||||
`}
|
||||
>
|
||||
{selectedThread && (
|
||||
|
||||
@ -108,16 +108,16 @@ export default function MessageComposer({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative rounded-xl border bg-card 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]",
|
||||
replyTarget ? "border-primary/[0.12]" : "border-border/50",
|
||||
"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:bg-card",
|
||||
replyTarget ? "border-primary/[0.12]" : "border-border/40",
|
||||
)}
|
||||
>
|
||||
<Textarea
|
||||
aria-label={`Message #${roomName}`}
|
||||
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",
|
||||
"placeholder:text-muted-foreground/30",
|
||||
"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/35",
|
||||
)}
|
||||
disabled={disabled || sending}
|
||||
onChange={(e) => handleInput(e.target.value)}
|
||||
@ -125,7 +125,7 @@ export default function MessageComposer({
|
||||
placeholder={
|
||||
replyTarget
|
||||
? `Reply to ${replyAuthorName}…`
|
||||
: `Message #${roomName}`
|
||||
: `Send a message in #${roomName}`
|
||||
}
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { api } from "@/client";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { useChannelState } from "./use-channel-state";
|
||||
import { ChannelHeader } from "./channel-header";
|
||||
import { ChannelThreadPanel } from "./channel-thread-panel";
|
||||
@ -10,26 +12,62 @@ export default function ChannelPage() {
|
||||
const { roomId } = useParams();
|
||||
const { state, actions } = useChannelState(roomId);
|
||||
const [showThreads, setShowThreads] = useState(false);
|
||||
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
|
||||
const [activeThreadSeq, setActiveThreadSeq] = useState<number>(0);
|
||||
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 (
|
||||
<div className="relative flex h-full min-w-0 flex-col overflow-hidden">
|
||||
{state.currentRoom ? (
|
||||
<ChannelHeader
|
||||
memberCount={undefined}
|
||||
onToggleSettings={() => setShowRoomSettings(true)}
|
||||
onToggleThreads={() => setShowThreads((v) => !v)}
|
||||
room={state.currentRoom}
|
||||
threads={state.threads}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-[52px] shrink-0 items-center border-b border-border/40 px-4">
|
||||
{state.loadingRooms ? (
|
||||
<span className="text-sm text-muted-foreground/40">Loading…</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground/40">
|
||||
Select a channel
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-muted-foreground/40">
|
||||
<MessageSquare className="size-4" />
|
||||
<span className="text-sm">Select a channel to start chatting</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@ -45,19 +83,23 @@ export default function ChannelPage() {
|
||||
onPinToggle={actions.handlePinToggle}
|
||||
onReactionToggle={actions.handleReactionToggle}
|
||||
onSend={actions.handleSend}
|
||||
onStartThread={actions.handleStartThread}
|
||||
onStartThread={handleStartThread}
|
||||
onViewThread={handleViewThread}
|
||||
onTyping={actions.handleTyping}
|
||||
roomId={roomId ?? ""}
|
||||
roomName={state.currentRoom?.name ?? ""}
|
||||
streamingMessages={state.streamingMessages}
|
||||
threads={state.threads}
|
||||
typingText={state.typingText}
|
||||
/>
|
||||
|
||||
{roomId && (
|
||||
<ChannelThreadPanel
|
||||
onClose={() => setShowThreads(false)}
|
||||
initialSeq={activeThreadSeq}
|
||||
onClose={closeThreadPanel}
|
||||
open={showThreads}
|
||||
roomId={roomId}
|
||||
selectedThreadId={activeThreadId}
|
||||
threads={state.threads}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -25,6 +25,7 @@ import {
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { MessageNewService } from "@/socket";
|
||||
import type { Thread } from "./thread-sidebar";
|
||||
|
||||
export function formatTime(iso: string) {
|
||||
const d = new Date(iso);
|
||||
@ -47,12 +48,12 @@ export function formatDate(iso: string) {
|
||||
|
||||
export function DateDivider({ date }: { date: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-4">
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-border/50 to-transparent" />
|
||||
<span className="shrink-0 rounded-full bg-muted/40 px-3 py-0.5 text-[11px] font-medium text-muted-foreground/50">
|
||||
<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/30 to-border/30" />
|
||||
<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}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -69,7 +70,9 @@ type Props = {
|
||||
onDelete?: (messageId: string) => void;
|
||||
onEdit?: (messageId: string, content: string) => void;
|
||||
onStartThread?: (messageId: string, seq: number) => void;
|
||||
onViewThread?: (threadId: string, seq: number) => void;
|
||||
onReactionToggle?: (messageId: string, emoji: string, add: boolean) => void;
|
||||
threads?: Thread[];
|
||||
};
|
||||
|
||||
export default function MessageItem({
|
||||
@ -84,7 +87,9 @@ export default function MessageItem({
|
||||
onDelete,
|
||||
onEdit,
|
||||
onStartThread,
|
||||
onViewThread,
|
||||
onReactionToggle,
|
||||
threads,
|
||||
}: Props) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editContent, setEditContent] = useState(message.content);
|
||||
@ -99,6 +104,8 @@ export default function MessageItem({
|
||||
| boolean
|
||||
| undefined;
|
||||
|
||||
const threadForMessage = threads?.find((t) => t.parent_seq === message.seq);
|
||||
|
||||
// Look up the replied-to message from the local message list
|
||||
const repliedMessage = message.in_reply_to
|
||||
? messages?.find((m) => m.id === message.in_reply_to)
|
||||
@ -176,27 +183,27 @@ export default function MessageItem({
|
||||
className={cn(
|
||||
"group relative flex gap-3 transition-colors duration-150",
|
||||
isCompact
|
||||
? "items-start px-4 py-[1px] hover:bg-accent/[0.03]"
|
||||
: "items-start px-4 py-1.5 hover:bg-accent/[0.03]",
|
||||
? "items-start px-4 py-0 hover:bg-black/[0.025]"
|
||||
: "items-start px-4 py-1 hover:bg-black/[0.03]",
|
||||
)}
|
||||
>
|
||||
{isCompact ? (
|
||||
<div className="w-9 shrink-0 pt-0.5 text-center">
|
||||
<span className="text-[10px] text-muted-foreground/0 transition-opacity duration-150 group-hover:text-muted-foreground/40">
|
||||
<div className="w-10 shrink-0 text-center">
|
||||
<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)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
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,
|
||||
)}
|
||||
>
|
||||
{message.sender.avatar_url ? (
|
||||
<img
|
||||
alt={name}
|
||||
className="size-full rounded-full object-cover"
|
||||
className="size-full rounded-[10px] object-cover"
|
||||
src={message.sender.avatar_url}
|
||||
/>
|
||||
) : (
|
||||
@ -207,20 +214,20 @@ export default function MessageItem({
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{!isCompact && showHeader && (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="cursor-pointer text-sm font-semibold text-foreground transition-colors hover:text-primary">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="cursor-pointer text-[15px] font-bold text-foreground transition-colors hover:text-primary">
|
||||
{name}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground/40">
|
||||
<span className="text-[11px] font-medium text-muted-foreground/35">
|
||||
{formatTime(message.send_at)}
|
||||
</span>
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
{isPinned && (
|
||||
<span className="text-amber-500/70" title="Pinned">
|
||||
<span className="text-amber-500/60" title="Pinned">
|
||||
<Pin className="size-3" />
|
||||
</span>
|
||||
)}
|
||||
@ -231,8 +238,8 @@ export default function MessageItem({
|
||||
{message.in_reply_to && (
|
||||
<div
|
||||
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",
|
||||
repliedMessage && "cursor-pointer transition-colors hover:bg-muted/[0.3]",
|
||||
"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.15] hover:border-border/30",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (repliedMessage) onReply?.(repliedMessage);
|
||||
@ -240,9 +247,9 @@ export default function MessageItem({
|
||||
title={replyPreview ? `Replying to: ${replyPreview}` : "Replying to a message"}
|
||||
role={repliedMessage ? "button" : undefined}
|
||||
>
|
||||
<CornerDownRight className="size-3 shrink-0 text-primary/40" />
|
||||
<CornerDownRight className="size-3 shrink-0 text-primary/50" />
|
||||
{replyAuthor ? (
|
||||
<span className="truncate text-[11px] font-medium text-primary/60">
|
||||
<span className="truncate text-[11px] font-semibold text-primary/60">
|
||||
{replyAuthor}
|
||||
</span>
|
||||
) : (
|
||||
@ -251,7 +258,7 @@ export default function MessageItem({
|
||||
</span>
|
||||
)}
|
||||
{replyPreview && (
|
||||
<span className="truncate text-[11px] text-muted-foreground/35">
|
||||
<span className="truncate text-[11px] text-muted-foreground/40">
|
||||
{replyPreview}
|
||||
</span>
|
||||
)}
|
||||
@ -262,7 +269,7 @@ export default function MessageItem({
|
||||
<div className="mt-1 space-y-2">
|
||||
<Textarea
|
||||
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}
|
||||
onBlur={handleSaveEdit}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
@ -274,38 +281,49 @@ export default function MessageItem({
|
||||
</div>
|
||||
</div>
|
||||
) : 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}
|
||||
</p>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap break-words text-[14px] text-foreground/90">
|
||||
<span className="rounded bg-muted/50 px-1.5 py-[1px] text-[10px] font-medium uppercase text-muted-foreground/60">
|
||||
<p className="whitespace-pre-wrap break-words text-[13px] text-foreground/85">
|
||||
<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}
|
||||
</span>{" "}
|
||||
{message.content}
|
||||
</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 && (
|
||||
<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) => (
|
||||
<button
|
||||
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
|
||||
? "border-primary/20 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-primary/15 bg-primary/[0.06] text-primary hover:bg-primary/[0.1]"
|
||||
: "border-border/30 bg-muted/20 text-muted-foreground/60 hover:border-border/50 hover:bg-muted/40",
|
||||
)}
|
||||
key={r.emoji}
|
||||
onClick={() => handleReaction(r.emoji)}
|
||||
title={r.reacted_by_me ? "Click to remove" : "Click to add"}
|
||||
>
|
||||
<span>{r.emoji}</span>
|
||||
<span className="text-[11px] tabular-nums">{r.count}</span>
|
||||
<span className="text-[13px]">{r.emoji}</span>
|
||||
<span className="text-[10px] font-semibold tabular-nums">{r.count}</span>
|
||||
</button>
|
||||
))}
|
||||
<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" />
|
||||
</button>
|
||||
</ReactionPicker>
|
||||
@ -313,10 +331,10 @@ export default function MessageItem({
|
||||
)}
|
||||
</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)}>
|
||||
<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"
|
||||
title="Add reaction"
|
||||
variant="ghost"
|
||||
@ -326,7 +344,7 @@ export default function MessageItem({
|
||||
</ReactionPicker>
|
||||
|
||||
<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)}
|
||||
size="icon"
|
||||
title="Reply"
|
||||
@ -336,10 +354,14 @@ export default function MessageItem({
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="size-7 cursor-pointer rounded-md"
|
||||
onClick={() => onStartThread?.(message.id, message.seq)}
|
||||
className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50"
|
||||
onClick={() =>
|
||||
threadForMessage
|
||||
? onViewThread?.(threadForMessage.id, message.seq)
|
||||
: onStartThread?.(message.id, message.seq)
|
||||
}
|
||||
size="icon"
|
||||
title="Start thread"
|
||||
title={threadForMessage ? "View thread" : "Start thread"}
|
||||
variant="ghost"
|
||||
>
|
||||
<MessageSquarePlus className="size-3.5" />
|
||||
@ -348,7 +370,7 @@ export default function MessageItem({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<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"
|
||||
title="More"
|
||||
variant="ghost"
|
||||
@ -419,10 +441,14 @@ export default function MessageItem({
|
||||
|
||||
<DropdownMenuItem
|
||||
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" />
|
||||
Start thread
|
||||
{threadForMessage ? "View thread" : "Start thread"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@ -80,6 +80,44 @@ export function useChannelState(roomId: string | undefined) {
|
||||
const lastSeq = useRef(0);
|
||||
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({
|
||||
queryKey: ["channel", "rooms"],
|
||||
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;
|
||||
if (result?.messages) {
|
||||
const msgs = result.messages as MessageNewService[];
|
||||
messageCache.persistBatch(msgs).catch(() => {});
|
||||
const allMsgs = result.messages as MessageNewService[];
|
||||
// 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) {
|
||||
setMessages((prev) => [...msgs, ...prev]);
|
||||
setHasMore(msgs.length >= 50);
|
||||
setMessages((prev) => [...channelMsgs, ...prev]);
|
||||
setHasMore(allMsgs.length >= 50);
|
||||
} else {
|
||||
setMessages(msgs);
|
||||
setHasMore(msgs.length >= 50);
|
||||
if (msgs.length > 0) {
|
||||
lastSeq.current = msgs[msgs.length - 1].seq;
|
||||
setMessages(channelMsgs);
|
||||
setHasMore(allMsgs.length >= 50);
|
||||
if (allMsgs.length > 0) {
|
||||
lastSeq.current = allMsgs[allMsgs.length - 1].seq;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -167,10 +208,13 @@ export function useChannelState(roomId: string | undefined) {
|
||||
const msg = event.data as MessageNewService;
|
||||
messageCache.persistMessage(msg).catch(() => {
|
||||
});
|
||||
// Only add to main channel if message is NOT in a thread
|
||||
if (!msg.thread) {
|
||||
setMessages((prev) => {
|
||||
if (prev.some((m) => m.id === msg.id)) return prev;
|
||||
return [...prev, msg];
|
||||
});
|
||||
}
|
||||
if (msg.seq > lastSeq.current) lastSeq.current = msg.seq;
|
||||
break;
|
||||
}
|
||||
@ -373,7 +417,8 @@ export function useChannelState(roomId: string | undefined) {
|
||||
{
|
||||
id: tc.id,
|
||||
room: tc.room.id,
|
||||
seq: tc.parent,
|
||||
seq: 0,
|
||||
parent_seq: tc.parent,
|
||||
title: "",
|
||||
created_by: tc.created_by,
|
||||
archived: false,
|
||||
@ -518,11 +563,14 @@ export function useChannelState(roomId: string | undefined) {
|
||||
try {
|
||||
const body: Record<string, unknown> = { content, content_type: "text" };
|
||||
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`,
|
||||
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) {
|
||||
messageCache.persistMessage(msg).catch(() => {});
|
||||
setMessages((prev) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user