Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add reactions to patch revisions
Sebastian Martinez committed 2 years ago
commit f0758de9f3acd0807f9e5f9d476448b0ee5fc32d
parent ebfec3fd8e20eb09b2ea79560def38f8e5246b6b
16 files changed +276 -176
modified httpd-client/index.ts
@@ -6,7 +6,7 @@ import type {
  Tree,
  DiffResponse,
} from "./lib/project.js";
-
import type { SuccessResponse } from "./lib/shared.js";
+
import type { SuccessResponse, CodeLocation, Range } from "./lib/shared.js";
import type { Comment, Embed } from "./lib/project/comment.js";
import type {
  Commit,
@@ -20,13 +20,11 @@ import type {
} from "./lib/project/commit.js";
import type { Issue, IssueState } from "./lib/project/issue.js";
import type {
-
  CodeLocation,
  LifecycleState,
  Merge,
  Patch,
  PatchState,
  PatchUpdateAction,
-
  Range,
  Review,
  Revision,
  Verdict,
modified httpd-client/lib/project/comment.ts
@@ -1,5 +1,6 @@
import type { z } from "zod";
-
import { array, number, object, string, tuple } from "zod";
+
import { array, boolean, number, object, string } from "zod";
+
import { codeLocationSchema } from "../shared";

export type Comment = z.infer<typeof commentSchema>;
export type Embed = z.infer<typeof commentSchema>["embeds"][0];
@@ -17,7 +18,9 @@ export const commentSchema = object({
    }),
  ),
  embeds: array(object({ name: string(), content: string() })),
-
  reactions: array(tuple([string(), string()])),
+
  reactions: array(object({ emoji: string(), authors: array(string()) })),
  timestamp: number(),
+
  location: codeLocationSchema.nullable().optional(),
+
  resolved: boolean(),
  replyTo: string().nullable(),
});
modified httpd-client/lib/project/issue.ts
@@ -1,5 +1,5 @@
-
import type { Comment, Embed } from "./comment.js";
-
import type { ZodSchema } from "zod";
+
import type { Embed } from "./comment.js";
+
import type { ZodSchema, z } from "zod";
import { array, boolean, literal, object, string, union } from "zod";

import { commentSchema } from "./comment.js";
@@ -16,16 +16,6 @@ const issueStateSchema = union([
  }),
]) satisfies ZodSchema<IssueState>;

-
export interface Issue {
-
  id: string;
-
  author: { id: string; alias?: string };
-
  title: string;
-
  state: IssueState;
-
  discussion: Comment[];
-
  labels: string[];
-
  assignees: string[];
-
}
-

export const issueSchema = object({
  id: string(),
  author: object({ id: string(), alias: string().optional() }),
@@ -34,7 +24,9 @@ export const issueSchema = object({
  discussion: array(commentSchema),
  labels: array(string()),
  assignees: array(string()),
-
}) satisfies ZodSchema<Issue>;
+
});
+

+
export type Issue = z.infer<typeof issueSchema>;

