Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Add comment threads
Merged rudolfs opened 1 year ago
28 files changed +1471 -169 56f55051 0693f4f8
modified public/index.css
@@ -29,6 +29,13 @@ body {
  font-weight: var(--font-weight-regular);
}

+
.global-commit {
+
  color: var(--color-foreground-dim);
+
  font-size: var(--font-size-small);
+
  font-family: var(--font-family-monospace);
+
  font-weight: var(--font-weight-semibold);
+
}
+

.global-flex {
  display: flex;
  align-items: center;
@@ -104,6 +111,21 @@ body {
    0 calc(100% - 4px)
  );

+
  --2px-top-corner-fill: polygon(
+
    0 4px,
+
    2px 4px,
+
    2px 2px,
+
    4px 2px,
+
    4px 0,
+
    calc(100% - 4px) 0,
+
    calc(100% - 4px) 2px,
+
    calc(100% - 2px) 2px,
+
    calc(100% - 2px) 4px,
+
    100% 4px,
+
    100% 100%,
+
    0 100%
+
  );
+

  --3px-corner-fill: polygon(
    0 6px,
    2px 6px,
added src/components/AnnounceSwitch.svelte
@@ -0,0 +1,80 @@
+
<script lang="ts" module>
+
  export const announce = writable<boolean>(loadAnnounce());
+

+
  function loadAnnounce(): boolean {
+
    const storedAnnounce = localStorage
+
      ? localStorage.getItem("announce")
+
      : null;
+

+
    if (storedAnnounce === null) {
+
      return true;
+
    } else {
+
      return storedAnnounce === "true";
+
    }
+
  }
+

+
  export function storeAnnounce(newAnnounce: boolean): void {
+
    announce.set(newAnnounce);
+
    if (localStorage) {
+
      localStorage.setItem("announce", newAnnounce.toString());
+
    } else {
+
      console.warn(
+
        "localStorage isn't available, not able to persist the selected announce preference without it.",
+
      );
+
    }
+
  }
+
</script>
+

+
<script lang="ts">
+
  import { writable } from "svelte/store";
+
</script>
+

+
<style>
+
  .container {
+
    background-color: var(--color-fill-ghost);
+
    clip-path: var(--2px-corner-fill);
+
    display: flex;
+
    height: 32px;
+
    align-items: center;
+
    padding: 0 2px;
+
  }
+
  button {
+
    height: 28px;
+
    cursor: pointer;
+
    display: flex;
+
    align-items: center;
+
    border: none;
+
    white-space: nowrap;
+
    touch-action: manipulation;
+
    clip-path: var(--1px-corner-fill);
+
    font-size: var(--font-size-small);
+
    margin: 0;
+
    padding: 0 1rem;
+
    color: var(--color-foreground-contrast);
+
    background-color: var(--color-fill-ghost);
+
    font-weight: var(--font-weight-semibold);
+
  }
+

+
  .active {
+
    color: var(--color-foreground-emphasized);
+
    background-color: var(--color-background-dip);
+
  }
+
</style>
+

+
<div class="container">
+
  <button
+
    class:active={$announce}
+
    onclick={() => {
+
      storeAnnounce(true);
+
    }}>
+
    Right away
+
  </button>
+

+
  <button
+
    class:active={!$announce}
+
    onclick={() => {
+
      storeAnnounce(false);
+
    }}>
+
    Periodically
+
  </button>
+
</div>
modified src/components/Border.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  export let variant: "primary" | "secondary" | "ghost";
+
  export let variant: "primary" | "secondary" | "ghost" | "float";
  export let hoverable: boolean = false;
  export let onclick: (() => void) | undefined = undefined;

@@ -7,7 +7,7 @@
  export let styleHeight: string | undefined = undefined;
  export let styleMinHeight: string | undefined = undefined;
  export let styleWidth: string | undefined = undefined;
-
  export let styleCursor: "default" | "pointer" = "default";
+
  export let styleCursor: "default" | "pointer" | "text" = "default";
  export let styleGap: string = "0.5rem";
  export let styleOverflow: string | undefined = undefined;

@@ -129,7 +129,6 @@
      "p3-1 p3-2 p3-3 p3-4 p3-5"
      "p4-1 p4-2 p4-3 p4-4 p4-5"
      "p5-1 p5-2 p5-3 p5-4 p5-5";
-
    overflow: hidden;
  }
  .container .p2-3,
  .container .p3-2,
added src/components/Comment.svelte
@@ -0,0 +1,212 @@
+
<script lang="ts">
+
  import type { Author } from "@bindings/cob/Author";
+
  import type { Edit } from "@bindings/cob/patch/Edit";
+
  import type { Reaction } from "@bindings/cob/Reaction";
+
  import type { Embed } from "@bindings/cob/thread/Embed";
+

+
  import { tick } from "svelte";
+

+
  import { closeFocused } from "./Popover.svelte";
+
  import * as utils from "@app/lib/utils";
+

+
  import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Id from "@app/components/Id.svelte";
+
  import Markdown from "@app/components/Markdown.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
+
  import ReactionSelector from "@app/components/ReactionSelector.svelte";
+
  import Reactions from "@app/components/Reactions.svelte";
+

+
  export let id: string | undefined = undefined;
+
  export let rid: string;
+
  export let author: Author;
+
  export let body: string;
+
  export let reactions: Reaction[] | undefined = undefined;
+
  export let embeds: Map<string, Embed> | undefined = undefined;
+
  export let caption = "commented";
+
  export let timestamp: number;
+
  export let lastEdit: Edit | undefined = undefined;
+
  export let disallowEmptyBody: boolean = false;
+

+
  export let editComment:
+
    | ((body: string, embeds: Embed[]) => Promise<void>)
+
    | undefined = undefined;
+
  export let reactOnComment:
+
    | ((authors: Author[], reaction: string) => Promise<void>)
+
    | undefined = undefined;
+

+
  let state: "read" | "edit" | "submit" = "read";
+

+
  async function toggleEdit() {
+
    if (state === "read") {
+
      state = "edit";
+
      await tick();
+
      utils.scrollIntoView(`edit-${id}`, {
+
        behavior: "smooth",
+
        block: "center",
+
      });
+
    } else if (state === "edit") {
+
      state = "read";
+
    }
+
  }
+
</script>
+

+
<style>
+
  .card {
+
    display: flex;
+
    flex-direction: column;
+
    padding: 0.5rem 0;
+
    gap: 0.5rem;
+
  }
+
  .card-header {
+
    display: flex;
+
    align-items: center;
+
    white-space: nowrap;
+
    flex-wrap: wrap;
+
    padding: 0 0.75rem;
+
    min-height: 1.5rem;
+
    gap: 0.5rem;
+
    font-size: var(--font-size-small);
+
  }
+
  .card-metadata {
+
    color: var(--color-fill-gray);
+
    font-size: var(--font-size-small);
+
  }
+
  .header-right {
+
    display: flex;
+
    margin-left: auto;
+
    gap: 0.5rem;
+
  }
+
  .card-body {
+
    display: flex;
+
    align-items: center;
+
    min-height: 1.625rem;
+
    word-wrap: break-word;
+
    font-size: var(--font-size-small);
+
    padding: 0 1rem;
+
  }
+
  .actions {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
    gap: 0.5rem;
+
    margin-left: 1rem;
+
  }
+
  .timestamp {
+
    font-size: var(--font-size-small);
+
    color: var(--color-fill-gray);
+
  }
+
  .edit-buttons {
+
    display: flex;
+
    gap: 0.25rem;
+
  }
+
</style>
+

+
<div class="card" {id}>
+
  <div style:position="relative">
+
    <div class="card-header">
+
      <slot class="icon" name="icon" />
+
      <NodeId {...utils.authorForNodeId(author)} />
+
      <slot name="caption">{caption}</slot>
+
      {#if id}
+
        <Id {id} variant="oid" />
+
      {/if}
+
      <span class="timestamp" title={utils.absoluteTimestamp(timestamp)}>
+
        {utils.formatTimestamp(timestamp)}
+
      </span>
+
      {#if lastEdit}
+
        <div
+
          class="card-metadata"
+
          title={utils.formatEditedCaption(
+
            lastEdit.author,
+
            lastEdit.timestamp,
+
          )}>
+
          • edited
+
        </div>
+
      {/if}
+
      <div class="header-right">
+
        {#if id && editComment}
+
          <div class="edit-buttons">
+
            <Icon styleCursor="pointer" name="pen" onclick={toggleEdit} />
+
          </div>
+
        {/if}
+
        {#if id && reactions && reactOnComment}
+
          <ReactionSelector
+
            popoverPositionRight="0"
+
            popoverPositionBottom="1.5rem"
+
            {reactions}
+
            on:select={async ({ detail: { authors, emoji } }) => {
+
              try {
+
                await reactOnComment(authors, emoji);
+
              } finally {
+
                closeFocused();
+
              }
+
            }} />
+
        {/if}
+
        <slot name="actions" />
+
      </div>
+
    </div>
+
  </div>
+

+
  {#if body.trim() === "" && state === "read"}
+
    <div class="card-body">
+
      <span class="txt-missing txt-small" style:line-height="1.625rem">
+
        No description.
+
      </span>
+
    </div>
+
  {:else}
+
    <div class="card-body">
+
      {#if editComment && state !== "read"}
+
        <div id={`edit-${id}`} style:width="100%">
+
          <ExtendedTextarea
+
            focus
+
            {body}
+
            {rid}
+
            {embeds}
+
            {disallowEmptyBody}
+
            borderVariant="ghost"
+
            submitInProgress={state === "submit"}
+
            submitCaption="Save"
+
            placeholder="Leave a comment"
+
            on:submit={async ({ detail: { comment, embeds } }) => {
+
              state = "submit";
+
              try {
+
                await editComment(comment, Array.from(embeds.values()));
+
              } finally {
+
                state = "read";
+
              }
+
            }}
+
            on:close={async () => {
+
              body = body;
+
              await tick();
+
              state = "read";
+
            }} />
+
        </div>
+
      {:else}
+
        <div style:width="100%">
+
          <div style:overflow="hidden">
+
            <Markdown {rid} breaks content={body} />
+
          </div>
+
        </div>
+
      {/if}
+
    </div>
+
  {/if}
+
  {#if reactions && reactions.length > 0}
+
    <div class="actions">
+
      {#if id && reactions && reactOnComment}
+
        <ReactionSelector
+
          popoverPositionLeft="0"
+
          popoverPositionBottom="1.5rem"
+
          {reactions}
+
          on:select={async ({ detail: { authors, emoji } }) => {
+
            try {
+
              await reactOnComment(authors, emoji);
+
            } finally {
+
              closeFocused();
+
            }
+
          }} />
+
      {/if}
+
      <Reactions handleReaction={reactOnComment} {reactions} />
+
    </div>
+
  {/if}
+
</div>
added src/components/CommentToggleInput.svelte
@@ -0,0 +1,74 @@
+
<script lang="ts">
+
  import type { Embed } from "@bindings/cob/thread/Embed";
+

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

+
  export let rid: string;
+
  export let body: string | undefined = undefined;
+
  export let placeholder: string | undefined = undefined;
+
  export let submitCaption: string | undefined = undefined;
+
  export let inline: boolean = false;
+
  export let focus: boolean = false;
+
  export let submit: (comment: string, embeds: Embed[]) => Promise<void>;
+
  export let onclose: (() => void) | undefined = undefined;
+
  export let onexpand: (() => void) | undefined = undefined;
+
  export let disallowEmptyBody: boolean = false;
+

+
  let state: "collapsed" | "expanded" | "submit";
+

+
  $: state = onclose !== undefined ? "expanded" : "collapsed";
+
</script>
+

+
<style>
+
  .inactive {
+
    padding: 0 0.75rem;
+
    font-size: var(--font-size-small);
+
    color: var(--color-fill-gray);
+
  }
+
</style>
+

+
{#if state !== "collapsed"}
+
  <ExtendedTextarea
+
    {disallowEmptyBody}
+
    {rid}
+
    {inline}
+
    {placeholder}
+
    {submitCaption}
+
    submitInProgress={state === "submit"}
+
    {focus}
+
    {body}
+
    stylePadding="0.5rem 0.75rem"
+
    on:close={() => {
+
      if (onclose !== undefined) {
+
        onclose();
+
      } else {
+
        state = "collapsed";
+
      }
+
    }}
+
    on:submit={async ({ detail: { comment, embeds } }) => {
+
      try {
+
        state = "submit";
+
        await submit(comment, Array.from(embeds.values()));
+
      } finally {
+
        state = "collapsed";
+
      }
+
    }} />
+
{:else}
+
  <Border
+
    hoverable
+
    styleCursor="text"
+
    variant="float"
+
    styleHeight="40px"
+
    styleWidth="100%"
+
    onclick={() => {
+
      state = "expanded";
+
      if (onexpand !== undefined) {
+
        onexpand();
+
      }
+
    }}>
+
    <div style:width="100%" class="inactive">
+
      {placeholder}
+
    </div>
+
  </Border>
+
{/if}
added src/components/ExtendedTextarea.svelte
@@ -0,0 +1,150 @@
+
<script lang="ts">
+
  import type { ComponentProps } from "svelte";
+
  import type { Embed } from "@bindings/cob/thread/Embed";
+

+
  import { createEventDispatcher } from "svelte";
+

+
  import * as utils from "@app/lib/utils";
+

+
  import Button from "./Button.svelte";
+
  import Icon from "./Icon.svelte";
+
  import Markdown from "./Markdown.svelte";
+
  import Textarea from "./Textarea.svelte";
+
  import OutlineButton from "./OutlineButton.svelte";
+

+
  export let rid: string;
+
  export let placeholder: string = "Leave your comment";
+
  export let submitCaption: string = "Comment";
+
  export let focus: boolean = false;
+
  export let inline: boolean = false;
+
  export let body: string = "";
+
  export let embeds: Map<string, Embed> = new Map();
+
  export let submitInProgress: boolean = false;
+
  export let disallowEmptyBody: boolean = false;
+
  export let isValid: () => boolean = () => {
+
    return true;
+
  };
+
  export let stylePadding: string | undefined = undefined;
+
  export let borderVariant: ComponentProps<Textarea>["borderVariant"] = "float";
+

+
  let preview: boolean = false;
+
  let selectionStart = 0;
+
  let selectionEnd = 0;
+
  let inputFiles: FileList | undefined = undefined;
+

+
  const inputId = `input-label-${crypto.randomUUID()}`;
+

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

+
  function submit() {
+
    dispatch("submit", { comment: body, embeds });
+
    preview = false;
+
  }
+
</script>
+

+
<style>
+
  .comment-section {
+
    display: flex;
+
    flex-direction: column;
+
    align-items: flex-start;
+
    gap: 1rem;
+
    width: 100%;
+
  }
+
  .inline {
+
    border: 0;
+
    padding: 0;
+
  }
+
  .actions {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
    width: 100%;
+
    gap: 1rem;
+
  }
+
  .buttons {
+
    display: flex;
+
    margin-left: auto;
+
    gap: 1rem;
+
  }
+
  .caption {
+
    font-size: var(--font-size-small);
+
    color: var(--color-fill-gray);
+
    display: flex;
+
    align-items: center;
+
    gap: 0.25rem;
+
  }
+
  .preview {
+
    font-size: var(--font-size-small);
+
    min-height: 6.8rem;
+
    padding: 0.75rem;
+
    margin-left: 1px;
+
    margin-top: 1px;
+
  }
+
</style>
+

+
<div class="comment-section" aria-label="extended-textarea" class:inline>
+
  {#if preview}
+
    <div class="preview">
+
      <Markdown {rid} breaks content={body} />
+
    </div>
+
  {:else}
+
    <input
+
      multiple
+
      bind:files={inputFiles}
+
      style:display="none"
+
      type="file"
+
      id={inputId} />
+
    <Textarea
+
      {borderVariant}
+
      {stylePadding}
+
      bind:selectionEnd
+
      bind:selectionStart
+
      {focus}
+
      on:submit={submit}
+
      bind:value={body}
+
      {placeholder} />
+
  {/if}
+
  <div class="actions">
+
    <OutlineButton
+
      disabled={submitInProgress}
+
      variant="ghost"
+
      onclick={() => {
+
        preview = false;
+
        dispatch("close");
+
      }}>
+
      Discard
+
    </OutlineButton>
+
    {#if !preview}
+
      <div class="caption">
+
        <Icon name="markdown" />
+
        Markdown is supported. Press {utils.modifierKey()}↵ to submit.
+
      </div>
+
    {/if}
+
    <div class="buttons">
+
      <OutlineButton
+
        variant="ghost"
+
        disabled={body.trim() === ""}
+
        onclick={() => (preview = !preview)}>
+
        <Icon name={preview ? "pen" : "eye"} />{preview ? "Edit" : "Preview"}
+
      </OutlineButton>
+
      <Button
+
        variant="ghost"
+
        disabled={!isValid() ||
+
          submitInProgress ||
+
          (disallowEmptyBody && body.trim() === "")}
+
        onclick={submit}>
+
        {#if submitInProgress}
+
          Loading...
+
        {:else}
+
          {submitCaption}
+
        {/if}
+
      </Button>
+
    </div>
+
  </div>
+
</div>
modified src/components/Header.svelte
@@ -1,6 +1,7 @@
<script lang="ts">
  import { nodeRunning } from "@app/lib/events";

+
  import AnnounceSwitch from "./AnnounceSwitch.svelte";
  import Border from "./Border.svelte";
  import Icon from "./Icon.svelte";
  import NakedButton from "./NakedButton.svelte";
@@ -87,11 +88,31 @@
            slot="toggle"
            let:toggle
            onclick={toggle}>
-
            <Icon name="more-vertical" />
+
            <Icon name="settings" />
          </NakedButton>
-
          <Border variant="ghost" slot="popover" stylePadding="0.5rem 1rem">
-
            <div style="display: flex; gap: 2rem; align-items: center;">
-
              Theme <ThemeSwitch />
+
          <Border
+
            variant="ghost"
+
            slot="popover"
+
            stylePadding="0.5rem 1rem"
+
            styleWidth="27rem">
+
            <div
+
              class="global-flex"
+
              style:flex-direction="column"
+
              style:align-items="flex-start"
+
              style:gap="1rem"
+
              style:width="100%">
+
              <div
+
                class="global-flex"
+
                style:justify-content="space-between"
+
                style:width="100%">
+
                Theme <ThemeSwitch />
+
              </div>
+
              <div
+
                class="global-flex"
+
                style:justify-content="space-between"
+
                style:width="100%">
+
                Announce changes <AnnounceSwitch />
+
              </div>
            </div>
          </Border>
        </Popover>
modified src/components/Icon.svelte
@@ -3,6 +3,7 @@

  export let size: "16" | "32" = "16";
  export let onclick: (() => void) | undefined = undefined;
+
  export let styleCursor: "default" | "pointer" = "default";

  export let name:
    | "arrow-left"
@@ -16,6 +17,7 @@
    | "delegate"
    | "diff"
    | "eye"
+
    | "face"
    | "file"
    | "inbox"
    | "issue"
@@ -28,6 +30,7 @@
    | "patch"
    | "pen"
    | "plus"
+
    | "reply"
    | "repo"
    | "revision"
    | "seedling"
@@ -51,6 +54,7 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<svg
+
  style:cursor={styleCursor}
  role="img"
  {onclick}
  width={size}
@@ -202,6 +206,29 @@
    <path d="M2.00002 9L2.00002 8H3.00002V9L2.00002 9Z" />
    <path d="M3.00002 7L3.00002 8H2.00002L2.00002 7H3.00002Z" />
    <path d="M13 9V8H14V9L13 9Z" />
+
  {:else if name === "face"}
+
    <path d="M6 13H8V14H6V13Z" />
+
    <path d="M10 13L8 13V14L10 14V13Z" />
+
    <path d="M3 6L3 8H2L2 6H3Z" />
+
    <path d="M13 6V8H14V6H13Z" />
+
    <path d="M4 12H6V13L4 13V12Z" />
+
    <path d="M12 12H10L10 13H12V12Z" />
+
    <path d="M4 4V6H3L3 4H4Z" />
+
    <path d="M12 4V6L13 6V4H12Z" />
+
    <path d="M4 10L4 12H3L3 10H4Z" />
+
    <path d="M12 10V12L13 12V10H12Z" />
+
    <path d="M6 4L4 4L4 3L6 3V4Z" />
+
    <path d="M10 4L12 4V3L10 3V4Z" />
+
    <path d="M3 8L3 10H2L2 8H3Z" />
+
    <path d="M13 8V10L14 10V8H13Z" />
+
    <path d="M8 3L6 3V2L8 2V3Z" />
+
    <path d="M8 3L10 3L10 2L8 2V3Z" />
+
    <path d="M9 6H11V7H9V6Z" />
+
    <path d="M5 9H6V10H5V9Z" />
+
    <path d="M10 9H11V10H10V9Z" />
+
    <path d="M6 10H7V11H6L6 10Z" />
+
    <path d="M7 10L10 10L10 11H7V10Z" />
+
    <path d="M5 6H7V7H5V6Z" />
  {:else if name === "file"}
    <path d="M10 4H11V5H10V4Z" />
    <path d="M11 5L12 5V6H11V5Z" />
@@ -412,6 +439,14 @@
  {:else if name === "plus"}
    <path d="M7.00002 2H9.00002V14H7.00002V2Z" />
    <path d="M14 7V9L2.00002 9L2.00002 7L14 7Z" />
+
  {:else if name === "reply"}
+
    <path d="M2.5 9V8H3.5V9H2.5Z" />
+
    <path d="M3.5 10L3.5 9L4.5 9V10L3.5 10Z" />
+
    <path d="M3.5 7V8L4.5 8V7L3.5 7Z" />
+
    <path d="M3.5 8L13.5 8V9L3.5 9V8Z" />
+
    <path d="M4.5 6H5.5V11H4.5V6Z" />
+
    <path d="M13.5 4H12.5V8L13.5 8L13.5 4Z" />
+
    <path d="M5.5 5H6.5V12H5.5V5Z" />
  {:else if name === "repo"}
    <path d="M13 5H14V7H13V5Z" />
    <path d="M13 10H14V11H13V10Z" />
added src/components/Id.svelte
@@ -0,0 +1,110 @@
+
<script lang="ts">
+
  import type { ComponentProps } from "svelte";
+

+
  import { debounce } from "lodash";
+
  import { writeText } from "@tauri-apps/plugin-clipboard-manager";
+

+
  import { formatOid } from "@app/lib/utils";
+

+
  import Icon from "./Icon.svelte";
+

+
  export let id: string;
+
  export let clipboard: string = id;
+
  export let shorten: boolean = true;
+
  export let variant: "oid" | "commit" | "none";
+
  export let ariaLabel: string | undefined = undefined;
+

+
  let icon: ComponentProps<Icon>["name"] = "copy";
+
  const text = "Click to copy";
+
  let tooltip = text;
+

+
  const restoreIcon = debounce(() => {
+
    icon = "copy";
+
    tooltip = text;
+
  }, 1000);
+

+
  async function copy() {
+
    await writeText(clipboard);
+
    icon = "checkmark";
+
    tooltip = "Copied to clipboard";
+
    restoreIcon();
+
  }
+

+
  let visible: boolean = false;
+
  export let debounceTimeout = 50;
+

+
  const setVisible = debounce((value: boolean) => {
+
    visible = value;
+
  }, debounceTimeout);
+
</script>
+

+
<style>
+
  .container {
+
    position: relative;
+
    display: inline-block;
+
  }
+
  .popover {
+
    position: absolute;
+
    left: 1rem;
+
    display: flex;
+
    align-items: center;
+
    flex-direction: row;
+
    gap: 0.5rem;
+
    justify-content: center;
+
    z-index: 20;
+
    bottom: 1.5rem;
+
    background: var(--color-fill-ghost);
+
    color: var(--color-fill-gray);
+
    box-shadow: var(--elevation-low);
+
    font-family: var(--font-family-sans-serif);
+
    font-size: var(--font-size-small);
+
    font-weight: var(--font-weight-regular);
+
    white-space: nowrap;
+
    padding: 0.125rem 0.5rem;
+
    clip-path: var(--1px-corner-fill);
+
  }
+
  .target-commit:hover {
+
    color: var(--color-foreground-contrast);
+
  }
+
  .target-oid:hover {
+
    color: var(--color-foreground-emphasized-hover);
+
  }
+
</style>
+

+
<div class="container">
+
  <!-- svelte-ignore a11y-click-events-have-key-events -->
+
  <div
+
    onmouseenter={() => {
+
      setVisible(true);
+
    }}
+
    onmouseleave={() => {
+
      setVisible(false);
+
    }}
+
    class="target-{variant} global-{variant}"
+
    style:cursor="pointer"
+
    aria-label={ariaLabel}
+
    onclick={async event => {
+
      event.stopPropagation();
+
      await copy();
+
      setVisible(true);
+
    }}
+
    role="button"
+
    tabindex="0">
+
    <slot>
+
      {#if shorten}
+
        {formatOid(id)}
+
      {:else}
+
        {id}
+
      {/if}
+
    </slot>
+
  </div>
+

+
  {#if visible}
+
    <div style:position="absolute">
+
      <div class="popover">
+
        <Icon name={icon} />
+
        {tooltip}
+
      </div>
+
    </div>
+
  {/if}
+
</div>
added src/components/IssueMetadata.svelte
@@ -0,0 +1,66 @@
+
<script lang="ts">
+
  import type { Issue } from "@bindings/cob/issue/Issue";
+

+
  import { authorForNodeId } from "@app/lib/utils";
+

+
  import Border from "@app/components/Border.svelte";
+
  import IssueStateBadge from "@app/components/IssueStateBadge.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
+

+
  export let issue: Issue;
+
</script>
+

+
<style>
+
  .divider {
+
    width: 2px;
+
    background-color: var(--color-fill-ghost);
+
    height: calc(100% + 4px);
+
    top: 0;
+
    position: relative;
+
  }
+
  .section {
+
    padding: 0.5rem;
+
    font-size: var(--font-size-small);
+
    display: flex;
+
    flex-direction: column;
+
    align-items: flex-start;
+
    height: 100%;
+
  }
+
  .section-title {
+
    margin-bottom: 0.5rem;
+
    color: var(--color-foreground-dim);
+
  }
+
</style>
+

+
<Border variant="ghost" styleGap="0">
+
  <div class="section" style:min-width="8rem">
+
    <div class="section-title">Status</div>
+
    <IssueStateBadge state={issue.state} />
+
  </div>
+

+
  <div class="divider"></div>
+

+
  <div class="section" style:flex="1">
+
    <div class="section-title">Labels</div>
+
    <div class="global-flex" style:flex-wrap="wrap">
+
      {#each issue.labels as label}
+
        <div class="global-counter txt-small">{label}</div>
+
      {:else}
+
        <span class="txt-missing">No labels.</span>
+
      {/each}
+
    </div>
+
  </div>
+

+
  <div class="divider"></div>
+

+
  <div class="section" style:flex="1">
+
    <div class="section-title">Assignees</div>
+
    <div class="global-flex" style:flex-wrap="wrap">
+
      {#each issue.assignees as assignee}
+
        <NodeId {...authorForNodeId(assignee)} />
+
      {:else}
+
        <span class="txt-missing">Not assigned to anyone.</span>
+
      {/each}
+
    </div>
+
  </div>
+
</Border>
added src/components/IssueStateBadge.svelte
@@ -0,0 +1,18 @@
+
<script lang="ts">
+
  import type { Issue } from "@bindings/cob/issue/Issue";
+

+
  import capitalize from "lodash/capitalize";
+
  import { issueStatusColor } from "@app/lib/utils";
+

+
  export let state: Issue["state"];
+
</script>
+

+
<div
+
  class="global-counter txt-small"
+
  style:width="fit-content"
+
  style:color="var(--color-foreground-match-background)"
+
  style:background-color={issueStatusColor[state.status]}>
+
  {capitalize(state.status)}{state.status === "closed"
+
    ? ` as ${state.reason}`
+
    : ""}
+
</div>
modified src/components/IssueTeaser.svelte
@@ -3,7 +3,6 @@

  import {
    authorForNodeId,
-
    formatOid,
    formatTimestamp,
    issueStatusBackgroundColor,
    issueStatusColor,
@@ -11,6 +10,7 @@
  import { push } from "@app/lib/router";

  import Icon from "./Icon.svelte";
+
  import Id from "./Id.svelte";
  import InlineTitle from "./InlineTitle.svelte";
  import NodeId from "./NodeId.svelte";

@@ -71,7 +71,7 @@
      <div class="global-flex txt-small">
        <NodeId {...authorForNodeId(issue.author)} />
        opened
-
        <div class="global-oid">{formatOid(issue.id)}</div>
+
        <Id id={issue.id} variant="oid" />
        {formatTimestamp(issue.timestamp)}
      </div>
    </div>
added src/components/IssueTimelineLifecycleAction.svelte
@@ -0,0 +1,22 @@
+
<script lang="ts">
+
  import type { Operation } from "@bindings/cob/issue/Operation";
+

+
  import { authorForNodeId, formatTimestamp } from "@app/lib/utils";
+

+
  import Border from "@app/components/Border.svelte";
+
  import IssueStateBadge from "@app/components/IssueStateBadge.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
+

+
  export let operation: Extract<Operation, { type: "lifecycle" }>;
+
</script>
+

+
<Border variant="float" stylePadding="1rem">
+
  <div class="txt-small">
+
    <div class="global-flex txt-small">
+
      <NodeId {...authorForNodeId(operation.author)} />
+
      changed status to
+
      <IssueStateBadge state={operation.state} />
+
      {formatTimestamp(operation.timestamp)}
+
    </div>
+
  </div>
+
</Border>
modified src/components/PatchTeaser.svelte
@@ -4,7 +4,6 @@

  import {
    authorForNodeId,
-
    formatOid,
    formatTimestamp,
    patchStatusBackgroundColor,
    patchStatusColor,
@@ -17,6 +16,7 @@
  import Icon from "./Icon.svelte";
  import InlineTitle from "./InlineTitle.svelte";
  import NodeId from "./NodeId.svelte";
+
  import Id from "./Id.svelte";

  let stats: Stats | undefined = undefined;

@@ -85,7 +85,7 @@
      <div class="global-flex txt-small">
        <NodeId {...authorForNodeId(patch.author)} />
        opened
-
        <div class="global-oid">{formatOid(patch.id)}</div>
+
        <Id id={patch.id} variant="oid" />
        {formatTimestamp(patch.timestamp)}
      </div>
    </div>
modified src/components/Popover.svelte
@@ -9,7 +9,6 @@

<script lang="ts">
  export let popoverContainerMinWidth: string | undefined = undefined;
-
  export let popoverBorderRadius: string | undefined = undefined;
  export let popoverPadding: string | undefined = undefined;
  export let popoverPositionBottom: string | undefined = undefined;
  export let popoverPositionLeft: string | undefined = undefined;
@@ -63,8 +62,7 @@
      style:left={popoverPositionLeft}
      style:right={popoverPositionRight}
      style:top={popoverPositionTop}
-
      style:padding={popoverPadding}
-
      style:border-radius={popoverBorderRadius}>
+
      style:padding={popoverPadding}>
      <slot name="popover" {toggle} />
    </div>
  {/if}
added src/components/ReactionSelector.svelte
@@ -0,0 +1,74 @@
+
<script lang="ts">
+
  import type { Reaction } from "@bindings/cob/Reaction";
+

+
  import { createEventDispatcher } from "svelte";
+

+
  import Border from "./Border.svelte";
+
  import Icon from "./Icon.svelte";
+
  import Popover from "./Popover.svelte";
+

+
  export let reactions: Reaction[] | undefined = undefined;
+
  export let popoverPositionBottom: string | undefined = undefined;
+
  export let popoverPositionRight: string | undefined = undefined;
+
  export let popoverPositionLeft: string | undefined = undefined;
+

+
  const availableReactions = ["👍", "👎", "😄", "🎉", "🙁", "🚀", "👀"];
+

+
  const dispatch = createEventDispatcher<{
+
    select: Reaction;
+
  }>();
+
</script>
+

+
<style>
+
  .selector {
+
    display: flex;
+
    align-items: center;
+
    gap: 2px;
+
  }
+

+
  button {
+
    cursor: pointer;
+
    border: 0;
+
    background: none;
+
    height: 24px;
+
    clip-path: var(--1px-corner-fill);
+
    margin: 0;
+
  }
+

+
  button:hover,
+
  button.active {
+
    background-color: var(--color-fill-ghost);
+
  }
+
</style>
+

+
<Popover
+
  {popoverPositionBottom}
+
  {popoverPositionRight}
+
  {popoverPositionLeft}
+
  popoverPadding="0">
+
  <Icon
+
    name="face"
+
    slot="toggle"
+
    let:toggle
+
    onclick={toggle}
+
    styleCursor="pointer" />
+
  <Border variant="ghost" slot="popover">
+
    <div class="selector">
+
      {#each availableReactions as reaction}
+
        {@const lookedUpReaction = reactions?.find(
+
          ({ emoji }) => emoji === reaction,
+
        )}
+
        <button
+
          class:active={Boolean(lookedUpReaction)}
+
          onclick={() => {
+
            dispatch(
+
              "select",
+
              lookedUpReaction || { emoji: reaction, authors: [] },
+
            );
+
          }}>
+
          {reaction}
+
        </button>
+
      {/each}
+
    </div>
+
  </Border>
+
</Popover>
added src/components/Reactions.svelte
@@ -0,0 +1,54 @@
+
<script lang="ts">
+
  import type { Author } from "@bindings/cob/Author";
+
  import type { Reaction } from "@bindings/cob/Reaction";
+

+
  export let reactions: Reaction[];
+
  export let handleReaction:
+
    | ((authors: Author[], reaction: string) => Promise<void>)
+
    | undefined;
+

+
  function authorsToTooltip(authors: Author[]) {
+
    return authors.map(a => a.alias ?? a.did).join("\n");
+
  }
+
</script>
+

+
<style>
+
  .reactions {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
  }
+
  .reaction {
+
    display: inline-flex;
+
    flex-direction: row;
+
    gap: 0.5rem;
+
    cursor: pointer;
+
  }
+
</style>
+

+
<div class="reactions">
+
  {#each reactions as { emoji, authors }}
+
    <div title={authorsToTooltip(authors)}>
+
      {#if handleReaction}
+
        <!-- svelte-ignore a11y_click_events_have_key_events -->
+
        <div
+
          role="button"
+
          tabindex="0"
+
          class="reaction txt-tiny"
+
          onclick={async () => {
+
            if (handleReaction) {
+
              await handleReaction(authors, emoji);
+
            }
+
          }}>
+
          <span>{emoji}</span>
+
          <span>{authors.length}</span>
+
        </div>
+
      {:else}
+
        <div class="reaction txt-tiny" style="padding: 2px 4px;">
+
          <span>{emoji}</span>
+
          <span>{authors.length}</span>
+
        </div>
+
      {/if}
+
    </div>
+
  {/each}
+
</div>
modified src/components/RepoCard.svelte
@@ -6,6 +6,7 @@
  import Border from "./Border.svelte";
  import Icon from "./Icon.svelte";
  import RepoHeader from "./RepoHeader.svelte";
+
  import Id from "./Id.svelte";

  export let repo: RepoInfo;
  export let selfDid: string;
@@ -46,7 +47,11 @@
        No description.
      {/if}
    </div>
-
    <div class="global-oid">{formatRepositoryId(repo.rid)}</div>
+
    <Id
+
      clipboard={repo.rid}
+
      shorten={false}
+
      variant="oid"
+
      id={formatRepositoryId(repo.rid)} />

    <div class="global-flex footer">
      <div class="global-flex">
modified src/components/TextInput.svelte
@@ -69,7 +69,5 @@
    bind:value
    autocomplete="off"
    spellcheck="false"
-
    on:input
-
    on:click
-
    on:change />
+
    on:input />
</Border>
modified src/components/Textarea.svelte
@@ -1,5 +1,9 @@
<script lang="ts">
-
  import { afterUpdate, beforeUpdate } from "svelte";
+
  import type { ComponentProps } from "svelte";
+

+
  import { afterUpdate, beforeUpdate, createEventDispatcher } from "svelte";
+

+
  import * as utils from "@app/lib/utils";

  import Border from "./Border.svelte";

@@ -8,6 +12,8 @@
  export let focus: boolean = false;
  export let size: "grow" | "resizable" | "fixed-height" = "grow";
  export let styleMinHeight: string | undefined = undefined;
+
  export let stylePadding: string = "0.75rem";
+
  export let borderVariant: ComponentProps<Border>["variant"] = "float";

  // Defaulting selectionStart and selectionEnd to 0, since no full support yet.
  export let selectionStart: number = 0;
@@ -47,21 +53,35 @@
      textareaElement.focus();
    }
  });
+

+
  const dispatch = createEventDispatcher<{
+
    submit: null;
+
  }>();
+

+
  function handleKeydown(event: KeyboardEvent) {
+
    const auxiliarKey = utils.isMac() ? event.metaKey : event.ctrlKey;
+
    if (auxiliarKey && event.key === "Enter") {
+
      dispatch("submit");
+
    }
+
    if (event.key === "Escape") {
+
      textareaElement?.blur();
+
    }
+
  }
</script>

<style>
  textarea {
-
    background-color: var(--color-background-dip);
+
    background-color: transparent;
    border: 0;
    color: var(--color-foreground-default);
    font-family: inherit;
    height: 5rem;
-
    padding: 0.75rem;
    width: 100%;
    min-height: 6.375rem;
    resize: none;
    overflow: hidden;
    outline: none;
+
    line-height: 1rem;
  }

  textarea::-webkit-scrollbar-corner {
@@ -81,11 +101,12 @@
</style>

<Border
-
  variant={focussed ? "secondary" : "ghost"}
+
  variant={focussed ? "secondary" : borderVariant}
  styleWidth="100%"
  {styleMinHeight}>
  <textarea
    style:min-height={styleMinHeight}
+
    style:padding={stylePadding}
    tabindex="0"
    bind:this={textareaElement}
    bind:value
@@ -96,12 +117,10 @@
      ? "scroll"
      : undefined}
    {placeholder}
-
    on:change
-
    on:click
    on:input
    on:focus={() => (focussed = true)}
    on:blur={() => (focussed = false)}
-
    on:paste
+
    on:keydown|stopPropagation={handleKeydown}
    on:keypress>
  </textarea>
</Border>
modified src/components/ThemeSwitch.svelte
@@ -28,13 +28,20 @@

<script lang="ts">
  import { writable } from "svelte/store";
-

-
  import Border from "./Border.svelte";
  import Icon from "./Icon.svelte";
</script>

<style>
+
  .container {
+
    background-color: var(--color-fill-ghost);
+
    clip-path: var(--2px-corner-fill);
+
    display: flex;
+
    height: 32px;
+
    align-items: center;
+
    padding: 0 2px;
+
  }
  button {
+
    height: 28px;
    cursor: pointer;
    display: flex;
    align-items: center;
@@ -42,39 +49,37 @@
    white-space: nowrap;
    touch-action: manipulation;
    clip-path: var(--1px-corner-fill);
-
    height: 24px;
    font-size: var(--font-size-small);
+
    margin: 0;
+
    padding: 0 1rem;
+
    color: var(--color-foreground-contrast);
+
    background-color: var(--color-fill-ghost);
+
    font-weight: var(--font-weight-semibold);
+
    gap: 6px;
+
  }
+

+
  .active {
+
    color: var(--color-foreground-emphasized);
+
    background-color: var(--color-background-dip);
  }
</style>

-
<Border variant="secondary">
+
<div class="container">
  <button
-
    style:background-color={$theme === "dark"
-
      ? "var(--color-fill-secondary)"
-
      : "transparent"}
+
    class:active={$theme === "dark"}
    onclick={() => {
      storeTheme("dark");
    }}>
-
    <span style="display: flex; align-items: center; gap: 0.5rem">
-
      <Icon name="moon" />
-
      Dark
-
    </span>
+
    <Icon name="moon" />
+
    Dark
  </button>

  <button
-
    style:background-color={$theme === "light"
-
      ? "var(--color-fill-secondary)"
-
      : "transparent"}
+
    class:active={$theme === "light"}
    onclick={() => {
      storeTheme("light");
    }}>
-
    <span
-
      style="display: flex; align-items: center; gap: 0.5rem"
-
      style:color={$theme === "light"
-
        ? "var(--color-foreground-white)"
-
        : "var(--color-foreground-contrast)"}>
-
      <Icon name="sun" />
-
      Light
-
    </span>
+
    <Icon name="sun" />
+
    Light
  </button>
-
</Border>
+
</div>
added src/components/Thread.svelte
@@ -0,0 +1,144 @@
+
<script lang="ts">
+
  import type { Author } from "@bindings/cob/Author";
+
  import type { Comment } from "@bindings/cob/thread/Comment";
+
  import type { Embed } from "@bindings/cob/thread/Embed";
+

+
  import { tick } from "svelte";
+
  import partial from "lodash/partial";
+

+
  import { scrollIntoView } from "@app/lib/utils";
+

+
  import CommentComponent from "@app/components/Comment.svelte";
+
  import CommentToggleInput from "@app/components/CommentToggleInput.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Border from "./Border.svelte";
+

+
  export let thread: {
+
    root: Comment;
+
    replies: Comment[];
+
  };
+
  export let rid: string;
+
  export let canEditComment: (author: string) => true | undefined;
+
  export let editComment:
+
    | ((commentId: string, body: string, embeds: Embed[]) => Promise<void>)
+
    | undefined;
+
  export let createReply:
+
    | ((commentId: string, comment: string, embeds: Embed[]) => Promise<void>)
+
    | undefined;
+
  export let reactOnComment:
+
    | ((
+
        commentId: string,
+
        authors: Author[],
+
        reaction: string,
+
      ) => Promise<void>)
+
    | undefined;
+

+
  async function toggleReply() {
+
    showReplyForm = !showReplyForm;
+
    if (!showReplyForm) {
+
      return;
+
    }
+

+
    await tick();
+
    scrollIntoView(`reply-${root.id}`, {
+
      behavior: "smooth",
+
      block: "center",
+
    });
+
  }
+

+
  let showReplyForm = false;
+

+
  $: root = thread.root;
+
  $: replies = thread.replies;
+

+
  $: style =
+
    replies.length > 0
+
      ? "--local-clip-path: var(--2px-top-corner-fill)"
+
      : "--local-clip-path: var(--2px-corner-fill)";
+
</script>
+

+
<style>
+
  .comments {
+
    display: flex;
+
    flex-direction: column;
+
    width: 100%;
+
  }
+

+
  .top-level-comment {
+
    position: relative;
+
  }
+
  /* We put the background and clip-path in a separate element to prevent
+
     popovers being clipped in the main element. */
+
  .top-level-comment::after {
+
    position: absolute;
+
    z-index: -1;
+
    content: " ";
+
    background-color: var(--color-background-float);
+
    clip-path: var(--local-clip-path);
+
    width: 100%;
+
    height: 100%;
+
    top: 0;
+
  }
+
</style>
+

+
<div class="comments" {style}>
+
  <div class="top-level-comment">
+
    <CommentComponent
+
      disallowEmptyBody
+
      {rid}
+
      id={root.id}
+
      lastEdit={root.edits.length > 1 ? root.edits.at(-1) : undefined}
+
      author={root.author}
+
      reactions={root.reactions}
+
      timestamp={root.edits.slice(-1)[0].timestamp}
+
      body={root.edits.slice(-1)[0].body}
+
      editComment={editComment &&
+
        canEditComment(root.author.did) &&
+
        partial(editComment, root.id)}
+
      reactOnComment={reactOnComment && partial(reactOnComment, root.id)}>
+
      <svelte:fragment slot="actions">
+
        <Icon name="reply" onclick={toggleReply} styleCursor="pointer" />
+
      </svelte:fragment>
+
    </CommentComponent>
+
  </div>
+
  {#if replies.length > 0 || (createReply && showReplyForm)}
+
    <Border variant="float">
+
      <div style:width="100%">
+
        {#if replies.length > 0}
+
          {#each replies as reply}
+
            <CommentComponent
+
              disallowEmptyBody
+
              {rid}
+
              lastEdit={reply.edits.length > 1 ? reply.edits.at(-1) : undefined}
+
              id={reply.id}
+
              author={reply.author}
+
              caption="replied"
+
              reactions={reply.reactions}
+
              timestamp={reply.edits.slice(-1)[0].timestamp}
+
              body={reply.edits.slice(-1)[0].body}
+
              editComment={editComment &&
+
                canEditComment(reply.author.did) &&
+
                partial(editComment, reply.id)}
+
              reactOnComment={reactOnComment &&
+
                partial(reactOnComment, reply.id)} />
+
          {/each}
+
        {/if}
+
        {#if createReply && showReplyForm}
+
          <div id={`reply-${root.id}`} style:padding="1rem">
+
            <CommentToggleInput
+
              disallowEmptyBody
+
              {rid}
+
              focus
+
              inline
+
              onclose={() => (showReplyForm = false)}
+
              placeholder="Reply to comment"
+
              submitCaption="Reply"
+
              onexpand={toggleReply}
+
              submit={partial(createReply, root.id)} />
+
          </div>
+
        {/if}
+
        <div></div>
+
      </div>
+
    </Border>
+
  {/if}
+
</div>
added src/lib/file.ts
@@ -0,0 +1,38 @@
+
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);
+
  });
+
}
+

+
export async function embed(file: File) {
+
  const bytes = new Uint8Array(await file.arrayBuffer());
+
  const oid = await parseGitOid(bytes);
+
  const content = await base64String(file);
+
  return { oid, name: file.name, content };
+
}
added src/lib/roles.ts
@@ -0,0 +1,26 @@
+
import { publicKeyFromDid } from "@app/lib/utils";
+

+
export function isDelegate(
+
  publicKey: string | undefined,
+
  delegates: string[],
+
): true | undefined {
+
  if (!publicKey) {
+
    return undefined;
+
  }
+
  return (
+
    delegates.some(delegate => publicKeyFromDid(delegate) === publicKey) ||
+
    undefined
+
  );
+
}
+

+
export function isDelegateOrAuthor(
+
  publicKey: string | undefined,
+
  delegates: string[],
+
  author: string,
+
): true | undefined {
+
  return (
+
    isDelegate(publicKey, delegates) ||
+
    publicKey === publicKeyFromDid(author) ||
+
    undefined
+
  );
+
}
modified src/lib/utils.ts
@@ -118,7 +118,9 @@ export function twemoji(

export function scrollIntoView(id: string, options?: ScrollIntoViewOptions) {
  const lineElement = document.getElementById(id);
-
  if (lineElement) lineElement.scrollIntoView(options);
+
  if (lineElement) {
+
    lineElement.scrollIntoView(options);
+
  }
}

export const issueStatusColor: Record<Issue["state"]["status"], string> = {
@@ -154,3 +156,26 @@ export const patchStatusBackgroundColor: Record<
export function authorForNodeId(author: Author): ComponentProps<NodeId> {
  return { publicKey: publicKeyFromDid(author.did), alias: author.alias };
}
+

+
export function absoluteTimestamp(timestamp: number) {
+
  return new Date(Number(timestamp)).toLocaleString();
+
}
+

+
export function formatEditedCaption(author: Author, timestamp: number) {
+
  return `${author.alias ? author.alias : truncateDid(author.did)} edited ${absoluteTimestamp(timestamp)}`;
+
}
+

+
export function isMac() {
+
  if (
+
    (navigator.platform && navigator.platform.includes("Mac")) ||
+
    navigator.userAgent.includes("OS X")
+
  ) {
+
    return true;
+
  } else {
+
    return false;
+
  }
+
}
+

+
export function modifierKey() {
+
  return isMac() ? "⌘" : "ctrl";
+
}
modified src/views/repo/CreateIssue.svelte
@@ -28,6 +28,7 @@
  let title: string = "";
  let description: string = "";
  let preview: boolean = false;
+
  const announce = false;

  const labels: string[] = [];
  const assignees: Author[] = [];
@@ -37,6 +38,7 @@
    const response: Issue = await invoke("create_issue", {
      rid: repo.rid,
      new: { title, description, labels, assignees, embeds },
+
      opts: { announce },
    });
    void router.push({
      resource: "repo.issue",
modified src/views/repo/Issue.svelte
@@ -1,35 +1,170 @@
<script lang="ts">
+
  import type { Author } from "@bindings/cob/Author";
  import type { Config } from "@bindings/config/Config";
+
  import type { Embed } from "@bindings/cob/thread/Embed";
  import type { Issue } from "@bindings/cob/issue/Issue";
-
  import type { RepoInfo } from "@bindings/repo/RepoInfo";
  import type { Operation } from "@bindings/cob/issue/Operation";
+
  import type { RepoInfo } from "@bindings/repo/RepoInfo";

-
  import capitalize from "lodash/capitalize";
+
  import partial from "lodash/partial";

+
  import * as roles from "@app/lib/roles";
  import {
-
    authorForNodeId,
-
    formatOid,
-
    formatTimestamp,
    issueStatusColor,
-
    truncateDid,
+
    publicKeyFromDid,
+
    scrollIntoView,
  } from "@app/lib/utils";
  import { invoke } from "@tauri-apps/api/core";

+
  import { announce } from "@app/components/AnnounceSwitch.svelte";
+

  import Border from "@app/components/Border.svelte";
+
  import CommentComponent from "@app/components/Comment.svelte";
+
  import CommentToggleInput from "@app/components/CommentToggleInput.svelte";
  import CopyableId from "@app/components/CopyableId.svelte";
  import Icon from "@app/components/Icon.svelte";
  import InlineTitle from "@app/components/InlineTitle.svelte";
-
  import Layout from "./Layout.svelte";
+
  import IssueMetadata from "@app/components/IssueMetadata.svelte";
+
  import IssueTimelineLifecycleAction from "@app/components/IssueTimelineLifecycleAction.svelte";
  import Link from "@app/components/Link.svelte";
-
  import Markdown from "@app/components/Markdown.svelte";
  import NodeId from "@app/components/NodeId.svelte";
+
  import Thread from "@app/components/Thread.svelte";
+

+
  import Layout from "./Layout.svelte";
+
  import { tick } from "svelte";

  export let repo: RepoInfo;
  export let issue: Issue;
  export let issues: Issue[];
  export let config: Config;

+
  let topLevelReplyOpen = false;
+

+
  // Close the comment textbox when switching between issues. The view doesn't
+
  // get destroyed when we switch between different issues in the sidebar and
+
  // because of that the top-level state gets retained when the issue changes.
+
  $: if (issue) {
+
    topLevelReplyOpen = false;
+
  }
+

  $: project = repo.payloads["xyz.radicle.project"]!;
+

+
  $: threads = issue.discussion
+
    .filter(
+
      comment =>
+
        (comment.id !== issue.discussion[0].id && !comment.replyTo) ||
+
        comment.replyTo === issue.discussion[0].id,
+
    )
+
    .map(thread => {
+
      return {
+
        root: thread,
+
        replies: issue.discussion
+
          .filter(comment => comment.replyTo === thread.id)
+
          .sort(
+
            (a, b) =>
+
              a.edits.slice(-1)[0].timestamp - b.edits.slice(-1)[0].timestamp,
+
          ),
+
      };
+
    }, []);
+

+
  async function toggleReply() {
+
    topLevelReplyOpen = !topLevelReplyOpen;
+
    if (!topLevelReplyOpen) {
+
      return;
+
    }
+

+
    await tick();
+
    scrollIntoView(`reply-${issue.id}`, {
+
      behavior: "smooth",
+
      block: "center",
+
    });
+
  }
+

+
  async function reload() {
+
    issue = await invoke("issue_by_id", {
+
      rid: repo.rid,
+
      id: issue.id,
+
    });
+
  }
+

+
  async function createComment(body: string, embeds: Embed[]) {
+
    try {
+
      await invoke("create_issue_comment", {
+
        rid: repo.rid,
+
        new: { id: issue.id, body, embeds },
+
        opts: { announce: $announce },
+
      });
+
    } catch (error) {
+
      console.error("Comment creation failed: ", error);
+
    } finally {
+
      await reload();
+
    }
+
  }
+

+
  async function createReply(replyTo: string, body: string, embeds: Embed[]) {
+
    try {
+
      await invoke("create_issue_comment", {
+
        rid: repo.rid,
+
        new: { id: issue.id, body, embeds, replyTo },
+
        opts: { announce: $announce },
+
      });
+
    } catch (error) {
+
      console.error("Comment reply creation failed", error);
+
    } finally {
+
      await reload();
+
    }
+
  }
+

+
  async function editComment(id: string, body: string, embeds: Embed[]) {
+
    try {
+
      await invoke("edit_issue", {
+
        rid: repo.rid,
+
        cobId: issue.id,
+
        action: {
+
          type: "comment.edit",
+
          id,
+
          body,
+
          embeds,
+
        },
+
        opts: { announce: $announce },
+
      });
+
    } catch (error) {
+
      if (error instanceof Error) {
+
        console.error("Issue comment editing failed: ", error);
+
      }
+
    } finally {
+
      await reload();
+
    }
+
  }
+

+
  async function reactOnComment(
+
    publicKey: string,
+
    commentId: string,
+
    authors: Author[],
+
    reaction: string,
+
  ) {
+
    try {
+
      await invoke("edit_issue", {
+
        rid: repo.rid,
+
        cobId: issue.id,
+
        action: {
+
          type: "comment.react",
+
          id: commentId,
+
          reaction,
+
          active: !authors.find(
+
            ({ did }) => publicKeyFromDid(did) === publicKey,
+
          ),
+
        },
+
        opts: { announce: $announce },
+
      });
+
    } catch (error) {
+
      if (error instanceof Error) {
+
        console.error("Editing reactions failed", error);
+
      }
+
    } finally {
+
      await reload();
+
    }
+
  }
</script>

<style>
@@ -41,6 +176,22 @@
    margin-bottom: 1rem;
    margin-top: 0.35rem;
  }
+
  .issue-body {
+
    margin-top: 1rem;
+
    position: relative;
+
  }
+
  /* We put the background and clip-path in a separate element to prevent
+
     popovers being clipped in the main element. */
+
  .issue-body::after {
+
    position: absolute;
+
    z-index: -1;
+
    content: " ";
+
    background-color: var(--color-background-float);
+
    clip-path: var(--2px-corner-fill);
+
    width: 100%;
+
    height: 100%;
+
    top: 0;
+
  }
  .issue-teaser {
    max-width: 11rem;
    white-space: nowrap;
@@ -55,30 +206,11 @@
  .content {
    padding: 0 1rem 1rem 1rem;
  }
-
  .body {
-
    background-color: var(--color-background-float);
-
    padding: 1rem;
-
    margin-top: 1rem;
-
    clip-path: var(--2px-corner-fill);
-
  }
-
  .divider {
+
  .connector {
    width: 2px;
-
    background-color: var(--color-fill-ghost);
-
    height: calc(100% + 8px);
-
    top: -2px;
-
    position: relative;
-
  }
-
  .section {
-
    padding: 0.5rem;
-
    font-size: var(--font-size-small);
-
    display: flex;
-
    flex-direction: column;
-
    align-items: flex-start;
-
    height: 100%;
-
  }
-
  .section-title {
-
    margin-bottom: 0.5rem;
-
    color: var(--color-foreground-dim);
+
    height: 1.5rem;
+
    margin-left: 1.25rem;
+
    background-color: var(--color-background-float);
  }
</style>

@@ -155,102 +287,75 @@
      <InlineTitle content={issue.title} fontSize="medium" />
    </div>

-
    <Border variant="ghost" styleGap="0">
-
      <div class="section" style:min-width="8rem">
-
        <div class="section-title">Status</div>
-
        <div
-
          class="global-counter txt-small"
-
          style:width="fit-content"
-
          style:color="var(--color-foreground-match-background)"
-
          style:background-color={issueStatusColor[issue.state.status]}>
-
          {capitalize(issue.state.status)}
-
        </div>
-
      </div>
-

-
      <div class="divider"></div>
+
    <IssueMetadata {issue} />

-
      <div class="section" style:flex="1">
-
        <div class="section-title">Labels</div>
-
        <div class="global-flex" style:flex-wrap="wrap">
-
          {#each issue.labels as label}
-
            <div class="global-counter txt-small">{label}</div>
-
          {:else}
-
            <span class="txt-missing">No labels.</span>
-
          {/each}
-
        </div>
-
      </div>
-

-
      <div class="divider"></div>
-

-
      <div class="section" style:flex="1">
-
        <div class="section-title">Assignees</div>
-
        <div class="global-flex" style:flex-wrap="wrap">
-
          {#each issue.assignees as assignee}
-
            <NodeId {...authorForNodeId(assignee)} />
-
          {:else}
-
            <span class="txt-missing">Not assigned to anyone.</span>
-
          {/each}
-
        </div>
-
      </div>
-
    </Border>
-

-
    <div class="txt-small body">
-
      <div class="global-flex txt-small" style:margin-bottom="1rem">
-
        <NodeId {...authorForNodeId(issue.author)} />
-
        opened
-
        <div class="global-oid">{formatOid(issue.id)}</div>
-
        {formatTimestamp(issue.timestamp)}
-
        {#if issue.discussion[0].edits.length > 1}
-
          {@const lastEdit = issue.discussion[0].edits.slice(-1)[0]}
-
          <span
-
            class="txt-missing"
-
            title={`${lastEdit.author.alias ? lastEdit.author.alias : truncateDid(lastEdit.author.did)} edited ${new Date(Number(lastEdit.timestamp)).toLocaleString()}`}>
-
            • edited
-
          </span>
-
        {/if}
-
      </div>
-
      {#if issue.discussion[0].edits.slice(-1)[0].body.trim() === ""}
-
        <span class="txt-missing" style:line-height="1.625rem">
-
          No description.
-
        </span>
-
      {:else}
-
        <Markdown
-
          rid={repo.rid}
-
          breaks
-
          content={issue.discussion[0].edits.slice(-1)[0].body} />
-
      {/if}
+
    <div class="issue-body">
+
      <CommentComponent
+
        rid={repo.rid}
+
        id={issue.id}
+
        lastEdit={issue.discussion[0].edits.length > 1
+
          ? issue.discussion[0].edits.at(-1)
+
          : undefined}
+
        author={issue.discussion[0].author}
+
        reactions={issue.discussion[0].reactions}
+
        timestamp={issue.discussion[0].edits.slice(-1)[0].timestamp}
+
        body={issue.discussion[0].edits.slice(-1)[0].body}
+
        editComment={roles.isDelegateOrAuthor(
+
          config.publicKey,
+
          repo.delegates.map(delegate => delegate.did),
+
          issue.discussion[0].author.did,
+
        ) && partial(editComment, issue.discussion[0].id)}
+
        reactOnComment={partial(
+
          reactOnComment,
+
          config.publicKey,
+
          issue.discussion[0].id,
+
        )}>
+
        <svelte:fragment slot="actions">
+
          <Icon styleCursor="pointer" name="reply" onclick={toggleReply} />
+
        </svelte:fragment>
+
        <svelte:fragment slot="caption">opened</svelte:fragment>
+
      </CommentComponent>
    </div>
+
    <div class="connector"></div>
+

    <div>
      {#await invoke<Operation[]>( "activity_by_id", { rid: repo.rid, typeName: "xyz.radicle.issue", id: issue.id }, ) then activity}
        {#each activity.slice(1) as op}
          {#if op.type === "lifecycle"}
-
            <div class="txt-small body">
-
              <div class="global-flex txt-small">
-
                <NodeId {...authorForNodeId(op.author)} />
-
                alias={op.author.alias} /> change of status to {op.state.status}
-
                <!-- <div class="global-oid"></div> -->
-
                {formatTimestamp(op.timestamp)}
-
              </div>
-
            </div>
+
            <IssueTimelineLifecycleAction operation={op} />
+
            <div class="connector"></div>
          {:else if op.type === "comment"}
-
            <div class="txt-small body">
-
              <Markdown rid={repo.rid} breaks content={op.body} />
-
              <div class="global-flex txt-small" style:margin-top="1.5rem">
-
                <NodeId {...authorForNodeId(op.author)} />
-
                {#if op.replyTo}
-
                  replied to <div class="global-oid">
-
                    {formatOid(op.replyTo)}
-
                  </div>
-
                {:else}
-
                  commented
-
                {/if}
-
                <!-- <div class="global-oid"></div> -->
-
                {formatTimestamp(op.timestamp)}
-
              </div>
-
            </div>
+
            {@const thread = threads.find(t => t.root.id === op.entryId)}
+
            {#if thread}
+
              <Thread
+
                {thread}
+
                rid={repo.rid}
+
                canEditComment={partial(
+
                  roles.isDelegateOrAuthor,
+
                  config.publicKey,
+
                  repo.delegates.map(delegate => delegate.did),
+
                )}
+
                {editComment}
+
                createReply={partial(createReply)}
+
                reactOnComment={partial(reactOnComment, config.publicKey)} />
+
              <div class="connector"></div>
+
            {/if}
          {/if}
        {/each}
      {/await}
    </div>
+

+
    <div id={`reply-${issue.id}`}>
+
      <CommentToggleInput
+
        disallowEmptyBody
+
        rid={repo.rid}
+
        focus
+
        onexpand={toggleReply}
+
        onclose={topLevelReplyOpen
+
          ? () => (topLevelReplyOpen = false)
+
          : undefined}
+
        placeholder="Leave a comment"
+
        submit={partial(createComment)} />
+
    </div>
  </div>
</Layout>
modified src/views/repo/Patch.svelte
@@ -6,7 +6,6 @@

  import {
    authorForNodeId,
-
    formatOid,
    formatTimestamp,
    patchStatusColor,
  } from "@app/lib/utils";
@@ -20,6 +19,7 @@
  import Link from "@app/components/Link.svelte";
  import NodeId from "@app/components/NodeId.svelte";
  import Markdown from "@app/components/Markdown.svelte";
+
  import Id from "@app/components/Id.svelte";

  export let repo: RepoInfo;
  export let patch: Patch;
@@ -158,13 +158,13 @@
      <div class="global-flex txt-small" style:margin-top="1.5rem">
        <NodeId {...authorForNodeId(patch.author)} />
        opened
-
        <div class="global-oid">{formatOid(patch.id)}</div>
+
        <Id id={patch.id} variant="oid" />
        {formatTimestamp(patch.timestamp)}
      </div>
    </div>
    <div class="txt-small" style:margin-top="1rem">Revisions</div>
    {#each revisions as revision}
-
      <div class="global-oid">{formatOid(revision.id)}</div>
+
      <Id id={revision.id} variant="oid" />
    {/each}
  </div>
</Layout>