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 { 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"
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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(() => {
|
||||||
});
|
});
|
||||||
|
// Only add to main channel if message is NOT in a thread
|
||||||
|
if (!msg.thread) {
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
if (prev.some((m) => m.id === msg.id)) return prev;
|
if (prev.some((m) => m.id === msg.id)) return prev;
|
||||||
return [...prev, msg];
|
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) => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user