Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ui/src/CurrentUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export class CurrentUser {
})
.catch(() => {
this.authenticating = false;
return this.snack('Login failed');
this.snack('Login failed');
});
};

Expand Down
19 changes: 19 additions & 0 deletions ui/src/apiAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> => {
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')) {
Expand Down
2 changes: 1 addition & 1 deletion ui/src/application/AppStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export class AppStore extends BaseStore<IApplication> {
protected requestDelete = (id: number): Promise<void> =>
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<void> => {
Expand Down
6 changes: 3 additions & 3 deletions ui/src/client/ClientStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ export class ClientStore extends BaseStore<IClient> {
axios.get<IClient[]>(`${config.get('url')}client`).then((response) => response.data);

protected requestDelete(id: number): Promise<void> {
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<void> => {
Expand Down
3 changes: 2 additions & 1 deletion ui/src/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);

Expand Down
18 changes: 14 additions & 4 deletions ui/src/message/Messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}>();
Expand All @@ -20,22 +21,25 @@ 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<Record<number, boolean>>({});
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)) {
messagesStore.loadMore(appId);
}
}, [appId]);

React.useEffect(() => () => deleteQueue.finalizePendingDeletes(), [appId, deleteQueue]);

const renderMessage = (_index: number, message: IMessage) => (
<Message
key={message.id}
Expand Down Expand Up @@ -110,7 +114,10 @@ const Messages = observer(() => {
id="refresh-all"
variant="contained"
color="primary"
onClick={() => messagesStore.refreshByApp(appId)}
onClick={() => {
deleteQueue.finalizePendingDeletes(appId);
messagesStore.refreshByApp(appId);
}}
style={{marginRight: 5}}>
Refresh
</Button>
Expand All @@ -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 && (
Expand Down
64 changes: 54 additions & 10 deletions ui/src/message/MessagesStore.ts
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';
Expand All @@ -17,6 +18,7 @@ interface MessagesState {

export class MessagesStore {
private state: Record<string, MessagesState> = {};
private pendingDeleteIds = new Set<number>();

private loading = false;

Expand All @@ -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,
});
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Comment on lines +106 to +117
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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);
await axios.delete(url, {
fetchOptions: {
keepalive: options?.keepalive,
credentials: 'include',
},
});

Copy link
Contributor Author

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. 👀

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems to work with

        await axios.delete(config.get('url') + 'message/' + message.id, {
            adapter: 'fetch',
            fetchOptions: {keepalive: true},
        });

};

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 (
Expand Down Expand Up @@ -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);
Copy link
Member

Choose a reason for hiding this comment

The 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()
Expand Down
139 changes: 139 additions & 0 deletions ui/src/message/messageDeleteQueue.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import Button from '@mui/material/Button';
Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Member

@jmattheis jmattheis Jan 14, 2026

Choose a reason for hiding this comment

The 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;
};
Loading
Loading