mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-18 19:30:27 +08:00
@@ -94,12 +94,17 @@ export class Fetcher {
|
||||
): Promise<TypeOf<T>> {
|
||||
const response = await this.fetch(params);
|
||||
|
||||
const responseBody = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
let responseBody = await response.text();
|
||||
try {
|
||||
responseBody = JSON.parse(responseBody);
|
||||
} catch (_e: unknown) {
|
||||
// We keep the original text response body.
|
||||
}
|
||||
throw new ResponseError(params.method, response, responseBody);
|
||||
}
|
||||
|
||||
const responseBody = await response.json();
|
||||
const result = schema.safeParse(responseBody);
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
|
||||
@@ -372,6 +372,7 @@ export class Client {
|
||||
title: string;
|
||||
description: string;
|
||||
assignees: string[];
|
||||
embeds: { name: string; content: string }[];
|
||||
labels: string[];
|
||||
},
|
||||
authToken: string,
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import type { ZodSchema } from "zod";
|
||||
import type { z } from "zod";
|
||||
import { array, number, object, string, tuple } from "zod";
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
author: { id: string; alias?: string };
|
||||
body: string;
|
||||
reactions: [string, string][];
|
||||
timestamp: number;
|
||||
replyTo: string | null;
|
||||
}
|
||||
export type Comment = z.infer<typeof commentSchema>;
|
||||
|
||||
export const commentSchema = object({
|
||||
id: string(),
|
||||
author: object({ id: string(), alias: string().optional() }),
|
||||
body: string(),
|
||||
embeds: array(object({ name: string(), content: string() })),
|
||||
reactions: array(tuple([string(), string()])),
|
||||
timestamp: number(),
|
||||
replyTo: string().nullable(),
|
||||
}) satisfies ZodSchema<Comment>;
|
||||
});
|
||||
|
||||
@@ -56,7 +56,12 @@ export type IssueUpdateAction =
|
||||
assignees: string[];
|
||||
}
|
||||
| { type: "lifecycle"; state: IssueState }
|
||||
| { type: "comment"; body: string; replyTo: string }
|
||||
| {
|
||||
type: "comment";
|
||||
body: string;
|
||||
embeds: { name: string; content: string }[];
|
||||
replyTo: string;
|
||||
}
|
||||
| { type: "comment.edit"; id: string; body: string }
|
||||
| { type: "comment.redact"; id: string }
|
||||
| {
|
||||
|
||||
@@ -120,6 +120,7 @@ describe("project", () => {
|
||||
title: "aaa",
|
||||
description: "bbb",
|
||||
assignees: [],
|
||||
embeds: [],
|
||||
labels: ["bug", "documentation"],
|
||||
},
|
||||
sessionId,
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function createIssueToBeModified(
|
||||
) {
|
||||
const { id } = await api.project.createIssue(
|
||||
cobRid,
|
||||
{ title: "aaa", description: "bbb", assignees: [], labels: [] },
|
||||
{ title: "aaa", description: "bbb", embeds: [], assignees: [], labels: [] },
|
||||
sessionId,
|
||||
);
|
||||
|
||||
|
||||
@@ -156,3 +156,8 @@ p {
|
||||
margin: 0 0.05em 0 0.1em;
|
||||
vertical-align: -0.1em;
|
||||
}
|
||||
.txt-overflow {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
+14
-26
@@ -1,11 +1,5 @@
|
||||
<script lang="ts" strictEvents>
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
const dispatch = createEventDispatcher<{ remove: number; click: null }>();
|
||||
|
||||
export let removeable: boolean = false;
|
||||
export let clickable: boolean = false;
|
||||
export let key: number;
|
||||
<script lang="ts">
|
||||
export let actionable: boolean = false;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -13,15 +7,12 @@
|
||||
user-select: none;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
.clickable:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--color-secondary-5);
|
||||
align-items: stretch;
|
||||
color: inherit;
|
||||
}
|
||||
.section {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
max-width: 13.5rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
@@ -30,8 +21,7 @@
|
||||
background-color: var(--color-secondary-3);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
.close {
|
||||
align-self: stretch;
|
||||
.icon {
|
||||
color: var(--color-secondary);
|
||||
border: none;
|
||||
border-bottom-right-radius: var(--border-radius);
|
||||
@@ -40,25 +30,23 @@
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
}
|
||||
.close:hover {
|
||||
.icon:hover {
|
||||
background-color: var(--color-secondary-5);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
.removeable {
|
||||
.actionable {
|
||||
border-bottom-right-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="chip">
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<span class="section text" class:removeable class:clickable on:click>
|
||||
<slot />
|
||||
<span class="section text" class:actionable>
|
||||
<slot name="content" />
|
||||
</span>
|
||||
{#if removeable}
|
||||
<button class="section close" on:click={() => dispatch("remove", key)}>
|
||||
✕
|
||||
</button>
|
||||
{#if actionable}
|
||||
<span class="section icon">
|
||||
<slot name="icon" />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
export let text: string;
|
||||
export let small = false;
|
||||
export let tiny = false;
|
||||
export let tooltip: string | undefined = undefined;
|
||||
|
||||
const dispatch = createEventDispatcher<{ copied: null }>();
|
||||
@@ -41,6 +42,10 @@
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
.clipboard.tiny {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.clipboard:hover :global(svg) {
|
||||
fill: var(--color-foreground);
|
||||
}
|
||||
@@ -55,6 +60,7 @@
|
||||
title={tooltip}
|
||||
class="clipboard"
|
||||
class:small
|
||||
class:tiny
|
||||
on:click|stopPropagation={copy}>
|
||||
<Icon name={icon} />
|
||||
</span>
|
||||
|
||||
@@ -123,7 +123,6 @@
|
||||
<div style:margin-top="1rem">
|
||||
<Reactions
|
||||
reactions={groupedReactions}
|
||||
clickable={Boolean($httpdStore.state === "authenticated")}
|
||||
on:remove={event => {
|
||||
if (id) {
|
||||
dispatch("react", { id, ...event.detail });
|
||||
|
||||
@@ -8,13 +8,25 @@
|
||||
import markdown from "@app/lib/markdown";
|
||||
import { Renderer } from "@app/lib/markdown";
|
||||
import { highlight } from "@app/lib/syntax";
|
||||
import { isUrl, twemoji, scrollIntoView, canonicalize } from "@app/lib/utils";
|
||||
import {
|
||||
isUrl,
|
||||
twemoji,
|
||||
scrollIntoView,
|
||||
canonicalize,
|
||||
isCommit,
|
||||
} from "@app/lib/utils";
|
||||
import { mimes } from "@app/lib/file";
|
||||
|
||||
export let content: string;
|
||||
// If present, resolve all relative links with respect to this URL
|
||||
export let linkBaseUrl: string | undefined = undefined;
|
||||
export let path: string = "/";
|
||||
export let rawPath: string | undefined = undefined;
|
||||
// If present, means we are in a preview context,
|
||||
// use this for image previews instead of /raw URLs.
|
||||
export let embeds:
|
||||
| Map<string, { name: string; content: string }>
|
||||
| undefined = undefined;
|
||||
|
||||
$: doc = matter(content);
|
||||
$: frontMatter = Object.entries(doc.data).filter(
|
||||
@@ -73,6 +85,28 @@
|
||||
for (const i of container.querySelectorAll("img")) {
|
||||
const imagePath = i.getAttribute("src");
|
||||
|
||||
// If the image is an oid embed
|
||||
if (imagePath && isCommit(imagePath)) {
|
||||
const embed = embeds?.get(imagePath);
|
||||
if (embed) {
|
||||
const fileExtension = embed.name.split(".").pop();
|
||||
if (fileExtension) {
|
||||
i.setAttribute("src", embed.content);
|
||||
}
|
||||
} else {
|
||||
const fileExtension = i.alt.split(".").pop();
|
||||
const url = new URL(rawPath);
|
||||
// If a user changes the alt text of an image,
|
||||
// the browser is still able to infer the mime type.
|
||||
if (fileExtension && fileExtension in mimes) {
|
||||
url.search = `?mime=${mimes[fileExtension]}`;
|
||||
}
|
||||
url.pathname = canonicalize(`blobs/${imagePath}`, url.pathname);
|
||||
i.setAttribute("src", url.toString());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Make sure the source isn't a URL before trying to fetch it from the repo
|
||||
if (
|
||||
imagePath &&
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import Chip from "@app/components/Chip.svelte";
|
||||
import { httpdStore } from "@app/lib/httpd";
|
||||
|
||||
export let reactions: Map<string, string[]>;
|
||||
export let clickable: boolean = false;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
remove: { nids: string[]; reaction: string };
|
||||
@@ -22,22 +22,35 @@
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.close {
|
||||
color: inherit;
|
||||
border: none;
|
||||
border-bottom-right-radius: var(--border-radius);
|
||||
border-top-right-radius: var(--border-radius);
|
||||
background-color: transparent;
|
||||
line-height: 1.5;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.close:hover {
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="reactions">
|
||||
{#each reactions as [reaction, nids], key}
|
||||
<Chip
|
||||
{key}
|
||||
{clickable}
|
||||
on:click={() => {
|
||||
if (clickable) {
|
||||
dispatch("remove", { nids, reaction });
|
||||
}
|
||||
}}>
|
||||
<div class="reaction txt-tiny">
|
||||
{#each reactions as [reaction, nids]}
|
||||
<Chip actionable={Boolean($httpdStore.state === "authenticated")}>
|
||||
<div slot="content" class="reaction txt-tiny">
|
||||
<span>{reaction}</span>
|
||||
<span title={nids.join("\n")}>{nids.length}</span>
|
||||
</div>
|
||||
<div slot="icon">
|
||||
<button
|
||||
class="close"
|
||||
on:click={() => dispatch("remove", { nids, reaction })}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</Chip>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { afterUpdate, beforeUpdate, createEventDispatcher } from "svelte";
|
||||
import { isMac } from "@app/lib/utils";
|
||||
|
||||
export let resizable: boolean = false;
|
||||
export let value: string | number | undefined = undefined;
|
||||
export let placeholder: string | undefined = undefined;
|
||||
export let focus: boolean = false;
|
||||
// Defaulting selectionStart and selectionEnd to 0, since no full support yet.
|
||||
export let selectionStart = 0;
|
||||
export let selectionEnd = 0;
|
||||
|
||||
let textareaElement: HTMLTextAreaElement | undefined = undefined;
|
||||
|
||||
@@ -27,6 +30,19 @@
|
||||
focus = false;
|
||||
}
|
||||
|
||||
beforeUpdate(() => {
|
||||
if (textareaElement) {
|
||||
({ selectionStart, selectionEnd } = textareaElement);
|
||||
}
|
||||
});
|
||||
|
||||
afterUpdate(() => {
|
||||
if (textareaElement) {
|
||||
textareaElement.setSelectionRange(selectionStart, selectionEnd);
|
||||
textareaElement.focus();
|
||||
}
|
||||
});
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
submit: null;
|
||||
}>();
|
||||
@@ -95,12 +111,14 @@
|
||||
<textarea
|
||||
bind:this={textareaElement}
|
||||
bind:value
|
||||
aria-label="textarea-comment"
|
||||
class="txt-small"
|
||||
class:resizable
|
||||
{placeholder}
|
||||
on:change
|
||||
on:click
|
||||
on:input
|
||||
on:drop
|
||||
on:keydown|stopPropagation={handleKeydown}
|
||||
on:keypress />
|
||||
|
||||
|
||||
@@ -7,13 +7,35 @@
|
||||
import { createEventDispatcher, tick } from "svelte";
|
||||
import { scrollIntoView } from "@app/lib/utils";
|
||||
import { httpdStore } from "@app/lib/httpd";
|
||||
import { embed } from "@app/lib/file";
|
||||
|
||||
export let newEmbeds: { name: string; content: string }[] = [];
|
||||
export let selectionStart = 0;
|
||||
export let selectionEnd = 0;
|
||||
export let thread: { root: Comment; replies: Comment[] };
|
||||
export let rawPath: string;
|
||||
export let showReplyTextarea = false;
|
||||
|
||||
let replyText = "";
|
||||
|
||||
function handleFileDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
const embeds = Array.from(event.dataTransfer.files).map(embed);
|
||||
void Promise.all(embeds).then(embeds =>
|
||||
embeds.forEach(embed => {
|
||||
newEmbeds.push({ name: embed.name, content: embed.content });
|
||||
const embedText = `\n`;
|
||||
replyText = replyText
|
||||
.slice(0, selectionStart)
|
||||
.concat(embedText, replyText.slice(selectionEnd));
|
||||
selectionStart += embedText.length;
|
||||
selectionEnd = selectionStart;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
showReplyTextarea = false;
|
||||
scrollIntoView(root.id, {
|
||||
@@ -36,12 +58,18 @@
|
||||
}
|
||||
|
||||
function reply() {
|
||||
dispatch("reply", { id: root.id, body: replyText });
|
||||
dispatch("reply", { id: root.id, embeds: newEmbeds, body: replyText });
|
||||
replyText = "";
|
||||
newEmbeds = [];
|
||||
showReplyTextarea = false;
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
reply: { id: string; body: string };
|
||||
reply: {
|
||||
id: string;
|
||||
embeds: { name: string; content: string }[];
|
||||
body: string;
|
||||
};
|
||||
react: { nids: string[]; commentId: string | undefined; reaction: string };
|
||||
cancel: never;
|
||||
}>();
|
||||
@@ -106,6 +134,9 @@
|
||||
focus={showReplyTextarea}
|
||||
bind:value={replyText}
|
||||
on:submit={reply}
|
||||
on:drop={handleFileDrop}
|
||||
bind:selectionStart
|
||||
bind:selectionEnd
|
||||
placeholder="Leave your reply" />
|
||||
<div class="actions">
|
||||
<Button variant="text" size="small" on:click={cancel}>Dismiss</Button>
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
async function parseGitOid(bytes: Uint8Array): Promise<string> {
|
||||
// Create the header
|
||||
const header = new TextEncoder().encode(`blob ${bytes.length}\0`);
|
||||
|
||||
// Concatenate the header and the original file content
|
||||
const combined = new Uint8Array(header.length + bytes.length);
|
||||
combined.set(header);
|
||||
combined.set(bytes, header.length);
|
||||
|
||||
// Compute the SHA-1 hash
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-1", combined);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
|
||||
// Convert the hash to a hexadecimal string
|
||||
return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function base64String(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event: ProgressEvent<FileReader>) => {
|
||||
if (event.target?.result && typeof event.target.result === "string") {
|
||||
resolve(event.target.result);
|
||||
} else {
|
||||
reject(new Error("Failed to generate base64 string"));
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
const mimes: Record<string, string> = {
|
||||
"3gp": "video/3gpp",
|
||||
"7z": "application/x-7z-compressed",
|
||||
aac: "audio/aac",
|
||||
avi: "video/x-msvideo",
|
||||
bin: "application/octet-stream",
|
||||
bmp: "image/bmp",
|
||||
bz: "application/x-bzip",
|
||||
bz2: "application/x-bzip2",
|
||||
csh: "application/x-csh",
|
||||
css: "text/css",
|
||||
csv: "text/csv",
|
||||
doc: "application/msword",
|
||||
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
epub: "application/epub+zip",
|
||||
gz: "application/gzip",
|
||||
gif: "image/gif",
|
||||
htm: "text/html",
|
||||
html: "text/html",
|
||||
ico: "image/vnd.microsoft.icon",
|
||||
jar: "application/java-archive",
|
||||
jpeg: "image/jpeg",
|
||||
jpg: "image/jpeg",
|
||||
js: "text/javascript",
|
||||
json: "application/json",
|
||||
mjs: "text/javascript",
|
||||
mp3: "audio/mpeg",
|
||||
mp4: "video/mp4",
|
||||
mpeg: "video/mpeg",
|
||||
odp: "application/vnd.oasis.opendocument.presentation",
|
||||
ods: "application/vnd.oasis.opendocument.spreadsheet",
|
||||
odt: "application/vnd.oasis.opendocument.text",
|
||||
oga: "audio/ogg",
|
||||
ogv: "video/ogg",
|
||||
ogx: "application/ogg",
|
||||
otf: "font/otf",
|
||||
png: "image/png",
|
||||
pdf: "application/pdf",
|
||||
php: "application/x-httpd-php",
|
||||
ppt: "application/vnd.ms-powerpoint",
|
||||
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
rar: "application/vnd.rar",
|
||||
rtf: "application/rtf",
|
||||
sh: "application/x-sh",
|
||||
svg: "image/svg+xml",
|
||||
tar: "application/x-tar",
|
||||
tif: "image/tiff",
|
||||
tiff: "image/tiff",
|
||||
ttf: "font/ttf",
|
||||
txt: "text/plain",
|
||||
wav: "audio/wav",
|
||||
weba: "audio/webm",
|
||||
webm: "video/webm",
|
||||
webp: "image/webp",
|
||||
woff: "font/woff",
|
||||
woff2: "font/woff2",
|
||||
xhtml: "application/xhtml+xml",
|
||||
xls: "application/vnd.ms-excel",
|
||||
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
xml: "application/xml",
|
||||
zip: "application/zip",
|
||||
};
|
||||
|
||||
async function embed(
|
||||
file: File,
|
||||
): Promise<{ oid: string; name: string; content: string }> {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const oid = await parseGitOid(bytes);
|
||||
const content = await base64String(file);
|
||||
return { oid, name: file.name, content };
|
||||
}
|
||||
|
||||
export { embed, mimes };
|
||||
@@ -166,6 +166,10 @@ export function isUrl(input: string): boolean {
|
||||
return /^https?:\/\//.test(input);
|
||||
}
|
||||
|
||||
export function isCommit(input: string): boolean {
|
||||
return /^[a-f0-9]{40}$/.test(input);
|
||||
}
|
||||
|
||||
export function isFulfilled<T>(
|
||||
input: PromiseSettledResult<T>,
|
||||
): input is PromiseFulfilledResult<T> {
|
||||
|
||||
@@ -37,9 +37,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
function removeAssignee({ detail: key }: { detail: number }) {
|
||||
updatedAssignees = updatedAssignees.filter((_, i) => i !== key);
|
||||
if (action === "create") {
|
||||
function removeAssignee(remove: string) {
|
||||
updatedAssignees = updatedAssignees.filter(assignee => assignee !== remove);
|
||||
if (action === "create" || action === "edit") {
|
||||
dispatch("save", updatedAssignees);
|
||||
}
|
||||
}
|
||||
@@ -62,11 +62,25 @@
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.close {
|
||||
color: inherit;
|
||||
border: none;
|
||||
border-bottom-right-radius: var(--border-radius);
|
||||
border-top-right-radius: var(--border-radius);
|
||||
background-color: transparent;
|
||||
line-height: 1.5;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.close:hover {
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.chip-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -97,15 +111,18 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="body">
|
||||
{#each updatedAssignees as assignee, key (assignee)}
|
||||
<Chip
|
||||
on:remove={removeAssignee}
|
||||
removeable={editInProgress || action === "create"}
|
||||
{key}>
|
||||
<div aria-label="chip" class="chip-content">
|
||||
{#each updatedAssignees as assignee (assignee)}
|
||||
<Chip actionable={editInProgress || action === "create"}>
|
||||
<div slot="content" aria-label="chip" class="txt-overflow chip-content">
|
||||
<Avatar inline nodeId={assignee} />
|
||||
<span>{formatNodeId(assignee)}</span>
|
||||
</div>
|
||||
<button
|
||||
slot="icon"
|
||||
class="section close"
|
||||
on:click={() => removeAssignee(assignee)}>
|
||||
✕
|
||||
</button>
|
||||
</Chip>
|
||||
{:else}
|
||||
<div class="txt-missing">No assignees</div>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts" strictEvents>
|
||||
import Chip from "@app/components/Chip.svelte";
|
||||
import Clipboard from "@app/components/Clipboard.svelte";
|
||||
|
||||
export let embeds: { name: string; content: string }[] = [];
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
font-size: var(--font-size-small);
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--color-foreground-6);
|
||||
}
|
||||
.body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.chip-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div>
|
||||
<div class="header">
|
||||
<span>Attachments</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
{#each embeds as embed}
|
||||
<Chip actionable>
|
||||
<div slot="content" aria-label="chip" class="chip-content">
|
||||
<span class="txt-overflow">{embed.name}</span>
|
||||
</div>
|
||||
<Clipboard
|
||||
slot="icon"
|
||||
text={`})`}
|
||||
tiny />
|
||||
</Chip>
|
||||
{:else}
|
||||
<div class="txt-missing">No attachments</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
export let title: string;
|
||||
export let subtitle: string[];
|
||||
export let error: Error;
|
||||
// This is more explicit than the standard error type.
|
||||
export let error: { message: string; stack?: string };
|
||||
</script>
|
||||
|
||||
<Modal {title} emoji="🚨">
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
} else {
|
||||
updatedLabels = [...updatedLabels, sanitizedValue];
|
||||
inputValue = "";
|
||||
if (action === "create") {
|
||||
if (action === "create" || action === "edit") {
|
||||
dispatch("save", updatedLabels);
|
||||
}
|
||||
}
|
||||
@@ -33,9 +33,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
function removeLabel({ detail: key }: { detail: number }) {
|
||||
updatedLabels = updatedLabels.filter((_, i) => i !== key);
|
||||
if (action === "create") {
|
||||
function removeLabel(remove: string) {
|
||||
updatedLabels = updatedLabels.filter(label => label !== remove);
|
||||
if (action === "create" || action === "edit") {
|
||||
dispatch("save", updatedLabels);
|
||||
}
|
||||
}
|
||||
@@ -57,10 +57,18 @@
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
.close {
|
||||
color: inherit;
|
||||
border: none;
|
||||
border-bottom-right-radius: var(--border-radius);
|
||||
border-top-right-radius: var(--border-radius);
|
||||
background-color: transparent;
|
||||
line-height: 1.5;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.close:hover {
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -91,12 +99,14 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="metadata-section-body">
|
||||
{#each updatedLabels as label, key (label)}
|
||||
<Chip
|
||||
on:remove={removeLabel}
|
||||
removeable={editInProgress || action === "create"}
|
||||
{key}>
|
||||
<div aria-label="chip" class="label">{label}</div>
|
||||
{#each updatedLabels as label}
|
||||
<Chip actionable={editInProgress || action === "create"}>
|
||||
<div slot="content" aria-label="chip" class="txt-overflow">{label}</div>
|
||||
<div slot="icon">
|
||||
<button class="section close" on:click={() => removeLabel(label)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</Chip>
|
||||
{:else}
|
||||
<div class="txt-missing">No labels</div>
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
|
||||
import { isEqual } from "lodash";
|
||||
|
||||
import * as router from "@app/lib/router";
|
||||
import * as modal from "@app/lib/modal";
|
||||
import * as router from "@app/lib/router";
|
||||
import * as utils from "@app/lib/utils";
|
||||
import { HttpdClient } from "@httpd-client";
|
||||
import { ResponseError } from "@httpd-client/lib/fetcher";
|
||||
import { embed } from "@app/lib/file";
|
||||
import { httpdStore } from "@app/lib/httpd";
|
||||
|
||||
import AssigneeInput from "@app/views/projects/Cob/AssigneeInput.svelte";
|
||||
@@ -17,11 +19,12 @@
|
||||
import Button from "@app/components/Button.svelte";
|
||||
import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
|
||||
import CobStateButton from "@app/views/projects/Cob/CobStateButton.svelte";
|
||||
import Embeds from "@app/views/projects/Cob/Embeds.svelte";
|
||||
import ErrorModal from "@app/views/projects/Cob/ErrorModal.svelte";
|
||||
import Floating, { closeFocused } from "@app/components/Floating.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import LabelInput from "./Cob/LabelInput.svelte";
|
||||
import Layout from "./Layout.svelte";
|
||||
import LabelInput from "@app/views/projects/Cob/LabelInput.svelte";
|
||||
import Layout from "@app/views/projects/Layout.svelte";
|
||||
import Markdown from "@app/components/Markdown.svelte";
|
||||
import ReactionSelector from "@app/components/ReactionSelector.svelte";
|
||||
import Reactions from "@app/components/Reactions.svelte";
|
||||
@@ -35,6 +38,9 @@
|
||||
const rawPath = utils.getRawBasePath(project.id, baseUrl, project.head);
|
||||
const api = new HttpdClient(baseUrl);
|
||||
|
||||
let newEmbeds: { name: string; content: string }[] = [];
|
||||
let selectionStart = 0;
|
||||
let selectionEnd = 0;
|
||||
let action: "edit" | "view";
|
||||
$: action =
|
||||
$httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)
|
||||
@@ -46,9 +52,47 @@
|
||||
["Close issue as other", { status: "closed", reason: "other" }],
|
||||
];
|
||||
|
||||
const MAX_BLOB_SIZE = 4_194_304;
|
||||
|
||||
function handleFileDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
const embeds = Array.from(event.dataTransfer.files).map(embed);
|
||||
void Promise.all(embeds).then(embeds =>
|
||||
embeds.forEach(embed => {
|
||||
if (embed.content.length > MAX_BLOB_SIZE) {
|
||||
modal.show({
|
||||
component: ErrorModal,
|
||||
props: {
|
||||
title: "File too large",
|
||||
subtitle: [
|
||||
"The file you tried to upload is too large.",
|
||||
"The maximum file size is 4MB.",
|
||||
],
|
||||
error: { message: `File ${embed.name} is too large` },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
newEmbeds.push({ name: embed.name, content: embed.content });
|
||||
const embedText = `\n`;
|
||||
commentBody = commentBody
|
||||
.slice(0, selectionStart)
|
||||
.concat(embedText, commentBody.slice(selectionEnd));
|
||||
selectionStart += embedText.length;
|
||||
selectionEnd = selectionStart;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function createReply({
|
||||
detail: reply,
|
||||
}: CustomEvent<{ id: string; body: string }>) {
|
||||
}: CustomEvent<{
|
||||
id: string;
|
||||
embeds: { name: string; content: string }[];
|
||||
body: string;
|
||||
}>) {
|
||||
if ($httpdStore.state === "authenticated" && reply.body.trim().length > 0) {
|
||||
const status = await updateIssue(
|
||||
project.id,
|
||||
@@ -56,6 +100,7 @@
|
||||
{
|
||||
type: "comment",
|
||||
body: reply.body,
|
||||
embeds: reply.embeds,
|
||||
replyTo: reply.id,
|
||||
},
|
||||
$httpdStore.session,
|
||||
@@ -72,7 +117,12 @@
|
||||
const status = await updateIssue(
|
||||
project.id,
|
||||
issue.id,
|
||||
{ type: "comment", body, replyTo: issue.id },
|
||||
{
|
||||
type: "comment",
|
||||
body,
|
||||
embeds: newEmbeds,
|
||||
replyTo: issue.id,
|
||||
},
|
||||
$httpdStore.session,
|
||||
api,
|
||||
);
|
||||
@@ -218,7 +268,10 @@
|
||||
"There was an error while refreshing this issue.",
|
||||
"Check your radicle-httpd logs for details.",
|
||||
],
|
||||
error,
|
||||
error: {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -237,7 +290,23 @@
|
||||
await api.project.updateIssue(projectId, issueId, action, session.id);
|
||||
return "success";
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error instanceof ResponseError && error.status === 413) {
|
||||
modal.show({
|
||||
component: ErrorModal,
|
||||
props: {
|
||||
title: "Issue editing failed",
|
||||
subtitle: [
|
||||
"Not able to upload the attached file.",
|
||||
"Try to reduce the size of your attachment.",
|
||||
"Check your radicle-httpd logs for details.",
|
||||
],
|
||||
error: {
|
||||
message: error.body as string,
|
||||
stack: error.stack,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else if (error instanceof Error) {
|
||||
modal.show({
|
||||
component: ErrorModal,
|
||||
props: {
|
||||
@@ -246,7 +315,10 @@
|
||||
"There was an error while updating the issue.",
|
||||
"Check your radicle-httpd logs for details.",
|
||||
],
|
||||
error,
|
||||
error: {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -256,6 +328,13 @@
|
||||
|
||||
const issueDescription = issue.discussion[0];
|
||||
|
||||
$: embeds = issue.discussion.reduce(
|
||||
(acc, comment) => {
|
||||
acc.push(...comment.embeds);
|
||||
return acc;
|
||||
},
|
||||
[] as { name: string; content: string }[],
|
||||
);
|
||||
$: selectedItem = issue.state.status === "closed" ? items[0] : items[1];
|
||||
$: threads = issue.discussion
|
||||
.filter(
|
||||
@@ -402,7 +481,6 @@
|
||||
{#if issueReactions.size > 0}
|
||||
<div style:margin-top="1rem">
|
||||
<Reactions
|
||||
clickable={$httpdStore.state === "authenticated"}
|
||||
reactions={issueReactions}
|
||||
on:remove={event =>
|
||||
handleReaction({ ...event.detail, id: issue.id })} />
|
||||
@@ -430,8 +508,12 @@
|
||||
<div style:margin-top="1rem">
|
||||
<Textarea
|
||||
resizable
|
||||
bind:selectionStart
|
||||
bind:selectionEnd
|
||||
on:drop={handleFileDrop}
|
||||
on:submit={async () => {
|
||||
await createComment(commentBody);
|
||||
newEmbeds = [];
|
||||
commentBody = "";
|
||||
}}
|
||||
bind:value={commentBody}
|
||||
@@ -448,6 +530,7 @@
|
||||
disabled={!commentBody}
|
||||
on:click={async () => {
|
||||
await createComment(commentBody);
|
||||
newEmbeds = [];
|
||||
commentBody = "";
|
||||
}}>
|
||||
Comment
|
||||
@@ -462,6 +545,7 @@
|
||||
assignees={issue.assignees}
|
||||
on:save={saveAssignees} />
|
||||
<LabelInput {action} labels={issue.labels} on:save={saveLabels} />
|
||||
<Embeds {embeds} />
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import * as router from "@app/lib/router";
|
||||
import * as utils from "@app/lib/utils";
|
||||
import { HttpdClient } from "@httpd-client";
|
||||
import { embed } from "@app/lib/file";
|
||||
import { httpdStore } from "@app/lib/httpd";
|
||||
|
||||
import AssigneeInput from "@app/views/projects/Cob/AssigneeInput.svelte";
|
||||
@@ -22,6 +23,9 @@
|
||||
export let baseUrl: BaseUrl;
|
||||
export let project: Project;
|
||||
|
||||
const newEmbeds: Map<string, { name: string; content: string }> = new Map();
|
||||
let selectionStart = 0;
|
||||
let selectionEnd = 0;
|
||||
let preview: boolean = false;
|
||||
let action: "create" | "view";
|
||||
$: action =
|
||||
@@ -32,20 +36,39 @@
|
||||
: "view";
|
||||
|
||||
let issueTitle = "";
|
||||
let issueText: string | undefined = undefined;
|
||||
let issueText = "";
|
||||
let assignees: string[] = [];
|
||||
let labels: string[] = [];
|
||||
|
||||
const api = new HttpdClient(baseUrl);
|
||||
|
||||
function handleFileDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
const embeds = Array.from(event.dataTransfer.files).map(embed);
|
||||
void Promise.all(embeds).then(embeds =>
|
||||
embeds.forEach(({ oid, name, content }) => {
|
||||
newEmbeds.set(oid, { name, content });
|
||||
const embedText = `\n`;
|
||||
issueText = issueText
|
||||
.slice(0, selectionStart)
|
||||
.concat(embedText, issueText.slice(selectionEnd));
|
||||
selectionStart += embedText.length;
|
||||
selectionEnd = selectionStart;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function createIssue(sessionId: string) {
|
||||
try {
|
||||
const result = await api.project.createIssue(
|
||||
project.id,
|
||||
{
|
||||
title: issueTitle,
|
||||
description: issueText ?? "",
|
||||
description: issueText,
|
||||
assignees: assignees,
|
||||
embeds: [...newEmbeds.values()],
|
||||
labels: labels,
|
||||
},
|
||||
sessionId,
|
||||
@@ -141,6 +164,9 @@
|
||||
{#if action === "create"}
|
||||
<Textarea
|
||||
resizable
|
||||
bind:selectionStart
|
||||
bind:selectionEnd
|
||||
on:drop={handleFileDrop}
|
||||
bind:value={issueText}
|
||||
on:submit={() => {
|
||||
void createIssue(session.id);
|
||||
@@ -150,6 +176,7 @@
|
||||
<p class="txt-missing">No description</p>
|
||||
{:else}
|
||||
<Markdown
|
||||
embeds={newEmbeds}
|
||||
content={issueText}
|
||||
rawPath={utils.getRawBasePath(
|
||||
project.id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, cobUrl, expect } from "@tests/support/fixtures.js";
|
||||
import { createProject } from "@tests/support/project";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
test("navigate issue listing", async ({ page }) => {
|
||||
await page.goto(cobUrl);
|
||||
@@ -51,12 +52,11 @@ test("adding and removing reactions", async ({ page, authenticatedPeer }) => {
|
||||
await expect(page.locator("span").filter({ hasText: "🎉 1" })).toBeVisible();
|
||||
await expect(page.locator(".reaction")).toHaveCount(2);
|
||||
|
||||
await page.locator("span").filter({ hasText: "👍 1" }).click();
|
||||
await page.locator("span").filter({ hasText: "✕" }).nth(1).click();
|
||||
await expect(page.locator("span").filter({ hasText: "👍 1" })).toBeHidden();
|
||||
await expect(page.locator(".reaction")).toHaveCount(1);
|
||||
|
||||
await commentReactionToggle.click();
|
||||
await page.getByRole("button", { name: "🎉" }).click();
|
||||
await page.locator("span").filter({ hasText: "✕" }).nth(0).click();
|
||||
await expect(page.locator("span").filter({ hasText: "🎉 1" })).toBeHidden();
|
||||
await expect(page.locator(".reaction")).toHaveCount(0);
|
||||
});
|
||||
@@ -205,3 +205,66 @@ test("go through the entire ui issue flow", async ({
|
||||
await page.getByRole("button", { name: "Close issue as other" }).click();
|
||||
await expect(page.getByText("closed as other")).toBeVisible();
|
||||
});
|
||||
|
||||
test("handling embeds", async ({ page, authenticatedPeer }) => {
|
||||
const buffer = await readFile("./public/images/radicle-228x228.png");
|
||||
const base64Data = buffer.toString("base64");
|
||||
const { rid } = await createProject(authenticatedPeer, "embeds");
|
||||
|
||||
await page.goto(
|
||||
`/nodes/${authenticatedPeer.httpdBaseUrl.hostname}:${authenticatedPeer.httpdBaseUrl.port}/${rid}/issues/new`,
|
||||
);
|
||||
|
||||
const dataTransfer = await page.evaluateHandle(data => {
|
||||
const arrayBuffer = Uint8Array.from(atob(data), c => c.charCodeAt(0));
|
||||
const dt = new DataTransfer();
|
||||
const file = new File([arrayBuffer.buffer], "radicle-228x228.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
dt.items.add(file);
|
||||
return dt;
|
||||
}, base64Data);
|
||||
|
||||
await page.getByPlaceholder("Title").fill("This is a title");
|
||||
await page
|
||||
.getByPlaceholder("Write a description")
|
||||
.fill("Here is some text\n\n");
|
||||
await page.dispatchEvent("textarea[aria-label=textarea-comment]", "drop", {
|
||||
dataTransfer,
|
||||
});
|
||||
await expect(page.getByPlaceholder("Write a description")).toHaveValue(
|
||||
"Here is some text\n\n\n",
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "Preview" }).click();
|
||||
await expect(
|
||||
page.getByRole("img", { name: "radicle-228x228.png" }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
await page
|
||||
.getByRole("img", { name: "radicle-228x228.png" })
|
||||
.getAttribute("src"),
|
||||
).toBe(
|
||||
"data:image/png;base64,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",
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "Submit" }).click();
|
||||
await expect(page.getByRole("button", { name: "Submit" })).toBeHidden();
|
||||
await expect(page.getByText("This is a title")).toBeVisible();
|
||||
await expect(page.getByText("Here is some text")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("img", { name: "radicle-228x228.png" }),
|
||||
).toBeVisible();
|
||||
const { scheme, hostname, port } = authenticatedPeer.httpdBaseUrl;
|
||||
expect(
|
||||
await page
|
||||
.getByRole("img", { name: "radicle-228x228.png" })
|
||||
.getAttribute("src"),
|
||||
).toBe(
|
||||
`${scheme}://${hostname}:${port}/raw/rad:z2J7s48EbCBckcEmj2dm5eaFVoBsy/blobs/bae036309c2182c7304c97956969369823b5c6ad?mime=image/png`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByLabel("chip").filter({ hasText: "radicle-228x228.png" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -138,7 +138,7 @@ test("add and remove reactions", async ({ page, authenticatedPeer }) => {
|
||||
await expect(page.locator("span").filter({ hasText: "🎉 1" })).toBeVisible();
|
||||
await expect(page.locator(".reaction")).toHaveCount(2);
|
||||
|
||||
await page.locator("span").filter({ hasText: "👍 1" }).click();
|
||||
await page.getByRole("button", { name: "✕" }).nth(1).click();
|
||||
await expect(page.locator("span").filter({ hasText: "👍 1" })).toBeHidden();
|
||||
await expect(page.locator(".reaction")).toHaveCount(1);
|
||||
|
||||
|
||||
@@ -310,8 +310,8 @@ export async function createSourceBrowsingFixture(
|
||||
gitOptions: gitOptions["bob"],
|
||||
});
|
||||
const bobProjectPath = Path.join(bob.checkoutPath, "source-browsing");
|
||||
await alice.startNode({ connect: [palm.address] });
|
||||
await bob.startNode({ connect: [palm.address] });
|
||||
await alice.startNode({ connect: [palm.address], alias: "alice" });
|
||||
await bob.startNode({ connect: [palm.address], alias: "bob" });
|
||||
await alice.waitForEvent({ type: "peerConnected", nid: palm.nodeId }, 1000);
|
||||
await palm.waitForEvent({ type: "peerConnected", nid: alice.nodeId }, 1000);
|
||||
await bob.waitForEvent({ type: "peerConnected", nid: palm.nodeId }, 1000);
|
||||
@@ -339,7 +339,9 @@ export async function createSourceBrowsingFixture(
|
||||
);
|
||||
// Needed due to rad init not pushing all branches.
|
||||
await alice.git(["push", "rad", "--all"], { cwd: aliceProjectPath });
|
||||
await alice.rad(["track", bob.nodeId], { cwd: aliceProjectPath });
|
||||
await alice.rad(["track", bob.nodeId, "--alias", "bob"], {
|
||||
cwd: aliceProjectPath,
|
||||
});
|
||||
|
||||
await alice.waitForRoutes(rid, alice.nodeId, palm.nodeId);
|
||||
await bob.waitForRoutes(rid, alice.nodeId, palm.nodeId);
|
||||
|
||||
@@ -51,14 +51,24 @@ export default async function globalSetup(): Promise<() => void> {
|
||||
gitOptions: gitOptions["alice"],
|
||||
});
|
||||
await palm.startHttpd(defaultHttpdPort);
|
||||
await palm.startNode({ policy: "track", scope: "all" });
|
||||
await palm.startNode({ policy: "track", scope: "all", alias: "palm" });
|
||||
|
||||
console.log("Creating source-browsing fixture");
|
||||
await createSourceBrowsingFixture(peerManager, palm);
|
||||
console.log("Creating markdown fixture");
|
||||
await createMarkdownFixture(palm);
|
||||
console.log("Creating cobs fixture");
|
||||
await createCobsFixture(palm);
|
||||
try {
|
||||
await createSourceBrowsingFixture(peerManager, palm);
|
||||
console.log("Creating source-browsing fixture");
|
||||
await createMarkdownFixture(palm);
|
||||
console.log("Creating markdown fixture");
|
||||
await createCobsFixture(palm);
|
||||
console.log("Creating cobs fixture");
|
||||
} catch (error) {
|
||||
console.log("");
|
||||
console.log("Not able to create the required fixtures.");
|
||||
console.log("Make sure you are not using binaries compiled for release.");
|
||||
console.log("");
|
||||
console.log(error);
|
||||
console.log("");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("Running tests");
|
||||
await palm.stopNode();
|
||||
} else {
|
||||
|
||||
@@ -1 +1 @@
|
||||
a3f460e67d90f3406ec2dfbd1f21c3876a38c65a
|
||||
9df1922f15ca5a27057ce0a8f7197efe3ff32ee3
|
||||
|
||||
@@ -319,6 +319,9 @@ export class RadiclePeer {
|
||||
"--json",
|
||||
]);
|
||||
|
||||
if (!entries) {
|
||||
throw new Error("No entries found in the routing table");
|
||||
}
|
||||
entries.split("\n").forEach(entry => {
|
||||
if (entry && entry.trim() !== "") {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user