Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Adding embed editing in issues and patches
Sebastian Martinez committed 2 years ago
commit 95ed1a0353e7c257cecfb1ccd02f17920ef7c6fd
parent 1167dcfab673af6e961328f7b79b50b098cb1fd2
18 files changed +246 -123
modified httpd-client/index.ts
@@ -6,7 +6,7 @@ import type {
  Tree,
  DiffResponse,
} from "./lib/project.js";
-
import type { Comment } from "./lib/project/comment.js";
+
import type { Comment, Embed } from "./lib/project/comment.js";
import type {
  Commit,
  CommitBlob,
@@ -52,6 +52,7 @@ export type {
  DiffContent,
  DiffFile,
  DiffResponse,
+
  Embed,
  HunkLine,
  Issue,
  IssueState,
modified httpd-client/lib/project.ts
@@ -1,5 +1,5 @@
-
import type { Embed } from "@app/lib/file.js";
import type { Commit, Commits } from "./project/commit.js";
+
import type { Embed } from "./project/comment.js";
import type { Fetcher, RequestOptions } from "./fetcher.js";
import type {
  Issue,
modified httpd-client/lib/project/issue.ts
@@ -1,5 +1,4 @@
-
import type { Embed } from "@app/lib/file.js";
-
import type { Comment } from "./comment.js";
+
import type { Comment, Embed } from "./comment.js";
import type { ZodSchema } from "zod";
import { array, boolean, literal, object, string, union } from "zod";

modified httpd-client/lib/project/patch.ts
@@ -1,5 +1,4 @@
-
import type { Embed } from "@app/lib/file.js";
-
import type { Comment } from "./comment.js";
+
import type { Comment, Embed } from "./comment.js";
import type { ZodSchema, z } from "zod";

import { commentSchema } from "./comment.js";
modified src/components/Comment.svelte
@@ -1,5 +1,5 @@
<script lang="ts" strictEvents>
-
  import type { Embed } from "@app/lib/file";
+
  import type { Embed } from "@httpd-client";
  import type { GroupedReactions } from "@app/lib/reactions";

  import { tick } from "svelte";
@@ -21,6 +21,7 @@
  export let body: string;
  export let enableAttachments: boolean = false;
  export let reactions: GroupedReactions | undefined = undefined;
+
  export let embeds: Map<string, Embed> | undefined = undefined;
  export let caption = "commented";
  export let rawPath: string;
  export let timestamp: number;
@@ -145,6 +146,7 @@
      <ExtendedTextarea
        {rawPath}
        {body}
+
        {embeds}
        {enableAttachments}
        submitInProgress={state === "submit"}
        submitCaption="Save"
@@ -152,7 +154,7 @@
        on:submit={async ({ detail: { comment, embeds } }) => {
          state = "submit";
          try {
-
            await editComment_(comment, embeds);
+
            await editComment_(comment, Array.from(embeds.values()));
          } finally {
            state = "read";
          }
modified src/components/CommentToggleInput.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { Embed } from "@app/lib/file";
+
  import type { Embed } from "@httpd-client";

  import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";

@@ -44,7 +44,7 @@
    on:submit={async ({ detail: { comment, embeds } }) => {
      try {
        state = "submit";
-
        await submit(comment, embeds);
+
        await submit(comment, Array.from(embeds.values()));
      } finally {
        state = "collapsed";
      }
modified src/components/ExtendedTextarea.svelte
@@ -1,5 +1,5 @@
<script lang="ts" strictEvents>
-
  import type { Embed, EmbedWithOid } from "@app/lib/file";
+
  import type { Embed } from "@httpd-client";

  import { createEventDispatcher } from "svelte";

@@ -23,10 +23,10 @@
  export let inline: boolean = false;
  export let rawPath: string;
  export let body: string = "";
+
  export let embeds: Map<string, Embed> = new Map();
  export let submitInProgress: boolean = false;

  let preview: boolean = false;
-
  let newEmbeds: EmbedWithOid[] = [];
  let selectionStart = 0;
  let selectionEnd = 0;
  let inputFiles: FileList | undefined = undefined;
@@ -34,15 +34,14 @@
  const inputId = `input-label-${crypto.randomUUID()}`;

  const dispatch = createEventDispatcher<{
-
    submit: { comment: string; embeds: Embed[] };
+
    submit: { comment: string; embeds: Map<string, Embed> };
    close: null;
    click: null;
  }>();

  function submit() {
-
    dispatch("submit", { comment: body, embeds: newEmbeds });
+
    dispatch("submit", { comment: body, embeds });
    preview = false;
-
    newEmbeds = [];
  }

  const MAX_BLOB_SIZE = 4_194_304;
@@ -86,10 +85,10 @@
  }

  function attachEmbeds(files: FileList) {
-
    const embeds = Array.from(files).map(embed);
-
    void Promise.all(embeds).then(embeds =>
-
      embeds.forEach(embed => {
-
        if (embed.content.length > MAX_BLOB_SIZE) {
+
    const embedPromise = Array.from(files).map(embed);
+
    void Promise.all(embedPromise).then(newEmbeds =>
+
      newEmbeds.forEach(({ oid, name, content }) => {
+
        if (content.length > MAX_BLOB_SIZE) {
          modal.show({
            component: ErrorModal,
            props: {
@@ -103,15 +102,8 @@
          });
          return;
        }
-
        newEmbeds = [
-
          ...newEmbeds,
-
          {
-
            oid: embed.oid,
-
            name: embed.name,
-
            content: embed.content,
-
          },
-
        ];
-
        const embedText = `![${embed.name}](${embed.oid})\n`;
+
        embeds.set(oid, { name, content });
+
        const embedText = `![${name}](${oid})\n`;
        body = body
          .slice(0, selectionStart)
          .concat(embedText, body.slice(selectionEnd));
@@ -196,7 +188,7 @@
  </Radio>
  {#if preview}
    <div class="preview">
-
      <Markdown {rawPath} content={body} embeds={newEmbeds} />
+
      <Markdown {rawPath} content={body} {embeds} />
    </div>
  {:else}
    <input
modified src/components/Markdown.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { EmbedWithOid } from "@app/lib/file";
+
  import type { Embed } from "@httpd-client";

  import dompurify from "dompurify";
  import matter from "@radicle/gray-matter";
@@ -28,7 +28,7 @@
  export let rawPath: string;
  // If present, means we are in a preview context,
  // use this for image previews instead of /raw URLs.
-
  export let embeds: EmbedWithOid[] | undefined = undefined;
+
  export let embeds: Map<string, Embed> | undefined = undefined;

  let container: HTMLElement;
  let frontMatter: [string, any][] | undefined = undefined;
@@ -112,10 +112,9 @@

      // If the image is an oid embed
      if (imagePath && isCommit(imagePath)) {
-
        const embed = embeds?.find(e => {
-
          return e.oid === imagePath;
-
        });
-
        if (embed) {
+
        const embed = embeds?.get(imagePath);
+
        // If the embed content is the base64 encoded image, use it directly.
+
        if (embed && embed.content.startsWith("data:")) {
          i.setAttribute("src", embed.content);
          continue;
        }
modified src/components/Thread.svelte
@@ -20,10 +20,11 @@
</script>

<script lang="ts" strictEvents>
-
  import type { Embed } from "@app/lib/file";
+
  import type { Embed } from "@httpd-client";

  import * as utils from "@app/lib/utils";
  import partial from "lodash/partial";
+
  import { parseEmbedIntoMap } from "@app/lib/file";
  import { tick } from "svelte";

  import CommentComponent from "@app/components/Comment.svelte";
@@ -88,13 +89,14 @@
<div class="comments">
  <div class="top-level-comment" class:has-replies={replies.length > 0}>
    <CommentComponent
+
      {enableAttachments}
      {rawPath}
      id={root.id}
      authorId={root.author.id}
      authorAlias={root.author.alias}
      reactions={root.reactions}
+
      embeds={parseEmbedIntoMap(root.embeds)}
      timestamp={root.timestamp}
-
      disableEdit={root.embeds.length > 0}
      body={root.body}
      editComment={editComment &&
        canEditComment(root.author.id) &&
@@ -107,6 +109,7 @@
    <div class="replies">
      {#each replies as reply}
        <CommentComponent
+
          {enableAttachments}
          {rawPath}
          id={reply.id}
          authorId={reply.author.id}
@@ -115,8 +118,8 @@
          isReply
          isLastReply={replies[replies.length - 1] === reply}
          reactions={reply.reactions}
+
          embeds={parseEmbedIntoMap(reply.embeds)}
          timestamp={reply.timestamp}
-
          disableEdit={reply.embeds.length > 0}
          body={reply.body}
          editComment={editComment &&
            canEditComment(reply.author.id) &&
modified src/lib/file.ts
@@ -1,10 +1,4 @@
-
export interface Embed {
-
  name: string;
-
  content: string;
-
}
-
export interface EmbedWithOid extends Embed {
-
  oid: string;
-
}
+
import type { Embed } from "@httpd-client";

async function parseGitOid(bytes: Uint8Array): Promise<string> {
  // Create the header
@@ -38,6 +32,13 @@ function base64String(file: File): Promise<string> {
  });
}

+
export function parseEmbedIntoMap(embeds: Embed[]) {
+
  return embeds.reduce((acc, embed) => {
+
    acc.set(embed.content.substring(4), embed);
+
    return acc;
+
  }, new Map());
+
}
+

const mimes: Record<string, string> = {
  "3gp": "video/3gpp",
  "7z": "application/x-7z-compressed",
@@ -101,7 +102,7 @@ const mimes: Record<string, string> = {
  zip: "application/zip",
};

-
async function embed(file: File): Promise<EmbedWithOid> {
+
async function embed(file: File) {
  const bytes = new Uint8Array(await file.arrayBuffer());
  const oid = await parseGitOid(bytes);
  const content = await base64String(file);
modified src/views/projects/Cob/Embeds.svelte
@@ -1,5 +1,5 @@
<script lang="ts" strictEvents>
-
  import type { Embed } from "@app/lib/file";
+
  import type { Embed } from "@httpd-client";

  import Badge from "@app/components/Badge.svelte";
  import Clipboard from "@app/components/Clipboard.svelte";
modified src/views/projects/Cob/Revision.svelte
@@ -1,8 +1,8 @@
<script lang="ts">
-
  import type { Embed } from "@httpd-client/lib/project/comment";
  import type {
    BaseUrl,
    DiffResponse,
+
    Embed,
    PatchState,
    Verdict,
  } from "@httpd-client";
modified src/views/projects/Issue.svelte
@@ -1,6 +1,11 @@
<script lang="ts">
-
  import type { BaseUrl, Issue, IssueState, Project } from "@httpd-client";
-
  import type { Embed } from "@app/lib/file";
+
  import type {
+
    BaseUrl,
+
    Issue,
+
    IssueState,
+
    Embed,
+
    Project,
+
  } from "@httpd-client";
  import type { Session } from "@app/lib/httpd";

  import isEqual from "lodash/isEqual";
@@ -15,6 +20,7 @@
  import { closeFocused } from "@app/components/Popover.svelte";
  import { groupReactions } from "@app/lib/reactions";
  import { httpdStore } from "@app/lib/httpd";
+
  import { parseEmbedIntoMap } from "@app/lib/file";

  import AssigneeInput from "@app/views/projects/Cob/AssigneeInput.svelte";
  import Badge from "@app/components/Badge.svelte";
@@ -358,14 +364,10 @@
    }
  }

-
  $: embeds = issue.discussion.reduce(
-
    (acc, comment) => {
-
      acc[comment.id] = comment.embeds;
-
      return acc;
-
    },
-
    {} as Record<string, Embed[]>,
+
  $: uniqueEmbeds = uniqBy(
+
    issue.discussion.flatMap(comment => comment.embeds),
+
    "content",
  );
-
  $: uniqueEmbeds = uniqBy(Object.values(embeds).flat(), "content");
  $: selectedItem = issue.state.status === "closed" ? items[0] : items[1];
  $: threads = issue.discussion
    .filter(
@@ -518,6 +520,7 @@
            <ExtendedTextarea
              rawPath={rawPath(project.head)}
              enableAttachments
+
              embeds={parseEmbedIntoMap(issue.discussion[0].embeds)}
              body={issue.discussion[0].body}
              submitCaption="Save"
              submitInProgress={issueState === "submit"}
@@ -535,7 +538,7 @@
                      issue.title,
                      issue.id,
                      comment,
-
                      embeds,
+
                      Array.from(embeds.values()),
                    );
                  } finally {
                    issueState = "read";
modified src/views/projects/Issue/New.svelte
@@ -1,6 +1,5 @@
<script lang="ts">
-
  import type { BaseUrl, Project } from "@httpd-client";
-
  import type { EmbedWithOid } from "@app/lib/file";
+
  import type { BaseUrl, Embed, Project } from "@httpd-client";

  import * as modal from "@app/lib/modal";
  import * as router from "@app/lib/router";
@@ -28,7 +27,6 @@
  export let rawPath: (commit?: string) => string;
  export let tracking: boolean;

-
  let newEmbeds: EmbedWithOid[] = [];
  let preview: boolean = false;
  let selectionStart = 0;
  let selectionEnd = 0;
@@ -41,6 +39,7 @@
  let creatingIssue: boolean = false;

  const api = new HttpdClient(baseUrl);
+
  const newEmbeds = new Map<string, Embed>();

  function handleFileDrop(event: { detail: DragEvent }) {
    event.detail.preventDefault();
@@ -48,10 +47,7 @@
      const embeds = Array.from(event.detail.dataTransfer.files).map(embed);
      void Promise.all(embeds).then(embeds =>
        embeds.forEach(({ oid, name, content }) => {
-
          newEmbeds = [
-
            ...newEmbeds,
-
            { oid: oid, name: name, content: content },
-
          ];
+
          newEmbeds.set(oid, { name, content });
          const embedText = `![${name}](${oid})\n`;
          issueText = issueText
            .slice(0, selectionStart)
modified src/views/projects/Patch.svelte
@@ -35,8 +35,7 @@
</script>

<script lang="ts">
-
  import type { BaseUrl, Patch } from "@httpd-client";
-
  import type { Embed } from "@app/lib/file";
+
  import type { BaseUrl, Embed, Patch } from "@httpd-client";
  import type { PatchView } from "./router";
  import type { Route } from "@app/lib/router";
  import type { ComponentProps } from "svelte";
@@ -45,10 +44,11 @@
  import * as role from "@app/lib/roles";
  import * as router from "@app/lib/router";
  import * as utils from "@app/lib/utils";
-
  import { HttpdClient } from "@httpd-client";
  import capitalize from "lodash/capitalize";
  import isEqual from "lodash/isEqual";
  import partial from "lodash/partial";
+
  import uniqBy from "lodash/uniqBy";
+
  import { HttpdClient } from "@httpd-client";
  import { httpdStore, type Session } from "@app/lib/httpd";

  import Badge from "@app/components/Badge.svelte";
@@ -60,6 +60,7 @@
  import CommitTeaser from "@app/views/projects/Commit/CommitTeaser.svelte";
  import DropdownList from "@app/components/DropdownList.svelte";
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
+
  import Embeds from "@app/views/projects/Cob/Embeds.svelte";
  import ErrorModal from "@app/modals/ErrorModal.svelte";
  import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
  import Icon from "@app/components/Icon.svelte";
@@ -468,6 +469,12 @@
    revisionId = view.revision;
  }

+
  $: uniqueEmbeds = uniqBy(
+
    patch.revisions.flatMap(({ discussions }) =>
+
      discussions.flatMap(comment => comment.embeds),
+
    ),
+
    "content",
+
  );
  $: description = patch.revisions[0].description;
  $: newDescription = description;
  $: patchReviews = computeReviews(patch);
@@ -961,6 +968,7 @@
            }
          }
        }} />
+
      <Embeds embeds={uniqueEmbeds} />
    </div>
  </div>
</Layout>
modified tests/e2e/project/issue.spec.ts
@@ -1,5 +1,9 @@
import { test, cobUrl, expect } from "@tests/support/fixtures.js";
-
import { createProject, expectReactionsToWork } from "@tests/support/project";
+
import {
+
  addEmbed,
+
  createProject,
+
  expectReactionsToWork,
+
} from "@tests/support/project";

test("navigate single issue", async ({ page }) => {
  await page.goto(`${cobUrl}/issues`);
@@ -127,3 +131,78 @@ test("add and remove reactions", async ({ page, authenticatedPeer }) => {
    .click();
  await expectReactionsToWork(page);
});
+

+
test("handling embeds", async ({ page, authenticatedPeer }) => {
+
  const { rid } = await createProject(authenticatedPeer, "embeds");
+
  await page.goto(
+
    `/nodes/${authenticatedPeer.httpdBaseUrl.hostname}:${authenticatedPeer.httpdBaseUrl.port}/${rid}/issues/new`,
+
  );
+
  await page.getByPlaceholder("Title").fill("This is a title");
+
  await page
+
    .getByPlaceholder("Write a description")
+
    .fill("Here is some text\n\n");
+
  await addEmbed(
+
    page,
+
    "./public/images/radicle-228x228.png",
+
    "radicle-228x228.png",
+
    "image/png",
+
  );
+
  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.locator(".badge").filter({ hasText: "radicle-228x228.png" }),
+
  ).toBeVisible();
+

+
  await page.getByRole("button", { name: "edit issue" }).click();
+
  await page
+
    .getByPlaceholder("Leave a description")
+
    .fill(
+
      "Here is some text\n\n![radicle-228x228.png](bae036309c2182c7304c97956969369823b5c6ad)\n\n",
+
    );
+
  await addEmbed(
+
    page,
+
    "./public/images/apple-touch-icon.png",
+
    "apple-touch-icon.png",
+
    "image/png",
+
  );
+
  await expect(page.getByPlaceholder("Leave a description")).toHaveValue(
+
    "Here is some text\n\n![radicle-228x228.png](bae036309c2182c7304c97956969369823b5c6ad)\n\n![apple-touch-icon.png](c69a66fa4c414c92bf75365fb5c92453fa95dcca)\n",
+
  );
+
  await page.getByRole("button", { name: "Save" }).click();
+
  await expect(page.getByRole("button", { name: "Submit" })).toBeHidden();
+
  await expect(
+
    page.locator(".badge").filter({ hasText: "apple-touch-icon.png" }),
+
  ).toBeVisible();
+
  await expect(
+
    page.locator(".badge").filter({ hasText: "radicle-228x228.png" }),
+
  ).toBeVisible();
+
});
modified tests/e2e/project/issues.spec.ts
@@ -1,5 +1,5 @@
import { test, cobUrl, expect } from "@tests/support/fixtures.js";
-
import { addEmbed, createProject } from "@tests/support/project";
+
import { createProject } from "@tests/support/project";

test("navigate issue listing", async ({ page }) => {
  await page.goto(cobUrl);
@@ -91,55 +91,3 @@ test("create a new issue", async ({ page, authenticatedPeer }) => {
  ).toBeVisible();
  await expect(page.locator(".badge").filter({ hasText: "bug" })).toBeVisible();
});
-

-
test("handling embeds", async ({ page, authenticatedPeer }) => {
-
  const { rid } = await createProject(authenticatedPeer, "embeds");
-
  await page.goto(
-
    `/nodes/${authenticatedPeer.httpdBaseUrl.hostname}:${authenticatedPeer.httpdBaseUrl.port}/${rid}/issues/new`,
-
  );
-
  await page.getByPlaceholder("Title").fill("This is a title");
-
  await page
-
    .getByPlaceholder("Write a description")
-
    .fill("Here is some text\n\n");
-
  await addEmbed(
-
    page,
-
    "./public/images/radicle-228x228.png",
-
    "radicle-228x228.png",
-
    "image/png",
-
  );
-
  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,iVBORw0KGgoAAAANSUhEUgAAAOQAAADkCAYAAACIV4iNAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAkkSURBVHgB7dxNbttGGMbxoSQrqeS0cGAjDZCF4WaVLLrQBXyBLHWA3sQ36QF0CV9AB+iiQRbZBA5iFInUWl/s+445CkVLbpMq9OPq/wMm4odEzdB8NENKYRbumX4/z8MWDQZZFiBj1/++jQBABoEEhBBIQAiBBIQQSEAIgQSEEEhACIEEhBBIQAiBBIQQSEAIgQSEEEhACIEEhBBIQAiBBIQQSEAIgQSEEEhAyM7fT4Z79Pw37L/toocEhBBIQAiBBIQQSEAIgQSEEEhACIEEhBBIQAiBBIQQSEAIgQSEEEhACIEEhBBIQAiBBIQQSEAIgQSEEEhACIEEhLQCtqrX+32r95g5OTkJ2/T69esAXfSQgBACCQghkIAQAgkIIZCAEAIJCCGQgBACCQghkIAQAgkIIZCAEAIJCCGQgBACCQghkIAQAgkIIZCAEAIJCCGQgJAs3DP9fr7Ve9bg/20wyO7VMU4PCQghkIAQAgkIIZCAEAIJCCGQgBACCQghkIAQAgkIIZCAEAIJCCGQgBACCQghkIAQAgkIIZCAEAIJCCGQgBACCQhphW9M/R44v/46Csp++aUblKnvvxC2e/x963v00EMCQggkIIRAAkIIJCCEQAJCCCQghEACQggkIIRAAkIIJCCEQAJCCCQghEACQggkIIRAAkIIJCCEQAJCCCQghEACQr75PXV2TVf8Hjjqdn3/0UMCQggkIIRAAkIIJCCEQAJCCCQghEACQggkIIRAAkIIJCCEQAJCCCQghEACQggkIIRAAkIIJCCEQAJCCCQghEACQrLqgl7v9zwAqMVw+NNKBqs9JD0mUKOzs7NGnufLUKYAZr7CHpsBQG08d5lJ82kihXHPhqyjAKAWNmT1+15OrCy8xB6y3+97MGMgA4A6eeZaRQZjz5hdXFwQSOBu+M3K06liHLumMD600rEh67sAoBa//fbzk0+fPo1t8srKbHlRpyhcZQVqZFdY4yi11+vF2WUAj46O8k6nkwUAtRmNRjFzw+Ewzi97yOK7EAIJ3IHT01P/QU6WApm/f/8+PgYAdYqZOz8/985wOWTNHj9+nI3H4wCgVtnh4eFyOgbSTijzDx8+0DsC9VvJXQxkOqG0izoBQL2K08Wo/LVHsCErF3WAesXMFV973PjekWErcAeKUWq8qFMOIT0kcHc+f+3h/3S73UUAUJvqdZv4s500k341AKBW2Y1zyOK7EM4hgfqlkWneSkuKS69Z9R4farZ9z59tt7ffz7dav8Eg26n6qf99v4Efw+dRajyHzNMvzcPnpAKogV23if+xo/rjcueBnAcAtWk0GrOLi4tlRxgDeXJy4gvmBwcH0wCgNq1Wa3p8fBzvpxPn/Z/BYBCHq5eXl7MAoDaWuakVD6NnMC9/Dzmzc8lJAFAby5yPSr0jjD1kOZALO7HkHBKo0atXrzxzyyvL/JYVuENnZ2dxqJrmCSRwt27+f0gAGggkIIRAAkIIJCCEQAJCCCQghEACQggkIIRAAkIIJCCEQAJCCCQghEACQggkIIRAAkIIJCCEQAJCCCQghEACQggkIIRAAkIIJCCEQAJCCCQghEACQggkIIRAAkIIJCCEQAJCCCQghEACQlrhnhkOf8oC/rd2/e9LDwkIIZCAEAIJCCGQgBACCQghkIAQAgkIIZCAEAIJCCGQgBACCQghkIAQAgkIIZCAEAIJCCGQgBACCQghkIAQAgkIuXf31Nm2fj/PA77atvffYJBxTx0AGggkIIRAAkIIJCCEQAJCCCQghEACQggkIIRAAkIIJCCEQAJCCCQghEACQggkIIRAAkIIJCCEQAJCCCQghEACQnbx/iXe5j0rD6x0rfywt7fXnU6n34XrewxlrVYrzGazvPT83JZlaVmaLp639k2KdfG15e2sqUsolsf3NYvZ9UavHjx48MmW/TEyPm9lumYb/9je09PT5vn5ubdv38r3RXsf2rZbRR1TfUOpTcu6Fe1d1rNc5+q+Wbc+rO6TdXX0dv1l7R2V2vuXlVn48vbea82we/wAzd68edPqdrttOzDbi8XCk9DwA6bRaCxseuFs3h/m9pjbgRSXl9f5suI5y3VeSuvmtr18zfq5LcrLy9J2nQVmZg/T+Xx+ZfXzIE6szIvnfTFra/Pw8LA1Ho/9g6h9/RZ75ZAt21Cta2VfrLRxzX6YF22rtrO6D70teWmbPu/tnXh7j46OJlbXr27vfbard/jyAHoPGXuNdrv93WQyadtBGntIOyjSp/KyR7B1cYGtK28n9wO7eP7Gfemv9delx+q203Sx3rc5z7JsanUa7e/vj+2DY/zs2bPJcDichi/n7+EfvO1Hjx51Pn78uF+0u13sB69Xbu+bletXqeut/PXher/deF1p/8R9lZZXtu2fDL5gbGX05MmT8bt37/yDKAY37JBdDWTTDvD227dv/aD0g/NhuB7G+jn1uqFlCKvhCRbizAKzbpgW5219sFDlV1dX1WFrqDx3+fw4cf2a2GNYuTo4OPjz8vLSD85Znuce1C8+QPv9fnMwGPgblNvrYYwjJBsqxucVdfW2BWtbrGOpnSvHSvGcZbtsG17vZXuq7Qur+628bZ+eFcWHqX+Gz8PzRdixQO7qfVkXz58/n1og40FmPcfUeo7YO3Y6ncyGSysBsh4qt9OaeFCl9d4T2MGUDrSseM7yNd5r+LwtD+m1oRLc0nvF80ebju/lwzt7TTxILYx+YMbh29eE0b18+TK/uLiY2nlkWjS1nrdpAW94HVMQU92KdpWnq4GM541FmGKdzY3nlfdbaR9Vt+3PWxTt9bb6Ywoi98zdEX4geG+4d3x87L1FJ1xf8EjlUSp27pWmvy8vX7N+07L9yvr9ddOl1/iyblGn1HM3iwP+azXK7X369Gmn/N52zra/rm2b2uTT69q9bn1pej9s2F82RF1p79nZWRqpYEdkpdK0izzeO3qJFz289Hq9PS/FspVSXp6m/bHYzt4/lXXbLb+fb8eHmV68jvn1zcH/ywFabq8f7M1NdSnXY90+SPMvXrxol5f7/Ib91b7tPYqS9n/DP3iKD5+dDCSfQqv+7f64MYQLm8+bblt+2/brsHY4Wlkfwprh9obtVF8XKq+/bb9tOnffKX8Dh1MvnjJRErgAAAAASUVORK5CYII=",
-
  );
-

-
  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.locator(".badge").filter({ hasText: "radicle-228x228.png" }),
-
  ).toBeVisible();
-
});
modified tests/e2e/project/threads.spec.ts
@@ -1,5 +1,6 @@
-
import { test } from "@tests/support/fixtures.js";
+
import { test, expect } from "@tests/support/fixtures.js";
import {
+
  addEmbed,
  createProject,
  expectReactionsToWork,
  expectThreadCommentingToWork,
@@ -70,3 +71,95 @@ test("add and remove reactions", async ({ page, authenticatedPeer }) => {
  await page.goto(`${authenticatedPeer.uiUrl()}/${rid}/patches/${patchId}`);
  await expectReactionsToWork(page);
});
+

+
test("handling embeds", async ({ page, authenticatedPeer }) => {
+
  const { rid, projectFolder } = await createProject(
+
    authenticatedPeer,
+
    "embeds",
+
  );
+
  await authenticatedPeer.rad(
+
    [
+
      "issue",
+
      "open",
+
      "--title",
+
      "This is an issue to handle embeds",
+
      "--description",
+
      "We'll give it a few comments and replies with embeds.",
+
    ],
+
    { cwd: projectFolder },
+
  );
+
  await page.goto(`${authenticatedPeer.uiUrl()}/${rid}/issues`);
+
  await page
+
    .getByRole("link", {
+
      name: "This is an issue to handle embeds",
+
    })
+
    .click();
+
  await page.getByRole("button", { name: "Leave your comment" }).click();
+

+
  await page
+
    .getByPlaceholder("Leave your comment")
+
    .fill("Here is some text\n\n");
+
  await addEmbed(
+
    page,
+
    "./public/images/radicle-228x228.png",
+
    "radicle-228x228.png",
+
    "image/png",
+
  );
+
  await expect(page.getByPlaceholder("Leave your comment")).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: "Comment" }).click();
+
  await expect(page.getByPlaceholder("Leave your comment")).toBeHidden();
+
  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.locator(".badge").filter({ hasText: "radicle-228x228.png" }),
+
  ).toBeVisible();
+

+
  await page.getByRole("button", { name: "edit comment" }).click();
+
  await page
+
    .getByPlaceholder("Leave your comment")
+
    .fill(
+
      "Here is some text\n\n![radicle-228x228.png](bae036309c2182c7304c97956969369823b5c6ad)\n\n",
+
    );
+
  await addEmbed(
+
    page,
+
    "./public/images/apple-touch-icon.png",
+
    "apple-touch-icon.png",
+
    "image/png",
+
  );
+
  await expect(page.getByPlaceholder("Leave your comment")).toHaveValue(
+
    "Here is some text\n\n![radicle-228x228.png](bae036309c2182c7304c97956969369823b5c6ad)\n\n![apple-touch-icon.png](c69a66fa4c414c92bf75365fb5c92453fa95dcca)\n",
+
  );
+
  await page.getByRole("button", { name: "Save" }).click();
+
  await expect(page.getByRole("button", { name: "Save" })).toBeHidden();
+
  await expect(
+
    page.locator(".badge").filter({ hasText: "apple-touch-icon.png" }),
+
  ).toBeVisible();
+
  await expect(
+
    page.locator(".badge").filter({ hasText: "radicle-228x228.png" }),
+
  ).toBeVisible();
+
});