Add issue embeds

Signed-off-by: Sebastian Martinez <me@sebastinez.dev>
This commit is contained in:
Sebastian Martinez
2023-09-27 16:40:49 +02:00
parent 2864a0311b
commit 1ef0f9dfd1
28 changed files with 585 additions and 107 deletions
+7 -2
View File
@@ -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;
+1
View File
@@ -372,6 +372,7 @@ export class Client {
title: string;
description: string;
assignees: string[];
embeds: { name: string; content: string }[];
labels: string[];
},
authToken: string,
+4 -10
View File
@@ -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>;
});
+6 -1
View File
@@ -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 }
| {
+1
View File
@@ -120,6 +120,7 @@ describe("project", () => {
title: "aaa",
description: "bbb",
assignees: [],
embeds: [],
labels: ["bug", "documentation"],
},
sessionId,
+1 -1
View File
@@ -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,
);
+5
View File
@@ -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
View File
@@ -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>
+6
View File
@@ -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>
-1
View File
@@ -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 });
+35 -1
View File
@@ -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 &&
+24 -11
View File
@@ -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>
+19 -1
View File
@@ -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 />
+33 -2
View File
@@ -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 = `![${embed.name}](${embed.oid})\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
View File
@@ -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 };
+4
View File
@@ -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> {
+27 -10
View File
@@ -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>
+52
View File
@@ -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={`![${embed.name}](${embed.content.substring(4)})`}
tiny />
</Chip>
{:else}
<div class="txt-missing">No attachments</div>
{/each}
</div>
</div>
+2 -1
View File
@@ -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 -14
View File
@@ -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>
+93 -9
View File
@@ -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 = `![${embed.name}](${embed.oid})\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>
+29 -2
View File
@@ -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 = `![${name}](${oid})\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,
+66 -3
View File
@@ -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![radicle-228x228.png](bae036309c2182c7304c97956969369823b5c6ad)\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();
});
+1 -1
View File
@@ -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);
+5 -3
View File
@@ -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);
+17 -7
View File
@@ -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
View File
@@ -1 +1 @@
a3f460e67d90f3406ec2dfbd1f21c3876a38c65a
9df1922f15ca5a27057ce0a8f7197efe3ff32ee3
+3
View File
@@ -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 {