Radish alpha
r
Radicle desktop app
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add issue timeline
Rūdolfs Ošiņš committed 1 year ago
commit 6326863f21de2d20f49f446a1d424e330602e3ac
parent 7aebf9531cafdf4388dbe9c977566182f62ef329
4 files changed +308 -68
added src/components/IssueTimeline.svelte
@@ -0,0 +1,242 @@
+
<script lang="ts">
+
  import type { Action } from "@bindings/cob/issue/Action";
+
  import type { Operation } from "@bindings/cob/Operation";
+
  import type { Author } from "@bindings/cob/Author";
+

+
  type FlattenedOperation = Action & {
+
    id: string;
+
    author: Author;
+
    timestamp: number;
+
    previous?: Action;
+
  };
+

+
  type StateTracker = Record<Action["type"], Action>;
+

+
  import {
+
    absoluteTimestamp,
+
    authorForNodeId,
+
    formatTimestamp,
+
    issueStatusColor,
+
    publicKeyFromDid,
+
  } from "@app/lib/utils";
+
  import Icon from "./Icon.svelte";
+
  import NodeId from "./NodeId.svelte";
+
  import { invoke } from "@app/lib/invoke";
+
  import Id from "./Id.svelte";
+

+
  interface Props {
+
    activity: Operation<Action>[];
+
  }
+

+
  /* eslint-disable prefer-const */
+
  let { activity }: Props = $props();
+
  /* eslint-enable prefer-const */
+

+
  const timeline = $derived(enrichActivity(flattenActivity(activity)));
+

+
  function flattenActivity(
+
    activity: Operation<Action>[],
+
  ): FlattenedOperation[] {
+
    return activity.flatMap(operation =>
+
      operation.actions.map(action => ({
+
        ...action,
+
        id: operation.id,
+
        author: operation.author,
+
        timestamp: operation.timestamp,
+
      })),
+
    );
+
  }
+

+
  function enrichActivity(
+
    flatActivity: FlattenedOperation[],
+
  ): FlattenedOperation[] {
+
    const result: FlattenedOperation[] = [];
+
    const timelineStateTracker: StateTracker = {} as StateTracker;
+

+
    flatActivity.forEach(entry => {
+
      if (timelineStateTracker[entry.type]) {
+
        result.push({ ...entry, previous: timelineStateTracker[entry.type] });
+
      } else {
+
        result.push(entry);
+
      }
+
      timelineStateTracker[entry.type] = entry;
+
    });
+

+
    return result;
+
  }
+

+
  function itemDiff(previousState: string[], newState: string[]) {
+
    const removed = previousState.filter(x => !newState.includes(x));
+
    const added = newState.filter(x => !previousState.includes(x));
+
    return { removed, added };
+
  }
+
</script>
+

+
<style>
+
  a {
+
    color: var(--color-foreground-default);
+
    text-decoration: none;
+
  }
+
  a:hover {
+
    text-decoration: underline;
+
    text-decoration-thickness: 1px;
+
    text-underline-offset: 2px;
+
  }
+
  .timeline {
+
    display: flex;
+
    gap: 0.75rem;
+
    flex-direction: column;
+
  }
+
  .timeline-item {
+
    display: flex;
+
    align-items: flex-start;
+
    gap: 0.5rem;
+
    word-break: break-word;
+
  }
+
  .wrapper {
+
    display: flex;
+
    flex-wrap: wrap;
+
    gap: 0.25rem;
+
  }
+
  .icon {
+
    padding-top: 3px;
+
  }
+
</style>
+

