From 9c232780c59205dd6ef2c15aa91e3c4bfe381f41 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sun, 21 Oct 2018 14:06:28 +0200 Subject: [PATCH] Migrate MessagesStore to mobx And use new infinite list implementation --- ui/src/actions/MessageAction.ts | 93 ------------------ ui/src/pages/Messages.tsx | 79 +++++++--------- ui/src/stores/MessageStore.ts | 114 ---------------------- ui/src/stores/MessagesStore.ts | 162 ++++++++++++++++++++++++++++++++ ui/src/tests/message.test.ts | 9 +- 5 files changed, 204 insertions(+), 253 deletions(-) delete mode 100644 ui/src/actions/MessageAction.ts delete mode 100644 ui/src/stores/MessageStore.ts create mode 100644 ui/src/stores/MessagesStore.ts diff --git a/ui/src/actions/MessageAction.ts b/ui/src/actions/MessageAction.ts deleted file mode 100644 index cd6cf49..0000000 --- a/ui/src/actions/MessageAction.ts +++ /dev/null @@ -1,93 +0,0 @@ -import axios, {AxiosResponse} from 'axios'; -import * as config from '../config'; -import dispatcher from '../stores/dispatcher'; -import {getToken} from './defaultAxios'; -import {snack} from './GlobalAction'; -import * as UserAction from './UserAction'; - -export function fetchMessagesApp(id: number, since: number) { - if (id === -1) { - return axios - .get(config.get('url') + 'message?since=' + since) - .then((resp: AxiosResponse) => { - newMessages(-1, resp.data); - }); - } else { - return axios - .get(config.get('url') + 'application/' + id + '/message?since=' + since) - .then((resp: AxiosResponse) => { - newMessages(id, resp.data); - }); - } -} - -function newMessages(id: number, data: IPagedMessages) { - dispatcher.dispatch({ - type: 'UPDATE_MESSAGES', - payload: { - messages: data.messages, - hasMore: 'next' in data.paging, - nextSince: data.paging.since, - id, - }, - }); -} - -/** - * Deletes all messages from the current user and an application. - * @param {int} id the application id - */ -export function deleteMessagesByApp(id: number) { - if (id === -1) { - axios.delete(config.get('url') + 'message').then(() => { - dispatcher.dispatch({type: 'DELETE_MESSAGES', payload: -1}); - snack('Messages deleted'); - }); - } else { - axios.delete(config.get('url') + 'application/' + id + '/message').then(() => { - dispatcher.dispatch({type: 'DELETE_MESSAGES', payload: id}); - snack('Deleted all messages from the application'); - }); - } -} - -export function deleteMessage(msg: IMessage) { - axios.delete(config.get('url') + 'message/' + msg.id).then(() => { - dispatcher.dispatch({type: 'DELETE_MESSAGE', payload: msg}); - snack('Message deleted'); - }); -} - -let wsActive = false; - -/** - * Starts listening to the stream for new messages. - */ -export function listenToWebSocket() { - if (!getToken() || wsActive) { - return; - } - wsActive = true; - - const wsUrl = config - .get('url') - .replace('http', 'ws') - .replace('https', 'wss'); - const ws = new WebSocket(wsUrl + 'stream?token=' + getToken()); - - ws.onerror = (e) => { - wsActive = false; - console.log('WebSocket connection errored', e); - }; - - ws.onmessage = (data) => - dispatcher.dispatch({type: 'ONE_MESSAGE', payload: JSON.parse(data.data) as IMessage}); - - ws.onclose = () => { - wsActive = false; - UserAction.tryAuthenticate().then(() => { - snack('WebSocket connection closed, trying again in 30 seconds.'); - setTimeout(listenToWebSocket, 30000); - }); - }; -} diff --git a/ui/src/pages/Messages.tsx b/ui/src/pages/Messages.tsx index f444fd2..ec1d12d 100644 --- a/ui/src/pages/Messages.tsx +++ b/ui/src/pages/Messages.tsx @@ -3,24 +3,21 @@ import Grid from '@material-ui/core/Grid'; import Typography from '@material-ui/core/Typography'; import React, {Component} from 'react'; import {RouteComponentProps} from 'react-router'; -import * as MessageAction from '../actions/MessageAction'; import DefaultPage from '../component/DefaultPage'; -import ReactList from '../component/FixedReactList'; import Message from '../component/Message'; import AppStore from '../stores/AppStore'; -import MessageStore from '../stores/MessageStore'; +import MessagesStore from '../stores/MessagesStore'; +import {observer} from 'mobx-react'; +// @ts-ignore +import InfiniteAnyHeight from 'react-infinite-any-height'; interface IProps extends RouteComponentProps<{id: string}> {} interface IState { appId: number; - messages: IMessage[]; - name: string; - hasMore: boolean; - nextSince?: number; - id?: number; } +@observer class Messages extends Component { private static appId(props: IProps) { if (props === undefined) { @@ -30,47 +27,47 @@ class Messages extends Component { return match.params.id !== undefined ? parseInt(match.params.id, 10) : -1; } - public state = {appId: -1, messages: [], name: 'unknown', hasMore: true}; + public state = {appId: -1}; - private list: ReactList | null = null; + private isLoadingMore = false; public componentWillReceiveProps(nextProps: IProps) { this.updateAllWithProps(nextProps); } public componentWillMount() { - MessageStore.on('change', this.updateAll); - AppStore.on('change', this.updateAll); + window.onscroll = () => { + if ( + window.innerHeight + window.pageYOffset >= + document.body.offsetHeight - window.innerHeight * 2 + ) { + this.checkIfLoadMore(); + } + }; this.updateAll(); } - public componentWillUnmount() { - MessageStore.removeListener('change', this.updateAll); - AppStore.removeListener('change', this.updateAll); - } - public render() { - const {name, messages, hasMore, appId} = this.state; + const {appId} = this.state; + const messages = MessagesStore.get(appId); + const hasMore = MessagesStore.canLoadMore(appId); + const name = AppStore.getName(appId); const hasMessages = messages.length !== 0; - const deleteMessages = () => MessageAction.deleteMessagesByApp(appId); return ( MessagesStore.removeByApp(appId)} buttonDisabled={!hasMessages}> {hasMessages ? (
- (this.list = el)} - itemRenderer={this.renderMessage} - length={messages.length} - threshold={1000} - pageSize={30} - type="variable" + list={messages.map(this.renderMessage)} + preloadAdditionalHeight={window.innerHeight * 2.5} + useWindowAsScrollContainer /> {hasMore ? ( @@ -90,27 +87,21 @@ class Messages extends Component { private updateAllWithProps = (props: IProps) => { const appId = Messages.appId(props); - const reset = MessageStore.shouldReset(appId); - if (reset !== false && this.list) { - this.list.clearCacheFromIndex(reset); - } - - this.setState({...MessageStore.get(appId), appId, name: AppStore.getName(appId)}); - if (!MessageStore.exists(appId)) { - MessageStore.loadNext(appId); + this.setState({appId}); + if (!MessagesStore.exists(appId)) { + MessagesStore.loadMore(appId); } }; private updateAll = () => this.updateAllWithProps(this.props); - private deleteMessage = (message: IMessage) => () => MessageAction.deleteMessage(message); + private deleteMessage = (message: IMessage) => () => MessagesStore.removeSingle(message); - private renderMessage = (index: number, key: string) => { + private renderMessage = (message: IMessage) => { this.checkIfLoadMore(); - const message: IMessage = this.state.messages[index]; return ( { }; private checkIfLoadMore() { - const {hasMore, messages, appId} = this.state; - if (hasMore) { - const [, maxRenderedIndex] = (this.list && this.list.getVisibleRange()) || [0, 0]; - if (maxRenderedIndex > messages.length - 30) { - MessageStore.loadNext(appId); - } + const {appId} = this.state; + if (!this.isLoadingMore && MessagesStore.canLoadMore(appId)) { + this.isLoadingMore = true; + MessagesStore.loadMore(appId).then(() => (this.isLoadingMore = false)); } } diff --git a/ui/src/stores/MessageStore.ts b/ui/src/stores/MessageStore.ts deleted file mode 100644 index f34a894..0000000 --- a/ui/src/stores/MessageStore.ts +++ /dev/null @@ -1,114 +0,0 @@ -import {EventEmitter} from 'events'; -import * as MessageAction from '../actions/MessageAction'; -import AppStore from './AppStore'; -import dispatcher, {IEvent} from './dispatcher'; - -class MessageStore extends EventEmitter { - private appToMessages: {[appId: number]: IAppMessages} = {}; - private reset: false | number = false; - private resetOnAll: false | number = false; - private loading = false; - - constructor() { - super(); - AppStore.on('change', () => { - this.updateApps(); - this.emit('change'); - }); - } - - public shouldReset(appId: number): false | number { - const reset = appId === -1 ? this.resetOnAll : this.reset; - if (reset !== false) { - this.reset = false; - this.resetOnAll = false; - } - return reset; - } - - public loadNext(id: number): void { - if (this.loading || !this.get(id).hasMore) { - return; - } - this.loading = true; - MessageAction.fetchMessagesApp(id, this.get(id).nextSince).catch( - () => (this.loading = false) - ); - } - - public get(id: number): IAppMessages { - if (this.exists(id)) { - return this.appToMessages[id]; - } else { - return {messages: [], nextSince: 0, hasMore: true}; - } - } - - public exists(id: number): boolean { - return this.appToMessages[id] !== undefined; - } - - public handle(data: IEvent): void { - const {payload} = data; - if (data.type === 'UPDATE_MESSAGES') { - if (this.exists(payload.id)) { - payload.messages = this.get(payload.id).messages.concat(payload.messages); - } - this.appToMessages[payload.id] = payload; - this.updateApps(); - this.loading = false; - this.emit('change'); - } else if (data.type === 'ONE_MESSAGE') { - if (this.exists(payload.appid)) { - this.appToMessages[payload.appid].messages.unshift(payload); - this.reset = 0; - } - if (this.exists(-1)) { - this.appToMessages[-1].messages.unshift(payload); - this.resetOnAll = 0; - } - this.updateApps(); - this.emit('change'); - } else if (data.type === 'DELETE_MESSAGE') { - this.resetOnAll = this.removeFromList(this.appToMessages[-1], payload); - this.reset = this.removeFromList(this.appToMessages[payload.appid], payload); - this.emit('change'); - } else if (data.type === 'DELETE_MESSAGES') { - const id = payload; - if (id === -1) { - this.appToMessages = {}; - } else { - delete this.appToMessages[-1]; - delete this.appToMessages[id]; - } - this.reset = 0; - this.emit('change'); - } - } - - private removeFromList(messages: IAppMessages, messageToDelete: IMessage): false | number { - if (messages) { - const index = messages.messages.findIndex( - (message) => message.id === messageToDelete.id - ); - if (index !== -1) { - messages.messages.splice(index, 1); - return index; - } - } - return false; - } - - private updateApps = (): void => { - const appToUrl: {[appId: number]: string} = {}; - AppStore.get().forEach((app) => (appToUrl[app.id] = app.image)); - Object.keys(this.appToMessages).forEach((key) => { - const appMessages: IAppMessages = this.appToMessages[key]; - appMessages.messages.forEach((message) => (message.image = appToUrl[message.appid])); - }); - }; -} - -const store = new MessageStore(); -dispatcher.register(store.handle.bind(store)); -export default store; diff --git a/ui/src/stores/MessagesStore.ts b/ui/src/stores/MessagesStore.ts new file mode 100644 index 0000000..c613ef8 --- /dev/null +++ b/ui/src/stores/MessagesStore.ts @@ -0,0 +1,162 @@ +import {BaseStore} from './BaseStore'; +import NewAppStore from './AppStore'; +import {action, IObservableArray, observable, reaction} from 'mobx'; +import axios, {AxiosResponse} from 'axios'; +import * as config from '../config'; +import {createTransformer} from 'mobx-utils'; +import SnackManager, {SnackReporter} from './SnackManager'; + +const AllMessages = -1; + +interface MessagesState { + messages: IObservableArray; + hasMore: boolean; + nextSince: number; + loaded: boolean; +} + +class MessagesStore { + @observable + private state: Record = {}; + @observable + public lastNewMessageAppId = 0; + + private loading = false; + + public constructor( + private readonly appStore: BaseStore, + private readonly snack: SnackReporter + ) { + reaction(() => appStore.getItems(), this.createEmptyStatesForApps); + } + + private stateOf = (appId: number, create = true) => { + if (this.state[appId] || !create) { + return this.state[appId] || this.emptyState(); + } + return (this.state[appId] = this.emptyState()); + }; + + public canLoadMore = (appId: number) => this.stateOf(appId, /*create*/ false).hasMore; + + @action + public loadMore = async (appId: number) => { + const state = this.stateOf(appId); + if (!state.hasMore || this.loading) { + return Promise.resolve(); + } + this.loading = true; + + const pagedResult = await this.fetchMessages(appId, state.nextSince).then( + (resp) => resp.data + ); + + state.messages.replace([...state.messages, ...pagedResult.messages]); + state.nextSince = pagedResult.paging.since || 0; + state.hasMore = 'next' in pagedResult.paging; + state.loaded = true; + this.loading = false; + return Promise.resolve(); + }; + + @action + public publishSingleMessage = (message: IMessage) => { + if (this.exists(AllMessages)) { + this.stateOf(AllMessages).messages.unshift(message); + } + if (this.exists(message.appid)) { + this.stateOf(message.appid).messages.unshift(message); + } + }; + + @action + public removeByApp = async (appId: number) => { + if (appId === AllMessages) { + await axios.delete(config.get('url') + 'message'); + this.snack('Deleted all messages'); + this.clearAll(); + } else { + await axios.delete(config.get('url') + 'application/' + appId + '/message'); + this.snack(`Deleted all messages from ${this.appStore.getByID(appId).name}`); + this.clear(AllMessages); + this.clear(appId); + } + await this.loadMore(appId); + }; + + @action + 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); + } + this.snack('Message deleted'); + }; + + public exists = (id: number) => this.stateOf(id).loaded; + + private removeFromList(messages: IMessage[], messageToDelete: IMessage): false | number { + if (messages) { + const index = messages.findIndex((message) => message.id === messageToDelete.id); + if (index !== -1) { + messages.splice(index, 1); + return index; + } + } + return false; + } + + private clearAll = () => { + this.state = {}; + this.createEmptyStatesForApps(this.appStore.getItems()); + }; + + private clear = (appId: number) => (this.state[appId] = this.emptyState()); + + private fetchMessages = ( + appId: number, + since: number + ): Promise> => { + if (appId === AllMessages) { + return axios.get(config.get('url') + 'message?since=' + since); + } else { + return axios.get( + config.get('url') + 'application/' + appId + '/message?since=' + since + ); + } + }; + + private getUnCached = (appId: number): Array => { + const appToImage = this.appStore + .getItems() + .reduce((all, app) => ({...all, [app.id]: app.image}), {}); + + return this.stateOf(appId, false).messages.map((message: IMessage) => { + return { + ...message, + image: appToImage[message.appid] || 'still loading', + }; + }); + }; + + public get = createTransformer(this.getUnCached); + + private clearCache = () => (this.get = createTransformer(this.getUnCached)); + + private createEmptyStatesForApps = (apps: IApplication[]) => { + apps.map((app) => app.id).forEach((id) => this.stateOf(id, /*create*/ true)); + this.clearCache(); + }; + + private emptyState = (): MessagesState => ({ + messages: observable.array(), + hasMore: true, + nextSince: 0, + loaded: false, + }); +} + +export default new MessagesStore(NewAppStore, SnackManager.snack); diff --git a/ui/src/tests/message.test.ts b/ui/src/tests/message.test.ts index 52fd4de..b942cc7 100644 --- a/ui/src/tests/message.test.ts +++ b/ui/src/tests/message.test.ts @@ -200,7 +200,7 @@ describe('Messages', () => { }); it('deletes a windows message', async () => { await navigate('Windows'); - await page.click('#messages .message:nth-child(2) .delete'); + await page.click('#messages span:nth-of-type(2) .message .delete'); await expectMessages({ all: [linux2, windows3, backup1, linux1, windows1], windows: [windows3, windows1], @@ -257,5 +257,12 @@ describe('Messages', () => { backup: [backup3], }); }); + it('deletes all backup messages and navigates to all messages', async () => { + await navigate('Backup'); + await page.click('#delete-all'); + await navigate('All Messages'); + await createMessage(backup3, backupServerToken); + expect(await extractMessages()).toEqual([backup3]); + }); it('does logout', async () => await auth.logout(page)); });