mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
refactor(site/src/pages/AgentsPage): use ChatMessagePart for editingFileBlocks (#23151)
Replace the ad-hoc camelCase file block shape ({ mediaType, fileId, data })
with snake_case fields matching ChatMessagePart from the API types.
The RenderBlock file variant now uses media_type/file_id instead of
mediaType/fileId. The parsers in messageParsing.ts and streamState.ts
pass validated ChatMessagePart objects through directly instead of
destructuring and reassembling with renamed fields. This eliminates
the needless API → camelCase → snake_case roundtrip that the edit
flow previously required.
Refs #22735
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { ChatQueuedMessage } from "api/typesGenerated";
|
||||
import type { ChatMessagePart, ChatQueuedMessage } from "api/typesGenerated";
|
||||
import {
|
||||
ModelSelector,
|
||||
type ModelSelectorOption,
|
||||
@@ -94,11 +94,7 @@ interface AgentChatInputProps {
|
||||
onStartQueueEdit?: (
|
||||
id: number,
|
||||
text: string,
|
||||
fileBlocks: readonly {
|
||||
mediaType: string;
|
||||
data?: string;
|
||||
fileId?: string;
|
||||
}[],
|
||||
fileBlocks: readonly ChatMessagePart[],
|
||||
) => void;
|
||||
onCancelQueueEdit?: () => void;
|
||||
// History editing state, owned by the parent.
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { deploymentSSHConfig } from "api/queries/deployment";
|
||||
import { workspaceById, workspaceByIdKey } from "api/queries/workspaces";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import type { ChatMessagePart } from "api/typesGenerated";
|
||||
import { useProxy } from "contexts/ProxyContext";
|
||||
import {
|
||||
getTerminalHref,
|
||||
@@ -102,18 +103,14 @@ export function useConversationEditingState(deps: {
|
||||
string | null
|
||||
>(null);
|
||||
const [editingFileBlocks, setEditingFileBlocks] = useState<
|
||||
readonly { mediaType: string; data?: string; fileId?: string }[]
|
||||
readonly ChatMessagePart[]
|
||||
>([]);
|
||||
|
||||
const handleEditUserMessage = useCallback(
|
||||
(
|
||||
messageId: number,
|
||||
text: string,
|
||||
fileBlocks?: readonly {
|
||||
mediaType: string;
|
||||
data?: string;
|
||||
fileId?: string;
|
||||
}[],
|
||||
fileBlocks?: readonly ChatMessagePart[],
|
||||
) => {
|
||||
setDraftBeforeHistoryEdit((prev) =>
|
||||
editingMessageId !== null ? prev : inputValueRef.current,
|
||||
@@ -143,15 +140,7 @@ export function useConversationEditingState(deps: {
|
||||
>(null);
|
||||
|
||||
const handleStartQueueEdit = useCallback(
|
||||
(
|
||||
id: number,
|
||||
text: string,
|
||||
fileBlocks: readonly {
|
||||
mediaType: string;
|
||||
data?: string;
|
||||
fileId?: string;
|
||||
}[],
|
||||
) => {
|
||||
(id: number, text: string, fileBlocks: readonly ChatMessagePart[]) => {
|
||||
setDraftBeforeQueueEdit((prev) =>
|
||||
editingQueuedMessageID === null ? inputValueRef.current : prev,
|
||||
);
|
||||
|
||||
@@ -246,10 +246,10 @@ function renderBlockList({
|
||||
);
|
||||
}
|
||||
case "file":
|
||||
if (block.mediaType.startsWith("image/")) {
|
||||
const src = block.fileId
|
||||
? `/api/experimental/chats/files/${block.fileId}`
|
||||
: `data:${block.mediaType};base64,${block.data}`;
|
||||
if (block.media_type.startsWith("image/")) {
|
||||
const src = block.file_id
|
||||
? `/api/experimental/chats/files/${block.file_id}`
|
||||
: `data:${block.media_type};base64,${block.data}`;
|
||||
return (
|
||||
<button
|
||||
key={`${keyPrefix}-file-${index}`}
|
||||
@@ -291,7 +291,7 @@ const ChatMessageItem = memo<{
|
||||
onEditUserMessage?: (
|
||||
messageId: number,
|
||||
text: string,
|
||||
fileBlocks?: Array<{ mediaType: string; data?: string; fileId?: string }>,
|
||||
fileBlocks?: readonly TypesGen.ChatMessagePart[],
|
||||
) => void;
|
||||
editingMessageId?: number | null;
|
||||
savingMessageId?: number | null;
|
||||
@@ -381,7 +381,7 @@ const ChatMessageItem = memo<{
|
||||
const fileBlocks = parsed.blocks.filter(
|
||||
(b): b is Extract<RenderBlock, { type: "file" }> =>
|
||||
b.type === "file" &&
|
||||
b.mediaType.startsWith("image/"),
|
||||
b.media_type.startsWith("image/"),
|
||||
);
|
||||
onEditUserMessage(
|
||||
message.id,
|
||||
@@ -408,15 +408,15 @@ const ChatMessageItem = memo<{
|
||||
{(() => {
|
||||
const imageBlocks = parsed.blocks.filter(
|
||||
(b): b is Extract<RenderBlock, { type: "file" }> =>
|
||||
b.type === "file" && b.mediaType.startsWith("image/"),
|
||||
b.type === "file" && b.media_type.startsWith("image/"),
|
||||
);
|
||||
if (imageBlocks.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{imageBlocks.map((block, i) => {
|
||||
const src = block.fileId
|
||||
? `/api/experimental/chats/files/${block.fileId}`
|
||||
: `data:${block.mediaType};base64,${block.data}`;
|
||||
const src = block.file_id
|
||||
? `/api/experimental/chats/files/${block.file_id}`
|
||||
: `data:${block.media_type};base64,${block.data}`;
|
||||
return (
|
||||
<button
|
||||
key={`user-file-${i}`}
|
||||
@@ -612,7 +612,7 @@ const StickyUserMessage: FC<{
|
||||
onEditUserMessage?: (
|
||||
messageId: number,
|
||||
text: string,
|
||||
fileBlocks?: Array<{ mediaType: string; data?: string; fileId?: string }>,
|
||||
fileBlocks?: readonly TypesGen.ChatMessagePart[],
|
||||
) => void;
|
||||
editingMessageId?: number | null;
|
||||
savingMessageId?: number | null;
|
||||
@@ -751,11 +751,7 @@ const StickyUserMessage: FC<{
|
||||
? (
|
||||
messageId: number,
|
||||
text: string,
|
||||
fileBlocks?: Array<{
|
||||
mediaType: string;
|
||||
data?: string;
|
||||
fileId?: string;
|
||||
}>,
|
||||
fileBlocks?: readonly TypesGen.ChatMessagePart[],
|
||||
) => {
|
||||
onEditUserMessage(messageId, text, fileBlocks);
|
||||
requestAnimationFrame(() => {
|
||||
@@ -873,7 +869,7 @@ interface ConversationTimelineProps {
|
||||
onEditUserMessage?: (
|
||||
messageId: number,
|
||||
text: string,
|
||||
fileBlocks?: Array<{ mediaType: string; data?: string; fileId?: string }>,
|
||||
fileBlocks?: readonly TypesGen.ChatMessagePart[],
|
||||
) => void;
|
||||
editingMessageId?: number | null;
|
||||
savingMessageId?: number | null;
|
||||
|
||||
@@ -258,9 +258,9 @@ describe("parseMessageContent", () => {
|
||||
expect(result.blocks).toHaveLength(1);
|
||||
expect(result.blocks[0]).toEqual({
|
||||
type: "file",
|
||||
mediaType: "image/png",
|
||||
media_type: "image/png",
|
||||
data: undefined,
|
||||
fileId: "abc-123-def",
|
||||
file_id: "abc-123-def",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -275,9 +275,9 @@ describe("parseMessageContent", () => {
|
||||
expect(result.blocks).toHaveLength(1);
|
||||
expect(result.blocks[0]).toEqual({
|
||||
type: "file",
|
||||
mediaType: "image/png",
|
||||
media_type: "image/png",
|
||||
data: "iVBORw0KGgo=",
|
||||
fileId: undefined,
|
||||
file_id: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -244,23 +244,17 @@ export const parseMessageContent = (content: unknown): ParsedMessageContent => {
|
||||
parsed.blocks = ensureToolBlock(parsed.blocks, id);
|
||||
break;
|
||||
}
|
||||
case "file": {
|
||||
const mediaType = asString(typedBlock.media_type);
|
||||
const data = asString(typedBlock.data);
|
||||
const fileId = asString(typedBlock.file_id);
|
||||
if (mediaType && (data || fileId)) {
|
||||
case "file":
|
||||
if (
|
||||
typedBlock.media_type &&
|
||||
(typedBlock.data || typedBlock.file_id)
|
||||
) {
|
||||
parsed.blocks = [
|
||||
...parsed.blocks,
|
||||
{
|
||||
type: "file",
|
||||
mediaType,
|
||||
data: data || undefined,
|
||||
fileId: fileId || undefined,
|
||||
},
|
||||
typedBlock as Extract<RenderBlock, { type: "file" }>,
|
||||
];
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "source": {
|
||||
const url = asString(typedBlock.url);
|
||||
const title = asString(typedBlock.title);
|
||||
|
||||
@@ -141,26 +141,17 @@ export const applyMessagePartToStreamState = (
|
||||
},
|
||||
};
|
||||
}
|
||||
case "file": {
|
||||
const mediaType = asString(part.media_type);
|
||||
const data = asString(part.data);
|
||||
const fileId = asString(part.file_id);
|
||||
if (!mediaType || (!data && !fileId)) {
|
||||
case "file":
|
||||
if (!part.media_type || (!part.data && !part.file_id)) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...nextState,
|
||||
blocks: [
|
||||
...nextState.blocks,
|
||||
{
|
||||
type: "file",
|
||||
mediaType,
|
||||
data: data || undefined,
|
||||
fileId: fileId || undefined,
|
||||
},
|
||||
part as Extract<RenderBlock, { type: "file" }>,
|
||||
],
|
||||
};
|
||||
}
|
||||
case "source": {
|
||||
const url = asString(part.url);
|
||||
const title = asString(part.title);
|
||||
|
||||
@@ -38,9 +38,9 @@ export type RenderBlock =
|
||||
}
|
||||
| {
|
||||
type: "file";
|
||||
mediaType: string;
|
||||
media_type: string;
|
||||
data?: string; // base64, absent when file_id is available
|
||||
fileId?: string;
|
||||
file_id?: string;
|
||||
}
|
||||
| {
|
||||
type: "file-reference";
|
||||
|
||||
@@ -48,7 +48,7 @@ interface AgentDetailTimelineProps {
|
||||
onEditUserMessage?: (
|
||||
messageId: number,
|
||||
text: string,
|
||||
fileBlocks?: readonly { mediaType: string; data?: string }[],
|
||||
fileBlocks?: readonly TypesGen.ChatMessagePart[],
|
||||
) => void;
|
||||
editingMessageId?: number | null;
|
||||
savingMessageId?: number | null;
|
||||
@@ -169,22 +169,14 @@ interface AgentDetailInputProps {
|
||||
onStartQueueEdit: (
|
||||
id: number,
|
||||
text: string,
|
||||
fileBlocks: readonly {
|
||||
mediaType: string;
|
||||
data?: string;
|
||||
fileId?: string;
|
||||
}[],
|
||||
fileBlocks: readonly TypesGen.ChatMessagePart[],
|
||||
) => void;
|
||||
onCancelQueueEdit: () => void;
|
||||
isEditingHistoryMessage: boolean;
|
||||
onCancelHistoryEdit: () => void;
|
||||
// File blocks from the message being edited, converted to
|
||||
// File parts from the message being edited, converted to
|
||||
// File objects and pre-populated into attachments.
|
||||
editingFileBlocks?: readonly {
|
||||
mediaType: string;
|
||||
data?: string;
|
||||
fileId?: string;
|
||||
}[];
|
||||
editingFileBlocks?: readonly TypesGen.ChatMessagePart[];
|
||||
}
|
||||
|
||||
export const AgentDetailInput: FC<AgentDetailInputProps> = ({
|
||||
@@ -258,29 +250,28 @@ export const AgentDetailInput: FC<AgentDetailInputProps> = ({
|
||||
return;
|
||||
}
|
||||
const files = editingFileBlocks.map((block, i) => {
|
||||
const ext = block.mediaType.split("/")[1] ?? "png";
|
||||
const mt = block.media_type ?? "application/octet-stream";
|
||||
const ext = mt.split("/")[1] ?? "png";
|
||||
// Empty File used as a Map key only, its content is never
|
||||
// read because the existing fileId is reused at send time.
|
||||
return new File([], `attachment-${i}.${ext}`, {
|
||||
type: block.mediaType,
|
||||
});
|
||||
// read because the existing file_id is reused at send time.
|
||||
return new File([], `attachment-${i}.${ext}`, { type: mt });
|
||||
});
|
||||
setAttachments(files);
|
||||
setPreviewUrls(
|
||||
new Map(
|
||||
files.map((f, i) => [
|
||||
f,
|
||||
`/api/experimental/chats/files/${editingFileBlocks[i].fileId}`,
|
||||
`/api/experimental/chats/files/${editingFileBlocks[i].file_id}`,
|
||||
]),
|
||||
),
|
||||
);
|
||||
const newUploadStates = new Map<File, UploadState>();
|
||||
for (const [i, file] of files.entries()) {
|
||||
const block = editingFileBlocks[i];
|
||||
if (block.fileId) {
|
||||
if (block.file_id) {
|
||||
newUploadStates.set(file, {
|
||||
status: "uploaded",
|
||||
fileId: block.fileId,
|
||||
fileId: block.file_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { API } from "api/api";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import type { ChatDiffStatus } from "api/typesGenerated";
|
||||
import type { ChatDiffStatus, ChatMessagePart } from "api/typesGenerated";
|
||||
import type { ModelSelectorOption } from "components/ai-elements";
|
||||
import { fn, spyOn } from "storybook/test";
|
||||
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||
@@ -47,11 +47,7 @@ const defaultEditing = {
|
||||
chatInputRef: { current: null },
|
||||
editorInitialValue: "",
|
||||
editingMessageId: null,
|
||||
editingFileBlocks: [] as readonly {
|
||||
mediaType: string;
|
||||
data?: string;
|
||||
fileId?: string;
|
||||
}[],
|
||||
editingFileBlocks: [] as readonly ChatMessagePart[],
|
||||
handleEditUserMessage: fn(),
|
||||
handleCancelHistoryEdit: fn(),
|
||||
editingQueuedMessageID: null,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import type { ChatDiffStatus } from "api/typesGenerated";
|
||||
import type { ChatDiffStatus, ChatMessagePart } from "api/typesGenerated";
|
||||
import type { ModelSelectorOption } from "components/ai-elements";
|
||||
import { ArchiveIcon } from "lucide-react";
|
||||
import { type FC, type RefObject, useEffect, useRef, useState } from "react";
|
||||
@@ -31,30 +31,18 @@ interface EditingState {
|
||||
chatInputRef: RefObject<ChatMessageInputRef | null>;
|
||||
editorInitialValue: string;
|
||||
editingMessageId: number | null;
|
||||
editingFileBlocks: readonly {
|
||||
mediaType: string;
|
||||
data?: string;
|
||||
fileId?: string;
|
||||
}[];
|
||||
editingFileBlocks: readonly ChatMessagePart[];
|
||||
handleEditUserMessage: (
|
||||
messageId: number,
|
||||
text: string,
|
||||
fileBlocks?: readonly {
|
||||
mediaType: string;
|
||||
data?: string;
|
||||
fileId?: string;
|
||||
}[],
|
||||
fileBlocks?: readonly ChatMessagePart[],
|
||||
) => void;
|
||||
handleCancelHistoryEdit: () => void;
|
||||
editingQueuedMessageID: number | null;
|
||||
handleStartQueueEdit: (
|
||||
id: number,
|
||||
text: string,
|
||||
fileBlocks: readonly {
|
||||
mediaType: string;
|
||||
data?: string;
|
||||
fileId?: string;
|
||||
}[],
|
||||
fileBlocks: readonly ChatMessagePart[],
|
||||
) => void;
|
||||
handleCancelQueueEdit: () => void;
|
||||
handleSendFromInput: (message: string, fileIds?: string[]) => void;
|
||||
|
||||
@@ -163,7 +163,7 @@ export const EditPassesFileBlocks: Story = {
|
||||
const editButton = canvas.getByRole("button", { name: "Edit" });
|
||||
await userEvent.click(editButton);
|
||||
expect(args.onEdit).toHaveBeenCalledWith(1, "Check this screenshot", [
|
||||
{ mediaType: "image/png", fileId: "abc-123" },
|
||||
{ type: "file", file_id: "abc-123", media_type: "image/png" },
|
||||
]);
|
||||
},
|
||||
};
|
||||
@@ -184,8 +184,8 @@ export const EditAttachmentOnlyMessage: Story = {
|
||||
const editButton = canvas.getByRole("button", { name: "Edit" });
|
||||
await userEvent.click(editButton);
|
||||
expect(args.onEdit).toHaveBeenCalledWith(1, "", [
|
||||
{ mediaType: "image/png", fileId: "img-1" },
|
||||
{ mediaType: "image/jpeg", fileId: "img-2" },
|
||||
{ type: "file", file_id: "img-1", media_type: "image/png" },
|
||||
{ type: "file", file_id: "img-2", media_type: "image/jpeg" },
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -38,21 +38,21 @@ describe("getQueuedMessageInfo", () => {
|
||||
|
||||
it("returns attachment label for a single file", () => {
|
||||
const result = getQueuedMessageInfo(
|
||||
makeMessage([{ type: "file", file_id: "a" }]),
|
||||
makeMessage([{ type: "file", file_id: "a", media_type: "image/png" }]),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
displayText: "[Queued message]",
|
||||
rawText: "",
|
||||
attachmentCount: 1,
|
||||
fileBlocks: [{ mediaType: "application/octet-stream", fileId: "a" }],
|
||||
fileBlocks: [{ type: "file", file_id: "a", media_type: "image/png" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns attachment label for multiple files", () => {
|
||||
const result = getQueuedMessageInfo(
|
||||
makeMessage([
|
||||
{ type: "file", file_id: "a" },
|
||||
{ type: "file", file_id: "b" },
|
||||
{ type: "file", file_id: "a", media_type: "image/png" },
|
||||
{ type: "file", file_id: "b", media_type: "image/png" },
|
||||
]),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
@@ -60,8 +60,8 @@ describe("getQueuedMessageInfo", () => {
|
||||
rawText: "",
|
||||
attachmentCount: 2,
|
||||
fileBlocks: [
|
||||
{ mediaType: "application/octet-stream", fileId: "a" },
|
||||
{ mediaType: "application/octet-stream", fileId: "b" },
|
||||
{ type: "file", file_id: "a", media_type: "image/png" },
|
||||
{ type: "file", file_id: "b", media_type: "image/png" },
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -70,14 +70,14 @@ describe("getQueuedMessageInfo", () => {
|
||||
const result = getQueuedMessageInfo(
|
||||
makeMessage([
|
||||
{ type: "text", text: "look" },
|
||||
{ type: "file", file_id: "a" },
|
||||
{ type: "file", file_id: "a", media_type: "image/png" },
|
||||
]),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
displayText: "look",
|
||||
rawText: "look",
|
||||
attachmentCount: 1,
|
||||
fileBlocks: [{ mediaType: "application/octet-stream", fileId: "a" }],
|
||||
fileBlocks: [{ type: "file", file_id: "a", media_type: "image/png" }],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,14 +107,14 @@ describe("getQueuedMessageInfo", () => {
|
||||
const result = getQueuedMessageInfo(
|
||||
makeMessage([
|
||||
{ type: "text", text: " " },
|
||||
{ type: "file", file_id: "a" },
|
||||
{ type: "file", file_id: "a", media_type: "image/png" },
|
||||
]),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
displayText: "[Queued message]",
|
||||
rawText: "",
|
||||
attachmentCount: 1,
|
||||
fileBlocks: [{ mediaType: "application/octet-stream", fileId: "a" }],
|
||||
fileBlocks: [{ type: "file", file_id: "a", media_type: "image/png" }],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,8 +146,8 @@ describe("getQueuedMessageInfo", () => {
|
||||
rawText: "check this",
|
||||
attachmentCount: 2,
|
||||
fileBlocks: [
|
||||
{ mediaType: "image/png", fileId: "img-1" },
|
||||
{ mediaType: "application/pdf", fileId: "doc-2" },
|
||||
{ type: "file", file_id: "img-1", media_type: "image/png" },
|
||||
{ type: "file", file_id: "doc-2", media_type: "application/pdf" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ChatQueuedMessage } from "api/typesGenerated";
|
||||
import type { ChatMessagePart, ChatQueuedMessage } from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import {
|
||||
@@ -16,17 +16,15 @@ import {
|
||||
import { type FC, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
interface FileBlock {
|
||||
mediaType: string;
|
||||
data?: string;
|
||||
fileId?: string;
|
||||
}
|
||||
|
||||
interface QueuedMessagesListProps {
|
||||
messages: readonly ChatQueuedMessage[];
|
||||
onDelete: (id: number) => Promise<void> | void;
|
||||
onPromote: (id: number) => Promise<void> | void;
|
||||
onEdit?: (id: number, text: string, fileBlocks: readonly FileBlock[]) => void;
|
||||
onEdit?: (
|
||||
id: number,
|
||||
text: string,
|
||||
fileBlocks: readonly ChatMessagePart[],
|
||||
) => void;
|
||||
editingMessageID?: number | null;
|
||||
className?: string;
|
||||
}
|
||||
@@ -35,20 +33,14 @@ interface QueuedMessageInfo {
|
||||
displayText: string;
|
||||
rawText: string;
|
||||
attachmentCount: number;
|
||||
fileBlocks: readonly FileBlock[];
|
||||
fileBlocks: readonly ChatMessagePart[];
|
||||
}
|
||||
|
||||
export const getQueuedMessageInfo = (
|
||||
message: ChatQueuedMessage,
|
||||
): QueuedMessageInfo => {
|
||||
const { content } = message;
|
||||
const fileBlocks: FileBlock[] = content
|
||||
.filter((p) => p.type === "file")
|
||||
.map((p) => ({
|
||||
mediaType: p.media_type ?? "application/octet-stream",
|
||||
fileId: p.file_id,
|
||||
data: p.data,
|
||||
}));
|
||||
const fileBlocks = content.filter((p) => p.type === "file");
|
||||
const rawText = content
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
|
||||
Reference in New Issue
Block a user