-
-
Notifications
You must be signed in to change notification settings - Fork 806
feat: Add undoable message delete with UI-layer snackbar actions and cleaner store boundaries #901
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b7c2df3
12295bf
f359824
1058e3e
1423a91
a504dac
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| import {BaseStore} from '../common/BaseStore'; | ||
| import {action, IObservableArray, observable, reaction, makeObservable} from 'mobx'; | ||
| import axios, {AxiosResponse} from 'axios'; | ||
| import {authFetch} from '../apiAuth'; | ||
| import * as config from '../config'; | ||
| import {createTransformer} from 'mobx-utils'; | ||
| import {SnackReporter} from '../snack/SnackManager'; | ||
|
|
@@ -17,6 +18,7 @@ interface MessagesState { | |
|
|
||
| export class MessagesStore { | ||
| private state: Record<string, MessagesState> = {}; | ||
| private pendingDeleteIds = new Set<number>(); | ||
|
|
||
| private loading = false; | ||
|
|
||
|
|
@@ -29,7 +31,10 @@ export class MessagesStore { | |
| loadMore: action, | ||
| publishSingleMessage: action, | ||
| removeByApp: action, | ||
| removeSingle: action, | ||
| removeSingleLocal: action, | ||
| restoreSingleLocal: action, | ||
| markPendingDelete: action, | ||
| clearPendingDelete: action, | ||
| clearAll: action, | ||
| refreshByApp: action, | ||
| }); | ||
|
|
@@ -59,7 +64,10 @@ export class MessagesStore { | |
| const pagedResult = await this.fetchMessages(appId, state.nextSince).then( | ||
| (resp) => resp.data | ||
| ); | ||
| state.messages.replace([...state.messages, ...pagedResult.messages]); | ||
| const incoming = pagedResult.messages.filter( | ||
| (message) => !this.pendingDeleteIds.has(message.id) | ||
| ); | ||
| state.messages.replace([...state.messages, ...incoming]); | ||
| state.nextSince = pagedResult.paging.since ?? 0; | ||
| state.hasMore = 'next' in pagedResult.paging; | ||
| state.loaded = true; | ||
|
|
@@ -93,15 +101,30 @@ export class MessagesStore { | |
| await this.loadMore(appId); | ||
| }; | ||
|
|
||
| public removeSingle = async (message: IMessage) => { | ||
| await axios.delete(config.get('url') + 'message/' + message.id); | ||
| if (this.exists(AllMessages)) { | ||
| this.removeFromList(this.state[AllMessages].messages, message); | ||
| } | ||
| if (this.exists(message.appid)) { | ||
| this.removeFromList(this.state[message.appid].messages, message); | ||
| public removeSingle = async (message: IMessage, options?: {keepalive?: boolean}) => { | ||
| const url = config.get('url') + 'message/' + message.id; | ||
| if (options?.keepalive) { | ||
| const response = await authFetch(url, { | ||
| method: 'DELETE', | ||
| keepalive: true, | ||
| credentials: 'include', | ||
| }); | ||
| if (!response.ok) { | ||
| throw new Error('Failed to delete message'); | ||
| } | ||
| return; | ||
| } | ||
| this.snack('Message deleted'); | ||
| await axios.delete(url); | ||
| }; | ||
|
|
||
| public removeSingleLocal = (message: IMessage) => { | ||
| const allIndex = this.exists(AllMessages) | ||
| ? this.removeFromList(this.state[AllMessages].messages, message) | ||
| : false; | ||
| const appIndex = this.exists(message.appid) | ||
| ? this.removeFromList(this.state[message.appid].messages, message) | ||
| : false; | ||
| return {allIndex, appIndex}; | ||
| }; | ||
|
|
||
| public sendMessage = async ( | ||
|
|
@@ -161,6 +184,27 @@ export class MessagesStore { | |
| } | ||
| }; | ||
|
|
||
| public restoreSingleLocal = ( | ||
| message: IMessage, | ||
| allIndex: false | number, | ||
| appIndex: false | number | ||
| ) => { | ||
| if (allIndex !== false && this.exists(AllMessages)) { | ||
| this.state[AllMessages].messages.splice(allIndex, 0, message); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good to have: Don't make this assumption here. there is an edge case where if you delete the message, send a new one in and then try to undo the order can get swapped. No need if it's too complicated |
||
| } | ||
| if (appIndex !== false && this.exists(message.appid)) { | ||
| this.state[message.appid].messages.splice(appIndex, 0, message); | ||
| } | ||
| }; | ||
|
|
||
| public markPendingDelete = (messageId: number) => { | ||
| this.pendingDeleteIds.add(messageId); | ||
| }; | ||
|
|
||
| public clearPendingDelete = (messageId: number) => { | ||
| this.pendingDeleteIds.delete(messageId); | ||
| }; | ||
|
|
||
| private getUnCached = (appId: number): Array<IMessage> => { | ||
| const appToImage: Partial<Record<string, string>> = this.appStore | ||
| .getItems() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| import Button from '@mui/material/Button'; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I feel like the current implementation is too complex, the storing of indexes seems error prone, as the index changes with each delete and each new message. I think it should be possible to implement this functionality without modifying the message state for pending deletions and only modify it when there is an actual delete. I've an idea in mind and will draft something today or later this week.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you have a look at #903 |
||
| import React from 'react'; | ||
| import {closeSnackbar, SnackbarKey} from 'notistack'; | ||
| import {SnackReporter} from '../snack/SnackManager'; | ||
| import {IMessage} from '../types'; | ||
| import {MessagesStore} from './MessagesStore'; | ||
|
|
||
| const UndoAutoHideMs = 5000; | ||
|
|
||
| interface PendingDelete { | ||
| message: IMessage; | ||
| allIndex: false | number; | ||
| appIndex: false | number; | ||
| snackKey: SnackbarKey; | ||
| } | ||
|
|
||
| class MessageDeleteQueue { | ||
| private pendingDeletes = new Map<number, PendingDelete>(); | ||
| private pagehideBound = false; | ||
|
|
||
| public constructor( | ||
| private readonly messagesStore: MessagesStore, | ||
| private readonly snack: SnackReporter | ||
| ) {} | ||
|
|
||
| public requestDelete = (message: IMessage) => { | ||
| this.ensurePagehideHandler(); | ||
| if (this.pendingDeletes.has(message.id)) { | ||
| return; | ||
| } | ||
| const {allIndex, appIndex} = this.messagesStore.removeSingleLocal(message); | ||
| if (allIndex === false && appIndex === false) { | ||
| return; | ||
| } | ||
| this.messagesStore.markPendingDelete(message.id); | ||
|
|
||
| const snackKey = this.snack('Message deleted', { | ||
| action: (key) => ( | ||
| <Button | ||
| color="inherit" | ||
| size="small" | ||
| onClick={() => this.undoDelete(message.id, key)}> | ||
| Undo | ||
| </Button> | ||
| ), | ||
| autoHideDuration: UndoAutoHideMs, | ||
| onExited: () => { | ||
| void this.finalizeDelete(message.id); | ||
| }, | ||
| }); | ||
|
|
||
| this.pendingDeletes.set(message.id, { | ||
| message, | ||
| allIndex, | ||
| appIndex, | ||
| snackKey, | ||
| }); | ||
| }; | ||
|
|
||
| public undoDelete = (messageId: number, snackKey: SnackbarKey) => { | ||
| const pending = this.pendingDeletes.get(messageId); | ||
| if (!pending) { | ||
| return; | ||
| } | ||
| this.pendingDeletes.delete(messageId); | ||
| this.messagesStore.clearPendingDelete(messageId); | ||
| this.messagesStore.restoreSingleLocal(pending.message, pending.allIndex, pending.appIndex); | ||
| closeSnackbar(snackKey); | ||
| this.snack('Delete undone'); | ||
| }; | ||
|
|
||
| public finalizePendingDeletes = (targetAppId?: number) => { | ||
| const pendingIds = Array.from(this.pendingDeletes.keys()); | ||
| pendingIds.forEach((messageId) => { | ||
| const pending = this.pendingDeletes.get(messageId); | ||
| if (!pending) { | ||
| return; | ||
| } | ||
| if ( | ||
| targetAppId != null && | ||
| targetAppId !== -1 && | ||
| pending.message.appid !== targetAppId | ||
| ) { | ||
| return; | ||
| } | ||
| void this.finalizeDelete(messageId, {closeSnack: true}); | ||
| }); | ||
| }; | ||
|
|
||
| private ensurePagehideHandler = () => { | ||
| if (this.pagehideBound || typeof window === 'undefined') { | ||
| return; | ||
| } | ||
| this.pagehideBound = true; | ||
| window.addEventListener('pagehide', this.handlePagehide); | ||
| window.addEventListener('beforeunload', this.handlePagehide); | ||
| }; | ||
|
|
||
| private handlePagehide = () => { | ||
| this.finalizePendingDeletes(); | ||
| }; | ||
|
|
||
| private finalizeDelete = async ( | ||
| messageId: number, | ||
| options?: {closeSnack?: boolean} | ||
| ): Promise<void> => { | ||
| const pending = this.pendingDeletes.get(messageId); | ||
| if (!pending) { | ||
| return; | ||
| } | ||
| this.pendingDeletes.delete(messageId); | ||
| this.messagesStore.clearPendingDelete(messageId); | ||
| if (options?.closeSnack) { | ||
| closeSnackbar(pending.snackKey); | ||
| } | ||
| try { | ||
| await this.messagesStore.removeSingle(pending.message, {keepalive: true}); | ||
| } catch { | ||
| this.messagesStore.restoreSingleLocal( | ||
| pending.message, | ||
| pending.allIndex, | ||
| pending.appIndex | ||
| ); | ||
| this.snack('Delete failed, message restored'); | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| let messageDeleteQueue: MessageDeleteQueue | null = null; | ||
|
|
||
| export const getMessageDeleteQueue = ( | ||
| messagesStore: MessagesStore, | ||
| snack: SnackReporter | ||
| ): MessageDeleteQueue => { | ||
| if (!messageDeleteQueue) { | ||
| messageDeleteQueue = new MessageDeleteQueue(messagesStore, snack); | ||
| } | ||
| return messageDeleteQueue; | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it seems like Axios is still using the default XHR adapter (no adapter configured), so fetchOptions is ignored and keepalive won’t take effect. 👀
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems to work with