diff --git a/ui/src/message/Messages.tsx b/ui/src/message/Messages.tsx index bd05e89b..02c582ff 100644 --- a/ui/src/message/Messages.tsx +++ b/ui/src/message/Messages.tsx @@ -12,6 +12,9 @@ import LoadingSpinner from '../common/LoadingSpinner'; import {useStores} from '../stores'; import {Virtuoso} from 'react-virtuoso'; import {PushMessageDialog} from './PushMessageDialog'; +import {enqueueSnackbar} from 'notistack'; + +const UndoAutoHideMs = 5000; const Messages = observer(() => { 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,7 @@ const Messages = observer(() => { const renderMessage = (_index: number, message: IMessage) => ( deleteMessage(message)} onExpand={(expanded) => (expandedState.current[message.id] = expanded)} title={message.title} date={message.date} diff --git a/ui/src/message/MessagesStore.ts b/ui/src/message/MessagesStore.ts index c62001cb..38d53c75 100644 --- a/ui/src/message/MessagesStore.ts +++ b/ui/src/message/MessagesStore.ts @@ -5,6 +5,7 @@ import * as config from '../config'; import {createTransformer} from 'mobx-utils'; import {SnackReporter} from '../snack/SnackManager'; import {IApplication, IMessage, IPagedMessages} from '../types'; +import {closeSnackbar, SnackbarKey} from 'notistack'; const AllMessages = -1; @@ -15,8 +16,14 @@ interface MessagesState { loaded: boolean; } +interface PendingDelete { + key: SnackbarKey; + message: IMessage; +} + export class MessagesStore { private state: Record = {}; + 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 ( @@ -166,12 +201,9 @@ export class MessagesStore { .getItems() .reduce((all, app) => ({...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); 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();