From ad778dd36d48843f80f2654a0e24bf12ef7cb4e4 Mon Sep 17 00:00:00 2001
From: Imtiaz Mehmood <116969564+imtiazmehmood@users.noreply.github.com>
Date: Wed, 8 May 2024 23:10:00 +0500
Subject: [PATCH] Feat/quick delete chat (#1302)

* feat:quick delete chat thread

* update:pull request template

* refactor bulk-deletion implementation

* unset pull_request_changes

* add border none for desktop support

* unset marks when toggling bulk mode

---------

Co-authored-by: timothycarambat <rambat1010@gmail.com>
---
 .../ThreadContainer/ThreadItem/index.jsx      | 63 ++++++++++++---
 .../ThreadContainer/index.jsx                 | 78 ++++++++++++++++++-
 frontend/src/models/workspaceThread.js        | 12 +++
 server/endpoints/workspaceThreads.js          | 23 ++++++
 server/models/workspaceThread.js              |  2 +-
 5 files changed, 164 insertions(+), 14 deletions(-)

diff --git a/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/ThreadItem/index.jsx b/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/ThreadItem/index.jsx
index e31f4793b..87fd55587 100644
--- a/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/ThreadItem/index.jsx
+++ b/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/ThreadItem/index.jsx
@@ -1,7 +1,13 @@
 import Workspace from "@/models/workspace";
 import paths from "@/utils/paths";
 import showToast from "@/utils/toast";
-import { DotsThree, PencilSimple, Trash } from "@phosphor-icons/react";
+import {
+  ArrowCounterClockwise,
+  DotsThree,
+  PencilSimple,
+  Trash,
+  X,
+} from "@phosphor-icons/react";
 import { useEffect, useRef, useState } from "react";
 import { useParams } from "react-router-dom";
 import truncate from "truncate";
@@ -14,7 +20,9 @@ export default function ThreadItem({
   workspace,
   thread,
   onRemove,
+  toggleMarkForDeletion,
   hasNext,
+  ctrlPressed = false,
 }) {
   const { slug } = useParams();
   const optionsContainer = useRef(null);
@@ -57,14 +65,30 @@ export default function ThreadItem({
       />
       <div className="flex w-full items-center justify-between pr-2 group relative">
         {thread.deleted ? (
-          <a className="w-full">
-            <p className={`text-left text-sm text-slate-400/50 italic`}>
-              deleted thread
-            </p>
-          </a>
+          <div className="w-full flex justify-between">
+            <div className="w-full ">
+              <p className={`text-left text-sm text-slate-400/50 italic`}>
+                deleted thread
+              </p>
+            </div>
+            {ctrlPressed && (
+              <button
+                type="button"
+                className="border-none"
+                onClick={() => toggleMarkForDeletion(thread.id)}
+              >
+                <ArrowCounterClockwise
+                  className="text-zinc-300 hover:text-white"
+                  size={18}
+                />
+              </button>
+            )}
+          </div>
         ) : (
           <a
-            href={window.location.pathname === linkTo ? "#" : linkTo}
+            href={
+              window.location.pathname === linkTo || ctrlPressed ? "#" : linkTo
+            }
             className="w-full"
             aria-current={isActive ? "page" : ""}
           >
@@ -79,15 +103,30 @@ export default function ThreadItem({
         )}
         {!!thread.slug && !thread.deleted && (
           <div ref={optionsContainer}>
-            <div className="flex items-center w-fit group-hover:visible md:invisible gap-x-1">
+            {ctrlPressed ? (
               <button
                 type="button"
-                onClick={() => setShowOptions(!showOptions)}
-                aria-label="Thread options"
+                className="border-none"
+                onClick={() => toggleMarkForDeletion(thread.id)}
               >
-                <DotsThree className="text-slate-300" size={25} />
+                <X
+                  className="text-zinc-300 hover:text-white"
+                  weight="bold"
+                  size={18}
+                />
               </button>
-            </div>
+            ) : (
+              <div className="flex items-center w-fit group-hover:visible md:invisible gap-x-1">
+                <button
+                  type="button"
+                  className="border-none"
+                  onClick={() => setShowOptions(!showOptions)}
+                  aria-label="Thread options"
+                >
+                  <DotsThree className="text-slate-300" size={25} />
+                </button>
+              </div>
+            )}
             {showOptions && (
               <OptionsMenu
                 containerRef={optionsContainer}
diff --git a/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/index.jsx b/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/index.jsx
index d3659e3f0..f3c0ac2a1 100644
--- a/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/index.jsx
+++ b/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/index.jsx
@@ -1,7 +1,7 @@
 import Workspace from "@/models/workspace";
 import paths from "@/utils/paths";
 import showToast from "@/utils/toast";
-import { Plus, CircleNotch } from "@phosphor-icons/react";
+import { Plus, CircleNotch, Trash } from "@phosphor-icons/react";
 import { useEffect, useState } from "react";
 import ThreadItem from "./ThreadItem";
 import { useParams } from "react-router-dom";
@@ -10,6 +10,7 @@ export default function ThreadContainer({ workspace }) {
   const { threadSlug = null } = useParams();
   const [threads, setThreads] = useState([]);
   const [loading, setLoading] = useState(true);
+  const [ctrlPressed, setCtrlPressed] = useState(false);
 
   useEffect(() => {
     async function fetchThreads() {
@@ -21,6 +22,43 @@ export default function ThreadContainer({ workspace }) {
     fetchThreads();
   }, [workspace.slug]);
 
+  // Enable toggling of meta-key (ctrl on win and cmd/fn on others)
+  useEffect(() => {
+    const handleKeyDown = (event) => {
+      if (["Control", "Meta"].includes(event.key)) {
+        setCtrlPressed((prev) => !prev);
+        // when toggling, unset bulk progress so
+        // previously marked threads that were never deleted
+        // come back to life.
+        setThreads((prev) =>
+          prev.map((t) => {
+            return { ...t, deleted: false };
+          })
+        );
+      }
+    };
+    window.addEventListener("keydown", handleKeyDown);
+    return () => {
+      window.removeEventListener("keydown", handleKeyDown);
+    };
+  }, []);
+
+  const toggleForDeletion = (id) => {
+    setThreads((prev) =>
+      prev.map((t) => {
+        if (t.id !== id) return t;
+        return { ...t, deleted: !t.deleted };
+      })
+    );
+  };
+
+  const handleDeleteAll = async () => {
+    const slugs = threads.filter((t) => t.deleted === true).map((t) => t.slug);
+    await Workspace.threads.deleteBulk(workspace.slug, slugs);
+    setThreads((prev) => prev.filter((t) => !t.deleted));
+    setCtrlPressed(false);
+  };
+
   function removeThread(threadId) {
     setThreads((prev) =>
       prev.map((_t) => {
@@ -28,6 +66,12 @@ export default function ThreadContainer({ workspace }) {
         return { ..._t, deleted: true };
       })
     );
+
+    // Show thread was deleted, but then remove from threads entirely so it will
+    // not appear in bulk-selection.
+    setTimeout(() => {
+      setThreads((prev) => prev.filter((t) => !t.deleted));
+    }, 500);
   }
 
   if (loading) {
@@ -58,6 +102,8 @@ export default function ThreadContainer({ workspace }) {
         <ThreadItem
           key={thread.slug}
           idx={i + 1}
+          ctrlPressed={ctrlPressed}
+          toggleMarkForDeletion={toggleForDeletion}
           activeIdx={activeThreadIdx}
           isActive={activeThreadIdx === i + 1}
           workspace={workspace}
@@ -66,6 +112,11 @@ export default function ThreadContainer({ workspace }) {
           hasNext={i !== threads.length - 1}
         />
       ))}
+      <DeleteAllThreadButton
+        ctrlPressed={ctrlPressed}
+        threads={threads}
+        onDelete={handleDeleteAll}
+      />
       <NewThreadButton workspace={workspace} />
     </div>
   );
@@ -113,3 +164,28 @@ function NewThreadButton({ workspace }) {
     </button>
   );
 }
+
+function DeleteAllThreadButton({ ctrlPressed, threads, onDelete }) {
+  if (!ctrlPressed || threads.filter((t) => t.deleted).length === 0)
+    return null;
+  return (
+    <button
+      type="button"
+      onClick={onDelete}
+      className="w-full relative flex h-[40px] items-center border-none hover:bg-red-400/20 rounded-lg group"
+    >
+      <div className="flex w-full gap-x-2 items-center pl-4">
+        <div className="bg-zinc-600 p-2 rounded-lg h-[24px] w-[24px] flex items-center justify-center">
+          <Trash
+            weight="bold"
+            size={14}
+            className="shrink-0 text-slate-100 group-hover:text-red-400"
+          />
+        </div>
+        <p className="text-white text-left text-sm group-hover:text-red-400">
+          Delete Selected
+        </p>
+      </div>
+    </button>
+  );
+}
diff --git a/frontend/src/models/workspaceThread.js b/frontend/src/models/workspaceThread.js
index b1bcaf644..039ee1868 100644
--- a/frontend/src/models/workspaceThread.js
+++ b/frontend/src/models/workspaceThread.js
@@ -62,6 +62,18 @@ const WorkspaceThread = {
       .then((res) => res.ok)
       .catch(() => false);
   },
+  deleteBulk: async function (workspaceSlug, threadSlugs = []) {
+    return await fetch(
+      `${API_BASE}/workspace/${workspaceSlug}/thread-bulk-delete`,
+      {
+        method: "DELETE",
+        body: JSON.stringify({ slugs: threadSlugs }),
+        headers: baseHeaders(),
+      }
+    )
+      .then((res) => res.ok)
+      .catch(() => false);
+  },
   chatHistory: async function (workspaceSlug, threadSlug) {
     const history = await fetch(
       `${API_BASE}/workspace/${workspaceSlug}/thread/${threadSlug}/chats`,
diff --git a/server/endpoints/workspaceThreads.js b/server/endpoints/workspaceThreads.js
index 6ebf2ef4a..05b584af5 100644
--- a/server/endpoints/workspaceThreads.js
+++ b/server/endpoints/workspaceThreads.js
@@ -92,6 +92,29 @@ function workspaceThreadEndpoints(app) {
     }
   );
 
+  app.delete(
+    "/workspace/:slug/thread-bulk-delete",
+    [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug],
+    async (request, response) => {
+      try {
+        const { slugs = [] } = reqBody(request);
+        if (slugs.length === 0) return response.sendStatus(200).end();
+
+        const user = await userFromSession(request, response);
+        const workspace = response.locals.workspace;
+        await WorkspaceThread.delete({
+          slug: { in: slugs },
+          user_id: user?.id ?? null,
+          workspace_id: workspace.id,
+        });
+        response.sendStatus(200).end();
+      } catch (e) {
+        console.log(e.message, e);
+        response.sendStatus(500).end();
+      }
+    }
+  );
+
   app.get(
     "/workspace/:slug/thread/:threadSlug/chats",
     [
diff --git a/server/models/workspaceThread.js b/server/models/workspaceThread.js
index 0f99082b4..a2a96f310 100644
--- a/server/models/workspaceThread.js
+++ b/server/models/workspaceThread.js
@@ -61,7 +61,7 @@ const WorkspaceThread = {
 
   delete: async function (clause = {}) {
     try {
-      await prisma.workspace_threads.delete({
+      await prisma.workspace_threads.deleteMany({
         where: clause,
       });
       return true;
-- 
GitLab