+
<div class="timeline txt-small">
+
  {#each timeline as op}
+
    {#if op.type === "lifecycle"}
+
      <div class="timeline-item">
+
        <div class="icon" style:color={issueStatusColor[op.state.status]}>
+
          <Icon name="issue" />
+
        </div>
+
        <div class="wrapper">
+
          <NodeId {...authorForNodeId(op.author)} />
+
          {#if op.state.status === "closed"}
+
            closed issue as {op.state.reason}
+
          {:else if op.state.status === "open"}
+
            reopened issue
+
          {/if}
+
          <div title={absoluteTimestamp(op.timestamp)}>
+
            {formatTimestamp(op.timestamp)}
+
          </div>
+
        </div>
+
      </div>
+
    {:else if op.type === "label" && op.previous && op.previous.type === op.type}
+
      {@const changed = itemDiff(op.previous?.labels ?? [], op.labels)}
+
      {#if changed.added.length || changed.removed.length}
+
        <div class="timeline-item">
+
          <div class="icon">
+
            <Icon name="label" />
+
          </div>
+
          <div class="wrapper">
+
            <NodeId {...authorForNodeId(op.author)} />
+
            {#if changed.added.length}
+
              added label{changed.added.length > 1 ? "s" : ""}
+
              {#each changed.added as label}
+
                <b>{label}</b>
+
              {/each}
+
            {/if}
+
            {#if changed.removed.length}
+
              removed label{changed.removed.length > 1 ? "s" : ""}
+
              {#each changed.removed as label}
+
                <b>{label}</b>
+
              {/each}
+
            {/if}
+
            <div title={absoluteTimestamp(op.timestamp)}>
+
              {formatTimestamp(op.timestamp)}
+
            </div>
+
          </div>
+
        </div>
+
      {/if}
+
    {:else if op.type === "assign" && op.previous && op.previous.type === op.type}
+
      {@const changed = itemDiff(op.previous?.assignees ?? [], op.assignees)}
+
      {#if changed.added.length || changed.removed.length}
+
        <div class="timeline-item">
+
          <div class="icon">
+
            <Icon name="user" />
+
          </div>
+
          <div class="wrapper">
+
            <NodeId {...authorForNodeId(op.author)} />
+
            {#if changed.added.length}
+
              assigned
+
              {#each changed.added as assignee}
+
                {#await invoke<string | null>( "alias", { nid: publicKeyFromDid(assignee) }, ) then alias}
+
                  <NodeId
+
                    {...authorForNodeId({
+
                      did: assignee,
+
                      alias: alias ?? undefined,
+
                    })} />
+
                {/await}
+
              {/each}
+
            {/if}
+
            {#if changed.removed.length}
+
              unassigned
+
              {#each changed.removed as assignee}
+
                {#await invoke<string | null>( "alias", { nid: publicKeyFromDid(assignee) }, ) then alias}
+
                  <NodeId
+
                    {...authorForNodeId({
+
                      did: assignee,
+
                      alias: alias ?? undefined,
+
                    })} />
+
                {/await}
+
              {/each}
+
            {/if}
+
            <div title={absoluteTimestamp(op.timestamp)}>
+
              {formatTimestamp(op.timestamp)}
+
            </div>
+
          </div>
+
        </div>
+
      {/if}
+
    {:else if op.type === "edit"}
+
      {#if op.previous && op.previous.type === op.type}
+
        <div class="timeline-item">
+
          <div class="icon">
+
            <Icon name="pen" />
+
          </div>
+
          <div class="wrapper">
+
            <NodeId {...authorForNodeId(op.author)} />
+
            changed title
+
            <s>{op.previous.title}</s>
+
            -> {op.title}
+
            <div title={absoluteTimestamp(op.timestamp)}>
+
              {formatTimestamp(op.timestamp)}
+
            </div>
+
          </div>
+
        </div>
+
      {/if}
+
    {:else if op.type === "comment"}
+
      {#if op.id === activity[0].id}
+
        <div class="timeline-item">
+
          <div class="icon" style:color="var(--color-fill-success)">
+
            <Icon name="issue" />
+
          </div>
+
          <div class="wrapper">
+
            <NodeId {...authorForNodeId(op.author)} />
+
            <div>opened issue <Id id={op.id} variant="oid" /></div>
+
            <div title={absoluteTimestamp(op.timestamp)}>
+
              {formatTimestamp(op.timestamp)}
+
            </div>
+
          </div>
+
        </div>
+
      {:else}
+
        <div class="timeline-item">
+
          <div class="icon">
+
            <Icon name="comment" />
+
          </div>
+
          <div class="wrapper">
+
            <NodeId {...authorForNodeId(op.author)} />
+
            <a href="#{op.id}">
+
              {op.replyTo && op.replyTo !== activity[0].id
+
                ? "replied to a comment"
+
                : "commented"}
+
            </a>
+
            <div title={absoluteTimestamp(op.timestamp)}>
+
              {formatTimestamp(op.timestamp)}
+
            </div>
+
          </div>
+
        </div>
+
      {/if}
+
    {/if}
+
  {/each}
+
</div>
deleted src/components/IssueTimelineLifecycleAction.svelte
@@ -1,28 +0,0 @@
-
<script lang="ts">
-
  import type { Operation } from "@bindings/cob/Operation";
-
  import type { Action } from "@bindings/cob/issue/Action";
-

-
  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";
-

-
  interface Props {
-
    action: Extract<Action, { type: "lifecycle" }>;
-
    op: Operation<Action>;
-
  }
-

-
  const { op, action }: Props = $props();
-
</script>
-

-
<Border variant="float" stylePadding="1rem">
-
  <div class="txt-small">
-
    <div class="global-flex txt-small">
-
      <NodeId {...authorForNodeId(op.author)} />
-
      changed status to
-
      <IssueStateBadge state={action.state} />
-
      {formatTimestamp(op.timestamp)}
-
    </div>
-
  </div>
-
</Border>
modified src/components/PatchTimeline.svelte
@@ -83,6 +83,7 @@
    display: flex;
    align-items: flex-start;
    gap: 0.5rem;
+
    word-break: break-word;
  }
  .wrapper {
    display: flex;
modified src/views/repo/Issue.svelte
@@ -24,6 +24,7 @@

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

+
  import AssigneeInput from "@app/components/AssigneeInput.svelte";
  import Border from "@app/components/Border.svelte";
  import CommentComponent from "@app/components/Comment.svelte";
  import CommentToggleInput from "@app/components/CommentToggleInput.svelte";
@@ -33,14 +34,13 @@
  import IssueSecondColumn from "@app/components/IssueSecondColumn.svelte";
  import IssueStateBadge from "@app/components/IssueStateBadge.svelte";
  import IssueStateButton from "@app/components/IssueStateButton.svelte";
-
  import IssueTimelineLifecycleAction from "@app/components/IssueTimelineLifecycleAction.svelte";
+
  import IssueTimeline from "@app/components/IssueTimeline.svelte";
  import LabelInput from "@app/components/LabelInput.svelte";
+
  import Sidebar from "@app/components/Sidebar.svelte";
  import TextInput from "@app/components/TextInput.svelte";
  import ThreadComponent from "@app/components/Thread.svelte";

  import Layout from "./Layout.svelte";
-
  import Sidebar from "@app/components/Sidebar.svelte";
-
  import AssigneeInput from "@app/components/AssigneeInput.svelte";

  interface Props {
    repo: RepoInfo;
@@ -72,6 +72,8 @@
  let labelSaveInProgress: boolean = $state(false);
  let assigneesSaveInProgress: boolean = $state(false);
  let focusReply: boolean = $state(false);
+
  let hideDiscussion = $state(false);
+
  let hideTimeline = $state(false);

  $effect(() => {
    // The component doesn't get destroyed when we switch between different
@@ -86,6 +88,8 @@
    editingTitle = false;
    updatedTitle = issue.title;
    focusReply = false;
+
    hideDiscussion = false;
+
    hideTimeline = false;
  });

  const project = $derived(repo.payloads["xyz.radicle.project"]!);
@@ -324,7 +328,7 @@
    width: 2rem;
  }
  .issue-body {
-
    margin-top: 1rem;
+
    margin: 1rem 0;
    position: relative;
  }
  /* We put the background and clip-path in a separate element to prevent
@@ -373,6 +377,9 @@
    margin-bottom: 0.5rem;
    color: var(--color-foreground-dim);
  }
+
  .hide {
+
    display: none;
+
  }
</style>

<Layout publicKey={config.publicKey}>
@@ -520,46 +527,64 @@
        {/snippet}
      </CommentComponent>
    </div>
-
    <div class="connector"></div>

-
    <div>
-
      {#each activity as op}
-
        {#each op.actions as action}
-
          {#if action.type === "lifecycle"}
-
            <IssueTimelineLifecycleAction {op} {action} />
-
            <div class="connector"></div>
-
          {:else if action.type === "comment"}
-
            {@const thread = threads.find(t => t.root.id === op.id)}
-
            {#if thread}
-
              <ThreadComponent
-
                {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}
+
    <div style:margin-bottom="1rem">
+
      <!-- svelte-ignore a11y_click_events_have_key_events -->
+
      <div
+
        role="button"
+
        tabindex="0"
+
        class="txt-semibold global-flex"
+
        style:margin-bottom="1rem"
+
        style:cursor="pointer"
+
        onclick={() => (hideDiscussion = !hideDiscussion)}>
+
        <Icon
+
          name={hideDiscussion ? "chevron-right" : "chevron-down"} />Discussion
+
      </div>
+
      <div class:hide={hideDiscussion}>
+
        {#each threads as thread}
+
          <ThreadComponent
+
            {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>
        {/each}
-
      {/each}
+

+
        <div id={`reply-${issue.id}`}>
+
          <CommentToggleInput
+
            disallowEmptyBody
+
            rid={repo.rid}
+
            focus={focusReply}
+
            onexpand={toggleReply}
+
            onclose={topLevelReplyOpen
+
              ? () => (topLevelReplyOpen = false)
+
              : undefined}
+
            placeholder="Leave a comment"
+
            submit={partial(createComment)} />
+
        </div>
+
      </div>
    </div>

-
    <div id={`reply-${issue.id}`}>
-
      <CommentToggleInput
-
        disallowEmptyBody
-
        rid={repo.rid}
-
        focus={focusReply}
-
        onexpand={toggleReply}
-
        onclose={topLevelReplyOpen
-
          ? () => (topLevelReplyOpen = false)
-
          : undefined}
-
        placeholder="Leave a comment"
-
        submit={partial(createComment)} />
+
    <div>
+
      <!-- svelte-ignore a11y_click_events_have_key_events -->
+
      <div
+
        role="button"
+
        tabindex="0"
+
        class="txt-semibold global-flex"
+
        style:margin-bottom="1rem"
+
        style:cursor="pointer"
+
        onclick={() => (hideTimeline = !hideTimeline)}>
+
        <Icon name={hideTimeline ? "chevron-right" : "chevron-down"} />Timeline
+
      </div>
+
      <div class:hide={hideTimeline}>
+
        <IssueTimeline {activity} />
+
      </div>
    </div>
  </div>
</Layout>