feat(01-04): add TanStack Virtual list virtualization and keyboard navigation hook
- Install @tanstack/react-virtual for virtualized list rendering - Create useRovingTabIndex hook in src/renderer/lib/keyboard.ts for arrow-key navigation - Add virtualization to 6 lists: TaskQueue, ApprovalList, ActivityList, CostList, AgentRoster, MessageList - MessageList uses measureElement for variable-height message items with auto-scroll - All 6 lists have role="listbox" with role="option" on rows for accessibility - All 6 lists support ArrowDown/ArrowUp/j/k/Home/End keyboard navigation - Add 8 unit tests for useRovingTabIndex covering all key bindings and edge cases
This commit is contained in:
1 parent
7ff8dc70a2
commit
d77486504d
10 files changed
+761
-175
No files matched your search
@@ -1,21 +1,42 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { ArrowRight, Lightning, MagnifyingGlass } from "@phosphor-icons/react"
|
||||
import { useMemo, useRef, useState } from "react"
|
||||
import { useVirtualizer } from "@tanstack/react-virtual"
|
||||
import { Lightning, MagnifyingGlass } from "@phosphor-icons/react"
|
||||
import type { ActivityRecord } from "@shared/types"
|
||||
import { useT } from "../../i18n"
|
||||
import { formatTime } from "../../lib/formatters"
|
||||
import { ActionButton, EmptyState, ListFrame, ListRow, Select } from "../ui"
|
||||
import { useRovingTabIndex } from "../../lib/keyboard"
|
||||
import { EmptyState, Select } from "../ui"
|
||||
import { ENTITY_ICONS, ENTITY_TYPE_OPTIONS } from "./execution-utils"
|
||||
|
||||
export function ActivityList({ activity }: { activity: ActivityRecord[] }) {
|
||||
const t = useT()
|
||||
const [search, setSearch] = useState(""); const [entityFilter, setEntityFilter] = useState<string>("all"); const [visibleCount, setVisibleCount] = useState(100)
|
||||
const [search, setSearch] = useState("")
|
||||
const [entityFilter, setEntityFilter] = useState<string>("all")
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let result = activity
|
||||
if (entityFilter !== "all") result = result.filter((e) => e.entityType === entityFilter)
|
||||
if (search.trim()) { const q = search.toLowerCase(); result = result.filter((e) => e.action.toLowerCase().includes(q) || e.actor.toLowerCase().includes(q) || e.detail.toLowerCase().includes(q) || e.entityType.toLowerCase().includes(q)) }
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase()
|
||||
result = result.filter((e) =>
|
||||
e.action.toLowerCase().includes(q) ||
|
||||
e.actor.toLowerCase().includes(q) ||
|
||||
e.detail.toLowerCase().includes(q) ||
|
||||
e.entityType.toLowerCase().includes(q),
|
||||
)
|
||||
}
|
||||
return result
|
||||
}, [activity, entityFilter, search])
|
||||
const visible = filtered.slice(0, visibleCount)
|
||||
|
||||
const { handleKeyDown, getTabIndex, focusedIndex } = useRovingTabIndex(filtered.length)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filtered.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 48,
|
||||
overscan: 15,
|
||||
})
|
||||
|
||||
if (activity.length === 0) return <EmptyState title={t("exec.noActivityYet")} />
|
||||
return (
|
||||
@@ -27,24 +48,58 @@ export function ActivityList({ activity }: { activity: ActivityRecord[] }) {
|
||||
</div>
|
||||
<Select value={entityFilter} onChange={setEntityFilter} options={ENTITY_TYPE_OPTIONS.map((v) => ({ value: v, label: v === "all" ? t("exec.allTypes") : v }))} />
|
||||
</div>
|
||||
{visible.length === 0 ? <EmptyState title={t("exec.noMatchingActivity")} /> : (
|
||||
<>
|
||||
<ListFrame>
|
||||
{visible.map((entry) => {
|
||||
{filtered.length === 0 ? <EmptyState title={t("exec.noMatchingActivity")} /> : (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="h-full max-h-[600px] overflow-auto border-y border-[color:var(--line)]"
|
||||
role="listbox"
|
||||
aria-label="Activity log"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const entry = filtered[virtualRow.index]
|
||||
const EntityIcon = ENTITY_ICONS[entry.entityType] ?? Lightning
|
||||
return (
|
||||
<ListRow key={entry.id}>
|
||||
<div
|
||||
key={entry.id}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
tabIndex={getTabIndex(virtualRow.index)}
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
className={`focus-ring outline-none border-b border-[color:var(--line)] px-5 py-4 last:border-b-0 ${virtualRow.index === focusedIndex ? "ring-1 ring-[color:var(--accent)] ring-inset" : ""}`}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2"><EntityIcon size={14} weight="duotone" className="text-[color:var(--muted)]" /><div className="text-[14px] text-[color:var(--text)]">{entry.action}</div></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<EntityIcon size={14} weight="duotone" className="text-[color:var(--muted)]" />
|
||||
<div className="text-[14px] text-[color:var(--text)]">{entry.action}</div>
|
||||
</div>
|
||||
<div className="mono text-[11px] text-[color:var(--muted)]">{formatTime(entry.createdAt)}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[13px] text-[color:var(--muted)]"><span className="font-medium text-[color:var(--muted-strong)]">{entry.actor}</span><span>·</span><span>{entry.detail || `${entry.entityType}:${entry.entityId}`}</span></div>
|
||||
</ListRow>
|
||||
<div className="flex items-center gap-2 text-[13px] text-[color:var(--muted)]">
|
||||
<span className="font-medium text-[color:var(--muted-strong)]">{entry.actor}</span>
|
||||
<span>·</span>
|
||||
<span>{entry.detail || `${entry.entityType}:${entry.entityId}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</ListFrame>
|
||||
{visibleCount < filtered.length ? <div className="flex justify-center pt-2"><ActionButton label={`Load more (${filtered.length - visibleCount} remaining)`} onClick={() => setVisibleCount((c) => c + 100)} icon={ArrowRight} /></div> : null}
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,21 +1,72 @@
|
||||
import { useRef } from "react"
|
||||
import { useVirtualizer } from "@tanstack/react-virtual"
|
||||
import type { ApprovalRecord } from "@shared/types"
|
||||
import { useT } from "../../i18n"
|
||||
import { EmptyState, ListFrame, StatusPill } from "../ui"
|
||||
import { useRovingTabIndex } from "../../lib/keyboard"
|
||||
import { EmptyState, StatusPill } from "../ui"
|
||||
|
||||
export function ApprovalList({ approvals, selectedApprovalId, onSelect }: { approvals: ApprovalRecord[]; selectedApprovalId: string | null; onSelect: (id: string) => void }) {
|
||||
const t = useT()
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
const { handleKeyDown, getTabIndex, focusedIndex } = useRovingTabIndex(approvals.length)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: approvals.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 52,
|
||||
overscan: 10,
|
||||
})
|
||||
|
||||
if (approvals.length === 0) return <EmptyState title={t("exec.noApprovals")} />
|
||||
return (
|
||||
<ListFrame>
|
||||
{approvals.map((approval) => (
|
||||
<button key={approval.id} className={`focus-ring relative w-full border-b border-[color:var(--line)] px-5 py-4 text-left transition last:border-b-0 ${selectedApprovalId === approval.id ? "bg-transparent" : "bg-transparent hover:bg-[color:var(--panel-soft)]"}`} onClick={() => onSelect(approval.id)}>
|
||||
{selectedApprovalId === approval.id ? <span className="absolute inset-y-3 left-0 w-0.5 rounded-full bg-[color:var(--warn)]" /> : null}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className={`text-[15px] text-[color:var(--text)] ${selectedApprovalId === approval.id ? "font-semibold" : "font-medium"}`}>{approval.payloadSummary}</div>
|
||||
<StatusPill status={approval.state} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</ListFrame>
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="h-full overflow-auto border-y border-[color:var(--line)]"
|
||||
role="listbox"
|
||||
aria-label={t("exec.noApprovals")}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const approval = approvals[virtualRow.index]
|
||||
return (
|
||||
<div
|
||||
key={approval.id}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
tabIndex={getTabIndex(virtualRow.index)}
|
||||
role="option"
|
||||
aria-selected={approval.id === selectedApprovalId}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
className={`focus-ring outline-none ${virtualRow.index === focusedIndex ? "ring-1 ring-[color:var(--accent)] ring-inset" : ""}`}
|
||||
>
|
||||
<button
|
||||
className={`relative w-full border-b border-[color:var(--line)] px-5 py-4 text-left transition last:border-b-0 ${selectedApprovalId === approval.id ? "bg-transparent" : "bg-transparent hover:bg-[color:var(--panel-soft)]"}`}
|
||||
onClick={() => onSelect(approval.id)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{selectedApprovalId === approval.id ? <span className="absolute inset-y-3 left-0 w-0.5 rounded-full bg-[color:var(--warn)]" /> : null}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className={`text-[15px] text-[color:var(--text)] ${selectedApprovalId === approval.id ? "font-semibold" : "font-medium"}`}>{approval.payloadSummary}</div>
|
||||
<StatusPill status={approval.state} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,38 +1,113 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { useMemo, useRef, useState } from "react"
|
||||
import { useVirtualizer } from "@tanstack/react-virtual"
|
||||
import type { ProfileSnapshot } from "@shared/types"
|
||||
import { useT } from "../../i18n"
|
||||
import { formatMoney } from "../../lib/formatters"
|
||||
import { EmptyState, ListFrame, ListRow, Select } from "../ui"
|
||||
import { useRovingTabIndex } from "../../lib/keyboard"
|
||||
import { EmptyState, Select } from "../ui"
|
||||
|
||||
export function CostList({ costs, agents, runs }: { costs: ProfileSnapshot["costs"]; agents: ProfileSnapshot["agents"]; runs: ProfileSnapshot["runs"] }) {
|
||||
const t = useT()
|
||||
const [connectorFilter, setConnectorFilter] = useState("all")
|
||||
const connectorIds = useMemo(() => { const ids = new Set(costs.map((c) => c.connectorId)); return Array.from(ids).sort() }, [costs])
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const connectorIds = useMemo(() => {
|
||||
const ids = new Set(costs.map((c) => c.connectorId))
|
||||
return Array.from(ids).sort()
|
||||
}, [costs])
|
||||
const filtered = useMemo(() => connectorFilter === "all" ? costs : costs.filter((c) => c.connectorId === connectorFilter), [costs, connectorFilter])
|
||||
const total = useMemo(() => filtered.reduce((sum, c) => sum + (c.amountUsd ?? 0), 0), [filtered])
|
||||
|
||||
// +1 for the total row at the bottom
|
||||
const { handleKeyDown, getTabIndex, focusedIndex } = useRovingTabIndex(filtered.length + 1)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filtered.length + 1, // +1 for total row
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 44,
|
||||
overscan: 10,
|
||||
})
|
||||
|
||||
if (costs.length === 0) return <EmptyState title={t("exec.noCostData")} />
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3"><Select value={connectorFilter} onChange={setConnectorFilter} options={[{ value: "all", label: t("exec.allConnectors") }, ...connectorIds.map((id) => ({ value: id, label: id }))]} /></div>
|
||||
<ListFrame>
|
||||
{filtered.map((entry) => (
|
||||
<ListRow key={entry.id} className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-[14px] text-[color:var(--text)]">{runs.find((run) => run.id === entry.runId)?.summary || "Run cost"}</div>
|
||||
<div className="text-[12px] text-[color:var(--muted)]">{agents.find((a) => a.id === entry.agentId)?.name ?? "Unknown agent"} · {entry.connectorId}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="mono text-[13px] text-[color:var(--text)]">{formatMoney(entry.amountUsd)}</div>
|
||||
{entry.unattributed ? <div className="text-[11px] text-[color:var(--warn)]">unattributed</div> : null}
|
||||
</div>
|
||||
</ListRow>
|
||||
))}
|
||||
<ListRow className="flex items-center justify-between">
|
||||
<div className="text-[14px] font-semibold text-[color:var(--text)]">Total ({filtered.length} entries)</div>
|
||||
<div className="mono text-[14px] font-semibold text-[color:var(--text)]">{formatMoney(total)}</div>
|
||||
</ListRow>
|
||||
</ListFrame>
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={connectorFilter} onChange={setConnectorFilter} options={[{ value: "all", label: t("exec.allConnectors") }, ...connectorIds.map((id) => ({ value: id, label: id }))]} />
|
||||
</div>
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="h-full max-h-[600px] overflow-auto border-y border-[color:var(--line)]"
|
||||
role="listbox"
|
||||
aria-label="Cost entries"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const isTotal = virtualRow.index === filtered.length
|
||||
if (isTotal) {
|
||||
return (
|
||||
<div
|
||||
key="__total__"
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
tabIndex={getTabIndex(virtualRow.index)}
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
className={`focus-ring outline-none border-b border-[color:var(--line)] px-5 py-4 last:border-b-0 ${virtualRow.index === focusedIndex ? "ring-1 ring-[color:var(--accent)] ring-inset" : ""}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[14px] font-semibold text-[color:var(--text)]">Total ({filtered.length} entries)</div>
|
||||
<div className="mono text-[14px] font-semibold text-[color:var(--text)]">{formatMoney(total)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const entry = filtered[virtualRow.index]
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
tabIndex={getTabIndex(virtualRow.index)}
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
className={`focus-ring outline-none border-b border-[color:var(--line)] px-5 py-4 last:border-b-0 ${virtualRow.index === focusedIndex ? "ring-1 ring-[color:var(--accent)] ring-inset" : ""}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-[14px] text-[color:var(--text)]">{runs.find((run) => run.id === entry.runId)?.summary || "Run cost"}</div>
|
||||
<div className="text-[12px] text-[color:var(--muted)]">{agents.find((a) => a.id === entry.agentId)?.name ?? "Unknown agent"} · {entry.connectorId}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="mono text-[13px] text-[color:var(--text)]">{formatMoney(entry.amountUsd)}</div>
|
||||
{entry.unattributed ? <div className="text-[11px] text-[color:var(--warn)]">unattributed</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +1,77 @@
|
||||
import { useRef } from "react"
|
||||
import { useVirtualizer } from "@tanstack/react-virtual"
|
||||
import type { RunRecord, TaskRecord } from "@shared/types"
|
||||
import { useT } from "../../i18n"
|
||||
import { EmptyState, ListFrame, StatusPill } from "../ui"
|
||||
import { useRovingTabIndex } from "../../lib/keyboard"
|
||||
import { EmptyState, StatusPill } from "../ui"
|
||||
import { PriorityDot } from "./execution-utils"
|
||||
|
||||
export function TaskQueue({ tasks, runs, selectedTaskId, onSelect }: { tasks: TaskRecord[]; runs: RunRecord[]; selectedTaskId: string | null; onSelect: (taskId: string) => void }) {
|
||||
const t = useT()
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
const { handleKeyDown, getTabIndex, focusedIndex } = useRovingTabIndex(tasks.length)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: tasks.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 52,
|
||||
overscan: 10,
|
||||
})
|
||||
|
||||
if (tasks.length === 0) return <EmptyState title={t("exec.taskQueueEmpty")} />
|
||||
return (
|
||||
<ListFrame>
|
||||
{tasks.map((task) => {
|
||||
const activeRun = runs.find((run) => run.id === task.activeRunId) ?? null
|
||||
return (
|
||||
<button key={task.id} className={`focus-ring relative w-full border-b border-[color:var(--line)] px-5 py-4 text-left transition last:border-b-0 ${selectedTaskId === task.id ? "bg-transparent" : "bg-transparent hover:bg-[color:var(--panel-soft)]"}`} onClick={() => onSelect(task.id)}>
|
||||
{selectedTaskId === task.id ? <span className="absolute inset-y-3 left-0 w-0.5 rounded-full bg-[color:var(--accent)]" /> : null}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<PriorityDot priority={task.priority} />
|
||||
<div className={`truncate text-[14px] text-[color:var(--text)] ${selectedTaskId === task.id ? "font-semibold" : "font-medium"}`}>{task.title}</div>
|
||||
</div>
|
||||
<StatusPill status={activeRun?.status ?? task.status} />
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="h-full overflow-auto border-y border-[color:var(--line)]"
|
||||
role="listbox"
|
||||
aria-label={t("exec.taskQueueEmpty")}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const task = tasks[virtualRow.index]
|
||||
const activeRun = runs.find((run) => run.id === task.activeRunId) ?? null
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
tabIndex={getTabIndex(virtualRow.index)}
|
||||
role="option"
|
||||
aria-selected={task.id === selectedTaskId}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
className={`focus-ring outline-none ${virtualRow.index === focusedIndex ? "ring-1 ring-[color:var(--accent)] ring-inset" : ""}`}
|
||||
>
|
||||
<button
|
||||
className={`relative w-full border-b border-[color:var(--line)] px-5 py-4 text-left transition last:border-b-0 ${selectedTaskId === task.id ? "bg-transparent" : "bg-transparent hover:bg-[color:var(--panel-soft)]"}`}
|
||||
onClick={() => onSelect(task.id)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{selectedTaskId === task.id ? <span className="absolute inset-y-3 left-0 w-0.5 rounded-full bg-[color:var(--accent)]" /> : null}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<PriorityDot priority={task.priority} />
|
||||
<div className={`truncate text-[14px] text-[color:var(--text)] ${selectedTaskId === task.id ? "font-semibold" : "font-medium"}`}>{task.title}</div>
|
||||
</div>
|
||||
<StatusPill status={activeRun?.status ?? task.status} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</ListFrame>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user