Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add issue embeds
Sebastian Martinez committed 2 years ago
commit 1ef0f9dfd1b223caab543b0bdb008fb675355c79
parent 2864a0311bfb7a23a73a26fae910c3f649735796
28 files changed +585 -107
modified httpd-client/lib/fetcher.ts
@@ -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;
modified httpd-client/lib/project.ts
@@ -372,6 +372,7 @@ export class Client {
      title: string;
      description: string;
      assignees: string[];
+
      embeds: { name: string; content: string }[];
      labels: string[];
    },
    authToken: string,
modified httpd-client/lib/project/comment.ts
@@ -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>;
+
});
modified httpd-client/lib/project/issue.ts
@@ -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 }
  | {
modified httpd-client/tests/project.test.ts
@@ -120,6 +120,7 @@ describe("project", () => {
          title: "aaa",
          description: "bbb",
          assignees: [],
+
          embeds: [],
          labels: ["bug", "documentation"],
        },
        sessionId,
modified httpd-client/tests/support/support.ts
@@ -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,
  );

modified public/typography.css
@@ -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;
+
}
modified src/components/Chip.svelte
@@ -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>
modified src/components/Clipboard.svelte
@@ -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>
modified src/components/Comment.svelte
@@ -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 });
modified src/components/Markdown.svelte
@@ -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 &&
modified src/components/Reactions.svelte
@@ -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>
modified src/components/Textarea.svelte
@@ -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 />

modified src/components/Thread.svelte
@@ -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>
added src/lib/file.ts
@@ -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 };
modified src/lib/utils.ts
@@ -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> {
modified src/views/projects/Cob/AssigneeInput.svelte
@@ -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>
added src/views/projects/Cob/Embeds.svelte
@@ -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>
modified src/views/projects/Cob/ErrorModal.svelte
@@ -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="🚨">
modified src/views/projects/Cob/LabelInput.svelte
@@ -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>
modified src/views/projects/Issue.svelte
@@ -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>
modified src/views/projects/Issue/New.svelte
@@ -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,
modified tests/e2e/project/issues.spec.ts
@@ -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();
+
});
modified tests/e2e/project/patches.spec.ts
@@ -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);

modified tests/support/fixtures.ts
@@ -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);
modified tests/support/globalSetup.ts
@@ -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 {
modified tests/support/heartwood-version
@@ -1 +1 @@
-
a3f460e67d90f3406ec2dfbd1f21c3876a38c65a
+
9df1922f15ca5a27057ce0a8f7197efe3ff32ee3
modified tests/support/peerManager.ts
@@ -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 {