export interface IssueCreated {
  success: boolean;
modified httpd-client/lib/project/patch.ts
@@ -1,5 +1,6 @@
import type { Embed } from "./comment.js";
import type { ZodSchema, z } from "zod";
+
import type { CodeLocation } from "../shared.js";

import { commentSchema } from "./comment.js";

@@ -14,6 +15,7 @@ import {
  tuple,
  union,
} from "zod";
+
import { codeLocationSchema } from "../shared.js";

export type PatchState =
  | { status: "draft" }
@@ -55,23 +57,15 @@ const mergeSchema = object({

export type Verdict = "accept" | "reject";

-
export interface Review {
-
  author: { id: string; alias?: string };
-
  verdict?: Verdict | null;
-
  summary: string | null;
-
  comments: string[];
-
  timestamp: number;
-
}
-

const reviewSchema = object({
  author: object({ id: string(), alias: string().optional() }),
  verdict: optional(union([literal("accept"), literal("reject")]).nullable()),
-
  comments: array(string()),
+
  comments: array(commentSchema),
  summary: string().nullable(),
  timestamp: number(),
-
}) satisfies ZodSchema<Review>;
+
});

-
export type Revision = z.infer<typeof revisionSchema>;
+
export type Review = z.infer<typeof reviewSchema>;

const revisionSchema = object({
  id: string(),
@@ -85,6 +79,13 @@ const revisionSchema = object({
      timestamp: number(),
    }),
  ),
+
  reactions: array(
+
    object({
+
      emoji: string(),
+
      location: codeLocationSchema.nullable(),
+
      authors: array(string()),
+
    }),
+
  ),
  base: string(),
  oid: string(),
  refs: array(string()),
@@ -93,17 +94,7 @@ const revisionSchema = object({
  timestamp: number(),
});

-
export interface Patch {
-
  id: string;
-
  author: { id: string; alias?: string };
-
  title: string;
-
  state: PatchState;
-
  target: string;
-
  labels: string[];
-
  merges: Merge[];
-
  assignees: string[];
-
  revisions: Revision[];
-
}
+
export type Revision = z.infer<typeof revisionSchema>;

export const patchSchema = object({
  id: string(),
@@ -115,7 +106,9 @@ export const patchSchema = object({
  merges: array(mergeSchema),
  assignees: array(string()),
  revisions: array(revisionSchema),
-
}) satisfies ZodSchema<Patch>;
+
});
+

+
export type Patch = z.infer<typeof patchSchema>;

export const patchesSchema = array(patchSchema) satisfies ZodSchema<Patch[]>;

@@ -124,23 +117,6 @@ export type LifecycleState =
  | { status: "open" }
  | { status: "archived" };

-
export type Range =
-
  | {
-
      type: "lines";
-
      range: { start: number; end: number };
-
    }
-
  | {
-
      type: "chars";
-
      line: number;
-
      range: { start: number; end: number };
-
    };
-

-
export type CodeLocation = {
-
  path: string;
-
  old?: Range;
-
  new?: Range;
-
};
-

export type PatchUpdateAction =
  | { type: "edit"; title: string; target: "delegates" }
  | { type: "label"; labels: string[] }
@@ -189,6 +165,13 @@ export type PatchUpdateAction =
      description: string;
      embeds?: Embed[];
    }
+
  | {
+
      type: "revision.react";
+
      revision: string;
+
      reaction: string;
+
      location?: CodeLocation;
+
      active: boolean;
+
    }
  | { type: "revision.redact"; revision: string }
  | {
      type: "revision.comment";
modified httpd-client/lib/shared.ts
@@ -1,4 +1,4 @@
-
import type { ZodSchema } from "zod";
+
import type { ZodSchema, z } from "zod";

import { array, boolean, literal, number, object, string, union } from "zod";

@@ -41,3 +41,25 @@ export const nodeConfigSchema = object({
  policy: union([literal("allow"), literal("block")]),
  scope: union([literal("followed"), literal("all")]),
});
+

+
export const rangeSchema = union([
+
  object({
+
    type: literal("lines"),
+
    range: object({ start: number(), end: number() }),
+
  }),
+
  object({
+
    type: literal("chars"),
+
    line: number(),
+
    range: object({ start: number(), end: number() }),
+
  }),
+
]);
+

+
export type Range = z.infer<typeof rangeSchema>;
+

+
export const codeLocationSchema = object({
+
  path: string(),
+
  old: rangeSchema.optional(),
+
  new: rangeSchema.optional(),
+
});
+

+
export type CodeLocation = z.infer<typeof codeLocationSchema>;
modified src/components/Comment.svelte
@@ -1,6 +1,5 @@
<script lang="ts" strictEvents>
  import type { Comment, Embed } from "@httpd-client";
-
  import type { GroupedReactions } from "@app/lib/reactions";

  import { tick } from "svelte";

@@ -20,7 +19,7 @@
  export let authorAlias: string | undefined = undefined;
  export let body: string;
  export let enableAttachments: boolean = false;
-
  export let reactions: GroupedReactions | undefined = undefined;
+
  export let reactions: Comment["reactions"] | undefined = undefined;
  export let embeds: Map<string, Embed> | undefined = undefined;
  export let caption = "commented";
  export let rawPath: string;
@@ -34,7 +33,7 @@
  export let editComment:
    | ((body: string, embeds: Embed[]) => Promise<void>)
    | undefined = undefined;
-
  export let handleReaction:
+
  export let reactOnComment:
    | ((nids: string[], reaction: string) => Promise<void>)
    | undefined = undefined;
</script>
@@ -175,22 +174,22 @@
      <Markdown {rawPath} content={body} />
    {/if}
  </div>
-
  {#if (id && handleReaction) || (id && reactions && reactions.size > 0)}
+
  {#if (id && reactOnComment) || (id && reactions && reactions.length > 0)}
    <div class="actions">
-
      {#if id && handleReaction}
-
        {@const handleReaction_ = handleReaction}
+
      {#if id && reactOnComment}
+
        {@const reactOnComment_ = reactOnComment}
        <ReactionSelector
          {reactions}
-
          on:select={async ({ detail: { nids, reaction } }) => {
+
          on:select={async ({ detail: { authors, emoji } }) => {
            try {
-
              await handleReaction_(nids, reaction);
+
              await reactOnComment_(authors, emoji);
            } finally {
              closeFocused();
            }
          }} />
      {/if}
-
      {#if id && reactions && reactions.size > 0}
-
        <Reactions {handleReaction} {reactions} />
+
      {#if id && reactions && reactions.length > 0}
+
        <Reactions handleReaction={reactOnComment} {reactions} />
      {/if}
    </div>
  {/if}
modified src/components/ReactionSelector.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { GroupedReactions } from "@app/lib/reactions";
+
  import type { Comment } from "@httpd-client";

  import { createEventDispatcher } from "svelte";

@@ -9,10 +9,10 @@
  import IconSmall from "./IconSmall.svelte";
  import Popover from "./Popover.svelte";

-
  export let reactions: GroupedReactions | undefined;
+
  export let reactions: Comment["reactions"] | undefined = undefined;

  const dispatch = createEventDispatcher<{
-
    select: { nids: string[]; reaction: string };
+
    select: { emoji: string; authors: string[] };
  }>();
</script>

@@ -56,13 +56,17 @@

    <div class="selector" slot="popover">
      {#each config.reactions as reaction}
+
        {@const lookedUpReaction = reactions?.find(
+
          ({ emoji }) => emoji === reaction,
+
        )}
        <button
-
          class:active={Boolean(reactions?.get(reaction)?.self)}
-
          on:click={() =>
-
            dispatch("select", {
-
              nids: reactions?.get(reaction)?.all ?? [],
-
              reaction,
-
            })}>
+
          class:active={Boolean(lookedUpReaction)}
+
          on:click={() => {
+
            dispatch(
+
              "select",
+
              lookedUpReaction || { emoji: reaction, authors: [] },
+
            );
+
          }}>
          {reaction}
        </button>
      {/each}
modified src/components/Reactions.svelte
@@ -1,11 +1,11 @@
<script lang="ts">
-
  import type { GroupedReactions } from "@app/lib/reactions";
+
  import type { Comment } from "@httpd-client";

  import IconButton from "./IconButton.svelte";

-
  export let reactions: GroupedReactions;
+
  export let reactions: Comment["reactions"];
  export let handleReaction:
-
    | ((nids: string[], reaction: string) => Promise<void>)
+
    | ((authors: string[], reaction: string) => Promise<void>)
    | undefined;
</script>

@@ -24,16 +24,16 @@
</style>

<div class="reactions">
-
  {#each reactions as [reaction, { all: nids }]}
+
  {#each reactions as { emoji, authors }}
    <IconButton
      on:click={async () => {
        if (handleReaction) {
-
          await handleReaction(nids, reaction);
+
          await handleReaction(authors, emoji);
        }
      }}>
      <div class="reaction txt-tiny">
-
        <span>{reaction}</span>
-
        <span title={nids.join("\n")}>{nids.length}</span>
+
        <span>{emoji}</span>
+
        <span title={authors.join("\n")}>{authors.length}</span>
      </div>
    </IconButton>
  {/each}
modified src/components/Thread.svelte
@@ -1,26 +1,6 @@
-
<script lang="ts" context="module">
-
  import type { Comment } from "@httpd-client";
-
  import { groupReactions } from "@app/lib/reactions";
-

-
  function groupReactionsInThread(thread: {
-
    root: Comment;
-
    replies: Comment[];
-
  }) {
-
    return {
-
      root: {
-
        ...thread.root,
-
        reactions: groupReactions(thread.root.reactions),
-
      },
-
      replies: thread.replies.map(reply => ({
-
        ...reply,
-
        reactions: groupReactions(reply.reactions),
-
      })),
-
    };
-
  }
-
</script>
-

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

  import * as utils from "@app/lib/utils";
  import partial from "lodash/partial";
@@ -44,7 +24,7 @@
  export let createReply:
    | ((commentId: string, comment: string, embeds: Embed[]) => Promise<void>)
    | undefined;
-
  export let handleReaction:
+
  export let reactOnComment:
    | ((commentId: string, nids: string[], reaction: string) => Promise<void>)
    | undefined;

@@ -57,9 +37,8 @@
    });
  }

-
  $: threadWithReactions = groupReactionsInThread(thread);
-
  $: root = threadWithReactions.root;
-
  $: replies = threadWithReactions.replies;
+
  $: root = thread.root;
+
  $: replies = thread.replies;
</script>

<style>
@@ -102,7 +81,7 @@
      editComment={editComment &&
        canEditComment(root.author.id) &&
        partial(editComment, root.id)}
-
      handleReaction={handleReaction && partial(handleReaction, root.id)}>
+
      reactOnComment={reactOnComment && partial(reactOnComment, root.id)}>
      <IconSmall name="chat" slot="icon" />
    </CommentComponent>
  </div>
@@ -126,8 +105,8 @@
          editComment={editComment &&
            canEditComment(reply.author.id) &&
            partial(editComment, reply.id)}
-
          handleReaction={handleReaction &&
-
            partial(handleReaction, reply.id)} />
+
          reactOnComment={reactOnComment &&
+
            partial(reactOnComment, reply.id)} />
      {/each}
    </div>
  {/if}
deleted src/lib/reactions.ts
@@ -1,17 +0,0 @@
-
export type GroupedReactions = Map<string, { all: string[]; self: boolean }>;
-

-
// Takes reactions from a comment and groups them by emoji
-
// and if the current user has reacted with that emoji.
-
export function groupReactions(
-
  reactions: [string, string][],
-
  publicKey?: string,
-
) {
-
  return reactions.reduce(
-
    (acc, [nid, emoji]) =>
-
      acc.set(emoji, {
-
        all: [...(acc.get(emoji)?.all ?? []), nid],
-
        self: publicKey === nid,
-
      }),
-
    new Map<string, { all: string[]; self: boolean }>(),
-
  );
-
}
modified src/views/projects/Cob/Revision.svelte
@@ -1,6 +1,7 @@
<script lang="ts">
  import type {
    BaseUrl,
+
    Comment,
    DiffResponse,
    Embed,
    PatchState,
@@ -11,6 +12,7 @@

  import * as utils from "@app/lib/utils";
  import { HttpdClient } from "@httpd-client";
+
  import { closeFocused } from "@app/components/Popover.svelte";
  import { onMount } from "svelte";
  import { parseEmbedIntoMap } from "@app/lib/file";

@@ -29,10 +31,12 @@
  import Markdown from "@app/components/Markdown.svelte";
  import NodeId from "@app/components/NodeId.svelte";
  import Popover from "@app/components/Popover.svelte";
+
  import ReactionSelector from "@app/components/ReactionSelector.svelte";
+
  import Reactions from "@app/components/Reactions.svelte";
  import Thread from "@app/components/Thread.svelte";

  export let baseUrl: BaseUrl;
-
  export let initialExpanded: boolean = false;
+
  export let initiallyExpanded: boolean = false;
  export let rawPath: (commit?: string) => string;
  export let patchId: string;
  export let patchState: PatchState;
@@ -44,6 +48,7 @@
  export let revisionEdits: Revision["edits"];
  export let revisionOid: string;
  export let revisionTimestamp: number;
+
  export let revisionReactions: Comment["reactions"];
  export let revisionAuthor: { id: string; alias?: string | undefined };
  export let revisionDescription: string;
  export let timelines: Timeline[];
@@ -57,14 +62,21 @@
  export let editComment:
    | ((commentId: string, body: string, embeds: Embed[]) => Promise<void>)
    | undefined;
-
  export let handleReaction:
-
    | ((commentId: string, nids: string[], reaction: string) => Promise<void>)
+
  export let reactOnRevision:
+
    | ((authors: string[], reaction: string) => Promise<void>)
+
    | undefined;
+
  export let reactOnComment:
+
    | ((
+
        commentId: string,
+
        authors: string[],
+
        reaction: string,
+
      ) => Promise<void>)
    | undefined;
  export let createReply:
    | ((commentId: string, comment: string, embeds: Embed[]) => Promise<void>)
    | undefined;

-
  let expanded = initialExpanded;
+
  let expanded = initiallyExpanded;
  const api = new HttpdClient(baseUrl);
  const latestEdit = revisionEdits.pop();

@@ -184,7 +196,6 @@
    color: var(--color-foreground-dim);
  }
  .revision-description {
-
    margin-bottom: 1rem;
    margin-left: 2rem;
  }
  .compare-dropdown-item {
@@ -209,6 +220,14 @@
    font-size: var(--font-size-small);
    color: var(--color-fill-gray);
  }
+
  .actions {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
    padding-left: 2rem;
+
    gap: 0.5rem;
+
    padding-bottom: 1rem;
+
  }
  .commits {
    position: relative;
    display: flex;
@@ -431,6 +450,27 @@
                content={revisionDescription} />
            </div>
          {/if}
+
          {#if reactOnRevision || revisionReactions.length > 0}
+
            <div class="actions">
+
              {#if reactOnRevision}
+
                {@const reactOnRevision_ = reactOnRevision}
+
                <ReactionSelector
+
                  reactions={revisionReactions}
+
                  on:select={async ({ detail: { emoji, authors } }) => {
+
                    try {
+
                      await reactOnRevision_(authors, emoji);
+
                    } finally {
+
                      closeFocused();
+
                    }
+
                  }} />
+
              {/if}
+
              {#if revisionReactions && revisionReactions.length > 0}
+
                <Reactions
+
                  handleReaction={reactOnRevision}
+
                  reactions={revisionReactions} />
+
              {/if}
+
            </div>
+
          {/if}
        </div>
        {#if loading}
          <div style:height="3.5rem">
@@ -471,7 +511,7 @@
            canEditComment={canEdit}
            {editComment}
            {createReply}
-
            {handleReaction} />
+
            {reactOnComment} />
        {:else if element.type === "merge"}
          <div class="connector" />
          <div class="action merge">
modified src/views/projects/Issue.svelte
@@ -19,7 +19,6 @@
  import * as utils from "@app/lib/utils";
  import { HttpdClient } from "@httpd-client";
  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";

@@ -164,7 +163,7 @@
    }
  }

-
  async function handleReaction(
+
  async function reactOnComment(
    session: Session,
    commentId: string,
    nids: string[],
@@ -569,11 +568,11 @@
          <div class="reactions">
            {#if session}
              <ReactionSelector
-
                reactions={groupReactions(issue.discussion[0].reactions)}
-
                on:select={async ({ detail: { nids, reaction } }) => {
+
                reactions={issue.discussion[0].reactions}
+
                on:select={async ({ detail: { authors, emoji } }) => {
                  try {
                    if (session) {
-
                      await handleReaction(session, issue.id, nids, reaction);
+
                      await reactOnComment(session, issue.id, authors, emoji);
                    }
                  } finally {
                    closeFocused();
@@ -582,9 +581,9 @@
            {/if}
            {#if issue.discussion[0].reactions.length > 0}
              <Reactions
-
                reactions={groupReactions(issue.discussion[0].reactions)}
+
                reactions={issue.discussion[0].reactions}
                handleReaction={session &&
-
                  partial(handleReaction, session, issue.id)} />
+
                  partial(reactOnComment, session, issue.id)} />
            {/if}
          </div>
        </div>
@@ -621,7 +620,7 @@
                )}
                editComment={session && partial(editComment, session.id)}
                createReply={session && partial(createReply, session.id)}
-
                handleReaction={session && partial(handleReaction, session)} />
+
                reactOnComment={session && partial(reactOnComment, session)} />
              <div class="connector" />
            {/each}
          </div>
modified src/views/projects/Patch.svelte
@@ -40,6 +40,7 @@
  import type { PatchView } from "./router";
  import type { Route } from "@app/lib/router";
  import type { ComponentProps } from "svelte";
+
  import type { Session } from "@app/lib/httpd";

  import * as modal from "@app/lib/modal";
  import * as role from "@app/lib/roles";
@@ -50,7 +51,7 @@
  import partial from "lodash/partial";
  import uniqBy from "lodash/uniqBy";
  import { HttpdClient } from "@httpd-client";
-
  import { httpdStore, type Session } from "@app/lib/httpd";
+
  import { httpdStore } from "@app/lib/httpd";
  import { parseEmbedIntoMap } from "@app/lib/file";

  import Badge from "@app/components/Badge.svelte";
@@ -75,6 +76,8 @@
  import Placeholder from "@app/components/Placeholder.svelte";
  import Popover, { closeFocused } from "@app/components/Popover.svelte";
  import Radio from "@app/components/Radio.svelte";
+
  import ReactionSelector from "@app/components/ReactionSelector.svelte";
+
  import Reactions from "@app/components/Reactions.svelte";
  import RevisionComponent from "@app/views/projects/Cob/Revision.svelte";
  import TextInput from "@app/components/TextInput.svelte";
  import Share from "./Share.svelte";
@@ -203,11 +206,11 @@
    }
  }

-
  async function handleReaction(
+
  async function reactOnComment(
    session: Session,
    revisionId: string,
    commentId: string,
-
    nids: string[],
+
    authors: string[],
    reaction: string,
  ) {
    try {
@@ -219,7 +222,7 @@
          revision: revisionId,
          comment: commentId,
          reaction,
-
          active: nids.includes(session.publicKey) ? false : true,
+
          active: authors.includes(session.publicKey) ? false : true,
        },
        session.id,
      );
@@ -363,6 +366,46 @@
    }
  }

+
  async function reactOnRevision(
+
    session: Session,
+
    revisionId: string,
+
    authors: string[],
+
    reaction: string,
+
  ) {
+
    try {
+
      await api.project.updatePatch(
+
        project.id,
+
        patch.id,
+
        {
+
          type: "revision.react",
+
          revision: revisionId,
+
          reaction,
+
          active: authors.includes(session.publicKey) ? false : true,
+
        },
+
        session.id,
+
      );
+
    } catch (error) {
+
      if (error instanceof Error) {
+
        modal.show({
+
          component: ErrorModal,
+
          props: {
+
            title: "Reacting on revision failed",
+
            subtitle: [
+
              "There was an error while trying to react to a revision.",
+
              "Check your radicle-httpd logs for details.",
+
            ],
+
            error: {
+
              message: error.message,
+
              stack: error.stack,
+
            },
+
          },
+
        });
+
      }
+
    } finally {
+
      await refreshPatch();
+
    }
+
  }
+

  async function saveLabels(sessionId: string, labels: string[]) {
    try {
      await api.project.updatePatch(
@@ -511,6 +554,7 @@
        revisionBase: string;
        revisionOid: string;
        revisionEdits: Revision["edits"];
+
        revisionReactions: Revision["reactions"];
        revisionAuthor: { id: string; alias?: string | undefined };
        revisionDescription: string;
      },
@@ -523,6 +567,7 @@
      revisionBase: rev.base,
      revisionOid: rev.oid,
      revisionEdits: rev.edits,
+
      revisionReactions: rev.reactions,
      revisionAuthor: rev.author,
      revisionDescription: rev.description,
    },
@@ -553,6 +598,7 @@
        })),
    ].sort((a, b) => a.timestamp - b.timestamp),
  ]);
+
  $: firstRevision = timelineTuple[0][0];
  $: session =
    $httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)
      ? $httpdStore.session
@@ -597,6 +643,12 @@
    padding: 1rem;
    height: 100%;
  }
+
  .actions {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
    gap: 0.5rem;
+
  }
  .tabs {
    font-size: var(--font-size-tiny);
    display: flex;
@@ -640,6 +692,7 @@
  }
  .revision-description {
    display: flex;
+
    flex-direction: column;
    width: 100%;
  }
  .diff-button-range {
@@ -736,12 +789,38 @@
                  }
                }} />
            {:else if description}
-
              <Markdown
-
                content={description}
-
                rawPath={rawPath(patch.revisions[0].id)} />
+
              <Markdown content={description} rawPath={rawPath(patch.id)} />
            {:else}
              <span class="txt-missing">No description available</span>
            {/if}
+
            {#if session || (firstRevision.revisionReactions && firstRevision.revisionReactions.length > 0)}
+
              <div class="actions">
+
                {#if session}
+
                  <ReactionSelector
+
                    reactions={firstRevision.revisionReactions}
+
                    on:select={async ({ detail: { emoji, authors } }) => {
+
                      if (session) {
+
                        try {
+
                          await reactOnRevision(
+
                            session,
+
                            patch.id,
+
                            authors,
+
                            emoji,
+
                          );
+
                        } finally {
+
                          closeFocused();
+
                        }
+
                      }
+
                    }} />
+
                {/if}
+
                {#if firstRevision.revisionReactions.length > 0}
+
                  <Reactions
+
                    handleReaction={session &&
+
                      partial(reactOnRevision, session, patch.id)}
+
                    reactions={firstRevision.revisionReactions} />
+
                {/if}
+
              </div>
+
            {/if}
          </div>
        </svelte:fragment>
        <div class="author" slot="author">
@@ -882,13 +961,15 @@
                partial(editRevision, session.id, revision.revisionId)}
              editComment={session &&
                partial(editComment, session.id, revision.revisionId)}
-
              handleReaction={session &&
-
                partial(handleReaction, session, revision.revisionId)}
+
              reactOnComment={session &&
+
                partial(reactOnComment, session, revision.revisionId)}
+
              reactOnRevision={session &&
+
                partial(reactOnRevision, session, revision.revisionId)}
              createReply={session &&
                partial(createReply, session.id, revision.revisionId)}
              patchId={patch.id}
              patchState={patch.state}
-
              initialExpanded={index === patch.revisions.length - 1}
+
              initiallyExpanded={index === patch.revisions.length - 1}
              previousRevId={previousRevision?.id}
              previousRevOid={previousRevision?.oid}>
              {#if index === patch.revisions.length - 1}
modified tests/e2e/project/issue.spec.ts
@@ -126,7 +126,8 @@ test("add and remove reactions", async ({ page, authenticatedPeer }) => {
  await page
    .getByRole("link", { name: "This is an issue to test reactions" })
    .click();
-
  await expectReactionsToWork(page);
+
  const reactionsLocator = page.locator(".actions").first();
+
  await expectReactionsToWork(page, reactionsLocator);
});

test("handling embeds", async ({ page, authenticatedPeer }) => {
modified tests/e2e/project/threads.spec.ts
@@ -67,7 +67,8 @@ test("add and remove reactions", async ({ page, authenticatedPeer }) => {
    }),
  );
  await page.goto(`${authenticatedPeer.uiUrl()}/${rid}/patches/${patchId}`);
-
  await expectReactionsToWork(page);
+
  const reactionsLocator = page.locator(".actions").first();
+
  await expectReactionsToWork(page, reactionsLocator);
});

test("handling embeds", async ({ page, authenticatedPeer }) => {
modified tests/support/project.ts
@@ -1,4 +1,4 @@
-
import type { Page } from "@playwright/test";
+
import type { Locator, Page } from "@playwright/test";
import type { RadiclePeer } from "@tests/support/peerManager";
import type { ExecaReturnValue } from "execa";

@@ -120,28 +120,43 @@ export async function expectLabelEditingToWork(page: Page) {
  ).toBeHidden();
}

-
export async function expectReactionsToWork(page: Page) {
+
export async function expectReactionsToWork(
+
  page: Page,
+
  reactionsLocator: Locator,
+
) {
  await page.getByRole("button", { name: "Leave your comment" }).click();
  await page.getByPlaceholder("Leave your comment").fill("This is a comment");
  await page.getByRole("button", { name: "Comment" }).click();
-
  const commentReactionToggle = page.getByTitle("toggle-reaction").first();
-
  await commentReactionToggle.click();
-
  await page.getByRole("button", { name: "👍" }).click();
-
  await expect(page.getByRole("button", { name: "👍 1" })).toBeVisible();
-

-
  await commentReactionToggle.click();
-
  await page.getByRole("button", { name: "🎉" }).click();
-
  await expect(page.getByRole("button", { name: "🎉 1" })).toBeVisible();
-
  await expect(page.locator(".reaction")).toHaveCount(2);
-

-
  await page.getByRole("button", { name: "👍" }).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: "🎉" }).first().click();
-
  await expect(page.getByRole("button", { name: "🎉 1" })).toBeHidden();
-
  await expect(page.locator(".reaction")).toHaveCount(0);
+
  const reactionsToggleBtn = reactionsLocator.getByRole("button", {
+
    name: "toggle-reaction-popover",
+
  });
+
  await reactionsToggleBtn.click();
+
  await reactionsLocator.getByRole("button", { name: "👍" }).click();
+
  await expect(
+
    reactionsLocator.getByRole("button", { name: "👍 1" }),
+
  ).toBeVisible();
+

+
  await reactionsToggleBtn.click();
+
  await reactionsLocator.getByRole("button", { name: "🎉" }).click();
+
  await expect(
+
    reactionsLocator.getByRole("button", { name: "🎉 1" }),
+
  ).toBeVisible();
+
  await expect(reactionsLocator.locator(".reaction")).toHaveCount(2);
+

+
  await reactionsLocator.getByRole("button", { name: "👍" }).click();
+
  await expect(
+
    reactionsLocator.locator("span").filter({ hasText: "👍 1" }),
+
  ).toBeHidden();
+
  await expect(reactionsLocator.locator(".reaction")).toHaveCount(1);
+

+
  await reactionsToggleBtn.click();
+
  await reactionsLocator
+
    .getByRole("button", { name: "🎉", exact: true })
+
    .click();
+
  await expect(
+
    reactionsLocator.getByRole("button", { name: "🎉 1" }),
+
  ).toBeHidden();
+
  await expect(reactionsLocator.locator(".reaction")).toHaveCount(0);
}

export async function addEmbed(