From f380b61fa01b498f664f9a69ea6aab6df774baad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C5=ABdolfs=20O=C5=A1i=C5=86=C5=A1?= Date: Wed, 15 Jan 2025 15:45:27 -0600 Subject: [PATCH] Add patch discussions --- src/components/Thread.svelte | 1 + src/views/repo/Patch.svelte | 335 ++++++++++++++++++++++++++--------- 2 files changed, 255 insertions(+), 81 deletions(-) diff --git a/src/components/Thread.svelte b/src/components/Thread.svelte index 8049842..7b87f94 100644 --- a/src/components/Thread.svelte +++ b/src/components/Thread.svelte @@ -80,6 +80,7 @@ .top-level-comment { position: relative; + z-index: 1; } /* We put the background and clip-path in a separate element to prevent popovers being clipped in the main element. */ diff --git a/src/views/repo/Patch.svelte b/src/views/repo/Patch.svelte index 451fdf0..4d4c933 100644 --- a/src/views/repo/Patch.svelte +++ b/src/views/repo/Patch.svelte @@ -10,8 +10,10 @@ import type { PatchStatus } from "./router"; import type { RepoInfo } from "@bindings/repo/RepoInfo"; import type { Revision } from "@bindings/cob/patch/Revision"; + import type { Thread } from "@bindings/cob/thread/Thread"; import partial from "lodash/partial"; + import { tick } from "svelte"; import * as roles from "@app/lib/roles"; import { @@ -19,6 +21,7 @@ patchStatusBackgroundColor, patchStatusColor, publicKeyFromDid, + scrollIntoView, } from "@app/lib/utils"; import { invoke } from "@app/lib/invoke"; import { nodeRunning } from "@app/lib/events"; @@ -29,6 +32,7 @@ import Border from "@app/components/Border.svelte"; import Changeset from "@app/components/Changeset.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"; @@ -41,6 +45,7 @@ import Sidebar from "@app/components/Sidebar.svelte"; import Tab from "@app/components/Tab.svelte"; import TextInput from "@app/components/TextInput.svelte"; + import ThreadComponent from "@app/components/Thread.svelte"; interface Props { repo: RepoInfo; @@ -73,8 +78,30 @@ let labelSaveInProgress: boolean = $state(false); let assigneesSaveInProgress: boolean = $state(false); let tab: "patch" | "revisions" = $state("patch"); - + let hideDiscussion = $state(false); let hideTimeline = $state(false); + let focusReply: boolean = $state(false); + let topLevelReplyOpen = $state(false); + + const threads = $derived( + ((revisions[0].discussion && + revisions[0].discussion + .filter( + comment => + (comment.id !== revisions[0].id && !comment.replyTo) || + comment.replyTo === revisions[0].id, + ) + .map(thread => { + return { + root: thread, + replies: + revisions[0].discussion && + revisions[0].discussion + .filter(comment => comment.replyTo === thread.id) + .sort((a, b) => a.edits[0].timestamp - b.edits[0].timestamp), + }; + }, [])) as Thread[]) || [], + ); $effect(() => { items = patches.content; @@ -89,49 +116,35 @@ tab = "patch"; editingTitle = false; updatedTitle = patch.title; + hideDiscussion = false; hideTimeline = false; }); const project = $derived(repo.payloads["xyz.radicle.project"]!); - async function loadHighlightedDiff(rid: string, base: string, head: string) { - return invoke("get_diff", { - rid, - options: { - base, - head, - unified: 5, - highlight: true, - }, - }); - } - - async function loadPatch(rid: string, patchId: string) { - patch = await invoke("patch_by_id", { - rid: rid, - id: patchId, - }); - revisions = await invoke("revisions_by_patch", { - rid: rid, - id: patchId, - }); - activity = await invoke[]>("activity_by_patch", { - rid: repo.rid, - id: patch.id, - }); - } + async function editTitle(rid: string, patchId: string, title: string) { + if (patch.title === updatedTitle) { + editingTitle = false; + return; + } - async function loadMoreSecondColumn() { - if (more) { - const p = await invoke>("list_patches", { - rid: repo.rid, - skip: cursor + 20, - take: 20, + try { + await invoke("edit_patch", { + rid, + cobId: patchId, + action: { + id: patchId, + type: "edit", + title, + target: "delegates", + }, + opts: { announce: $nodeRunning && $announce }, }); - - cursor = p.cursor; - more = p.more; - items = [...items, ...p.content]; + editingTitle = false; + } catch (error) { + console.error("Editing title failed: ", error); + } finally { + await reload(); } } @@ -175,29 +188,25 @@ } } - async function reload() { - [config, repo, patches, patch, revisions, activity] = await Promise.all([ - invoke("config"), - invoke("repo_by_id", { - rid: repo.rid, - }), - invoke>("list_patches", { - rid: repo.rid, - status, - }), - invoke("patch_by_id", { - rid: repo.rid, - id: patch.id, - }), - invoke("revisions_by_patch", { - rid: repo.rid, - id: patch.id, - }), - invoke[]>("activity_by_patch", { + async function saveState(state: Patch["state"]) { + try { + await invoke("edit_patch", { rid: repo.rid, - id: patch.id, - }), - ]); + cobId: patch.id, + action: { + type: "lifecycle", + state, + }, + opts: { announce: $nodeRunning && $announce }, + }); + if (initialStatus !== undefined) { + status = state["status"]; + } + } catch (error) { + console.error("Changing state failed", error); + } finally { + await reload(); + } } async function editRevision( @@ -218,7 +227,7 @@ opts: { announce: $nodeRunning && $announce }, }); } catch (error) { - console.error("Patch revision editing failed: ", error); + console.error("Editing revision failed: ", error); } finally { await reload(); } @@ -251,52 +260,162 @@ } } - async function saveState(state: Patch["state"]) { + async function editComment(commentId: string, body: string, embeds: Embed[]) { try { await invoke("edit_patch", { rid: repo.rid, cobId: patch.id, action: { - type: "lifecycle", - state, + type: "revision.comment.edit", + comment: commentId, + body, + revision: revisions[0].id, + embeds, }, opts: { announce: $nodeRunning && $announce }, }); - if (initialStatus !== undefined) { - status = state["status"]; - } } catch (error) { - console.error("Changing patch state failed", error); + console.error("Eediting comment failed: ", error); } finally { await reload(); } } - async function editTitle(id: string, title: string) { - if (patch.title === updatedTitle) { - editingTitle = false; - return; + async function createReply(replyTo: string, body: string, embeds: Embed[]) { + try { + await invoke("create_patch_comment", { + rid: repo.rid, + new: { id: patch.id, body, embeds, replyTo, revision: revisions[0].id }, + opts: { announce: $nodeRunning && $announce }, + }); + } catch (error) { + console.error("Creating reply failed", error); + } finally { + await reload(); } + } + async function reactOnComment( + publicKey: string, + commentId: string, + authors: Author[], + reaction: string, + ) { try { await invoke("edit_patch", { rid: repo.rid, cobId: patch.id, action: { - id, - type: "edit", - title, - target: "delegates", + type: "revision.comment.react", + comment: commentId, + reaction, + revision: revisions[0].id, + active: !authors.find( + ({ did }) => publicKeyFromDid(did) === publicKey, + ), }, opts: { announce: $nodeRunning && $announce }, }); - editingTitle = false; } catch (error) { - console.error("Patch title editing failed: ", error); + console.error("Editing comment reactions failed", error); + } finally { + await reload(); + } + } + + async function createComment(body: string, embeds: Embed[]) { + try { + await invoke("create_patch_comment", { + rid: repo.rid, + new: { id: patch.id, body, embeds, revision: revisions[0].id }, + opts: { announce: $nodeRunning && $announce }, + }); + } catch (error) { + console.error("Creating comment failed: ", error); } finally { await reload(); } } + + async function toggleReply() { + topLevelReplyOpen = !topLevelReplyOpen; + if (!topLevelReplyOpen) { + return; + } + + await tick(); + scrollIntoView(`reply-${patch.id}`, { + behavior: "smooth", + block: "center", + }); + focusReply = true; + } + + async function loadPatch(rid: string, patchId: string) { + patch = await invoke("patch_by_id", { + rid: rid, + id: patchId, + }); + revisions = await invoke("revisions_by_patch", { + rid: rid, + id: patchId, + }); + activity = await invoke[]>("activity_by_patch", { + rid: repo.rid, + id: patch.id, + }); + } + + async function loadHighlightedDiff(rid: string, base: string, head: string) { + return invoke("get_diff", { + rid, + options: { + base, + head, + unified: 5, + highlight: true, + }, + }); + } + + async function loadMoreSecondColumn() { + if (more) { + const p = await invoke>("list_patches", { + rid: repo.rid, + skip: cursor + 20, + take: 20, + }); + + cursor = p.cursor; + more = p.more; + items = [...items, ...p.content]; + } + } + + async function reload() { + [config, repo, patches, patch, revisions, activity] = await Promise.all([ + invoke("config"), + invoke("repo_by_id", { + rid: repo.rid, + }), + invoke>("list_patches", { + rid: repo.rid, + status, + }), + invoke("patch_by_id", { + rid: repo.rid, + id: patch.id, + }), + invoke("revisions_by_patch", { + rid: repo.rid, + id: patch.id, + }), + invoke[]>("activity_by_patch", { + rid: repo.rid, + id: patch.id, + }), + ]); + } @@ -425,7 +550,7 @@ autofocus onSubmit={async () => { if (updatedTitle.trim().length > 0) { - await editTitle(patch.id, updatedTitle); + await editTitle(repo.rid, patch.id, updatedTitle); } }} onDismiss={() => { @@ -437,7 +562,7 @@ name="checkmark" onclick={async () => { if (updatedTitle.trim().length > 0) { - await editTitle(patch.id, updatedTitle); + await editTitle(repo.rid, patch.id, updatedTitle); } }} /> delegate.did), revisions[0].author.did, ) && partial(editRevision, revisions[0].id)}> + {#snippet actions()} + + {/snippet} @@ -568,6 +696,51 @@ +
+ +
(hideDiscussion = !hideDiscussion)}> + Discussion +
+
+ {#each threads as thread} + delegate.did), + )} + editComment={partial(editComment)} + createReply={partial(createReply)} + reactOnComment={partial(reactOnComment, config.publicKey)} /> +
+ {/each} + +
+ (topLevelReplyOpen = false) + : undefined} + placeholder="Leave a comment" + submit={partial(createComment)} /> +
+
+
+
Timeline
-
+