diff --git a/client/package-lock.json b/client/package-lock.json index 51880d923..c42901c0d 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -20,6 +20,7 @@ "@tiptap/pm": "^2.1.12", "@tiptap/react": "^2.1.12", "@tiptap/starter-kit": "^2.1.12", + "@types/react-icons": "^3.0.0", "node-fetch": "^3.3.2", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -1648,6 +1649,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-icons": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/react-icons/-/react-icons-3.0.0.tgz", + "integrity": "sha512-Vefs6LkLqF61vfV7AiAqls+vpR94q67gunhMueDznG+msAkrYgRxl7gYjNem/kZ+as2l2mNChmF1jRZzzQQtMg==", + "deprecated": "This is a stub types definition. react-icons provides its own type definitions, so you do not need this installed.", + "dependencies": { + "react-icons": "*" + } + }, "node_modules/@types/scheduler": { "version": "0.16.5", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.5.tgz", diff --git a/client/package.json b/client/package.json index 1e532a118..1a5b54071 100644 --- a/client/package.json +++ b/client/package.json @@ -23,6 +23,7 @@ "@tiptap/pm": "^2.1.12", "@tiptap/react": "^2.1.12", "@tiptap/starter-kit": "^2.1.12", + "@types/react-icons": "^3.0.0", "node-fetch": "^3.3.2", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/client/src/routes/inbox.tsx b/client/src/routes/inbox.tsx index c10a750cf..ae3ca5e8a 100644 --- a/client/src/routes/inbox.tsx +++ b/client/src/routes/inbox.tsx @@ -30,6 +30,7 @@ import { IconFolderOff, IconCheck, IconX, + IconTrash, } from "@tabler/icons-react"; interface Thread { @@ -86,6 +87,10 @@ export default function InboxPage() { const [response, setResponse] = useState(undefined); + const [storedResponses, setStoredResponses] = useState<{ + [key: number]: Response; + }>({}); + const viewport = useRef(null); const editor = useEditor( @@ -176,11 +181,20 @@ export default function InboxPage() { }; const getResponse = () => { + // Checks if response is already stored + const currEmailID = + activeThread.emailList[activeThread.emailList.length - 1].id; + if (storedResponses[currEmailID]) { + const oldResponse = storedResponses[currEmailID]; + setResponse(oldResponse); + setContent(oldResponse.content.replace("\n", "
")); + return; + } + + // Otherwise fetches response from server const formData = new FormData(); - formData.append( - "id", - activeThread.emailList[activeThread.emailList.length - 1].id.toString(), - ); + formData.append("id", currEmailID.toString()); + fetch(`/api/emails/get_response`, { method: "POST", body: formData, @@ -194,6 +208,9 @@ export default function InboxPage() { }); }) .then((data) => { + setStoredResponses((oldResponses) => { + return { ...oldResponses, [currEmailID]: data }; + }); setResponse(data); setContent(data.content.replaceAll("\n", "
")); }); @@ -391,6 +408,66 @@ export default function InboxPage() { }); }; + const deleteThread = () => { + notifications.show({ + id: "loading", + title: "Loading", + color: "red", + message: "Deleting thread...", + loading: true, + autoClose: false, + }); + const formData = new FormData(); + formData.append("id", active.toString()); + fetch("/api/emails/delete", { + method: "POST", + body: formData, + }) + .then((res) => { + if (res.ok) return res.json(); + notifications.update({ + id: "loading", + title: "Error!", + color: "red", + loading: false, + message: "Something went wrong!", + }); + }) + .then(() => { + setThreads((oldThreads) => { + const updatedThreads = oldThreads.filter( + (thread) => thread.id !== active, + ); + + let newActive = -1; + + //setting to next email hopefully + if (updatedThreads.length > 0) { + const currentIndex = oldThreads.findIndex( + (thread) => thread.id === active, + ); + if (currentIndex >= 0 && currentIndex < updatedThreads.length) { + newActive = updatedThreads[currentIndex].id; + } else if (currentIndex >= updatedThreads.length) { + newActive = updatedThreads[updatedThreads.length - 1].id; + } + } + setActive(newActive); + return updatedThreads; + }); + notifications.update({ + id: "loading", + title: "Success!", + color: "green", + message: "Deleted thread", + icon: , + autoClose: 2000, + withCloseButton: false, + loading: false, + }); + }); + }; + useEffect(() => { if (activeThread && activeThread.emailList.length > threadSize) { if (viewport && viewport.current) @@ -752,6 +829,16 @@ export default function InboxPage() { Unresolve )} + + {!activeThread.resolved && ( + + )} diff --git a/server/controllers/emails.py b/server/controllers/emails.py index ca0667444..2f2271f02 100644 --- a/server/controllers/emails.py +++ b/server/controllers/emails.py @@ -371,8 +371,11 @@ def send_email(): ).scalar() if not thread: return {"message": "Thread not found"}, 400 + + # replace
with \n in body + breaked_line_text = data["body"].replace("
", "\n") clean_regex = re.compile("<.*?>") - clean_text = re.sub(clean_regex, " ", data["body"]) + clean_text = re.sub(clean_regex, " ", breaked_line_text) context = {"body": data["body"]} template = env.get_template("template.html") body = template.render(**context) @@ -519,6 +522,24 @@ def unresolve(): return {"message": "Successfully updated"}, 200 +@emails.route("/delete", methods=["POST"]) +def delete(): + """POST /delete + + Delete an email thread. + """ + data = request.form + thread = db.session.execute(select(Thread).where(Thread.id == data["id"])).scalar() + if not thread: + return {"message": "Thread not found"}, 400 + db.session.delete(thread) + db.session.commit() + + print("deleted thread", flush=True) + + return {"message": "Successfully deleted"}, 200 + + @emails.route("/get_threads", methods=["GET"]) def get_threads(): """GET /get_threads diff --git a/server/models/email.py b/server/models/email.py index 5f4758500..2aafca92f 100644 --- a/server/models/email.py +++ b/server/models/email.py @@ -41,7 +41,13 @@ class Email(db.Model): message_id: Mapped[str] = mapped_column(nullable=False) response: Mapped[Optional["Response"]] = relationship( - "Response", back_populates="email", init=False + "Response", + back_populates="email", + cascade="all, delete-orphan", + single_parent=True, + uselist=False, + passive_deletes=True, + init=False, ) is_reply: Mapped[bool] = mapped_column(nullable=False)