From 7bd057c8449a181abd702dc47ced67b6c4e9995d Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Wed, 14 Jan 2026 18:46:36 +0100 Subject: [PATCH 1/2] feat: add delete message undo Co-authored-by: AlliotTech <24980252+AlliotTech@users.noreply.github.com> --- ui/src/message/Message.tsx | 10 +++++++- ui/src/message/Messages.tsx | 28 ++++++++++++++++++++-- ui/src/message/MessagesStore.ts | 41 ++++++++++++++++++++++++++++++--- ui/src/reactions.ts | 3 +++ 4 files changed, 76 insertions(+), 6 deletions(-) diff --git a/ui/src/message/Message.tsx b/ui/src/message/Message.tsx index da036528..a12e9040 100644 --- a/ui/src/message/Message.tsx +++ b/ui/src/message/Message.tsx @@ -12,6 +12,8 @@ import * as config from '../config'; import {IMessageExtras} from '../types'; import {contentType, RenderMode} from './extras'; import {TimeAgoFormatter} from '../common/TimeAgoFormatter'; +import {useStores} from '../stores'; +import {observer} from 'mobx-react-lite'; const PREVIEW_LENGTH = 500; @@ -92,6 +94,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ })); interface IProps { + id: number; title: string; image?: string; date: string; @@ -116,6 +119,7 @@ const priorityColor = (priority: number) => { const Message = ({ fDelete, + id, title, date, image, @@ -127,6 +131,7 @@ const Message = ({ expanded: initialExpanded, }: IProps) => { const theme = useTheme(); + const {messagesStore} = useStores(); const contentRef = React.useRef(null); const {classes} = useStyles(); const [expanded, setExpanded] = React.useState(initialExpanded); @@ -162,6 +167,9 @@ const Message = ({ return {content}; } }; + if (!messagesStore.visible(id)) { + return <>; + } return (
{ const {id} = useParams<{id: string}>(); @@ -28,7 +31,25 @@ const Messages = observer(() => { const expandedState = React.useRef>({}); const app = appId === -1 ? undefined : appStore.getByIDOrUndefined(appId); - const deleteMessage = (message: IMessage) => () => messagesStore.removeSingle(message); + const deleteMessage = (message: IMessage) => { + const key = enqueueSnackbar({ + message: 'Message deleted', + variant: 'info', + action: () => ( + + ), + disableWindowBlurListener: true, + transitionDuration: {enter: 0, exit: 0}, + autoHideDuration: UndoAutoHideMs, + onExited: () => messagesStore.removeSingle(message), + }); + messagesStore.addPendingDelete({message, key}); + }; React.useEffect(() => { if (!messagesStore.loaded(appId)) { @@ -39,7 +60,8 @@ const Messages = observer(() => { const renderMessage = (_index: number, message: IMessage) => ( deleteMessage(message)} onExpand={(expanded) => (expandedState.current[message.id] = expanded)} title={message.title} date={message.date} @@ -70,6 +92,8 @@ const Messages = observer(() => { }; const renderMessages = () => ( + // Virtuoso logs errors when elements have 0px size as this normally means it's miscalculated. + // In our case this is fine because we "hide" pending deletions by rendering nothing. = {}; + private pendingDeletes: Map = observable.map(); private loading = false; @@ -24,8 +31,12 @@ export class MessagesStore { private readonly appStore: BaseStore, private readonly snack: SnackReporter ) { - makeObservable(this, { + makeObservable(this, { state: observable, + pendingDeletes: observable, + addPendingDelete: action, + executePendingDeletes: action, + cancelPendingDelete: action, loadMore: action, publishSingleMessage: action, removeByApp: action, @@ -93,15 +104,39 @@ export class MessagesStore { await this.loadMore(appId); }; + public addPendingDelete = (pending: PendingDelete) => + this.pendingDeletes.set(pending.message.id, pending); + + public cancelPendingDelete = (message: IMessage): boolean => { + const pending = this.pendingDeletes.get(message.id); + if (pending) { + this.pendingDeletes.delete(message.id); + closeSnackbar(pending.key); + } + return !!pending; + }; + + public executePendingDeletes = () => + Array.from(this.pendingDeletes.values()).forEach(({message}) => this.removeSingle(message)); + + public visible = (message: number): boolean => !this.pendingDeletes.has(message); + public removeSingle = async (message: IMessage) => { - await axios.delete(config.get('url') + 'message/' + message.id); + if (!this.pendingDeletes.has(message.id)) { + return; + } + + await axios.delete(config.get('url') + 'message/' + message.id, { + adapter: 'fetch', + fetchOptions: {keepalive: true}, + }); if (this.exists(AllMessages)) { this.removeFromList(this.state[AllMessages].messages, message); } if (this.exists(message.appid)) { this.removeFromList(this.state[message.appid].messages, message); } - this.snack('Message deleted'); + this.cancelPendingDelete(message); }; public sendMessage = async ( diff --git a/ui/src/reactions.ts b/ui/src/reactions.ts index 39178291..a51bc209 100644 --- a/ui/src/reactions.ts +++ b/ui/src/reactions.ts @@ -5,6 +5,9 @@ import {StoreMapping} from './stores'; const AUDIO_REPEAT_DELAY = 1000; export const registerReactions = (stores: StoreMapping) => { + window.addEventListener('pagehide', stores.messagesStore.executePendingDeletes); + window.addEventListener('beforeunload', stores.messagesStore.executePendingDeletes); + const clearAll = () => { stores.messagesStore.clearAll(); stores.appStore.clear(); From d8aac869b4a7767b9006c5828635497deb438029 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Fri, 16 Jan 2026 12:10:53 +0100 Subject: [PATCH 2/2] fixup! feat: add delete message undo --- ui/src/message/Message.tsx | 10 +--------- ui/src/message/Messages.tsx | 3 --- ui/src/message/MessagesStore.ts | 9 +++------ 3 files changed, 4 insertions(+), 18 deletions(-) diff --git a/ui/src/message/Message.tsx b/ui/src/message/Message.tsx index a12e9040..da036528 100644 --- a/ui/src/message/Message.tsx +++ b/ui/src/message/Message.tsx @@ -12,8 +12,6 @@ import * as config from '../config'; import {IMessageExtras} from '../types'; import {contentType, RenderMode} from './extras'; import {TimeAgoFormatter} from '../common/TimeAgoFormatter'; -import {useStores} from '../stores'; -import {observer} from 'mobx-react-lite'; const PREVIEW_LENGTH = 500; @@ -94,7 +92,6 @@ const useStyles = makeStyles()((theme: Theme) => ({ })); interface IProps { - id: number; title: string; image?: string; date: string; @@ -119,7 +116,6 @@ const priorityColor = (priority: number) => { const Message = ({ fDelete, - id, title, date, image, @@ -131,7 +127,6 @@ const Message = ({ expanded: initialExpanded, }: IProps) => { const theme = useTheme(); - const {messagesStore} = useStores(); const contentRef = React.useRef(null); const {classes} = useStyles(); const [expanded, setExpanded] = React.useState(initialExpanded); @@ -167,9 +162,6 @@ const Message = ({ return {content}; } }; - if (!messagesStore.visible(id)) { - return <>; - } return (
{ const renderMessage = (_index: number, message: IMessage) => ( deleteMessage(message)} onExpand={(expanded) => (expandedState.current[message.id] = expanded)} title={message.title} @@ -92,8 +91,6 @@ const Messages = observer(() => { }; const renderMessages = () => ( - // Virtuoso logs errors when elements have 0px size as this normally means it's miscalculated. - // In our case this is fine because we "hide" pending deletions by rendering nothing. ({...all, [app.id]: app.image}), {}); - return this.stateOf(appId, false).messages.map( - (message: IMessage): IMessage => ({ - ...message, - image: appToImage[message.appid], - }) - ); + return this.stateOf(appId, false) + .messages.filter((message) => !this.pendingDeletes.has(message.id)) + .map((message: IMessage): IMessage => ({...message, image: appToImage[message.appid]})); }; public get = createTransformer(this.getUnCached);