Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Enable adding and removing reactions
Sebastian Martinez committed 2 years ago
commit 7edd6dcdf0f5b8593829e957d6d16af2456f7332
parent 3eecaeda29fa5bbd25c0c271042f389e89052438
11 files changed +297 -58
modified src/components/Chip.svelte
@@ -1,19 +1,25 @@
<script lang="ts" strictEvents>
  import { createEventDispatcher } from "svelte";

-
  const dispatch = createEventDispatcher<{ remove: number }>();
+
  const dispatch = createEventDispatcher<{ remove: number; click: null }>();

  export let removeable: boolean = false;
+
  export let clickable: boolean = false;
  export let key: number;
</script>

<style>
  .chip {
+
    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);
+
  }
  .section {
    display: flex;
    align-items: center;
@@ -45,7 +51,9 @@
</style>

<div class="chip">
-
  <span class="section text" class:removeable>
+
  <!-- 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>
  {#if removeable}
modified src/components/Comment.svelte
@@ -1,13 +1,15 @@
<script lang="ts" strictEvents>
  import type { AuthorAliasColor } from "@app/components/Authorship.svelte";

+
  import { createEventDispatcher } from "svelte";
+
  import { httpdStore } from "@app/lib/httpd";
+

  import Authorship from "@app/components/Authorship.svelte";
  import Button from "@app/components/Button.svelte";
-
  import Chip from "@app/components/Chip.svelte";
  import Icon from "@app/components/Icon.svelte";
  import Markdown from "@app/components/Markdown.svelte";
+
  import Reactions from "@app/components/Reactions.svelte";
  import Textarea from "@app/components/Textarea.svelte";
-
  import { createEventDispatcher } from "svelte";

  export let id: string | undefined = undefined;
  export let authorId: string;
@@ -21,12 +23,10 @@
  export let caption = "commented";
  export let rawPath: string;

-
  const dispatch = createEventDispatcher<{ toggleReply: null }>();
-

-
  $: groupedReactions = reactions?.reduce(
-
    (acc, [nid, emoji]) => acc.set(emoji, [...(acc.get(emoji) ?? []), nid]),
-
    new Map<string, string[]>(),
-
  );
+
  const dispatch = createEventDispatcher<{
+
    toggleReply: null;
+
    react: { nids: string[]; id: string; reaction: string };
+
  }>();
</script>

<style>
@@ -51,16 +51,11 @@
    display: flex;
    justify-content: flex-end;
  }
-
  .action {
-
    display: flex;
-
    gap: 0.5rem;
-
  }
  .reactions {
    margin-top: 1rem;
  }
-
  .reaction {
-
    display: inline-flex;
-
    flex-direction: row;
+
  .action {
+
    display: flex;
    gap: 0.5rem;
  }
</style>
@@ -99,16 +94,9 @@
    {:else}
      <Markdown {rawPath} content={body} />
    {/if}
-
    {#if groupedReactions.size > 0}
+
    {#if id && (reactions.length > 0 || $httpdStore.state === "authenticated")}
      <div class="reactions">
-
        {#each groupedReactions as [reaction, nids], key}
-
          <Chip {key}>
-
            <div class="reaction">
-
              <span>{reaction}</span>
-
              <span title={nids.join("\n")}>{nids.length}</span>
-
            </div>
-
          </Chip>
-
        {/each}
+
        <Reactions {id} {reactions} on:react />
      </div>
    {/if}
  </div>
added src/components/Reactions.svelte
@@ -0,0 +1,112 @@
+
<script lang="ts" strictEvents>
+
  import { createEventDispatcher } from "svelte";
+

+
  import Button from "@app/components/Button.svelte";
+
  import Chip from "@app/components/Chip.svelte";
+
  import Floating, { closeFocused } from "@app/components/Floating.svelte";
+
  import config from "@app/config.json";
+
  import { httpdStore } from "@app/lib/httpd";
+

+
  export let id: string;
+
  export let reactions: [string, string][];
+

+
  const dispatch = createEventDispatcher<{
+
    react: { nids: string[]; id: string; reaction: string };
+
  }>();
+

+
  $: groupedReactions = reactions?.reduce(
+
    (acc, [nid, emoji]) => acc.set(emoji, [...(acc.get(emoji) ?? []), nid]),
+
    new Map<string, string[]>(),
+
  );
+
</script>
+

+
<style>
+
  section {
+
    position: relative;
+
    display: flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
  }
+
  .reaction {
+
    display: inline-flex;
+
    flex-direction: row;
+
    gap: 0.5rem;
+
    font-size: var(--font-size-tiny);
+
  }
+
  .toggle {
+
    /* Height of one reaction */
+
    height: 1.525rem;
+
  }
+
  .toggle:hover {
+
    color: var(--color-foreground-5);
+
  }
+
  .reaction-selector {
+
    position: absolute;
+
    bottom: 2.2rem;
+
    display: flex;
+
    align-items: center;
+
    background-color: var(--color-background-1);
+
    border-radius: var(--border-radius-small);
+
    border: 1px solid var(--color-foreground-3);
+
    box-shadow: var(--elevation-low);
+
    padding: 0.2rem;
+
    gap: 0.2rem;
+
  }
+
  .reaction-selector button {
+
    padding: 0.5rem;
+
    border: 0;
+
    background-color: transparent;
+
  }
+
  .reaction-selector button.active {
+
    border-radius: var(--border-radius-small);
+
    background-color: var(--color-background);
+
  }
+
  .reaction-selector button:hover {
+
    cursor: pointer;
+
    border-radius: var(--border-radius-small);
+
    background-color: var(--color-background);
+
  }
+
</style>
+

+
<section>
+
  {#if $httpdStore.state === "authenticated"}
+
    <Floating>
+
      <div class="reaction-selector" slot="modal">
+
        {#each config.reactions as reaction}
+
          <button
+
            class:active={groupedReactions
+
              .get(reaction)
+
              ?.includes($httpdStore.session.publicKey)}
+
            on:click={() => {
+
              dispatch("react", {
+
                nids: groupedReactions.get(reaction) ?? [],
+
                id,
+
                reaction,
+
              });
+
              closeFocused();
+
            }}>
+
            {reaction}
+
          </button>
+
        {/each}
+
      </div>
+
      <Button variant="secondary" slot="toggle" size="tiny">+</Button>
+
    </Floating>
+
  {/if}
+
  {#if groupedReactions.size > 0}
+
    {#each groupedReactions as [reaction, nids], key}
+
      <Chip
+
        {key}
+
        clickable={$httpdStore.state === "authenticated"}
+
        on:click={() => {
+
          if ($httpdStore.state === "authenticated") {
+
            dispatch("react", { nids, id, reaction });
+
          }
+
        }}>
+
        <div class="reaction">
+
          <span>{reaction}</span>
+
          <span title={nids.join("\n")}>{nids.length}</span>
+
        </div>
+
      </Chip>
+
    {/each}
+
  {/if}
+
</section>
modified src/components/Thread.svelte
@@ -42,6 +42,7 @@

  const dispatch = createEventDispatcher<{
    reply: { id: string; body: string };
+
    react: { nids: string[]; commentId: string | undefined; reaction: string };
    cancel: never;
  }>();

@@ -81,7 +82,8 @@
      timestamp={root.timestamp}
      body={root.body}
      showReplyIcon={Boolean($httpdStore.state === "authenticated")}
-
      on:toggleReply={toggleReply} />
+
      on:toggleReply={toggleReply}
+
      on:react />
  </div>
  {#each replies as reply}
    <div class="comment reply">
@@ -93,7 +95,8 @@
        caption="replied"
        reactions={reply.reactions}
        timestamp={reply.timestamp}
-
        body={reply.body} />
+
        body={reply.body}
+
        on:react />
    </div>
  {/each}
  {#if showReplyTextarea}
modified src/config.json
@@ -14,6 +14,7 @@
      }
    ]
  },
+
  "reactions": ["👍", "👎", "😄", "🎉", "🙁", "🚀", "👀"],
  "projects": {
    "pinned": [
      {
modified src/views/projects/Cob/Revision.svelte
@@ -339,6 +339,7 @@
            <Thread
              rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
              thread={element.inner}
+
              on:react
              on:reply />
          {:else if element.type === "merge"}
            <div
@@ -384,7 +385,8 @@
                    baseUrl,
                    projectHead,
                  )}
-
                  body={review.summary} />
+
                  body={review.summary}
+
                  on:react />
              </div>
            {:else}
              <div
modified src/views/projects/Issue.svelte
@@ -15,7 +15,6 @@
  import Authorship from "@app/components/Authorship.svelte";
  import Badge from "@app/components/Badge.svelte";
  import Button from "@app/components/Button.svelte";
-
  import Chip from "@app/components/Chip.svelte";
  import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
  import CobStateButton from "@app/views/projects/Cob/CobStateButton.svelte";
  import ErrorModal from "@app/views/projects/Cob/ErrorModal.svelte";
@@ -25,6 +24,7 @@
  import Markdown from "@app/components/Markdown.svelte";
  import Textarea from "@app/components/Textarea.svelte";
  import ThreadComponent from "@app/components/Thread.svelte";
+
  import Reactions from "@app/components/Reactions.svelte";

  export let baseUrl: BaseUrl;
  export let issue: Issue;
@@ -33,11 +33,6 @@
  const rawPath = utils.getRawBasePath(project.id, baseUrl, project.head);
  const api = new HttpdClient(baseUrl);

-
  $: groupedReactions = issue.discussion[0].reactions.reduce(
-
    (acc, [nid, emoji]) => acc.set(emoji, [...(acc.get(emoji) ?? []), nid]),
-
    new Map<string, string[]>(),
-
  );
-

  let action: "edit" | "view";
  $: action =
    $httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)
@@ -85,6 +80,32 @@
    }
  }

+
  async function handleReaction({
+
    detail: { nids, id, reaction },
+
  }: CustomEvent<{ nids: string[]; id: string; reaction: string }>) {
+
    if ($httpdStore.state === "authenticated") {
+
      try {
+
        const status = await updateIssue(
+
          project.id,
+
          issue.id,
+
          {
+
            type: "comment.react",
+
            id,
+
            reaction,
+
            active: nids.includes($httpdStore.session.publicKey) ? false : true,
+
          },
+
          $httpdStore.session,
+
          api,
+
        );
+
        if (status === "success") {
+
          issue = await refreshIssue(project.id, issue, api);
+
        }
+
      } catch (e) {
+
        console.error(e);
+
      }
+
    }
+
  }
+

  async function editTitle({ detail: title }: CustomEvent<string>) {
    if (
      $httpdStore.state === "authenticated" &&
@@ -262,17 +283,6 @@
    padding-left: 1rem;
    margin-left: 1rem;
  }
-
  .reactions {
-
    display: inline-flex;
-
    gap: 0.5rem;
-
    margin-top: 1rem;
-
    user-select: none;
-
  }
-
  .reaction {
-
    display: inline-flex;
-
    flex-direction: row;
-
    gap: 0.5rem;
-
  }

  .actions {
    display: flex;
@@ -287,6 +297,9 @@
    flex-wrap: nowrap;
    gap: 0.5rem;
  }
+
  .reactions {
+
    margin-top: 1rem;
+
  }
  .thread {
    margin: 1rem 0;
  }
@@ -341,16 +354,12 @@
          <Markdown
            content={issue.discussion[0].body}
            rawPath={utils.getRawBasePath(project.id, baseUrl, project.head)} />
-
          {#if issue.discussion[0].reactions}
-
            <div class="reactions txt-tiny">
-
              {#each groupedReactions as [reaction, nids], key}
-
                <Chip {key}>
-
                  <div class="reaction">
-
                    <span>{reaction}</span>
-
                    <span title={nids.join("\n")}>{nids.length}</span>
-
                  </div>
-
                </Chip>
-
              {/each}
+
          {#if issue.discussion[0].reactions.length > 0 || $httpdStore.state === "authenticated"}
+
            <div class="reactions">
+
              <Reactions
+
                id={issue.id}
+
                reactions={issue.discussion[0].reactions}
+
                on:react={handleReaction} />
            </div>
          {/if}
        </div>
@@ -363,7 +372,11 @@
      </CobHeader>
      {#each threads as thread (thread.root.id)}
        <div class="thread">
-
          <ThreadComponent {thread} {rawPath} on:reply={createReply} />
+
          <ThreadComponent
+
            {thread}
+
            {rawPath}
+
            on:reply={createReply}
+
            on:react={handleReaction} />
        </div>
      {/each}
      {#if $httpdStore.state === "authenticated"}
modified src/views/projects/Patch.svelte
@@ -80,6 +80,25 @@
      patch = await api.project.getPatchById(project.id, patch.id);
    }
  }
+
  async function handleReaction({
+
    detail: { nids, id, reaction },
+
  }: CustomEvent<{ nids: string[]; id: string; reaction: string }>) {
+
    if ($httpdStore.state === "authenticated") {
+
      await api.project.updatePatch(
+
        project.id,
+
        patch.id,
+
        {
+
          type: "revision.comment.react",
+
          revision: revisionId,
+
          comment: id,
+
          reaction,
+
          active: nids.includes($httpdStore.session.publicKey) ? false : true,
+
        },
+
        $httpdStore.session.id,
+
      );
+
      patch = await api.project.getPatchById(project.id, patch.id);
+
    }
+
  }
  function badgeColor(status: string): Variant {
    if (status === "draft") {
      return "foreground";
@@ -435,6 +454,7 @@
            projectHead={project.head}
            {...revision}
            first={index === 0}
+
            on:react={handleReaction}
            on:reply={createReply}
            patchId={patch.id}
            expanded={index === patch.revisions.length - 1}
modified tests/e2e/project/issues.spec.ts
@@ -19,6 +19,48 @@ test("navigate single issue", async ({ page }) => {
  );
});

+
test("adding and removing reactions", async ({ page, authenticatedPeer }) => {
+
  await page.goto(authenticatedPeer.uiUrl());
+
  const { rid, projectFolder } = await createProject(
+
    authenticatedPeer,
+
    "handle-reactions",
+
  );
+
  await authenticatedPeer.rad(
+
    [
+
      "issue",
+
      "open",
+
      "--title",
+
      "This is an issue to test reactions",
+
      "--description",
+
      "We'll write some comments and add and remove reactions to them",
+
    ],
+
    { cwd: projectFolder },
+
  );
+
  await page.goto(
+
    `${authenticatedPeer.uiUrl()}/${rid}/issues/48af7d329e5b44ee8d348eeb7e341370243db9ad`,
+
  );
+
  const commentReactionToggle = page.locator(".card-body .toggle").first();
+
  await page.getByPlaceholder("Leave your comment").fill("This is a comment");
+
  await page.getByRole("button", { name: "Comment" }).click();
+
  await commentReactionToggle.click();
+
  await page.getByRole("button", { name: "👍" }).click();
+
  await expect(page.locator("span").filter({ hasText: "👍 1" })).toBeVisible();
+

+
  await commentReactionToggle.click();
+
  await page.getByRole("button", { name: "🎉" }).click();
+
  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 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 expect(page.locator("span").filter({ hasText: "🎉 1" })).toBeHidden();
+
  await expect(page.locator(".reaction")).toHaveCount(0);
+
});
+

test("test issue counters", async ({ page, authenticatedPeer }) => {
  const { rid, projectFolder } = await createProject(
    authenticatedPeer,
modified tests/e2e/project/patches.spec.ts
@@ -38,6 +38,56 @@ test("navigate patch details", async ({ page }) => {
  }
});

+
test("adding and removing reactions", async ({ page, authenticatedPeer }) => {
+
  await page.goto(authenticatedPeer.uiUrl());
+
  const { rid, projectFolder } = await createProject(
+
    authenticatedPeer,
+
    "handle-reactions",
+
  );
+
  await authenticatedPeer.git(["switch", "-c", "feature-1"], {
+
    cwd: projectFolder,
+
  });
+
  await authenticatedPeer.git(
+
    ["commit", "--allow-empty", "-m", "Reaction patch"],
+
    {
+
      cwd: projectFolder,
+
    },
+
  );
+
  await authenticatedPeer.git(["push", "rad", "HEAD:refs/patches"], {
+
    cwd: projectFolder,
+
  });
+
  await authenticatedPeer.rad(
+
    [
+
      "comment",
+
      "bfc3bc2c6af29920283f83e7ada9d52b2d4d3a57",
+
      "--message",
+
      "This is a comment for reactions",
+
    ],
+
    { cwd: projectFolder },
+
  );
+
  await page.goto(
+
    `${authenticatedPeer.uiUrl()}/${rid}/patches/bfc3bc2c6af29920283f83e7ada9d52b2d4d3a57`,
+
  );
+
  const commentReactionToggle = page.locator(".card-body .toggle").first();
+
  await commentReactionToggle.click();
+
  await page.getByRole("button", { name: "👍" }).click();
+
  await expect(page.locator("span").filter({ hasText: "👍 1" })).toBeVisible();
+

+
  await commentReactionToggle.click();
+
  await page.getByRole("button", { name: "🎉" }).click();
+
  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 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 expect(page.locator("span").filter({ hasText: "🎉 1" })).toBeHidden();
+
  await expect(page.locator(".reaction")).toHaveCount(0);
+
});
+

test("test patches counters", async ({ page, authenticatedPeer }) => {
  const { rid, projectFolder, defaultBranch } = await createProject(
    authenticatedPeer,
modified tests/support/heartwood-version
@@ -1 +1 @@
-
95a314688589b8b974015fef57f1e0b0751692ad
+
f6bd7a3dc6176adbd8b55f3a0801b6ecb26b1649