diff --git a/ui/src/CurrentUser.ts b/ui/src/CurrentUser.ts index c642348b..73a12290 100644 --- a/ui/src/CurrentUser.ts +++ b/ui/src/CurrentUser.ts @@ -92,7 +92,7 @@ export class CurrentUser { }) .catch(() => { this.authenticating = false; - return this.snack('Login failed'); + this.snack('Login failed'); }); }; diff --git a/ui/src/apiAuth.ts b/ui/src/apiAuth.ts index 183c8d66..a1e3226a 100644 --- a/ui/src/apiAuth.ts +++ b/ui/src/apiAuth.ts @@ -2,6 +2,25 @@ import axios from 'axios'; import {CurrentUser} from './CurrentUser'; import {SnackReporter} from './snack/SnackManager'; +export type TokenProvider = () => string; + +let tokenProvider: TokenProvider = () => ''; + +export const setTokenProvider = (provider: TokenProvider) => { + tokenProvider = provider; +}; + +export const authFetch = (input: RequestInfo | URL, init?: RequestInit): Promise => { + const headers = new Headers(init?.headers); + if (!headers.has('x-gotify-key')) { + const token = tokenProvider(); + if (token) { + headers.set('x-gotify-key', token); + } + } + return fetch(input, {...init, headers}); +}; + export const initAxios = (currentUser: CurrentUser, snack: SnackReporter) => { axios.interceptors.request.use((config) => { if (!config.headers.has('x-gotify-key')) { diff --git a/ui/src/application/AppStore.ts b/ui/src/application/AppStore.ts index 25c8bf17..2d90ed67 100644 --- a/ui/src/application/AppStore.ts +++ b/ui/src/application/AppStore.ts @@ -26,7 +26,7 @@ export class AppStore extends BaseStore { protected requestDelete = (id: number): Promise => axios.delete(`${config.get('url')}application/${id}`).then(() => { this.onDelete(); - return this.snack('Application deleted'); + this.snack('Application deleted'); }); public uploadImage = async (id: number, file: Blob): Promise => { diff --git a/ui/src/client/ClientStore.ts b/ui/src/client/ClientStore.ts index 3e7edd72..9a997e09 100644 --- a/ui/src/client/ClientStore.ts +++ b/ui/src/client/ClientStore.ts @@ -20,9 +20,9 @@ export class ClientStore extends BaseStore { axios.get(`${config.get('url')}client`).then((response) => response.data); protected requestDelete(id: number): Promise { - return axios - .delete(`${config.get('url')}client/${id}`) - .then(() => this.snack('Client deleted')); + return axios.delete(`${config.get('url')}client/${id}`).then(() => { + this.snack('Client deleted'); + }); } public update = async (id: number, name: string): Promise => { diff --git a/ui/src/index.tsx b/ui/src/index.tsx index 175d6cf6..55ccea24 100644 --- a/ui/src/index.tsx +++ b/ui/src/index.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import {createRoot} from 'react-dom/client'; import 'typeface-roboto'; -import {initAxios} from './apiAuth'; +import {initAxios, setTokenProvider} from './apiAuth'; import * as config from './config'; import Layout from './layout/Layout'; import {unregister} from './registerServiceWorker'; @@ -51,6 +51,7 @@ const initStores = (): StoreMapping => { config.set('url', prodUrl); const stores = initStores(); initAxios(stores.currentUser, stores.snackManager.snack); + setTokenProvider(stores.currentUser.token); registerReactions(stores); diff --git a/ui/src/message/Messages.tsx b/ui/src/message/Messages.tsx index bd05e89b..5accf2d4 100644 --- a/ui/src/message/Messages.tsx +++ b/ui/src/message/Messages.tsx @@ -12,6 +12,7 @@ import LoadingSpinner from '../common/LoadingSpinner'; import {useStores} from '../stores'; import {Virtuoso} from 'react-virtuoso'; import {PushMessageDialog} from './PushMessageDialog'; +import {getMessageDeleteQueue} from './messageDeleteQueue'; const Messages = observer(() => { const {id} = useParams<{id: string}>(); @@ -20,15 +21,16 @@ const Messages = observer(() => { const [deleteAll, setDeleteAll] = React.useState(false); const [pushMessageOpen, setPushMessageOpen] = React.useState(false); const [isLoadingMore, setLoadingMore] = React.useState(false); - const {messagesStore, appStore} = useStores(); + const {messagesStore, appStore, snackManager} = useStores(); const messages = messagesStore.get(appId); const hasMore = messagesStore.canLoadMore(appId); const name = appStore.getName(appId); const hasMessages = messages.length !== 0; const expandedState = React.useRef>({}); const app = appId === -1 ? undefined : appStore.getByIDOrUndefined(appId); + const deleteQueue = getMessageDeleteQueue(messagesStore, snackManager.snack); - const deleteMessage = (message: IMessage) => () => messagesStore.removeSingle(message); + const deleteMessage = (message: IMessage) => () => deleteQueue.requestDelete(message); React.useEffect(() => { if (!messagesStore.loaded(appId)) { @@ -36,6 +38,8 @@ const Messages = observer(() => { } }, [appId]); + React.useEffect(() => () => deleteQueue.finalizePendingDeletes(), [appId, deleteQueue]); + const renderMessage = (_index: number, message: IMessage) => ( { id="refresh-all" variant="contained" color="primary" - onClick={() => messagesStore.refreshByApp(appId)} + onClick={() => { + deleteQueue.finalizePendingDeletes(appId); + messagesStore.refreshByApp(appId); + }} style={{marginRight: 5}}> Refresh @@ -133,7 +140,10 @@ const Messages = observer(() => { title="Confirm Delete" text={'Delete all messages?'} fClose={() => setDeleteAll(false)} - fOnSubmit={() => messagesStore.removeByApp(appId)} + fOnSubmit={() => { + deleteQueue.finalizePendingDeletes(appId); + messagesStore.removeByApp(appId); + }} /> )} {pushMessageOpen && app && ( diff --git a/ui/src/message/MessagesStore.ts b/ui/src/message/MessagesStore.ts index c62001cb..3625834e 100644 --- a/ui/src/message/MessagesStore.ts +++ b/ui/src/message/MessagesStore.ts @@ -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 = {}; + private pendingDeleteIds = new Set(); 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); + } + 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 => { const appToImage: Partial> = this.appStore .getItems() diff --git a/ui/src/message/messageDeleteQueue.tsx b/ui/src/message/messageDeleteQueue.tsx new file mode 100644 index 00000000..351be9ea --- /dev/null +++ b/ui/src/message/messageDeleteQueue.tsx @@ -0,0 +1,139 @@ +import Button from '@mui/material/Button'; +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(); + 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) => ( + + ), + 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 => { + 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; +}; diff --git a/ui/src/snack/SnackManager.ts b/ui/src/snack/SnackManager.ts index 06aacff7..dacdfedb 100644 --- a/ui/src/snack/SnackManager.ts +++ b/ui/src/snack/SnackManager.ts @@ -1,11 +1,26 @@ -import {enqueueSnackbar} from 'notistack'; +import {CloseReason, enqueueSnackbar, SnackbarKey} from 'notistack'; +import type {ReactNode, SyntheticEvent} from 'react'; export interface SnackReporter { - (message: string): void; + (message: string, options?: SnackOptions): SnackbarKey; +} + +export interface SnackOptions { + action?: (key: number | string) => ReactNode; + autoHideDuration?: number; + variant?: 'default' | 'error' | 'success' | 'warning' | 'info'; + onClose?: (event: SyntheticEvent | null, reason: CloseReason, key?: SnackbarKey) => void; + onExited?: (node: HTMLElement, key: SnackbarKey) => void; } export class SnackManager { - public snack: SnackReporter = (message: string): void => { - enqueueSnackbar({message, variant: 'info'}); + public snack: SnackReporter = (message: string, options?: SnackOptions): SnackbarKey => { + return enqueueSnackbar(message, { + variant: options?.variant ?? 'info', + action: options?.action, + autoHideDuration: options?.autoHideDuration, + onClose: options?.onClose, + onExited: options?.onExited, + }); }; } diff --git a/ui/src/tests/message.test.ts b/ui/src/tests/message.test.ts index d9b41727..7f608480 100644 --- a/ui/src/tests/message.test.ts +++ b/ui/src/tests/message.test.ts @@ -114,6 +114,7 @@ describe('Messages', () => { await waitToDisappear(page, '#push-message-dialog'); expect(await extractMessages(1)).toEqual([m('UI Test', 'Hello from UI')]); await page.click('#messages .message .delete'); + await waitForExists(page, 'button', 'Undo'); expect(await extractMessages(0)).toEqual([]); await navigate('All Messages'); }); diff --git a/ui/src/user/UserStore.ts b/ui/src/user/UserStore.ts index 5068b69c..6098164d 100644 --- a/ui/src/user/UserStore.ts +++ b/ui/src/user/UserStore.ts @@ -19,9 +19,9 @@ export class UserStore extends BaseStore { axios.get(`${config.get('url')}user`).then((response) => response.data); protected requestDelete(id: number): Promise { - return axios - .delete(`${config.get('url')}user/${id}`) - .then(() => this.snack('User deleted')); + return axios.delete(`${config.get('url')}user/${id}`).then(() => { + this.snack('User deleted'); + }); } public create = async (name: string, pass: string, admin: boolean) => {