perf(ui-chat): memoize tool and command rows
This commit is contained in:
1 parent
b6f99fad55
commit
bcd14d776c
3 files changed
+198
-167
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts'
|
||||
import { DisclosureRow, IconApiOutlineRegular, TextShimmer } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import a11yCss from './accessibility.module.css'
|
||||
@@ -6,6 +6,8 @@ import css from './GenericCommandCard.module.css'
|
||||
|
||||
type CommandRowState = 'running' | 'ok' | 'error'
|
||||
|
||||
const COMMAND_ICON = <IconApiOutlineRegular size={14} />
|
||||
|
||||
/** Node state → row state semantic (running while unsettled; outcome kind after). */
|
||||
function stateOf(outcome: CommandRowOwnerProps['node']['outcome']): CommandRowState {
|
||||
if (outcome === null) return 'running'
|
||||
@@ -19,7 +21,12 @@ export interface GenericCommandCardProps extends CommandRowOwnerProps {
|
||||
runningSummary?: string | undefined
|
||||
}
|
||||
|
||||
export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCardProps) {
|
||||
/**
|
||||
* Render a command summary and its lazily mounted multiline output.
|
||||
* @param props - command, locale, and optional running label.
|
||||
* @returns the command disclosure.
|
||||
*/
|
||||
export const GenericCommandCard = memo(function GenericCommandCard({ node, t, runningSummary }: GenericCommandCardProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const text = node.outcome?.text
|
||||
const summary = node.outcome === null
|
||||
@@ -32,6 +39,18 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa
|
||||
const running = state === 'running'
|
||||
const body = text !== undefined && text.includes('\n') ? text : null
|
||||
const open = expanded && body !== null
|
||||
const toggle = useCallback(() => { setExpanded(value => !value) }, [])
|
||||
const collapsedContent = useMemo(() => (
|
||||
<>
|
||||
<span className={css.separator} aria-hidden />
|
||||
<span className={css.summary} data-error={state === 'error' || undefined}>
|
||||
<TextShimmer active={running}>{summary}</TextShimmer>
|
||||
</span>
|
||||
</>
|
||||
), [running, state, summary])
|
||||
const content = useMemo(() => open
|
||||
? <pre className={css.body} data-error={state === 'error' || undefined}>{body}</pre>
|
||||
: undefined, [body, open, state])
|
||||
return (
|
||||
<div className={css.root} data-variant="others" data-state={state}>
|
||||
{state === 'running' && <span className={a11yCss.visuallyHidden}>{t('row.running')}</span>}
|
||||
@@ -41,25 +60,18 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa
|
||||
leadingClassName={css.leading}
|
||||
titleClassName={css.title}
|
||||
chevronClassName={css.chevron}
|
||||
icon={<IconApiOutlineRegular size={14} />}
|
||||
icon={COMMAND_ICON}
|
||||
title={title}
|
||||
running={running}
|
||||
open={open}
|
||||
expandable={body !== null}
|
||||
expandOnRowClick
|
||||
keepContentWhenOpen
|
||||
onToggle={() => { setExpanded(value => !value) }}
|
||||
collapsedContent={(
|
||||
<>
|
||||
<span className={css.separator} aria-hidden />
|
||||
<span className={css.summary} data-error={state === 'error' || undefined}>
|
||||
<TextShimmer active={running}>{summary}</TextShimmer>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
onToggle={toggle}
|
||||
collapsedContent={collapsedContent}
|
||||
>
|
||||
<pre className={css.body} data-error={state === 'error' || undefined}>{body}</pre>
|
||||
{content}
|
||||
</DisclosureRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import { memo, useCallback, useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
CodeBlock, DiffBlock, DisclosureRow, IconInspectOutlineRegular, ReadBlock, SearchBlock,
|
||||
@@ -101,7 +101,12 @@ function stateStatus(state: ToolRowState, t: TranslateNS<'conversation'>): strin
|
||||
}
|
||||
}
|
||||
|
||||
export function ToolRow({
|
||||
/**
|
||||
* Render one localized tool summary and lazily mounted result card.
|
||||
* @param props - tool state, summary, output, and navigation callbacks.
|
||||
* @returns the tool disclosure.
|
||||
*/
|
||||
export const ToolRow = memo(function ToolRow({
|
||||
t,
|
||||
variant,
|
||||
toolName,
|
||||
@@ -134,9 +139,9 @@ export function ToolRow({
|
||||
const readLabels = useMemo(() => readBlockLabels(t), [t])
|
||||
const searchLabels = useMemo(() => searchBlockLabels(t), [t])
|
||||
const webLabels = useMemo(() => webBlockLabels(t), [t])
|
||||
const terminalBody = terminal === undefined || terminal === null
|
||||
const terminalBody = useMemo(() => terminal === undefined || terminal === null
|
||||
? null
|
||||
: localizeTerminalCardModel(terminal, t)
|
||||
: localizeTerminalCardModel(terminal, t), [terminal, t])
|
||||
const diffBody = diff ?? null
|
||||
const readBody = read ?? null
|
||||
const imageBody = image !== undefined && image !== null && renderSlot !== undefined && loadImage !== undefined
|
||||
@@ -173,26 +178,159 @@ export function ToolRow({
|
||||
}, [diffBody])
|
||||
const settledWithCue = state === 'error' || state === 'stopped'
|
||||
const suffix = settledWithCue ? null : summarySuffix ?? diffStat
|
||||
const toggleExpand = () => {
|
||||
const toggleExpand = useCallback(() => {
|
||||
setExpanded(v => !v)
|
||||
}
|
||||
const openFile = filePath !== undefined && onOpenFile !== undefined && !settledWithCue
|
||||
}, [])
|
||||
const openFile = useMemo(() => filePath !== undefined && onOpenFile !== undefined && !settledWithCue
|
||||
? (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
if (filePathLine === undefined) onOpenFile(filePath)
|
||||
else onOpenFile(filePath, { line: filePathLine })
|
||||
}
|
||||
: undefined
|
||||
: undefined, [filePath, filePathLine, onOpenFile, settledWithCue])
|
||||
// Keep Enter/Space on the focused path link from bubbling to the row's
|
||||
// keydown handler, which would preventDefault() the key and toggle expand
|
||||
// instead of activating the link — the keyboard analogue of openFile's
|
||||
// stopPropagation. The native button still fires its own onClick from the key.
|
||||
const fileLinkKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
const fileLinkKeyDown = useCallback((event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation()
|
||||
}
|
||||
}, [])
|
||||
// The code variant's program renders through CodeBlock (shiki), so only its
|
||||
// output joins the IN/OUT card; every other variant's input does too.
|
||||
const cardBody = variant === 'code' ? null : bodyText
|
||||
const collapsedContent = useMemo(() => summaryText !== '' && (
|
||||
/* An empty summary drops the separator with it (a row that is only
|
||||
its title shows no trailing dot). */
|
||||
<>
|
||||
<span className={css.sep} aria-hidden />
|
||||
{openFile !== undefined ? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.fileLink}
|
||||
onClick={openFile}
|
||||
onKeyDown={fileLinkKeyDown}
|
||||
>
|
||||
<TextShimmer active={running}>{summaryText}</TextShimmer>
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className={clsx(
|
||||
css.summary,
|
||||
state === 'error' && css.errorSummary,
|
||||
state === 'stopped' && css.stoppedSummary,
|
||||
)}
|
||||
>
|
||||
<TextShimmer active={running}>{summaryText}</TextShimmer>
|
||||
</span>
|
||||
)}
|
||||
{suffix !== null && (
|
||||
<TextShimmer className={clsx(css.summarySuffix, suffix === diffStat && css.diffStat)} active={running}>{suffix}</TextShimmer>
|
||||
)}
|
||||
</>
|
||||
), [diffStat, fileLinkKeyDown, openFile, running, state, suffix, summaryText])
|
||||
const expandedContent = useMemo(() => open ? (
|
||||
<div className={clsx(css.bodyWrap, detailsBody !== null && css.detailsBodyWrap)}>
|
||||
{askQuestionBody !== null
|
||||
? <AskQuestionCard card={askQuestionBody} />
|
||||
: terminalBody !== null
|
||||
? (
|
||||
<TerminalBlock
|
||||
{...terminalBody.card}
|
||||
maxLines={Infinity}
|
||||
labels={terminalLabels}
|
||||
className={css.terminalBody}
|
||||
/>
|
||||
)
|
||||
: diffBody !== null
|
||||
? <DiffBlock {...diffBody.card} labels={diffLabels} maxLines={CHAT_DIFF_MAX_LINES} className={css.diffBody} />
|
||||
: readBody !== null
|
||||
? <ReadBlock {...readBody} labels={readLabels} maxLines={CHAT_READ_MAX_LINES} className={css.readBody} />
|
||||
: imageBody !== null
|
||||
? (
|
||||
/* Label, gallery, then the result's OWN envelope text. The text
|
||||
comes from the image card model (which reads the result's text
|
||||
block), never from the row's flattened output: an image read's
|
||||
content is [text envelope, image block] and flattening
|
||||
JSON.stringifies the image block, printing the raw attachment
|
||||
object under the picture. It is not redundant either — the
|
||||
attachment slot can render nothing, and then this line is the
|
||||
only evidence an image was returned. */
|
||||
<div className={css.imageBody}>
|
||||
<div className={css.imageLabel}>{imageBody.label}</div>
|
||||
{renderSlot !== undefined && loadImage !== undefined && renderSlot('tool.call.images', {
|
||||
images: imageBody.images,
|
||||
loadImage,
|
||||
align: 'start',
|
||||
})}
|
||||
<div className={css.imageMeta}>{imageBody.text}</div>
|
||||
</div>
|
||||
)
|
||||
: searchBody !== null
|
||||
? (
|
||||
<>
|
||||
<SearchBlock
|
||||
{...searchBody.card}
|
||||
labels={searchLabels}
|
||||
maxLines={CHAT_SEARCH_MAX_LINES}
|
||||
className={css.searchBody}
|
||||
/>
|
||||
{/* A capped search's recovery locator lives only in the result
|
||||
text; show it below the card so the dropped rows survive. */}
|
||||
{searchBody.recovery !== undefined && (
|
||||
<div className={css.searchRecovery}>{searchBody.recovery}</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: webBody !== null
|
||||
? <WebBlock {...webBody} labels={webLabels} className={css.webBody} />
|
||||
: detailsBody !== null
|
||||
? <ToolDetails model={detailsBody} hasInspect={inspect !== undefined} t={t} onOpenFile={onOpenFile} />
|
||||
: (
|
||||
<>
|
||||
{variant === 'code' && bodyText !== null && (
|
||||
<div className={css.bodyScroll}>
|
||||
<CodeBlock code={bodyText} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
|
||||
</div>
|
||||
)}
|
||||
{(cardBody !== null || outputText !== null) && (
|
||||
<div className={css.ioCard}>
|
||||
{cardBody !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>{t('row.input')}</span>
|
||||
<span className={css.ioText}>{cardBody}</span>
|
||||
</div>
|
||||
)}
|
||||
{cardBody !== null && outputText !== null && (
|
||||
<span className={css.ioDivider} aria-hidden />
|
||||
)}
|
||||
{outputText !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>{t('row.output')}</span>
|
||||
<span className={css.ioText} data-error={state === 'error' || undefined}>
|
||||
{outputText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{inspect !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.inspectButton}
|
||||
onClick={inspect}
|
||||
>
|
||||
<IconInspectOutlineRegular />
|
||||
{t('row.inspect')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : undefined, [
|
||||
open, detailsBody, askQuestionBody, terminalBody, terminalLabels, diffBody, diffLabels, readBody, readLabels,
|
||||
imageBody, renderSlot, loadImage, searchBody, searchLabels, webBody, webLabels, inspect, t, onOpenFile,
|
||||
variant, bodyText, cardBody, outputText, state,
|
||||
])
|
||||
return (
|
||||
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
@@ -209,135 +347,10 @@ export function ToolRow({
|
||||
expandOnRowClick
|
||||
keepContentWhenOpen
|
||||
onToggle={toggleExpand}
|
||||
collapsedContent={summaryText !== '' && (
|
||||
/* An empty summary drops the separator with it (a row that is only
|
||||
its title shows no trailing dot). */
|
||||
<>
|
||||
<span className={css.sep} aria-hidden />
|
||||
{openFile !== undefined ? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.fileLink}
|
||||
onClick={openFile}
|
||||
onKeyDown={fileLinkKeyDown}
|
||||
>
|
||||
<TextShimmer active={running}>{summaryText}</TextShimmer>
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className={clsx(
|
||||
css.summary,
|
||||
state === 'error' && css.errorSummary,
|
||||
state === 'stopped' && css.stoppedSummary,
|
||||
)}
|
||||
>
|
||||
<TextShimmer active={running}>{summaryText}</TextShimmer>
|
||||
</span>
|
||||
)}
|
||||
{suffix !== null && (
|
||||
<TextShimmer className={clsx(css.summarySuffix, suffix === diffStat && css.diffStat)} active={running}>{suffix}</TextShimmer>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
collapsedContent={collapsedContent}
|
||||
>
|
||||
<div className={clsx(css.bodyWrap, detailsBody !== null && css.detailsBodyWrap)}>
|
||||
{askQuestionBody !== null
|
||||
? <AskQuestionCard card={askQuestionBody} />
|
||||
: terminalBody !== null
|
||||
? (
|
||||
<TerminalBlock
|
||||
{...terminalBody.card}
|
||||
maxLines={Infinity}
|
||||
labels={terminalLabels}
|
||||
className={css.terminalBody}
|
||||
/>
|
||||
)
|
||||
: diffBody !== null
|
||||
? <DiffBlock {...diffBody.card} labels={diffLabels} maxLines={CHAT_DIFF_MAX_LINES} className={css.diffBody} />
|
||||
: readBody !== null
|
||||
? <ReadBlock {...readBody} labels={readLabels} maxLines={CHAT_READ_MAX_LINES} className={css.readBody} />
|
||||
: imageBody !== null
|
||||
? (
|
||||
/* Label, gallery, then the result's OWN envelope text. The text
|
||||
comes from the image card model (which reads the result's text
|
||||
block), never from the row's flattened output: an image read's
|
||||
content is [text envelope, image block] and flattening
|
||||
JSON.stringifies the image block, printing the raw attachment
|
||||
object under the picture. It is not redundant either — the
|
||||
attachment slot can render nothing, and then this line is the
|
||||
only evidence an image was returned. */
|
||||
<div className={css.imageBody}>
|
||||
<div className={css.imageLabel}>{imageBody.label}</div>
|
||||
{renderSlot !== undefined && loadImage !== undefined && renderSlot('tool.call.images', {
|
||||
images: imageBody.images,
|
||||
loadImage,
|
||||
align: 'start',
|
||||
})}
|
||||
<div className={css.imageMeta}>{imageBody.text}</div>
|
||||
</div>
|
||||
)
|
||||
: searchBody !== null
|
||||
? (
|
||||
<>
|
||||
<SearchBlock
|
||||
{...searchBody.card}
|
||||
labels={searchLabels}
|
||||
maxLines={CHAT_SEARCH_MAX_LINES}
|
||||
className={css.searchBody}
|
||||
/>
|
||||
{/* A capped search's recovery locator lives only in the result
|
||||
text; show it below the card so the dropped rows survive. */}
|
||||
{searchBody.recovery !== undefined && (
|
||||
<div className={css.searchRecovery}>{searchBody.recovery}</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: webBody !== null
|
||||
? <WebBlock {...webBody} labels={webLabels} className={css.webBody} />
|
||||
: detailsBody !== null
|
||||
? <ToolDetails model={detailsBody} hasInspect={inspect !== undefined} t={t} onOpenFile={onOpenFile} />
|
||||
: (
|
||||
<>
|
||||
{variant === 'code' && bodyText !== null && (
|
||||
<div className={css.bodyScroll}>
|
||||
<CodeBlock code={bodyText} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
|
||||
</div>
|
||||
)}
|
||||
{(cardBody !== null || outputText !== null) && (
|
||||
<div className={css.ioCard}>
|
||||
{cardBody !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>{t('row.input')}</span>
|
||||
<span className={css.ioText}>{cardBody}</span>
|
||||
</div>
|
||||
)}
|
||||
{cardBody !== null && outputText !== null && (
|
||||
<span className={css.ioDivider} aria-hidden />
|
||||
)}
|
||||
{outputText !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>{t('row.output')}</span>
|
||||
<span className={css.ioText} data-error={state === 'error' || undefined}>
|
||||
{outputText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{inspect !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.inspectButton}
|
||||
onClick={inspect}
|
||||
>
|
||||
<IconInspectOutlineRegular />
|
||||
{t('row.inspect')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{expandedContent}
|
||||
</DisclosureRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState, type KeyboardEvent } from 'react'
|
||||
import { memo, useCallback, useMemo, useState, type KeyboardEvent } from 'react'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
@@ -21,6 +21,8 @@ import css from './bash-sample.module.css'
|
||||
|
||||
type BashRowProps = ToolCallViewProps & PropsLocale<'conversation'>
|
||||
|
||||
const BASH_ICON = <IconApiOutlineRegular size={14} />
|
||||
|
||||
/** Visually hidden status for the color-only running sweep and error tone. */
|
||||
function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null {
|
||||
switch (state) {
|
||||
@@ -31,14 +33,19 @@ function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** Renders expandable Bash output with an accessible lifecycle label. */
|
||||
export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: BashRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
/**
|
||||
* Render expandable Bash output with an accessible lifecycle label.
|
||||
* @param props - tool call, Session sources, locale, and inspection callback.
|
||||
* @returns the Bash output row.
|
||||
*/
|
||||
export const BashRow = memo(function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: BashRowProps) {
|
||||
const model = useMemo(() => toolRowModel(toolName, block), [toolName, block])
|
||||
// An omitted shell workdir is the session workspace; relative values resolve
|
||||
// against it before reaching the terminal primitive.
|
||||
const cwd = useSessions(list => list.byId[sessionId]?.cwd)
|
||||
const terminalModel = terminalCardModel(block, cwd)
|
||||
const terminal = terminalModel === null ? null : localizeTerminalCardModel(terminalModel, t)
|
||||
const terminalModel = useMemo(() => terminalCardModel(block, cwd), [block, cwd])
|
||||
const terminal = useMemo(() => terminalModel === null ? null : localizeTerminalCardModel(terminalModel, t), [terminalModel, t])
|
||||
const labels = useMemo(() => terminalBlockLabels(t), [t])
|
||||
// A failing exit status is the terminal card's own error signal (the call
|
||||
// itself settles isError:false), surfaced through the row's error summary.
|
||||
const state = model.state === 'ok' && terminalModel !== null && terminalFailed(terminalModel)
|
||||
@@ -64,25 +71,24 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
|
||||
? model.errorSummary ?? normalSummary
|
||||
: state === 'stopped' ? t('bash.stopped') : null
|
||||
const running = state === 'running'
|
||||
const toggleExpand = () => {
|
||||
const toggleExpand = useCallback(() => {
|
||||
setExpanded(v => !v)
|
||||
}
|
||||
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
}, [])
|
||||
const toggleFromKeyboard = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return
|
||||
event.preventDefault()
|
||||
toggleExpand()
|
||||
}
|
||||
const businessIcon = <IconApiOutlineRegular size={14} />
|
||||
}, [expandable, toggleExpand])
|
||||
const leading = open
|
||||
? <IconChevronUpOutlineRegular className={css.chevron} />
|
||||
: expandable
|
||||
? (
|
||||
<>
|
||||
<span className={css.iconIdle}>{businessIcon}</span>
|
||||
<span className={css.iconIdle}>{BASH_ICON}</span>
|
||||
<IconChevronDownOutlineRegular className={clsx(css.chevron, css.chevronHover)} />
|
||||
</>
|
||||
)
|
||||
: businessIcon
|
||||
: BASH_ICON
|
||||
return (
|
||||
<div className={css.card}>
|
||||
<div
|
||||
@@ -116,7 +122,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
|
||||
<TerminalBlock
|
||||
{...terminal.card}
|
||||
maxLines={Infinity}
|
||||
labels={terminalBlockLabels(t)}
|
||||
labels={labels}
|
||||
className={css.terminal}
|
||||
/>
|
||||
)
|
||||
@@ -151,7 +157,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
/** Registers the standalone Bash conversation-row sample. */
|
||||
export const bashToolviewSample = {
|
||||
|
||||
Reference in new issue
Block a user