diff --git a/app/src/backend.ts b/app/src/backend.ts index 4d22b5d6..9903aa2c 100644 --- a/app/src/backend.ts +++ b/app/src/backend.ts @@ -78,6 +78,12 @@ export class Backend extends EventEmitter { id: chatID, messages: new MessageTree(), } as Chat; + + if (response[chatID].deleted) { + chatManager.deleteChat(chatID); + continue; + } + chat.title = response[chatID].title || chat.title; chat.messages.addMessages(response[chatID].messages); chatManager.loadChat(chat); @@ -131,6 +137,14 @@ export class Backend extends EventEmitter { return null; } + async deleteChat(id: string) { + if (!this.isAuthenticated) { + return; + } + + return this.post(endpoint + '/delete', { id }); + } + async get(url: string) { const response = await fetch(url); if (!response.ok) { diff --git a/app/src/chat-manager.ts b/app/src/chat-manager.ts index f36f9fd9..5c9131bb 100644 --- a/app/src/chat-manager.ts +++ b/app/src/chat-manager.ts @@ -28,13 +28,20 @@ export class ChatManager extends EventEmitter { }); channel.onmessage = (message: { - type: 'chat-update', + type: 'chat-update' | 'chat-delete', data: string, }) => { - const chat = deserializeChat(message.data); - const id = chat.id; - this.chats.set(id, chat); - this.emit(id); + switch (message.type) { + case 'chat-update': + const chat = deserializeChat(message.data); + const id = chat.id; + this.chats.set(id, chat); + this.emit(id); + break; + case 'chat-delete': + this.deleteChat(message.data, false); + break; + } }; (async () => { @@ -69,7 +76,7 @@ export class ChatManager extends EventEmitter { public async sendMessage(message: UserSubmittedMessage) { const chat = this.chats.get(message.chatID); - if (!chat) { + if (!chat || chat.deleted) { throw new Error('Chat not found'); } @@ -101,7 +108,7 @@ export class ChatManager extends EventEmitter { public async regenerate(message: Message, requestedParameters: Parameters) { const chat = this.chats.get(message.chatID); - if (!chat) { + if (!chat || chat.deleted) { throw new Error('Chat not found'); } @@ -116,7 +123,7 @@ export class ChatManager extends EventEmitter { const latestMessage = messages[messages.length - 1]; const chat = this.chats.get(latestMessage.chatID); - if (!chat) { + if (!chat || chat.deleted) { throw new Error('Chat not found'); } @@ -250,12 +257,19 @@ export class ChatManager extends EventEmitter { const serialized = await idb.get('chats'); if (serialized) { for (const chat of serialized) { - const messages = new MessageTree(); - for (const m of chat.messages) { - messages.addMessage(m); + try { + if (chat.deleted) { + continue; + } + const messages = new MessageTree(); + for (const m of chat.messages) { + messages.addMessage(m); + } + chat.messages = messages; + this.loadChat(chat); + } catch (e) { + console.error(e); } - chat.messages = messages; - this.loadChat(chat); } this.emit('update'); } @@ -268,6 +282,11 @@ export class ChatManager extends EventEmitter { } const existing = this.chats.get(chat.id); + + if (existing && existing.deleted) { + return; + } + if (existing && existing.title && !chat.title) { chat.title = existing.title; } @@ -283,6 +302,15 @@ export class ChatManager extends EventEmitter { public get(id: string): Chat | undefined { return this.chats.get(id); } + + public deleteChat(id: string, broadcast = true) { + this.chats.delete(id); + this.search.delete(id); + this.emit(id); + if (broadcast) { + channel.postMessage({ type: 'chat-delete', data: id }); + } + } } export class Search { @@ -309,6 +337,10 @@ export class Search { } } + public delete(id: string) { + this.index.remove({ id }); + } + public query(query: string) { if (!query?.trim().length) { const searchResults = Array.from(this.chats.values()) diff --git a/app/src/components/sidebar/index.tsx b/app/src/components/sidebar/index.tsx index 09c6ac90..7ffcf293 100644 --- a/app/src/components/sidebar/index.tsx +++ b/app/src/components/sidebar/index.tsx @@ -103,6 +103,7 @@ export default function Sidebar(props: { className?: string; }) { const intl = useIntl(); + const context = useAppContext(); const dispatch = useAppDispatch(); const sidebarOpen = useAppSelector(selectSidebarOpen); const onBurgerClick = useCallback(() => dispatch(toggleSidebar()), [dispatch]); @@ -147,13 +148,13 @@ export default function Sidebar(props: { backend.current?.logout()} icon={}> - + */} )} - ), [sidebarOpen, width, ref, burgerLabel, onBurgerClick, dispatch]); + ), [sidebarOpen, width, ref, burgerLabel, onBurgerClick, dispatch, context.chat.chats.size]); return elem; } \ No newline at end of file diff --git a/app/src/components/sidebar/recent-chats.tsx b/app/src/components/sidebar/recent-chats.tsx index 9749b665..f0635a2c 100644 --- a/app/src/components/sidebar/recent-chats.tsx +++ b/app/src/components/sidebar/recent-chats.tsx @@ -1,10 +1,13 @@ import styled from '@emotion/styled'; import { useCallback, useEffect } from 'react'; import { FormattedMessage } from 'react-intl'; -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import { useAppContext } from '../../context'; import { useAppDispatch } from '../../store'; import { toggleSidebar } from '../../store/sidebar'; +import { ActionIcon, Menu } from '@mantine/core'; +import { useModals } from '@mantine/modals'; +import { backend } from '../../backend'; const Container = styled.div` margin: calc(1.618rem - 1rem); @@ -19,8 +22,9 @@ const Empty = styled.p` const ChatList = styled.div``; -const ChatListItem = styled(Link)` +const ChatListItemLink = styled(Link)` display: block; + position: relative; padding: 0.4rem 1rem; margin: 0.218rem 0; line-height: 1.7; @@ -35,15 +39,13 @@ const ChatListItem = styled(Link)` background: #2b3d54; } - &, * { - color: white; - } - strong { display: block; font-weight: 400; font-size: 1rem; line-height: 1.6; + padding-right: 1rem; + color: white; } p { @@ -51,8 +53,77 @@ const ChatListItem = styled(Link)` font-weight: 200; opacity: 0.8; } + + .mantine-ActionIcon-root { + position: absolute; + right: 0.5rem; + top: 50%; + margin-top: -14px; + } `; +function ChatListItem(props: { chat: any, onClick: any, selected: boolean }) { + const c = props.chat; + const context = useAppContext(); + const modals = useModals(); + const navigate = useNavigate(); + + const onDelete = useCallback(() => { + modals.openConfirmModal({ + title: "Are you sure you want to delete this chat?", + children:

The chat "{c.title}" will be permanently deleted. This cannot be undone.

, + labels: { + confirm: "Delete permanently", + cancel: "Cancel", + }, + confirmProps: { + color: 'red', + }, + onConfirm: async () => { + try { + await backend.current?.deleteChat(c.chatID); + context.chat.deleteChat(c.chatID); + navigate('/'); + } catch (e) { + console.error(e); + modals.openConfirmModal({ + title: "Something went wrong", + children:

The chat "{c.title}" could not be deleted.

, + labels: { + confirm: "Try again", + cancel: "Cancel", + }, + onConfirm: onDelete, + }); + } + }, + }); + }, [c.chatID, c.title]); + + return ( + + {c.title || } + {props.selected && ( + + + + + + + + }> + + + + + )} + + ); +} + export default function RecentChats(props: any) { const context = useAppContext(); const dispatch = useAppDispatch(); @@ -79,13 +150,7 @@ export default function RecentChats(props: any) { {recentChats.length > 0 && {recentChats.map(c => ( - - {c.title || } - + ))} } {recentChats.length === 0 && diff --git a/app/src/types.ts b/app/src/types.ts index c9724951..8d86da81 100644 --- a/app/src/types.ts +++ b/app/src/types.ts @@ -44,6 +44,7 @@ export interface Chat { title?: string | null; created: number; updated: number; + deleted?: boolean; } export function serializeChat(chat: Chat): string { diff --git a/server/src/database/index.ts b/server/src/database/index.ts index 1ccd41ee..703d2ff6 100644 --- a/server/src/database/index.ts +++ b/server/src/database/index.ts @@ -12,4 +12,6 @@ export default abstract class Database { public abstract insertMessages(userID: string, messages: any[]): Promise; public abstract createShare(userID: string|null, id: string): Promise; public abstract setTitle(userID: string, chatID: string, title: string): Promise; + public abstract deleteChat(userID: string, chatID: string): Promise; + public abstract getDeletedChatIDs(userID: string): Promise; } \ No newline at end of file diff --git a/server/src/database/sqlite.ts b/server/src/database/sqlite.ts index 11b02ab9..03361f2b 100644 --- a/server/src/database/sqlite.ts +++ b/server/src/database/sqlite.ts @@ -46,6 +46,12 @@ export class SQLiteAdapter extends Database { title TEXT )`); + db.run(`CREATE TABLE IF NOT EXISTS deleted_chats ( + id TEXT PRIMARY KEY, + user_id TEXT, + deleted_at DATETIME + )`); + db.run(`CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, user_id TEXT, @@ -170,4 +176,25 @@ export class SQLiteAdapter extends Database { }); }); } + + public async deleteChat(userID: string, chatID: string): Promise { + db.serialize(() => { + db.run(`DELETE FROM chats WHERE id = ? AND user_id = ?`, [chatID, userID]); + db.run(`DELETE FROM messages WHERE chat_id = ? AND user_id = ?`, [chatID, userID]); + db.run(`INSERT INTO deleted_chats (id, user_id, deleted_at) VALUES (?, ?, ?)`, [chatID, userID, new Date()]); + console.log(`[database:sqlite] deleted chat ${chatID}`); + }); + } + + public async getDeletedChatIDs(userID: string): Promise { + return new Promise((resolve, reject) => { + db.all(`SELECT * FROM deleted_chats WHERE user_id = ?`, [userID], (err: any, rows: any) => { + if (err) { + reject(err); + } else { + resolve(rows.map((row: any) => row.id)); + } + }); + }); + } } \ No newline at end of file diff --git a/server/src/endpoints/delete-chat.ts b/server/src/endpoints/delete-chat.ts new file mode 100644 index 00000000..9d11a702 --- /dev/null +++ b/server/src/endpoints/delete-chat.ts @@ -0,0 +1,13 @@ +import express from 'express'; +import RequestHandler from "./base"; + +export default class DeleteChatRequestHandler extends RequestHandler { + async handler(req: express.Request, res: express.Response) { + await this.context.database.deleteChat(this.userID!, req.body.id); + res.json({ status: 'ok' }); + } + + public isProtected() { + return true; + } +} \ No newline at end of file diff --git a/server/src/endpoints/sync.ts b/server/src/endpoints/sync.ts index 16b8fe14..32c4be84 100644 --- a/server/src/endpoints/sync.ts +++ b/server/src/endpoints/sync.ts @@ -3,9 +3,10 @@ import RequestHandler from "./base"; export default class SyncRequestHandler extends RequestHandler { async handler(req: express.Request, res: express.Response) { - const [chats, messages] = await Promise.all([ + const [chats, messages, deletedChatIDs] = await Promise.all([ this.context.database.getChats(this.userID!), this.context.database.getMessages(this.userID!), + this.context.database.getDeletedChatIDs(this.userID!), ]); const output: Record = {}; @@ -26,6 +27,12 @@ export default class SyncRequestHandler extends RequestHandler { output[c.id] = chat; } + for (const chatID of deletedChatIDs) { + output[chatID] = { + deleted: true + }; + } + res.json(output); } diff --git a/server/src/index.ts b/server/src/index.ts index efd29583..7c696234 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -20,6 +20,7 @@ import SessionRequestHandler from './endpoints/session'; import GetShareRequestHandler from './endpoints/get-share'; import { configurePassport } from './passport'; import { configureAuth0 } from './auth0'; +import DeleteChatRequestHandler from './endpoints/delete-chat'; process.on('unhandledRejection', (reason, p) => { console.error('Unhandled Rejection at: Promise', p, 'reason:', reason); @@ -77,6 +78,7 @@ export default class ChatServer { this.app.get('/chatapi/session', (req, res) => new SessionRequestHandler(this, req, res)); this.app.post('/chatapi/messages', (req, res) => new MessagesRequestHandler(this, req, res)); this.app.post('/chatapi/title', (req, res) => new TitleRequestHandler(this, req, res)); + this.app.post('/chatapi/delete', (req, res) => new DeleteChatRequestHandler(this, req, res)); this.app.post('/chatapi/sync', (req, res) => new SyncRequestHandler(this, req, res)); this.app.get('/chatapi/share/:id', (req, res) => new GetShareRequestHandler(this, req, res)); this.app.post('/chatapi/share', (req, res) => new ShareRequestHandler(this, req, res));