Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
.. AddRepoButton.svelte AnnounceSwitch.svelte AppSidebar.svelte AssigneeInput.svelte BadgeCounterSwitch.svelte Button.svelte Changes.svelte Changeset.svelte CheckoutPatchButton.svelte CheckoutRepoButton.svelte Clipboard.svelte CobCacheWarning.svelte CobCommitTeaser.svelte CodeFontSwitch.svelte Command.svelte Comment.svelte CommentToggleInput.svelte CommitsContainer.svelte CompactCommitAuthorship.svelte ConfirmClear.svelte CopyableId.svelte Diff.svelte DiffStatBadge.svelte Discussion.svelte DropdownList.svelte DropdownListItem.svelte EditableTitle.svelte ExtendedTextarea.svelte ExternalLink.svelte FileBlock.svelte FileDiff.svelte FileTreeFile.svelte FileTreeFolder.svelte FontSizeSwitch.svelte FullscreenModalPortal.svelte FullWindowError.svelte FuzzySearch.svelte HoverPopover.svelte Icon.svelte Id.svelte IdentityButton.svelte InboxList.svelte InfiniteScrollSentinel.svelte InlineTitle.svelte IssueStateButton.svelte IssueTeaser.svelte IssueTimeline.svelte JobCob.svelte Label.svelte LabelInput.svelte Markdown.svelte NewPatchButton.svelte NodeId.svelte NodeStatusButton.svelte NotificationsByRepo.svelte NotificationTeaser.svelte PatchMetadata.svelte PatchStateButton.svelte PatchTeaser.svelte PatchTimeline.svelte Path.svelte Popover.svelte PreviewSwitch.svelte RadicleWordmark.svelte Reactions.svelte ReactionSelector.svelte RepoAvatar.svelte RepoHeader.svelte Review.svelte Revision.svelte RevisionBadges.svelte RevisionReviews.svelte Revisions.svelte ScrollArea.svelte SidebarRepoList.svelte Spinner.svelte Textarea.svelte TextInput.svelte ThemeSwitch.svelte Thread.svelte Topbar.svelte Tree.svelte UpdateSwitch.svelte UserAvatar.svelte VerdictBadge.svelte VerdictButton.svelte VisibilityBadge.svelte
radicle-desktop src components IssueTimeline.svelte
<script lang="ts">
  import type { Author } from "@bindings/cob/Author";
  import type { Action } from "@bindings/cob/issue/Action";
  import type { Operation } from "@bindings/cob/Operation";

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

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

  import {
    absoluteTimestamp,
    authorForNodeId,
    formatTimestamp,
    issueStatusColor,
    pluralize,
  } from "@app/lib/utils";

  import Icon from "@app/components/Icon.svelte";
  import Id from "@app/components/Id.svelte";
  import NodeId from "@app/components/NodeId.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<A>(previousState: A[], newState: A[]) {
    const removed = previousState.filter(x => !newState.includes(x));
    const added = newState.filter(x => !previousState.includes(x));
    return { removed, added };
  }
</script>

<style>
  .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-body-m-regular">
  {#each timeline as op}
    {#if op.type === "lifecycle"}
      <div class="timeline-item">
        <div class="icon" style:color={issueStatusColor[op.state.status]}>
          {#if op.state.status === "open"}
            <Icon name="issue" />
          {:else}
            <Icon name="issue-closed" />
          {/if}
        </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"}
      <div class="timeline-item">
        <div class="icon">
          <Icon name="label" />
        </div>
        <div class="wrapper">
          <NodeId {...authorForNodeId(op.author)} />
          {#if op.previous && op.previous.type === op.type}
            {@const changed = itemDiff(op.previous?.labels ?? [], op.labels)}
            {#if changed.added.length || changed.removed.length}
              {#if changed.added.length}
                added {pluralize("label", changed.removed.length)}
                {#each changed.added as label}
                  <b>{label}</b>
                {/each}
              {/if}
              {#if changed.removed.length}
                removed {pluralize("label", changed.removed.length)}
                {#each changed.removed as label}
                  <b>{label}</b>
                {/each}
              {/if}
            {/if}
          {:else}
            added {pluralize("label", op.labels.length)}
            {#each op.labels as label}
              <b>{label}</b>
            {/each}
          {/if}
          <div title={absoluteTimestamp(op.timestamp)}>
            {formatTimestamp(op.timestamp)}
          </div>
        </div>
      </div>
    {:else if op.type === "assign"}
      <div class="timeline-item">
        <div class="icon">
          <Icon name="avatar-incognito" />
        </div>
        <div class="wrapper">
          <NodeId {...authorForNodeId(op.author)} />
          {#if op.previous && op.previous.type === op.type}
            {@const changed = itemDiff<Author>(
              op.previous?.assignees ?? [],
              op.assignees,
            )}
            {#if changed.added.length || changed.removed.length}
              {#if changed.added.length}
                assigned
                {#each changed.added as assignee}
                  <NodeId {...authorForNodeId(assignee)} />
                {/each}
              {/if}
              {#if changed.removed.length}
                unassigned
                {#each changed.removed as assignee}
                  <NodeId {...authorForNodeId(assignee)} />
                {/each}
              {/if}
            {/if}
          {:else}
            assigned
            {#each op.assignees as assignee}
              <NodeId {...authorForNodeId(assignee)} />
            {/each}
          {/if}
          <div title={absoluteTimestamp(op.timestamp)}>
            {formatTimestamp(op.timestamp)}
          </div>
        </div>
      </div>
    {:else if op.type === "edit"}
      {#if op.previous && op.previous.type === op.type}
        <div class="timeline-item">
          <div class="icon">
            <Icon name="edit" />
          </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-feedback-success-text)">
            <Icon name="issue" />
          </div>
          <div class="wrapper">
            <NodeId {...authorForNodeId(op.author)} />
            <div>
              opened issue <Id id={op.id} clipboard={op.id} />
            </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 class="global-link" 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>