mirror of
https://seed.radicle.dev/rad:z4D5UCArafTzTQpDZNQRuqswh3ury.git
synced 2026-10-18 19:10:45 +08:00
Link and mention releases
- Route release and release list links to their views - Offer releases for a pasted ID or link - List delegate releases in # search - Show release references as chips - Cache release lookups for mentions - Record release mentions in ADR 0003
This commit is contained in:
committed by
Rūdolfs Ošiņš
parent
595a81150b
commit
e930bfd397
@@ -38,7 +38,8 @@ text as an absolute identifier:
|
||||
| Person | `did:key:<NID>` |
|
||||
|
||||
- Object ids are always full. The composer expands a prefix and offers every
|
||||
match when several objects share it.
|
||||
match when several objects share it. It searches only delegate releases;
|
||||
a full release id or link resolves any release.
|
||||
- Identifiers are never relative to the current repo.
|
||||
- Explorer URLs are accepted as input and normalised on insert. Where a URL
|
||||
is ambiguous, as in `/tree/<branch>/<path>` with a branch containing
|
||||
@@ -54,7 +55,7 @@ from explorer routes. It is tested against the RIP 4 vectors and
|
||||
`radicle-uri`'s cases.
|
||||
|
||||
**Clients decide presentation.** This app shows repos, issues, patches,
|
||||
commits and people as chips, and every other entity above as a link
|
||||
releases, commits and people as chips, and every other entity above as a link
|
||||
labelled with the repo name, such as `heartwood: src/lib.rs`. Either
|
||||
navigates in-app when the repo is local and falls back to the configured
|
||||
`publicExplorer`, which a right click also offers. A resource a client does
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Issue } from "@bindings/cob/issue/Issue";
|
||||
import type { Patch } from "@bindings/cob/patch/Patch";
|
||||
import type { Release } from "@bindings/cob/release/Release";
|
||||
import type { Config } from "@bindings/config/Config";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
@@ -8,6 +9,7 @@
|
||||
cachedConfig,
|
||||
cachedIssueById,
|
||||
cachedPatchById,
|
||||
cachedReleaseById,
|
||||
cachedRepoById,
|
||||
cachedRepoCommit,
|
||||
} from "@app/lib/invoke";
|
||||
@@ -49,6 +51,7 @@
|
||||
let resolvedRepoName: string | undefined = $state(undefined);
|
||||
let issue: Issue | undefined = $state(undefined);
|
||||
let patch: Patch | undefined = $state(undefined);
|
||||
let release = $state<Release | undefined>(undefined);
|
||||
let commitSummary: string | undefined = $state(undefined);
|
||||
let missing = $state(false);
|
||||
let config: Config | undefined = $state(undefined);
|
||||
@@ -136,6 +139,27 @@
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (target.type !== "cob" || target.kind !== "release") return;
|
||||
let cancelled = false;
|
||||
void cachedReleaseById(target.rid, target.oid)
|
||||
.then(result => {
|
||||
if (cancelled) return;
|
||||
if (result) release = result;
|
||||
else missing = true;
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) missing = true;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
const releaseTitle = $derived(
|
||||
release ? release.title || release.tagName : undefined,
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (target.type !== "commit") return;
|
||||
let cancelled = false;
|
||||
@@ -365,6 +389,27 @@
|
||||
<span class="mention-oid">{formatOid(target.oid)}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{:else if target.kind === "release"}
|
||||
<a
|
||||
class="mention"
|
||||
class:unresolved={missing}
|
||||
{href}
|
||||
target={missing ? "_blank" : undefined}
|
||||
rel={missing ? "noopener noreferrer" : undefined}
|
||||
onclick={handleClick}
|
||||
onmousedown={keepSelection}
|
||||
oncontextmenu={openMenu}
|
||||
title={missing
|
||||
? `${fallback} · ${target.oid} — not replicated locally`
|
||||
: `${releaseTitle ?? fallback} · ${target.oid}`}>
|
||||
<span class="mention-status">
|
||||
<Icon name="parcel" />
|
||||
</span>
|
||||
<span class="mention-label">{releaseTitle ?? fallback}</span>
|
||||
{#if missing || (release && !releaseTitle)}
|
||||
<span class="mention-oid">{formatOid(target.oid)}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<a
|
||||
class="mention"
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<script lang="ts">
|
||||
import type { AliasSuggestion } from "@bindings/cob/AliasSuggestion";
|
||||
import type { Author } from "@bindings/cob/Author";
|
||||
import type { Release } from "@bindings/cob/release/Release";
|
||||
import type { RepoInfo } from "@bindings/repo/RepoInfo";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
@@ -29,8 +30,10 @@
|
||||
cachedIssueById,
|
||||
cachedListIssueCandidates,
|
||||
cachedListPatchCandidates,
|
||||
cachedListReleaseCandidates,
|
||||
cachedListReposSummary,
|
||||
cachedPatchById,
|
||||
cachedReleaseById,
|
||||
cachedRepoById,
|
||||
cachedRepoCommit,
|
||||
cachedRepoCommitsByPrefix,
|
||||
@@ -321,6 +324,25 @@
|
||||
};
|
||||
}
|
||||
|
||||
if (target.kind === "release") {
|
||||
const release = await cachedReleaseById(target.rid, target.oid).catch(
|
||||
() => undefined,
|
||||
);
|
||||
const label = release
|
||||
? releaseTitle(release)
|
||||
: `release ${formatOid(target.oid)}`;
|
||||
|
||||
return {
|
||||
key,
|
||||
target,
|
||||
label,
|
||||
primary: label,
|
||||
secondary: formatOid(target.oid),
|
||||
icon: "parcel",
|
||||
haystack: label,
|
||||
};
|
||||
}
|
||||
|
||||
const patch = await cachedPatchById(target.rid, target.oid).catch(
|
||||
() => undefined,
|
||||
);
|
||||
@@ -431,9 +453,10 @@
|
||||
}
|
||||
|
||||
async function resolveOid(oid: string): Promise<Suggestion[]> {
|
||||
const [issue, patch, commit] = await Promise.all([
|
||||
const [issue, patch, release, commit] = await Promise.all([
|
||||
cachedIssueById(rid, oid).catch(() => undefined),
|
||||
cachedPatchById(rid, oid).catch(() => undefined),
|
||||
cachedReleaseById(rid, oid).catch(() => undefined),
|
||||
cachedRepoCommit(rid, oid).catch(() => undefined),
|
||||
]);
|
||||
const rows: Suggestion[] = [];
|
||||
@@ -460,6 +483,9 @@
|
||||
haystack: patch.title,
|
||||
});
|
||||
}
|
||||
if (release) {
|
||||
rows.push(releaseRow(release));
|
||||
}
|
||||
if (commit && rows.length === 0) {
|
||||
rows.push({
|
||||
key: `commit:${oid}`,
|
||||
@@ -564,9 +590,10 @@
|
||||
}
|
||||
|
||||
async function collectCobs(): Promise<Suggestion[]> {
|
||||
const [allIssues, allPatches] = await Promise.all([
|
||||
const [allIssues, allPatches, allReleases] = await Promise.all([
|
||||
cachedListIssueCandidates(rid).catch(() => []),
|
||||
cachedListPatchCandidates(rid).catch(() => []),
|
||||
cachedListReleaseCandidates(rid).catch(() => []),
|
||||
]);
|
||||
const rows = [
|
||||
...allIssues.map(issue => ({
|
||||
@@ -601,6 +628,10 @@
|
||||
icon: patchIcon[patch.state.status],
|
||||
haystack: `${patch.title} ${patch.id}`,
|
||||
})),
|
||||
...allReleases.map(release => ({
|
||||
timestamp: release.createdAt,
|
||||
...releaseRow(release),
|
||||
})),
|
||||
];
|
||||
|
||||
return rows
|
||||
@@ -608,6 +639,26 @@
|
||||
.map(({ timestamp: _timestamp, ...row }) => row);
|
||||
}
|
||||
|
||||
function releaseTitle(release: Release): string {
|
||||
return (
|
||||
release.title || release.tagName || `release ${formatOid(release.id)}`
|
||||
);
|
||||
}
|
||||
|
||||
function releaseRow(release: Release): Suggestion {
|
||||
const label = releaseTitle(release);
|
||||
|
||||
return {
|
||||
key: `release:${release.id}`,
|
||||
target: { type: "cob", kind: "release", rid, oid: release.id },
|
||||
label,
|
||||
primary: label,
|
||||
secondary: formatOid(release.id),
|
||||
icon: "parcel",
|
||||
haystack: `${label} ${release.tagName ?? ""} ${release.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
const patchIcon: Record<string, IconName> = {
|
||||
draft: "patch-draft",
|
||||
open: "patch",
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { DiffOptions } from "@bindings/cob/DiffOptions";
|
||||
import type { Issue } from "@bindings/cob/issue/Issue";
|
||||
import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery";
|
||||
import type { Patch } from "@bindings/cob/patch/Patch";
|
||||
import type { Release } from "@bindings/cob/release/Release";
|
||||
import type { ReleaseFilter } from "@bindings/cob/release/ReleaseFilter";
|
||||
import type { Config } from "@bindings/config/Config";
|
||||
import type { Diff } from "@bindings/diff/Diff";
|
||||
import type { Stats } from "@bindings/diff/Stats";
|
||||
@@ -231,6 +233,23 @@ export const cachedListPatchCandidates = cached(
|
||||
{ max: 10, ttl: 30_000 },
|
||||
);
|
||||
|
||||
async function listReleaseCandidates(rid: string): Promise<Release[]> {
|
||||
const releases = await invoke<PaginatedQuery<Release[]>>("list_releases", {
|
||||
rid,
|
||||
filter: { scope: "trusted", showRedacted: false } satisfies ReleaseFilter,
|
||||
skip: 0,
|
||||
take: MENTION_CANDIDATE_TAKE,
|
||||
});
|
||||
|
||||
return releases.content;
|
||||
}
|
||||
|
||||
export const cachedListReleaseCandidates = cached(
|
||||
listReleaseCandidates,
|
||||
(...[rid]) => `release_candidates:${rid}`,
|
||||
{ max: 10, ttl: 30_000 },
|
||||
);
|
||||
|
||||
const missTtl = 5_000;
|
||||
|
||||
async function repoById(rid: string): Promise<RepoInfo | null> {
|
||||
@@ -266,6 +285,17 @@ export const cachedPatchById = cached(
|
||||
missTtl,
|
||||
);
|
||||
|
||||
async function releaseById(rid: string, id: string): Promise<Release | null> {
|
||||
return withTestBackend(tauri.invoke, "release_by_id", { rid, id });
|
||||
}
|
||||
|
||||
export const cachedReleaseById = cached(
|
||||
releaseById,
|
||||
(...[rid, id]) => `release_by_id:${rid}:${id}`,
|
||||
{ max: 200, ttl: 60_000 },
|
||||
missTtl,
|
||||
);
|
||||
|
||||
async function repoCommit(rid: string, sha: string): Promise<Commit | null> {
|
||||
return withTestBackend(tauri.invoke, "repo_commit", { rid, sha });
|
||||
}
|
||||
|
||||
+24
-4
@@ -23,9 +23,20 @@ import { explorerLink, formatOid, truncateId } from "@app/lib/utils";
|
||||
export type Entity =
|
||||
| { type: "node"; nid: string }
|
||||
| { type: "repo"; rid: string }
|
||||
| { type: "cob"; kind: "issue" | "patch"; rid: string; oid: string }
|
||||
| {
|
||||
type: "cob";
|
||||
kind: "issue" | "patch" | "release";
|
||||
rid: string;
|
||||
oid: string;
|
||||
}
|
||||
| { type: "commit"; rid: string; oid: string };
|
||||
|
||||
const cobTypes = {
|
||||
issue: issueType,
|
||||
patch: patchType,
|
||||
release: releaseType,
|
||||
};
|
||||
|
||||
export function toEntity(reference: RadReference): Entity | undefined {
|
||||
if (reference.type === "did") return { type: "node", nid: reference.node };
|
||||
|
||||
@@ -44,6 +55,9 @@ export function toEntity(reference: RadReference): Entity | undefined {
|
||||
if (resource.typeName === patchType) {
|
||||
return { type: "cob", kind: "patch", rid, oid: resource.oid };
|
||||
}
|
||||
if (resource.typeName === releaseType) {
|
||||
return { type: "cob", kind: "release", rid, oid: resource.oid };
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
@@ -68,7 +82,7 @@ export function toRadReference(target: Entity): RadReference {
|
||||
repo,
|
||||
resource: {
|
||||
type: "cob",
|
||||
typeName: target.kind === "issue" ? issueType : patchType,
|
||||
typeName: cobTypes[target.kind],
|
||||
oid: target.oid,
|
||||
},
|
||||
},
|
||||
@@ -179,6 +193,9 @@ export function referenceRoute(reference: RadReference): RepoRoute | undefined {
|
||||
reviewId: undefined,
|
||||
};
|
||||
}
|
||||
if (resource.typeName === releaseType) {
|
||||
return { resource: "repo.release", rid, release: resource.oid };
|
||||
}
|
||||
}
|
||||
if (resource.type === "commit" || resource.type === "tag") {
|
||||
return {
|
||||
@@ -196,6 +213,9 @@ export function referenceRoute(reference: RadReference): RepoRoute | undefined {
|
||||
if (typeName === patchType) {
|
||||
return { resource: "repo.patches", rid, status: undefined };
|
||||
}
|
||||
if (typeName === releaseType) {
|
||||
return { resource: "repo.releases", rid };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -213,7 +233,7 @@ const cobLists: Record<
|
||||
> = {
|
||||
[issueType]: { label: "Issues", icon: "issue" },
|
||||
[patchType]: { label: "Patches", icon: "patch" },
|
||||
[releaseType]: { label: "Releases", icon: "archive" },
|
||||
[releaseType]: { label: "Releases", icon: "parcel" },
|
||||
};
|
||||
|
||||
export function describeLink(
|
||||
@@ -269,7 +289,7 @@ export function describeLink(
|
||||
label: `${repoName}: ${release}`,
|
||||
primary: `Release ${formatOid(resource.oid)}`,
|
||||
secondary: repoName,
|
||||
icon: "archive",
|
||||
icon: "parcel",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { RadiclePeer } from "@tests/support/peerManager.js";
|
||||
|
||||
import * as Fs from "node:fs/promises";
|
||||
import * as Path from "node:path";
|
||||
|
||||
import {
|
||||
createCollaborators,
|
||||
createProject,
|
||||
} from "@tests/support/collaboration.js";
|
||||
import {
|
||||
expect,
|
||||
goto,
|
||||
reload,
|
||||
test,
|
||||
useBackend,
|
||||
@@ -85,6 +89,45 @@ test("a pasted patch URI becomes a chip that opens the patch", async ({
|
||||
await expect(page).toHaveURL(new RegExp(`/patches/${patchId}`));
|
||||
});
|
||||
|
||||
test("a pasted release ID becomes a chip that opens the release", async ({
|
||||
page,
|
||||
peer,
|
||||
}) => {
|
||||
const { rid, issueId, repoFolder } = await openIssue(page, peer);
|
||||
const file = Path.join(repoFolder, "build.tar");
|
||||
await Fs.writeFile(file, "artifact bytes");
|
||||
// The native file dialog only exists under Tauri.
|
||||
await page.route(/\/pick_artifact_files$/, route =>
|
||||
route.fulfill({ json: [file] }),
|
||||
);
|
||||
await goto(page, `/repos/${rid}/releases`);
|
||||
await page.getByRole("button", { name: "New release" }).click();
|
||||
await page.getByPlaceholder("Pick a commit or paste a SHA").click();
|
||||
await page.getByText("Add notes").click();
|
||||
await page.getByRole("button", { name: "Choose files" }).click();
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
const releaseId = new URL(page.url()).pathname.split("/").pop()!;
|
||||
const uri = `${rid}/cob/dev.radicle.artifact/${releaseId}`;
|
||||
|
||||
await goto(page, `/repos/${rid}/issues/${issueId}`);
|
||||
await type(page, "#Add");
|
||||
await expect(suggestion(page, "Add notes")).toBeVisible();
|
||||
await commentBox(page).fill("");
|
||||
|
||||
await type(page, releaseId);
|
||||
await expect(suggestion(page, "Add notes")).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(commentBox(page)).toHaveValue(`[Add notes](${uri}) `);
|
||||
|
||||
await comment(page);
|
||||
await reload(page);
|
||||
const chip = rendered(page).locator("a.mention", { hasText: "Add notes" });
|
||||
await expect(chip).toBeVisible();
|
||||
await chip.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/releases/${releaseId}`));
|
||||
});
|
||||
|
||||
test("unsafe and invalid references stay inert", async ({ page, peer }) => {
|
||||
const { rid } = await openIssue(page, peer);
|
||||
|
||||
@@ -214,10 +257,10 @@ test("every reference type renders and links where it should", async ({
|
||||
await expect(link(`${repo}/home/remotes/${nid}`)).toHaveText("remote");
|
||||
await expect(link(`${repo}/issues?status=all`)).toHaveText("issues");
|
||||
await expect(link(`${repo}/patches`)).toHaveText("patches");
|
||||
await expect(
|
||||
link(`${explorer}/${rid}/releases/${missingOid}`),
|
||||
).toHaveAttribute("target", "_blank");
|
||||
await expect(link(`${explorer}/${rid}/releases`)).toHaveText("releases");
|
||||
const missingRelease = link(`${explorer}/${rid}/releases/${missingOid}`);
|
||||
await expect(missingRelease).toHaveClass(/unresolved/);
|
||||
await expect(missingRelease).toHaveAttribute("target", "_blank");
|
||||
await expect(link(`${repo}/releases`)).toHaveText("releases");
|
||||
await expect(
|
||||
body.locator("a:not([href])", { hasText: "tree" }),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -57,6 +57,10 @@ describe("deepLinkTarget", () => {
|
||||
reviewId: undefined,
|
||||
},
|
||||
],
|
||||
[
|
||||
`${rid}/cob/dev.radicle.artifact/${oid}`,
|
||||
{ resource: "repo.release", rid, release: oid },
|
||||
],
|
||||
[`${rid}/commit/${oid}`, { resource: "repo.commit", rid, commit: oid }],
|
||||
[
|
||||
`${rid}/commit/main?path=src/lib.rs#L10`,
|
||||
@@ -82,13 +86,6 @@ describe("deepLinkTarget", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("opens the explorer for a page the app lacks", () => {
|
||||
expect(target(`${rid}/cob/dev.radicle.artifact/${oid}`)).toEqual({
|
||||
type: "external",
|
||||
url: `${explorer}/releases/${oid}`,
|
||||
});
|
||||
});
|
||||
|
||||
test("goes nowhere when neither has a page", () => {
|
||||
expect(target(`${rid}/tree/${oid}`)).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -152,13 +152,21 @@ describe("explorer links without a chip", () => {
|
||||
describe("releases and lines", () => {
|
||||
const node = "https://radicle.network/nodes/seed";
|
||||
|
||||
test("offers a release as a link", () => {
|
||||
expect(trigger(`${node}/${rid}/releases/${oid}`)).toMatchObject({
|
||||
test.each([
|
||||
`${node}/${rid}/releases/${oid}`,
|
||||
`${rid}/cob/dev.radicle.artifact/${oid}`,
|
||||
])("offers %s as a release", link => {
|
||||
expect(trigger(link)).toMatchObject({
|
||||
kind: "identifier",
|
||||
target: { type: "cob", kind: "release", rid, oid },
|
||||
});
|
||||
});
|
||||
|
||||
test("offers a release list as a link", () => {
|
||||
expect(trigger(`${node}/${rid}/releases`)).toMatchObject({
|
||||
kind: "link",
|
||||
reference: {
|
||||
uri: {
|
||||
resource: { type: "cob", typeName: "dev.radicle.artifact", oid },
|
||||
},
|
||||
uri: { resource: { type: "cob", typeName: "dev.radicle.artifact" } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,14 @@ describe("parseEntityHref", () => {
|
||||
`${rid}/cob/xyz.radicle.patch/${oid}`,
|
||||
{ type: "cob", kind: "patch", rid, oid },
|
||||
],
|
||||
[
|
||||
`${rid}/cob/dev.radicle.artifact/${oid}`,
|
||||
{ type: "cob", kind: "release", rid, oid },
|
||||
],
|
||||
[
|
||||
`https://app.radicle.xyz/nodes/seed/${rid}/releases/${oid}`,
|
||||
{ type: "cob", kind: "release", rid, oid },
|
||||
],
|
||||
[
|
||||
`https://app.radicle.xyz/nodes/seed/${rid}/patches/${oid}`,
|
||||
{ type: "cob", kind: "patch", rid, oid },
|
||||
@@ -235,17 +243,21 @@ describe("referenceRoute", () => {
|
||||
`${rid}/cob/xyz.radicle.patch`,
|
||||
{ resource: "repo.patches", rid, status: undefined },
|
||||
],
|
||||
[
|
||||
`${rid}/cob/dev.radicle.artifact/${oid}`,
|
||||
{ resource: "repo.release", rid, release: oid },
|
||||
],
|
||||
[`${rid}/cob/dev.radicle.artifact`, { resource: "repo.releases", rid }],
|
||||
])("%s", (href, expected) => {
|
||||
expect(referenceRoute(parseReference(href)!)).toEqual(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
`${rid}/cob/dev.radicle.artifact`,
|
||||
`${rid}/tree/${oid}`,
|
||||
`did:key:${nid}`,
|
||||
])("has no page for %s", href => {
|
||||
expect(referenceRoute(parseReference(href)!)).toBeUndefined();
|
||||
});
|
||||
test.each([`${rid}/tree/${oid}`, `did:key:${nid}`])(
|
||||
"has no page for %s",
|
||||
href => {
|
||||
expect(referenceRoute(parseReference(href)!)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("describeLink", () => {
|
||||
@@ -262,11 +274,11 @@ describe("describeLink", () => {
|
||||
[`${rid}/tag/v1.0`, "heartwood: v1.0", "label"],
|
||||
[`${rid}/cob/xyz.radicle.issue`, "heartwood: issues", "issue"],
|
||||
[`${rid}/cob/xyz.radicle.patch`, "heartwood: patches", "patch"],
|
||||
[`${rid}/cob/dev.radicle.artifact`, "heartwood: releases", "archive"],
|
||||
[`${rid}/cob/dev.radicle.artifact`, "heartwood: releases", "parcel"],
|
||||
[
|
||||
`${rid}/cob/dev.radicle.artifact/${oid}`,
|
||||
"heartwood: release 5e46830",
|
||||
"archive",
|
||||
"parcel",
|
||||
],
|
||||
[`${rid}/${nid}`, "heartwood: z6MknS…tBU8Vi", "repository"],
|
||||
])("%s", (href, label, icon) => {
|
||||
|
||||
Reference in New Issue
Block a user