1076 lines
37 KiB
TypeScript
1076 lines
37 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
|
|
import Link from "next/link";
|
||
|
|
import { useSession } from "next-auth/react";
|
||
|
|
import { useChatSubscription } from "@/lib/use-chat-subscription";
|
||
|
|
import { useRouter } from "next/navigation";
|
||
|
|
import { useQueryClient } from "@tanstack/react-query";
|
||
|
|
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
|
|
import { ChatComposer } from "./chat-composer";
|
||
|
|
import { useChatMutationRecovery } from "./use-chat-mutation-recovery";
|
||
|
|
import { ChatMessageList, type ChatMessageView } from "./chat-message-list";
|
||
|
|
import {
|
||
|
|
advanceCatchUpWindow,
|
||
|
|
chatBadge,
|
||
|
|
createChatScope,
|
||
|
|
mergeChatMessages,
|
||
|
|
messageError,
|
||
|
|
rootReadIsProven,
|
||
|
|
type CatchUpWindow,
|
||
|
|
} from "./chat-utils";
|
||
|
|
import { api } from "@/lib/trpc";
|
||
|
|
|
||
|
|
const PAGE_SIZE = 50;
|
||
|
|
|
||
|
|
export function ReadFailureAlert({ onRetry }: { onRetry: () => void }) {
|
||
|
|
return (
|
||
|
|
<div role="alert" className="p-3 text-sm">
|
||
|
|
Could not mark messages read. Unread status has not advanced.{" "}
|
||
|
|
<Button variant="link" onClick={onRetry}>
|
||
|
|
Retry
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
type PendingMessage = {
|
||
|
|
key: string;
|
||
|
|
body: string;
|
||
|
|
parentMessageId?: string;
|
||
|
|
failed: boolean;
|
||
|
|
error?: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
export function ChatWorkspace(props: { workspace: string; channelId?: string }) {
|
||
|
|
const userId = useSession().data?.user?.id;
|
||
|
|
return <ChatWorkspaceScope key={JSON.stringify([userId, props.workspace, props.channelId])} {...props} />;
|
||
|
|
}
|
||
|
|
|
||
|
|
function ChatWorkspaceScope({
|
||
|
|
workspace,
|
||
|
|
channelId,
|
||
|
|
}: {
|
||
|
|
workspace: string;
|
||
|
|
channelId?: string;
|
||
|
|
}) {
|
||
|
|
const router = useRouter();
|
||
|
|
const currentUserId = useSession().data?.user?.id;
|
||
|
|
const utils = api.useUtils();
|
||
|
|
const queryClient = useQueryClient();
|
||
|
|
const scopeRef = useRef(createChatScope(workspace, channelId));
|
||
|
|
// Render-time selection closes the gap before effects run after a route change.
|
||
|
|
scopeRef.current.select(workspace, channelId);
|
||
|
|
useEffect(() => {
|
||
|
|
const scope = createChatScope(workspace, channelId);
|
||
|
|
scopeRef.current = scope;
|
||
|
|
return () => scope.dispose();
|
||
|
|
}, [workspace, channelId]);
|
||
|
|
const revoked = scopeRef.current.isRevoked();
|
||
|
|
const purge = useCallback(() => {
|
||
|
|
if (scopeRef.current.isRevoked()) return;
|
||
|
|
scopeRef.current.revoke();
|
||
|
|
const valid = scopeRef.current.captureCleanup();
|
||
|
|
// Cancel in-flight reads before clearing cached chat data; stale responses
|
||
|
|
// must not repopulate a revoked conversation during navigation.
|
||
|
|
const chatQuery = {
|
||
|
|
predicate: (query: { queryKey: readonly unknown[] }) => {
|
||
|
|
const path = query.queryKey[0];
|
||
|
|
const options = query.queryKey[1];
|
||
|
|
return Array.isArray(path) && path[0] === "chat" &&
|
||
|
|
!!options && typeof options === "object" && "input" in options &&
|
||
|
|
!!options.input && typeof options.input === "object" &&
|
||
|
|
"workspace" in options.input && options.input.workspace === workspace;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
void queryClient.cancelQueries(chatQuery).then(() => {
|
||
|
|
if (valid()) queryClient.removeQueries(chatQuery);
|
||
|
|
});
|
||
|
|
setRecent([]);
|
||
|
|
setPending([]);
|
||
|
|
setMessageChange(null);
|
||
|
|
setOlderRoots([]);
|
||
|
|
setLatestRoots([]);
|
||
|
|
setOlderReplies([]);
|
||
|
|
setLatestReplies([]);
|
||
|
|
setRefreshedRoot(null);
|
||
|
|
setThreadId(null);
|
||
|
|
router.replace(`/${workspace}/chat`);
|
||
|
|
}, [queryClient, router, workspace]);
|
||
|
|
const resolved = api.workspaces.resolve.useQuery({ handle: workspace });
|
||
|
|
const viewer = api.chat.viewer.useQuery({ workspace });
|
||
|
|
const channels = api.chat.listChannels.useQuery(
|
||
|
|
{ workspace },
|
||
|
|
{ refetchInterval: 15000 },
|
||
|
|
);
|
||
|
|
const summary = api.chat.unreadSummary.useQuery(
|
||
|
|
{ workspace },
|
||
|
|
{ refetchInterval: 15000 },
|
||
|
|
);
|
||
|
|
const channel = channels.data?.find((item) => item.id === channelId);
|
||
|
|
useEffect(() => {
|
||
|
|
if (channelId && channels.data && !channel && !channels.isError) purge();
|
||
|
|
}, [channelId, channels.data, channels.isError, channel, purge]);
|
||
|
|
const readable = !!channel && !!resolved.data && !revoked;
|
||
|
|
const recovery = useChatMutationRecovery({ workspace, channelId, enabled: readable });
|
||
|
|
const recoveryRefresh = recovery.refresh;
|
||
|
|
useChatSubscription(workspace, channels.data?.map((item) => item.id) ?? [], recoveryRefresh);
|
||
|
|
const denied = (
|
||
|
|
error: { data?: { code?: string } | null } | null | undefined,
|
||
|
|
) => error?.data?.code === "NOT_FOUND" || error?.data?.code === "FORBIDDEN";
|
||
|
|
const scope = { workspace, channelId: channelId ?? "" };
|
||
|
|
const [before, setBefore] = useState<string | undefined>();
|
||
|
|
const [olderRoots, setOlderRoots] = useState<ChatMessageView[]>([]);
|
||
|
|
const [latestRoots, setLatestRoots] = useState<ChatMessageView[]>([]);
|
||
|
|
const history = api.chat.history.useQuery(
|
||
|
|
{ ...scope, limit: PAGE_SIZE, before },
|
||
|
|
{ enabled: readable },
|
||
|
|
);
|
||
|
|
const [threadId, setThreadId] = useState<string | null>(null);
|
||
|
|
const threadScope = useRef(createChatScope(workspace, threadId ?? undefined));
|
||
|
|
threadScope.current.select(workspace, threadId ?? undefined);
|
||
|
|
const [threadBefore, setThreadBefore] = useState<string | undefined>();
|
||
|
|
const [olderReplies, setOlderReplies] = useState<ChatMessageView[]>([]);
|
||
|
|
const [latestReplies, setLatestReplies] = useState<ChatMessageView[]>([]);
|
||
|
|
const [refreshedRoot, setRefreshedRoot] = useState<ChatMessageView | null>(
|
||
|
|
null,
|
||
|
|
);
|
||
|
|
const thread = api.chat.threadHistory.useQuery(
|
||
|
|
{
|
||
|
|
...scope,
|
||
|
|
rootMessageId: threadId ?? "",
|
||
|
|
limit: PAGE_SIZE,
|
||
|
|
before: threadBefore,
|
||
|
|
},
|
||
|
|
{ enabled: readable && !!threadId },
|
||
|
|
);
|
||
|
|
const roots = useMemo(
|
||
|
|
() =>
|
||
|
|
mergeChatMessages(
|
||
|
|
mergeChatMessages(olderRoots, latestRoots),
|
||
|
|
history.data?.messages ?? [],
|
||
|
|
),
|
||
|
|
[olderRoots, latestRoots, history.data],
|
||
|
|
);
|
||
|
|
// There is no channel-wide high-water endpoint. Start from the oldest loaded
|
||
|
|
// root's predecessor so replies between loaded roots are never skipped.
|
||
|
|
// Empty channels still need zero to discover their first message.
|
||
|
|
const historyStart = history.data?.messages.reduce<string | null>(
|
||
|
|
(min, item) =>
|
||
|
|
min === null || BigInt(item.sequence) < BigInt(min) ? item.sequence : min,
|
||
|
|
null,
|
||
|
|
);
|
||
|
|
const fallbackAfter = historyStart
|
||
|
|
? (BigInt(historyStart) - 1n).toString()
|
||
|
|
: "0";
|
||
|
|
const readBoundary =
|
||
|
|
summary.data?.channels.find((item) => item.channelId === channelId)
|
||
|
|
?.readSequence ?? "0";
|
||
|
|
// The visible page is a safe lower bound only when it is the entire root
|
||
|
|
// history and none of those roots has a reply that could lie before it.
|
||
|
|
const completeRootHistory =
|
||
|
|
history.data?.hasMore === false &&
|
||
|
|
history.data.messages.every((item) => item.replyCount === 0);
|
||
|
|
const initialAfter =
|
||
|
|
completeRootHistory || BigInt(readBoundary) >= BigInt(fallbackAfter)
|
||
|
|
? fallbackAfter
|
||
|
|
: readBoundary;
|
||
|
|
const [catchUpWindow, setCatchUpWindow] = useState<CatchUpWindow | null>(
|
||
|
|
null,
|
||
|
|
);
|
||
|
|
const activeWindow = catchUpWindow ?? { after: initialAfter };
|
||
|
|
const catchUp = api.chat.catchUp.useQuery(
|
||
|
|
{ ...scope, ...activeWindow, limit: 100 },
|
||
|
|
{
|
||
|
|
enabled: readable && !!history.data && !!summary.data,
|
||
|
|
refetchInterval: 5000,
|
||
|
|
},
|
||
|
|
);
|
||
|
|
const [scanned, setScanned] = useState<string | null>(null);
|
||
|
|
const [unseenReply, setUnseenReply] = useState<string | null>(null);
|
||
|
|
const [recent, setRecent] = useState<ChatMessageView[]>([]);
|
||
|
|
const [pending, setPending] = useState<PendingMessage[]>([]);
|
||
|
|
const [messageChange, setMessageChange] = useState<{
|
||
|
|
message: ChatMessageView;
|
||
|
|
type: "edit_message" | "delete_message";
|
||
|
|
body: string;
|
||
|
|
key: string;
|
||
|
|
failed: boolean;
|
||
|
|
error?: string;
|
||
|
|
} | null>(null);
|
||
|
|
const editMutation = api.chat.editMessage.useMutation();
|
||
|
|
const deleteMutation = api.chat.deleteMessage.useMutation();
|
||
|
|
const accessRevoked = [
|
||
|
|
history.error,
|
||
|
|
catchUp.error,
|
||
|
|
thread.error,
|
||
|
|
summary.error,
|
||
|
|
viewer.error,
|
||
|
|
recovery.error as { data?: { code?: string } } | null,
|
||
|
|
].some(denied);
|
||
|
|
useEffect(() => {
|
||
|
|
if (accessRevoked) purge();
|
||
|
|
}, [accessRevoked, purge]);
|
||
|
|
const previousScope = useRef(`${workspace}:${channelId ?? ""}`);
|
||
|
|
useEffect(() => {
|
||
|
|
const nextScope = `${workspace}:${channelId ?? ""}`;
|
||
|
|
if (previousScope.current === nextScope) return;
|
||
|
|
previousScope.current = nextScope;
|
||
|
|
setRecent([]);
|
||
|
|
setPending([]);
|
||
|
|
setMessageChange(null);
|
||
|
|
processedPage.current = undefined;
|
||
|
|
setThreadId(null);
|
||
|
|
setBefore(undefined);
|
||
|
|
setOlderRoots([]);
|
||
|
|
setLatestRoots([]);
|
||
|
|
setRefreshedRoot(null);
|
||
|
|
setThreadBefore(undefined);
|
||
|
|
setOlderReplies([]);
|
||
|
|
setCatchUpWindow(null);
|
||
|
|
setScanned(null);
|
||
|
|
setUnseenReply(null);
|
||
|
|
}, [channelId, workspace]);
|
||
|
|
useEffect(() => {
|
||
|
|
setThreadBefore(undefined);
|
||
|
|
setOlderReplies([]);
|
||
|
|
setLatestReplies([]);
|
||
|
|
setRefreshedRoot(null);
|
||
|
|
}, [threadId]);
|
||
|
|
// Previously paged roots and replies are held outside React Query. Mirror refreshed
|
||
|
|
// cached pages into those arrays so edits to old pages become visible too.
|
||
|
|
useEffect(
|
||
|
|
() =>
|
||
|
|
queryClient.getQueryCache().subscribe((event) => {
|
||
|
|
if (
|
||
|
|
event.type !== "updated" ||
|
||
|
|
event.action.type !== "success" ||
|
||
|
|
scopeRef.current.isRevoked()
|
||
|
|
)
|
||
|
|
return;
|
||
|
|
const key = event.query.queryKey;
|
||
|
|
const path = key[0];
|
||
|
|
const options = key[1];
|
||
|
|
if (
|
||
|
|
!Array.isArray(path) ||
|
||
|
|
path[0] !== "chat" ||
|
||
|
|
(path[1] !== "history" && path[1] !== "threadHistory") ||
|
||
|
|
!options ||
|
||
|
|
typeof options !== "object" ||
|
||
|
|
!("input" in options)
|
||
|
|
)
|
||
|
|
return;
|
||
|
|
const input = options.input;
|
||
|
|
if (
|
||
|
|
!input ||
|
||
|
|
typeof input !== "object" ||
|
||
|
|
!("channelId" in input) ||
|
||
|
|
!("workspace" in input) ||
|
||
|
|
input.channelId !== channelId ||
|
||
|
|
input.workspace !== workspace
|
||
|
|
)
|
||
|
|
return;
|
||
|
|
const data: unknown = event.query.state.data;
|
||
|
|
if (
|
||
|
|
!data ||
|
||
|
|
typeof data !== "object" ||
|
||
|
|
!("messages" in data || "replies" in data)
|
||
|
|
)
|
||
|
|
return;
|
||
|
|
if (
|
||
|
|
path[1] === "history" &&
|
||
|
|
"messages" in data &&
|
||
|
|
Array.isArray(data.messages)
|
||
|
|
) {
|
||
|
|
const messages = data.messages as ChatMessageView[];
|
||
|
|
setOlderRoots((current) => mergeChatMessages(current, messages));
|
||
|
|
}
|
||
|
|
if (
|
||
|
|
path[1] === "threadHistory" &&
|
||
|
|
"replies" in data &&
|
||
|
|
Array.isArray(data.replies) &&
|
||
|
|
"rootMessageId" in input &&
|
||
|
|
input.rootMessageId === threadId
|
||
|
|
) {
|
||
|
|
setOlderReplies((current) =>
|
||
|
|
mergeChatMessages(current, data.replies as ChatMessageView[]),
|
||
|
|
);
|
||
|
|
if ("root" in data && data.root && typeof data.root === "object")
|
||
|
|
setRefreshedRoot((current) => mergeChatMessages(current ? [current] : [], [data.root as ChatMessageView])[0]);
|
||
|
|
}
|
||
|
|
}),
|
||
|
|
[queryClient, workspace, channelId, threadId],
|
||
|
|
);
|
||
|
|
const processedPage = useRef<typeof catchUp.data>(undefined);
|
||
|
|
useEffect(() => {
|
||
|
|
const page = catchUp.data;
|
||
|
|
if (!page || scopeRef.current.isRevoked() || processedPage.current === page) return;
|
||
|
|
// A cached page from another interval must not advance this interval.
|
||
|
|
if (
|
||
|
|
BigInt(page.next) < BigInt(activeWindow.after) ||
|
||
|
|
(activeWindow.through !== undefined &&
|
||
|
|
page.through !== activeWindow.through)
|
||
|
|
)
|
||
|
|
return;
|
||
|
|
processedPage.current = page;
|
||
|
|
setRecent((current) => mergeChatMessages(current, page.messages));
|
||
|
|
if (page.messages.length) {
|
||
|
|
// Catch-up also carries durable revisions for messages already paged in.
|
||
|
|
setOlderRoots((current) =>
|
||
|
|
mergeChatMessages(
|
||
|
|
current,
|
||
|
|
page.messages.filter((item) => !item.parentMessageId),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
setOlderReplies((current) =>
|
||
|
|
mergeChatMessages(
|
||
|
|
current,
|
||
|
|
page.messages.filter((item) => item.parentMessageId === threadId),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
setScanned((current) =>
|
||
|
|
current &&
|
||
|
|
BigInt(current) > BigInt(page.hasMore ? page.next : page.through)
|
||
|
|
? current
|
||
|
|
: page.hasMore
|
||
|
|
? page.next
|
||
|
|
: page.through,
|
||
|
|
);
|
||
|
|
const reply = page.messages.find((item) => item.parentMessageId);
|
||
|
|
if (reply)
|
||
|
|
setUnseenReply((current) =>
|
||
|
|
current && BigInt(current) < BigInt(reply.sequence)
|
||
|
|
? current
|
||
|
|
: reply.sequence,
|
||
|
|
);
|
||
|
|
setCatchUpWindow((current) =>
|
||
|
|
advanceCatchUpWindow(
|
||
|
|
current ?? { after: activeWindow.after, through: activeWindow.through },
|
||
|
|
page,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}, [catchUp.data, activeWindow.after, activeWindow.through, threadId]);
|
||
|
|
const sendMutation = api.chat.send.useMutation();
|
||
|
|
const readMutation = api.chat.markChannelRead.useMutation({
|
||
|
|
onSuccess: () => {
|
||
|
|
if (!scopeRef.current.isRevoked()) void utils.chat.unreadSummary.invalidate({ workspace });
|
||
|
|
},
|
||
|
|
});
|
||
|
|
const reading = useRef<string | null>(null);
|
||
|
|
const acknowledged = useRef("0");
|
||
|
|
const [readFailure, setReadFailure] = useState<string | null>(null);
|
||
|
|
useEffect(() => {
|
||
|
|
reading.current = null;
|
||
|
|
acknowledged.current = "0";
|
||
|
|
setReadFailure(null);
|
||
|
|
}, [channelId, workspace]);
|
||
|
|
const viewed = useCallback(
|
||
|
|
(sequence: string) => {
|
||
|
|
if (
|
||
|
|
!readable ||
|
||
|
|
!channelId ||
|
||
|
|
document.visibilityState !== "visible" ||
|
||
|
|
readFailure ||
|
||
|
|
!rootReadIsProven(sequence, scanned, unseenReply, readBoundary) ||
|
||
|
|
reading.current
|
||
|
|
)
|
||
|
|
return;
|
||
|
|
if (
|
||
|
|
BigInt(sequence) <= BigInt(readBoundary) ||
|
||
|
|
BigInt(sequence) <= BigInt(acknowledged.current)
|
||
|
|
)
|
||
|
|
return;
|
||
|
|
const valid = scopeRef.current.capture();
|
||
|
|
reading.current = sequence;
|
||
|
|
readMutation.mutate(
|
||
|
|
{ workspace, channelId, sequence },
|
||
|
|
{
|
||
|
|
onSuccess: () => {
|
||
|
|
if (valid()) acknowledged.current = sequence;
|
||
|
|
},
|
||
|
|
onError: (error) => {
|
||
|
|
if (!valid()) return;
|
||
|
|
if (denied(error)) purge();
|
||
|
|
else setReadFailure(sequence);
|
||
|
|
},
|
||
|
|
onSettled: () => {
|
||
|
|
if (valid()) reading.current = null;
|
||
|
|
},
|
||
|
|
},
|
||
|
|
);
|
||
|
|
},
|
||
|
|
[
|
||
|
|
readBoundary,
|
||
|
|
readFailure,
|
||
|
|
scanned,
|
||
|
|
unseenReply,
|
||
|
|
readable,
|
||
|
|
channelId,
|
||
|
|
readMutation,
|
||
|
|
workspace,
|
||
|
|
purge,
|
||
|
|
],
|
||
|
|
);
|
||
|
|
const send = useCallback(
|
||
|
|
async (
|
||
|
|
body: string,
|
||
|
|
parentMessageId?: string,
|
||
|
|
key = crypto.randomUUID(),
|
||
|
|
) => {
|
||
|
|
if (!resolved.data || !channelId || !readable || channel?.archivedAt)
|
||
|
|
return;
|
||
|
|
const valid = scopeRef.current.capture();
|
||
|
|
const requestedThread = threadId;
|
||
|
|
const validThread = threadScope.current.capture();
|
||
|
|
setPending((items) => [
|
||
|
|
...items.filter((item) => item.key !== key),
|
||
|
|
{ key, body, parentMessageId, failed: false },
|
||
|
|
]);
|
||
|
|
try {
|
||
|
|
const result = await sendMutation.mutateAsync({
|
||
|
|
workspace,
|
||
|
|
command: {
|
||
|
|
type: "send_message",
|
||
|
|
workspaceId: resolved.data.id,
|
||
|
|
channelId,
|
||
|
|
context: { type: "workspace", workspaceId: resolved.data.id },
|
||
|
|
body,
|
||
|
|
clientIdempotencyKey: key,
|
||
|
|
...(parentMessageId ? { parentMessageId } : {}),
|
||
|
|
},
|
||
|
|
});
|
||
|
|
if (!valid()) return;
|
||
|
|
const confirmed: ChatMessageView = {
|
||
|
|
...result.message,
|
||
|
|
createdAt: new Date(),
|
||
|
|
};
|
||
|
|
if (parentMessageId && validThread()) {
|
||
|
|
setLatestReplies((current) =>
|
||
|
|
mergeChatMessages(current, [confirmed]),
|
||
|
|
);
|
||
|
|
} else if (!parentMessageId) {
|
||
|
|
setLatestRoots((current) => mergeChatMessages(current, [confirmed]));
|
||
|
|
}
|
||
|
|
setPending((items) => items.filter((item) => item.key !== key));
|
||
|
|
// Refreshes are best-effort after confirmation. A failed refresh must
|
||
|
|
// never turn a persisted send into a retryable pending row.
|
||
|
|
void Promise.allSettled([
|
||
|
|
utils.chat.history.invalidate({ workspace, channelId }),
|
||
|
|
utils.chat.history
|
||
|
|
.fetch({ workspace, channelId, limit: PAGE_SIZE })
|
||
|
|
.then((page) => {
|
||
|
|
if (!valid()) return;
|
||
|
|
setLatestRoots((current) =>
|
||
|
|
mergeChatMessages(current, page.messages),
|
||
|
|
);
|
||
|
|
}),
|
||
|
|
utils.chat.threadHistory.invalidate({ workspace, channelId }),
|
||
|
|
...(parentMessageId
|
||
|
|
? [
|
||
|
|
utils.chat.threadHistory
|
||
|
|
.fetch({
|
||
|
|
workspace,
|
||
|
|
channelId,
|
||
|
|
rootMessageId: parentMessageId,
|
||
|
|
limit: PAGE_SIZE,
|
||
|
|
})
|
||
|
|
.then((page) => {
|
||
|
|
if (valid() && validThread() && requestedThread === parentMessageId)
|
||
|
|
setLatestReplies((current) =>
|
||
|
|
mergeChatMessages(current, page.replies),
|
||
|
|
);
|
||
|
|
}),
|
||
|
|
]
|
||
|
|
: []),
|
||
|
|
utils.chat.catchUp.invalidate({ workspace, channelId }),
|
||
|
|
utils.chat.unreadSummary.invalidate({ workspace }),
|
||
|
|
]);
|
||
|
|
} catch (error) {
|
||
|
|
if (!valid()) return;
|
||
|
|
const code =
|
||
|
|
error &&
|
||
|
|
typeof error === "object" &&
|
||
|
|
"data" in error &&
|
||
|
|
error.data &&
|
||
|
|
typeof error.data === "object" &&
|
||
|
|
"code" in error.data
|
||
|
|
? String(error.data.code)
|
||
|
|
: undefined;
|
||
|
|
if (code === "NOT_FOUND" || code === "FORBIDDEN") {
|
||
|
|
purge();
|
||
|
|
} else if (!scopeRef.current.isRevoked()) {
|
||
|
|
setPending((items) =>
|
||
|
|
items.map((item) =>
|
||
|
|
item.key === key
|
||
|
|
? { ...item, failed: true, error: messageError(code) }
|
||
|
|
: item,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[
|
||
|
|
resolved.data,
|
||
|
|
channelId,
|
||
|
|
channel?.archivedAt,
|
||
|
|
readable,
|
||
|
|
sendMutation,
|
||
|
|
workspace,
|
||
|
|
utils,
|
||
|
|
purge,
|
||
|
|
threadId,
|
||
|
|
],
|
||
|
|
);
|
||
|
|
const applyMessageChange = useCallback(
|
||
|
|
async (change: NonNullable<typeof messageChange>) => {
|
||
|
|
if (!resolved.data || !channelId || !readable || channel?.archivedAt)
|
||
|
|
return;
|
||
|
|
const valid = scopeRef.current.capture();
|
||
|
|
const { message, type, body, key } = change;
|
||
|
|
const command = {
|
||
|
|
workspaceId: resolved.data.id,
|
||
|
|
channelId,
|
||
|
|
context: { type: "workspace" as const, workspaceId: resolved.data.id },
|
||
|
|
messageId: message.id,
|
||
|
|
expectedRevision: message.revision ?? 1,
|
||
|
|
clientMutationId: key,
|
||
|
|
};
|
||
|
|
setMessageChange({ ...change, failed: false, error: undefined });
|
||
|
|
try {
|
||
|
|
const outcome =
|
||
|
|
type === "edit_message"
|
||
|
|
? await editMutation.mutateAsync({
|
||
|
|
workspace,
|
||
|
|
command: { ...command, type, body },
|
||
|
|
})
|
||
|
|
: await deleteMutation.mutateAsync({
|
||
|
|
workspace,
|
||
|
|
command: { ...command, type },
|
||
|
|
});
|
||
|
|
if (!valid()) return;
|
||
|
|
const updated: ChatMessageView = {
|
||
|
|
...message,
|
||
|
|
revision: outcome.result.revision,
|
||
|
|
status: outcome.result.status,
|
||
|
|
body: type === "delete_message" ? "" : body.trim(),
|
||
|
|
...(type === "delete_message"
|
||
|
|
? { deletedAt: new Date() }
|
||
|
|
: { editedAt: new Date() }),
|
||
|
|
};
|
||
|
|
setRecent((current) =>
|
||
|
|
current.some((item) => item.id === updated.id)
|
||
|
|
? mergeChatMessages(current, [updated])
|
||
|
|
: current,
|
||
|
|
);
|
||
|
|
setOlderRoots((current) =>
|
||
|
|
current.map((item) =>
|
||
|
|
item.id === updated.id
|
||
|
|
? mergeChatMessages([item], [updated])[0]
|
||
|
|
: item,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
if ("parentMessageId" in message && message.parentMessageId) {
|
||
|
|
setLatestReplies((current) => mergeChatMessages(current, [updated]));
|
||
|
|
} else {
|
||
|
|
setLatestRoots((current) => mergeChatMessages(current, [updated]));
|
||
|
|
}
|
||
|
|
setOlderReplies((current) =>
|
||
|
|
current.map((item) =>
|
||
|
|
item.id === updated.id
|
||
|
|
? mergeChatMessages([item], [updated])[0]
|
||
|
|
: item,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
setMessageChange(null);
|
||
|
|
// Old root and thread pages may be retained locally. Refetch every cached page,
|
||
|
|
// not only the newest page; revision-aware merging prevents late stale responses
|
||
|
|
// from resurrecting an older edit or a tombstone.
|
||
|
|
void Promise.allSettled([
|
||
|
|
queryClient.refetchQueries({
|
||
|
|
predicate: (query) => {
|
||
|
|
const path = query.queryKey[0];
|
||
|
|
const options = query.queryKey[1];
|
||
|
|
return (
|
||
|
|
Array.isArray(path) &&
|
||
|
|
path[0] === "chat" &&
|
||
|
|
(path[1] === "history" ||
|
||
|
|
path[1] === "threadHistory" ||
|
||
|
|
path[1] === "catchUp") &&
|
||
|
|
!!options && typeof options === "object" && "input" in options &&
|
||
|
|
!!options.input && typeof options.input === "object" &&
|
||
|
|
"workspace" in options.input && options.input.workspace === workspace
|
||
|
|
);
|
||
|
|
},
|
||
|
|
type: "all",
|
||
|
|
}),
|
||
|
|
utils.chat.unreadSummary.invalidate({ workspace }),
|
||
|
|
]);
|
||
|
|
} catch (error) {
|
||
|
|
if (!valid()) return;
|
||
|
|
if (denied(error as { data?: { code?: string } })) {
|
||
|
|
purge();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const code =
|
||
|
|
error &&
|
||
|
|
typeof error === "object" &&
|
||
|
|
"data" in error &&
|
||
|
|
error.data &&
|
||
|
|
typeof error.data === "object" &&
|
||
|
|
"code" in error.data
|
||
|
|
? String(error.data.code)
|
||
|
|
: undefined;
|
||
|
|
if (code === "CONFLICT") {
|
||
|
|
void queryClient.refetchQueries({
|
||
|
|
predicate: (query) => {
|
||
|
|
const path = query.queryKey[0];
|
||
|
|
const options = query.queryKey[1];
|
||
|
|
return (
|
||
|
|
Array.isArray(path) &&
|
||
|
|
path[0] === "chat" &&
|
||
|
|
(path[1] === "history" || path[1] === "threadHistory") &&
|
||
|
|
!!options && typeof options === "object" && "input" in options &&
|
||
|
|
!!options.input && typeof options.input === "object" &&
|
||
|
|
"workspace" in options.input && options.input.workspace === workspace
|
||
|
|
);
|
||
|
|
},
|
||
|
|
type: "all",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
setMessageChange((current) =>
|
||
|
|
current?.key === key
|
||
|
|
? {
|
||
|
|
...current,
|
||
|
|
failed: true,
|
||
|
|
error:
|
||
|
|
code === "CONFLICT"
|
||
|
|
? "Message changed. Refresh messages before editing again."
|
||
|
|
: messageError(code),
|
||
|
|
}
|
||
|
|
: current,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[
|
||
|
|
resolved.data,
|
||
|
|
channelId,
|
||
|
|
readable,
|
||
|
|
channel?.archivedAt,
|
||
|
|
editMutation,
|
||
|
|
deleteMutation,
|
||
|
|
workspace,
|
||
|
|
queryClient,
|
||
|
|
utils,
|
||
|
|
purge,
|
||
|
|
],
|
||
|
|
);
|
||
|
|
const recovered = (messages: ChatMessageView[]) =>
|
||
|
|
mergeChatMessages(messages, recovery.messages.flatMap((item) => {
|
||
|
|
const loaded = messages.find((message) => message.id === item.id);
|
||
|
|
return loaded ? [{ ...loaded, ...item }] : [];
|
||
|
|
}));
|
||
|
|
const rootsWithRecent = useMemo(
|
||
|
|
() =>
|
||
|
|
mergeChatMessages(
|
||
|
|
mergeChatMessages(roots, latestRoots),
|
||
|
|
recent.filter(
|
||
|
|
(item) => !("parentMessageId" in item) || !item.parentMessageId,
|
||
|
|
) as typeof roots,
|
||
|
|
),
|
||
|
|
[roots, latestRoots, recent],
|
||
|
|
);
|
||
|
|
const replies = useMemo(
|
||
|
|
() =>
|
||
|
|
mergeChatMessages(
|
||
|
|
mergeChatMessages(olderReplies, latestReplies),
|
||
|
|
thread.data?.replies ?? [],
|
||
|
|
),
|
||
|
|
[olderReplies, latestReplies, thread.data],
|
||
|
|
);
|
||
|
|
const visibleReplies = useMemo(
|
||
|
|
() =>
|
||
|
|
mergeChatMessages(
|
||
|
|
mergeChatMessages(replies, latestReplies),
|
||
|
|
recent.filter(
|
||
|
|
(item) =>
|
||
|
|
"parentMessageId" in item && item.parentMessageId === threadId,
|
||
|
|
) as typeof replies,
|
||
|
|
),
|
||
|
|
[replies, latestReplies, recent, threadId],
|
||
|
|
);
|
||
|
|
const canModerate =
|
||
|
|
viewer.data?.role === "admin" ||
|
||
|
|
viewer.data?.role === "owner" ||
|
||
|
|
channel?.ownerUserId === currentUserId;
|
||
|
|
const startChange = (
|
||
|
|
message: ChatMessageView,
|
||
|
|
type: "edit_message" | "delete_message",
|
||
|
|
) => {
|
||
|
|
if (
|
||
|
|
!message.revision ||
|
||
|
|
channel?.archivedAt ||
|
||
|
|
message.status === "deleted" ||
|
||
|
|
message.deletedAt ||
|
||
|
|
(type === "edit_message"
|
||
|
|
? message.authorUserId !== currentUserId
|
||
|
|
: message.authorUserId !== currentUserId && !canModerate)
|
||
|
|
)
|
||
|
|
return;
|
||
|
|
setMessageChange({
|
||
|
|
message,
|
||
|
|
type,
|
||
|
|
body: message.body,
|
||
|
|
key: crypto.randomUUID(),
|
||
|
|
failed: false,
|
||
|
|
});
|
||
|
|
};
|
||
|
|
const counts = new Map(
|
||
|
|
summary.data?.channels.map((item) => [item.channelId, item]),
|
||
|
|
);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<main
|
||
|
|
className="flex h-full min-h-0 min-w-0 flex-1"
|
||
|
|
aria-label="Workspace chat"
|
||
|
|
>
|
||
|
|
<aside
|
||
|
|
className="flex w-56 shrink-0 flex-col border-r"
|
||
|
|
aria-label="Channels"
|
||
|
|
>
|
||
|
|
<h1 className="border-b p-4 text-lg font-semibold">Chat</h1>
|
||
|
|
{channels.isLoading && <p className="p-3 text-sm">Loading channels…</p>}
|
||
|
|
{channels.isError && (
|
||
|
|
<p role="alert" className="p-3 text-sm">
|
||
|
|
Could not load channels.{" "}
|
||
|
|
<Button variant="link" onClick={() => void channels.refetch()}>
|
||
|
|
Retry
|
||
|
|
</Button>
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
{channels.data?.length === 0 && (
|
||
|
|
<p className="p-3 text-sm text-muted-foreground">No channels yet.</p>
|
||
|
|
)}
|
||
|
|
<ScrollArea className="min-h-0 flex-1">
|
||
|
|
<nav aria-label="Chat channels" className="flex flex-col gap-1 p-2">
|
||
|
|
{channels.data
|
||
|
|
?.filter((item) => !item.archivedAt)
|
||
|
|
.map((item) => {
|
||
|
|
const unread = counts.get(item.id);
|
||
|
|
const badge = chatBadge({
|
||
|
|
unreadCount: unread?.unreadCount ?? 0,
|
||
|
|
mentionCount: unread?.mentionCount ?? 0,
|
||
|
|
directUnreadCount:
|
||
|
|
item.kind === "direct"
|
||
|
|
? Math.max(
|
||
|
|
0,
|
||
|
|
(unread?.unreadCount ?? 0) -
|
||
|
|
(unread?.mentionCount ?? 0),
|
||
|
|
)
|
||
|
|
: 0,
|
||
|
|
});
|
||
|
|
return (
|
||
|
|
<Link
|
||
|
|
key={item.id}
|
||
|
|
href={`/${workspace}/chat/${item.id}`}
|
||
|
|
aria-current={item.id === channelId ? "page" : undefined}
|
||
|
|
className="flex items-center justify-between gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring aria-[current=page]:bg-accent"
|
||
|
|
>
|
||
|
|
<span className="truncate">
|
||
|
|
{item.kind === "direct"
|
||
|
|
? "Direct message"
|
||
|
|
: (item.name ?? "Channel")}
|
||
|
|
</span>
|
||
|
|
{(badge.dot || badge.count > 0) && (
|
||
|
|
<span
|
||
|
|
className="shrink-0 rounded-full bg-primary px-1.5 text-xs text-primary-foreground"
|
||
|
|
aria-label={
|
||
|
|
badge.count > 0
|
||
|
|
? `${badge.count} mentions or direct unread messages`
|
||
|
|
: "Unread messages"
|
||
|
|
}
|
||
|
|
>
|
||
|
|
{badge.count > 0 ? badge.count : "●"}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</Link>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</nav>
|
||
|
|
</ScrollArea>
|
||
|
|
</aside>
|
||
|
|
{!channelId ? (
|
||
|
|
<div className="flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground">
|
||
|
|
Select a channel to start chatting.
|
||
|
|
</div>
|
||
|
|
) : channels.isLoading || resolved.isLoading ? (
|
||
|
|
<div role="status" className="p-6">
|
||
|
|
Loading channel…
|
||
|
|
</div>
|
||
|
|
) : channels.isError || resolved.isError ? (
|
||
|
|
<div role="alert" className="p-6">
|
||
|
|
Could not load channel.{" "}
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
onClick={() => {
|
||
|
|
void channels.refetch();
|
||
|
|
void resolved.refetch();
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
Retry
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
) : accessRevoked || !channel ? (
|
||
|
|
<div role="alert" className="p-6">
|
||
|
|
Channel unavailable or access removed.
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<section
|
||
|
|
className="flex min-h-0 min-w-0 flex-1 flex-col"
|
||
|
|
aria-label={`Conversation: ${channel.name ?? "Direct message"}`}
|
||
|
|
>
|
||
|
|
<header className="border-b p-4 font-semibold">
|
||
|
|
{channel.name ?? "Direct message"}
|
||
|
|
</header>
|
||
|
|
{history.isLoading && (
|
||
|
|
<p role="status" className="p-4">
|
||
|
|
Loading messages…
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
{history.isError && !accessRevoked && (
|
||
|
|
<div role="alert" className="p-4">
|
||
|
|
Could not load messages.{" "}
|
||
|
|
<Button variant="outline" onClick={() => void history.refetch()}>
|
||
|
|
Retry
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<div
|
||
|
|
className="min-h-0 flex-1 overflow-y-auto"
|
||
|
|
role="region"
|
||
|
|
aria-label="Channel messages"
|
||
|
|
>
|
||
|
|
{history.data?.hasMore && (
|
||
|
|
<div className="p-3 text-center">
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
disabled={history.isFetching}
|
||
|
|
onClick={() => {
|
||
|
|
setOlderRoots((items) =>
|
||
|
|
mergeChatMessages(items, history.data?.messages ?? []),
|
||
|
|
);
|
||
|
|
setBefore(history.data?.next ?? undefined);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
Load older messages
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{!history.isLoading && !rootsWithRecent.length && (
|
||
|
|
<p className="p-4 text-sm text-muted-foreground">
|
||
|
|
No messages yet.
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
<ChatMessageList
|
||
|
|
messages={recovered(rootsWithRecent)}
|
||
|
|
onViewed={viewed}
|
||
|
|
onThread={setThreadId}
|
||
|
|
currentUserId={currentUserId}
|
||
|
|
canModerate={canModerate}
|
||
|
|
readOnly={!!channel.archivedAt}
|
||
|
|
onEdit={(message) => startChange(message, "edit_message")}
|
||
|
|
onDelete={(message) => startChange(message, "delete_message")}
|
||
|
|
pending={pending
|
||
|
|
.filter((item) => !item.parentMessageId)
|
||
|
|
.map((item) => ({
|
||
|
|
...item,
|
||
|
|
retry: () => void send(item.body, undefined, item.key),
|
||
|
|
}))}
|
||
|
|
/>
|
||
|
|
{messageChange && (
|
||
|
|
<div
|
||
|
|
className="border-t p-3"
|
||
|
|
role="group"
|
||
|
|
aria-label="Message change"
|
||
|
|
>
|
||
|
|
<p className="text-sm">
|
||
|
|
{messageChange.type === "edit_message"
|
||
|
|
? "Edit message"
|
||
|
|
: "Delete message?"}
|
||
|
|
</p>
|
||
|
|
{messageChange.type === "edit_message" && (
|
||
|
|
<textarea
|
||
|
|
aria-label="Edited message"
|
||
|
|
className="w-full rounded border p-2"
|
||
|
|
value={messageChange.body}
|
||
|
|
onChange={(event) =>
|
||
|
|
setMessageChange((current) =>
|
||
|
|
current
|
||
|
|
? {
|
||
|
|
...current,
|
||
|
|
body: event.target.value,
|
||
|
|
key: crypto.randomUUID(),
|
||
|
|
failed: false,
|
||
|
|
}
|
||
|
|
: null,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
{messageChange.error && (
|
||
|
|
<p role="alert" className="text-sm">
|
||
|
|
{messageChange.error}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
disabled={
|
||
|
|
editMutation.isPending ||
|
||
|
|
deleteMutation.isPending ||
|
||
|
|
(messageChange.type === "edit_message" &&
|
||
|
|
!messageChange.body.trim()) ||
|
||
|
|
(messageChange.failed &&
|
||
|
|
messageChange.error?.startsWith("Message changed"))
|
||
|
|
}
|
||
|
|
onClick={() => void applyMessageChange(messageChange)}
|
||
|
|
>
|
||
|
|
{messageChange.failed
|
||
|
|
? "Retry"
|
||
|
|
: messageChange.type === "edit_message"
|
||
|
|
? "Save"
|
||
|
|
: "Confirm delete"}
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="ghost"
|
||
|
|
onClick={() => setMessageChange(null)}
|
||
|
|
>
|
||
|
|
Cancel
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{readFailure && (
|
||
|
|
<ReadFailureAlert
|
||
|
|
onRetry={() => {
|
||
|
|
const sequence = readFailure;
|
||
|
|
setReadFailure(null);
|
||
|
|
reading.current = sequence;
|
||
|
|
readMutation.mutate(
|
||
|
|
{ workspace, channelId, sequence },
|
||
|
|
{
|
||
|
|
onSuccess: () => {
|
||
|
|
acknowledged.current = sequence;
|
||
|
|
},
|
||
|
|
onError: (error) => {
|
||
|
|
if (denied(error)) purge();
|
||
|
|
else setReadFailure(sequence);
|
||
|
|
},
|
||
|
|
onSettled: () => {
|
||
|
|
reading.current = null;
|
||
|
|
},
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
{catchUp.isError && !accessRevoked && (
|
||
|
|
<div role="alert" className="p-3 text-sm">
|
||
|
|
Could not check for new messages.{" "}
|
||
|
|
<Button variant="link" onClick={() => void catchUp.refetch()}>
|
||
|
|
Retry
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
{!channel.archivedAt && (
|
||
|
|
<ChatComposer
|
||
|
|
onSend={(body) => void send(body)}
|
||
|
|
label="Message channel"
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</section>
|
||
|
|
)}
|
||
|
|
{channel && threadId && !accessRevoked && (
|
||
|
|
<aside
|
||
|
|
className="flex w-80 shrink-0 flex-col border-l"
|
||
|
|
aria-label="Thread"
|
||
|
|
>
|
||
|
|
<div className="flex items-center justify-between border-b p-3">
|
||
|
|
<h2 className="font-semibold">Replies</h2>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
onClick={() => setThreadId(null)}
|
||
|
|
aria-label="Close thread"
|
||
|
|
>
|
||
|
|
Close
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
<div
|
||
|
|
className="min-h-0 flex-1 overflow-y-auto"
|
||
|
|
role="region"
|
||
|
|
aria-label="Thread messages"
|
||
|
|
>
|
||
|
|
{thread.isError && (
|
||
|
|
<p role="alert" className="p-3">
|
||
|
|
Thread unavailable.{" "}
|
||
|
|
<Button variant="link" onClick={() => void thread.refetch()}>
|
||
|
|
Retry
|
||
|
|
</Button>
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
{thread.data?.root && (
|
||
|
|
<ChatMessageList
|
||
|
|
messages={recovered(mergeChatMessages(
|
||
|
|
[thread.data.root],
|
||
|
|
refreshedRoot ? [refreshedRoot] : [],
|
||
|
|
))}
|
||
|
|
currentUserId={currentUserId}
|
||
|
|
canModerate={canModerate}
|
||
|
|
readOnly={!!channel.archivedAt}
|
||
|
|
onEdit={(message) => startChange(message, "edit_message")}
|
||
|
|
onDelete={(message) => startChange(message, "delete_message")}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
{thread.data?.hasMore && (
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
onClick={() => {
|
||
|
|
setOlderReplies((items) =>
|
||
|
|
mergeChatMessages(items, thread.data?.replies ?? []),
|
||
|
|
);
|
||
|
|
setThreadBefore(thread.data?.next ?? undefined);
|
||
|
|
}}
|
||
|
|
disabled={thread.isFetching}
|
||
|
|
>
|
||
|
|
Load older replies
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
<ChatMessageList
|
||
|
|
messages={recovered(visibleReplies)}
|
||
|
|
currentUserId={currentUserId}
|
||
|
|
canModerate={canModerate}
|
||
|
|
readOnly={!!channel.archivedAt}
|
||
|
|
onEdit={(message) => startChange(message, "edit_message")}
|
||
|
|
onDelete={(message) => startChange(message, "delete_message")}
|
||
|
|
pending={pending
|
||
|
|
.filter((item) => item.parentMessageId === threadId)
|
||
|
|
.map((item) => ({
|
||
|
|
...item,
|
||
|
|
retry: () => void send(item.body, threadId, item.key),
|
||
|
|
}))}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
{!channel.archivedAt && (
|
||
|
|
<ChatComposer
|
||
|
|
key={threadId}
|
||
|
|
onSend={(body) => void send(body, threadId)}
|
||
|
|
label="Reply in thread"
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</aside>
|
||
|
|
)}
|
||
|
|
</main>
|
||
|
|
);
|
||
|
|
}
|