This commit is contained in:
Jannis Mattheis 2018-08-22 20:17:51 +02:00
parent 1afa51959f
commit a3f081307b
38 changed files with 729 additions and 428 deletions

View File

@ -38,14 +38,14 @@ const styles = (theme: Theme) => ({
}); });
interface IState { interface IState {
darkTheme: boolean darkTheme: boolean;
redirect: boolean redirect: boolean;
showSettings: boolean showSettings: boolean;
loggedIn: boolean loggedIn: boolean;
admin: boolean admin: boolean;
name: string name: string;
authenticating: boolean authenticating: boolean;
version: string version: string;
} }
class Layout extends React.Component<WithStyles<'content'>, IState> { class Layout extends React.Component<WithStyles<'content'>, IState> {
@ -64,7 +64,7 @@ class Layout extends React.Component<WithStyles<'content'>, IState> {
public componentDidMount() { public componentDidMount() {
if (this.state.version === Layout.defaultVersion) { if (this.state.version === Layout.defaultVersion) {
axios.get(config.get('url') + 'version').then((resp:AxiosResponse<IVersion>) => { axios.get(config.get('url') + 'version').then((resp: AxiosResponse<IVersion>) => {
this.setState({...this.state, version: resp.data.version}); this.setState({...this.state, version: resp.data.version});
}); });
} }
@ -97,31 +97,41 @@ class Layout extends React.Component<WithStyles<'content'>, IState> {
const {name, admin, version, loggedIn, showSettings, authenticating} = this.state; const {name, admin, version, loggedIn, showSettings, authenticating} = this.state;
const {classes} = this.props; const {classes} = this.props;
const theme = this.state.darkTheme ? darkTheme : lightTheme; const theme = this.state.darkTheme ? darkTheme : lightTheme;
const loginRoute = () => (loggedIn ? (<Redirect to="/"/>) : (<Login/>)); const loginRoute = () => (loggedIn ? <Redirect to="/" /> : <Login />);
return ( return (
<MuiThemeProvider theme={theme}> <MuiThemeProvider theme={theme}>
<HashRouter> <HashRouter>
<div style={{display: 'flex'}}> <div style={{display: 'flex'}}>
<CssBaseline/> <CssBaseline />
<Header admin={admin} name={name} version={version} loggedIn={loggedIn} <Header
toggleTheme={this.toggleTheme} showSettings={this.showSettings}/> admin={admin}
<Navigation loggedIn={loggedIn}/> name={name}
version={version}
loggedIn={loggedIn}
toggleTheme={this.toggleTheme}
showSettings={this.showSettings}
/>
<Navigation loggedIn={loggedIn} />
<main className={classes.content}> <main className={classes.content}>
<Switch> <Switch>
{authenticating ? <Route path="/"><LoadingSpinner/></Route> : null} {authenticating ? (
<Route exact path="/login" render={loginRoute}/> <Route path="/">
{loggedIn ? null : <Redirect to="/login"/>} <LoadingSpinner />
<Route exact path="/" component={Messages}/> </Route>
<Route exact path="/messages/:id" component={Messages}/> ) : null}
<Route exact path="/applications" component={Applications}/> <Route exact path="/login" render={loginRoute} />
<Route exact path="/clients" component={Clients}/> {loggedIn ? null : <Redirect to="/login" />}
<Route exact path="/users" component={Users}/> <Route exact path="/" component={Messages} />
<Route exact path="/messages/:id" component={Messages} />
<Route exact path="/applications" component={Applications} />
<Route exact path="/clients" component={Clients} />
<Route exact path="/users" component={Users} />
</Switch> </Switch>
</main> </main>
{showSettings && <SettingsDialog fClose={this.hideSettings}/>} {showSettings && <SettingsDialog fClose={this.hideSettings} />}
<ScrollUpButton/> <ScrollUpButton />
<SnackBarHandler/> <SnackBarHandler />
</div> </div>
</HashRouter> </HashRouter>
</MuiThemeProvider> </MuiThemeProvider>

View File

@ -15,10 +15,13 @@ export function fetchApps() {
* @param {int} id the application id * @param {int} id the application id
*/ */
export function deleteApp(id: number) { export function deleteApp(id: number) {
axios.delete(config.get('url') + 'application/' + id).then(() => { axios
fetchApps(); .delete(config.get('url') + 'application/' + id)
dispatcher.dispatch({type: 'DELETE_MESSAGES', payload: id}); .then(() => {
}).then(() => snack('Application deleted')); fetchApps();
dispatcher.dispatch({type: 'DELETE_MESSAGES', payload: id});
})
.then(() => snack('Application deleted'));
} }
/** /**
@ -27,7 +30,8 @@ export function deleteApp(id: number) {
* @param {string} description the description of the application. * @param {string} description the description of the application.
*/ */
export function createApp(name: string, description: string) { export function createApp(name: string, description: string) {
axios.post(config.get('url') + 'application', {name, description}) axios
.post(config.get('url') + 'application', {name, description})
.then(fetchApps) .then(fetchApps)
.then(() => snack('Application created')); .then(() => snack('Application created'));
} }
@ -40,7 +44,10 @@ export function createApp(name: string, description: string) {
export function uploadImage(id: number, file: Blob) { export function uploadImage(id: number, file: Blob) {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
axios.post(config.get('url') + 'application/' + id + '/image', formData, axios
{headers: {'content-type': 'multipart/form-data'}}).then(fetchApps) .post(config.get('url') + 'application/' + id + '/image', formData, {
headers: {'content-type': 'multipart/form-data'},
})
.then(fetchApps)
.then(() => snack('Application image updated')); .then(() => snack('Application image updated'));
} }

View File

@ -18,7 +18,10 @@ export function fetchClients() {
* @param {int} id the client id * @param {int} id the client id
*/ */
export function deleteClient(id: number) { export function deleteClient(id: number) {
axios.delete(config.get('url') + 'client/' + id).then(fetchClients).then(() => snack('Client deleted')); axios
.delete(config.get('url') + 'client/' + id)
.then(fetchClients)
.then(() => snack('Client deleted'));
} }
/** /**
@ -26,5 +29,8 @@ export function deleteClient(id: number) {
* @param {string} name the client name * @param {string} name the client name
*/ */
export function createClient(name: string) { export function createClient(name: string) {
axios.post(config.get('url') + 'client', {name}).then(fetchClients).then(() => snack('Client created')); axios
.post(config.get('url') + 'client', {name})
.then(fetchClients)
.then(() => snack('Client created'));
} }

View File

@ -1,4 +1,4 @@
import {AxiosResponse} from "axios"; import {AxiosResponse} from 'axios';
import dispatcher from '../stores/dispatcher'; import dispatcher from '../stores/dispatcher';
import * as AppAction from './AppAction'; import * as AppAction from './AppAction';
import * as ClientAction from './ClientAction'; import * as ClientAction from './ClientAction';

View File

@ -7,11 +7,14 @@ import * as UserAction from './UserAction';
export function fetchMessagesApp(id: number, since: number) { export function fetchMessagesApp(id: number, since: number) {
if (id === -1) { if (id === -1) {
return axios.get(config.get('url') + 'message?since=' + since).then((resp: AxiosResponse<IPagedMessages>) => { return axios
newMessages(-1, resp.data); .get(config.get('url') + 'message?since=' + since)
}); .then((resp: AxiosResponse<IPagedMessages>) => {
newMessages(-1, resp.data);
});
} else { } else {
return axios.get(config.get('url') + 'application/' + id + '/message?since=' + since) return axios
.get(config.get('url') + 'application/' + id + '/message?since=' + since)
.then((resp: AxiosResponse<IPagedMessages>) => { .then((resp: AxiosResponse<IPagedMessages>) => {
newMessages(id, resp.data); newMessages(id, resp.data);
}); });
@ -20,7 +23,8 @@ export function fetchMessagesApp(id: number, since: number) {
function newMessages(id: number, data: IPagedMessages) { function newMessages(id: number, data: IPagedMessages) {
dispatcher.dispatch({ dispatcher.dispatch({
type: 'UPDATE_MESSAGES', payload: { type: 'UPDATE_MESSAGES',
payload: {
messages: data.messages, messages: data.messages,
hasMore: 'next' in data.paging, hasMore: 'next' in data.paging,
nextSince: data.paging.since, nextSince: data.paging.since,
@ -40,11 +44,10 @@ export function deleteMessagesByApp(id: number) {
snack('Messages deleted'); snack('Messages deleted');
}); });
} else { } else {
axios.delete(config.get('url') + 'application/' + id + '/message') axios.delete(config.get('url') + 'application/' + id + '/message').then(() => {
.then(() => { dispatcher.dispatch({type: 'DELETE_MESSAGES', payload: id});
dispatcher.dispatch({type: 'DELETE_MESSAGES', payload: id}); snack('Deleted all messages from the application');
snack('Deleted all messages from the application'); });
});
} }
} }
@ -66,7 +69,10 @@ export function listenToWebSocket() {
} }
wsActive = true; wsActive = true;
const wsUrl = config.get('url').replace('http', 'ws').replace('https', 'wss'); const wsUrl = config
.get('url')
.replace('http', 'ws')
.replace('https', 'wss');
const ws = new WebSocket(wsUrl + 'stream?token=' + getToken()); const ws = new WebSocket(wsUrl + 'stream?token=' + getToken());
ws.onerror = (e) => { ws.onerror = (e) => {
@ -74,7 +80,8 @@ export function listenToWebSocket() {
console.log('WebSocket connection errored', e); console.log('WebSocket connection errored', e);
}; };
ws.onmessage = (data) => dispatcher.dispatch({type: 'ONE_MESSAGE', payload: JSON.parse(data.data) as IMessage}); ws.onmessage = (data) =>
dispatcher.dispatch({type: 'ONE_MESSAGE', payload: JSON.parse(data.data) as IMessage});
ws.onclose = () => { ws.onclose = () => {
wsActive = false; wsActive = false;

View File

@ -16,20 +16,29 @@ export function login(username: string, password: string) {
const browser = detect(); const browser = detect();
const name = (browser && browser.name + ' ' + browser.version) || 'unknown browser'; const name = (browser && browser.name + ' ' + browser.version) || 'unknown browser';
authenticating(); authenticating();
axios.create().request({ axios
url: config.get('url') + 'client', .create()
method: 'POST', .request({
data: {name}, url: config.get('url') + 'client',
auth: {username, password}, method: 'POST',
}).then((resp) => { data: {name},
snack(`A client named '${name}' was created for your session.`); auth: {username, password},
setAuthorizationToken(resp.data.token); })
tryAuthenticate().then(GlobalAction.initialLoad) .then((resp) => {
.catch(() => console.log('create client succeeded, but authenticated with given token failed')); snack(`A client named '${name}' was created for your session.`);
}).catch(() => { setAuthorizationToken(resp.data.token);
snack('Login failed'); tryAuthenticate()
noAuthentication(); .then(GlobalAction.initialLoad)
}); .catch(() =>
console.log(
'create client succeeded, but authenticated with given token failed'
)
);
})
.catch(() => {
snack('Login failed');
noAuthentication();
});
} }
/** Log the user out. */ /** Log the user out. */
@ -44,17 +53,21 @@ export function logout() {
} }
export function tryAuthenticate() { export function tryAuthenticate() {
return axios.create().get(config.get('url') + 'current/user', {headers: {'X-Gotify-Key': getToken()}}).then((resp) => { return axios
dispatcher.dispatch({type: 'AUTHENTICATED', payload: resp.data}); .create()
return resp; .get(config.get('url') + 'current/user', {headers: {'X-Gotify-Key': getToken()}})
}).catch((resp) => { .then((resp) => {
if (getToken()) { dispatcher.dispatch({type: 'AUTHENTICATED', payload: resp.data});
setAuthorizationToken(null); return resp;
snack('Authentication failed, try to re-login. (client or user was deleted)'); })
} .catch((resp) => {
noAuthentication(); if (getToken()) {
return Promise.reject(resp); setAuthorizationToken(null);
}); snack('Authentication failed, try to re-login. (client or user was deleted)');
}
noAuthentication();
return Promise.reject(resp);
});
} }
export function checkIfAlreadyLoggedIn() { export function checkIfAlreadyLoggedIn() {
@ -80,7 +93,9 @@ function authenticating() {
* @param {string} pass * @param {string} pass
*/ */
export function changeCurrentUser(pass: string) { export function changeCurrentUser(pass: string) {
axios.post(config.get('url') + 'current/user/password', {pass}).then(() => snack('Password changed')); axios
.post(config.get('url') + 'current/user/password', {pass})
.then(() => snack('Password changed'));
} }
/** Fetches all users. */ /** Fetches all users. */
@ -95,7 +110,10 @@ export function fetchUsers() {
* @param {int} id the user id * @param {int} id the user id
*/ */
export function deleteUser(id: number) { export function deleteUser(id: number) {
axios.delete(config.get('url') + 'user/' + id).then(fetchUsers).then(() => snack('User deleted')); axios
.delete(config.get('url') + 'user/' + id)
.then(fetchUsers)
.then(() => snack('User deleted'));
} }
/** /**
@ -105,7 +123,10 @@ export function deleteUser(id: number) {
* @param {bool} admin if true, the user is an administrator * @param {bool} admin if true, the user is an administrator
*/ */
export function createUser(name: string, pass: string, admin: boolean) { export function createUser(name: string, pass: string, admin: boolean) {
axios.post(config.get('url') + 'user', {name, pass, admin}).then(fetchUsers).then(() => snack('User created')); axios
.post(config.get('url') + 'user', {name, pass, admin})
.then(fetchUsers)
.then(() => snack('User created'));
} }
/** /**

View File

@ -1,12 +1,17 @@
import Button from 'material-ui/Button'; import Button from 'material-ui/Button';
import Dialog, {DialogActions, DialogContent, DialogContentText, DialogTitle} from 'material-ui/Dialog'; import Dialog, {
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from 'material-ui/Dialog';
import React from 'react'; import React from 'react';
interface IProps { interface IProps {
title: string title: string;
text: string text: string;
fClose: VoidFunction fClose: VoidFunction;
fOnSubmit: VoidFunction fOnSubmit: VoidFunction;
} }
export default function ConfirmDialog({title, text, fClose, fOnSubmit}: IProps) { export default function ConfirmDialog({title, text, fClose, fOnSubmit}: IProps) {
@ -22,8 +27,10 @@ export default function ConfirmDialog({title, text, fClose, fOnSubmit}: IProps)
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={fClose}>No</Button> <Button onClick={fClose}>No</Button>
<Button onClick={submitAndClose} color="primary" variant="raised">Yes</Button> <Button onClick={submitAndClose} color="primary" variant="raised">
Yes
</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
); );
} }

View File

@ -1,4 +1,4 @@
import {WithStyles} from "material-ui"; import {WithStyles} from 'material-ui';
import Paper from 'material-ui/Paper'; import Paper from 'material-ui/Paper';
import {withStyles} from 'material-ui/styles'; import {withStyles} from 'material-ui/styles';
import * as React from 'react'; import * as React from 'react';
@ -10,7 +10,7 @@ const styles = () => ({
}); });
interface IProps { interface IProps {
style?: object, style?: object;
} }
const Container: React.SFC<IProps & WithStyles<'paper'>> = ({classes, children, style}) => { const Container: React.SFC<IProps & WithStyles<'paper'>> = ({classes, children, style}) => {

View File

@ -4,23 +4,38 @@ import Typography from 'material-ui/Typography';
import React, {SFC} from 'react'; import React, {SFC} from 'react';
interface IProps { interface IProps {
title: string title: string;
buttonTitle?: string buttonTitle?: string;
fButton?: VoidFunction fButton?: VoidFunction;
buttonDisabled?: boolean buttonDisabled?: boolean;
maxWidth?: number maxWidth?: number;
hideButton?: boolean hideButton?: boolean;
} }
const DefaultPage: SFC<IProps> = ({title, buttonTitle, fButton, buttonDisabled = false, maxWidth = 700, hideButton, children}) => ( const DefaultPage: SFC<IProps> = ({
title,
buttonTitle,
fButton,
buttonDisabled = false,
maxWidth = 700,
hideButton,
children,
}) => (
<main style={{margin: '0 auto', maxWidth}}> <main style={{margin: '0 auto', maxWidth}}>
<Grid container spacing={24}> <Grid container spacing={24}>
<Grid item xs={12} style={{display: 'flex'}}> <Grid item xs={12} style={{display: 'flex'}}>
<Typography variant="display1" style={{flex: 1}}> <Typography variant="display1" style={{flex: 1}}>
{title} {title}
</Typography> </Typography>
{hideButton ? null : <Button variant="raised" color="primary" disabled={buttonDisabled} {hideButton ? null : (
onClick={fButton}>{buttonTitle}</Button>} <Button
variant="raised"
color="primary"
disabled={buttonDisabled}
onClick={fButton}>
{buttonTitle}
</Button>
)}
</Grid> </Grid>
{children} {children}
</Grid> </Grid>

View File

@ -16,7 +16,6 @@ class FixedReactList extends ReactList {
super.cacheSizes(); super.cacheSizes();
} }
public clearCacheFromIndex(startIndex: number): void { public clearCacheFromIndex(startIndex: number): void {
this.ignoreNextCacheUpdate = true; this.ignoreNextCacheUpdate = true;
@ -25,16 +24,19 @@ class FixedReactList extends ReactList {
this.cache = {}; this.cache = {};
} else { } else {
// @ts-ignore accessing private member // @ts-ignore accessing private member
Object.keys(this.cache).filter((index) => index >= startIndex).forEach((index) => { Object.keys(this.cache)
// @ts-ignore accessing private member .filter((index) => +index >= startIndex)
delete this.cache[index]; .forEach((index) => {
}); // @ts-ignore accessing private member
delete this.cache[index];
});
} }
}; }
public componentDidUpdate() { public componentDidUpdate() {
// @ts-ignore accessing private member const hasCacheForLastRenderedItem =
const hasCacheForLastRenderedItem = Object.keys(this.cache).length && this.cache[this.getVisibleRange()[1]]; // @ts-ignore accessing private member
Object.keys(this.cache).length && this.cache[this.getVisibleRange()[1]];
// @ts-ignore accessing private member // @ts-ignore accessing private member
super.componentDidUpdate(); super.componentDidUpdate();
if (!hasCacheForLastRenderedItem) { if (!hasCacheForLastRenderedItem) {

View File

@ -1,4 +1,4 @@
import {Theme, WithStyles} from "material-ui"; import {Theme, WithStyles} from 'material-ui';
import AccountCircle from 'material-ui-icons/AccountCircle'; import AccountCircle from 'material-ui-icons/AccountCircle';
import Chat from 'material-ui-icons/Chat'; import Chat from 'material-ui-icons/Chat';
import DevicesOther from 'material-ui-icons/DevicesOther'; import DevicesOther from 'material-ui-icons/DevicesOther';
@ -33,15 +33,15 @@ const styles = (theme: Theme) => ({
}, },
}); });
type Styles = WithStyles<'link' | 'titleName' | 'title' | 'appBar'> type Styles = WithStyles<'link' | 'titleName' | 'title' | 'appBar'>;
interface IProps { interface IProps {
loggedIn: boolean loggedIn: boolean;
name: string name: string;
admin: boolean admin: boolean;
version: string version: string;
toggleTheme: VoidFunction toggleTheme: VoidFunction;
showSettings: VoidFunction showSettings: VoidFunction;
} }
class Header extends Component<IProps & Styles> { class Header extends Component<IProps & Styles> {
@ -53,18 +53,25 @@ class Header extends Component<IProps & Styles> {
<Toolbar> <Toolbar>
<div className={classes.title}> <div className={classes.title}>
<a href="https://github.com/gotify/server" className={classes.link}> <a href="https://github.com/gotify/server" className={classes.link}>
<Typography variant="headline" className={classes.titleName} color="inherit"> <Typography
variant="headline"
className={classes.titleName}
color="inherit">
Gotify Gotify
</Typography> </Typography>
</a> </a>
<a href={'https://github.com/gotify/server/releases/tag/v' + version} className={classes.link}> <a
href={'https://github.com/gotify/server/releases/tag/v' + version}
className={classes.link}>
<Typography variant="button" color="inherit"> <Typography variant="button" color="inherit">
@{version} @{version}
</Typography> </Typography>
</a> </a>
</div> </div>
{loggedIn && this.renderButtons(name, admin)} {loggedIn && this.renderButtons(name, admin)}
<IconButton onClick={toggleTheme} color="inherit"><LightbulbOutline/></IconButton> <IconButton onClick={toggleTheme} color="inherit">
<LightbulbOutline />
</IconButton>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
); );
@ -74,18 +81,37 @@ class Header extends Component<IProps & Styles> {
const {classes, showSettings} = this.props; const {classes, showSettings} = this.props;
return ( return (
<div> <div>
{admin {admin ? (
? <Link className={classes.link} to="/users"> <Link className={classes.link} to="/users">
<Button color="inherit"><SupervisorAccount/>&nbsp;users</Button></Link> <Button color="inherit">
: ''} <SupervisorAccount />
&nbsp;users
</Button>
</Link>
) : (
''
)}
<Link className={classes.link} to="/applications"> <Link className={classes.link} to="/applications">
<Button color="inherit"><Chat/>&nbsp;apps</Button> <Button color="inherit">
<Chat />
&nbsp;apps
</Button>
</Link> </Link>
<Link className={classes.link} to="/clients"><Button color="inherit"> <Link className={classes.link} to="/clients">
<DevicesOther/>&nbsp;clients</Button> <Button color="inherit">
<DevicesOther />
&nbsp;clients
</Button>
</Link> </Link>
<Button color="inherit" onClick={showSettings}><AccountCircle/>&nbsp;{name}</Button> <Button color="inherit" onClick={showSettings}>
<Button color="inherit" onClick={UserAction.logout}><ExitToApp/>&nbsp;Logout</Button> <AccountCircle />
&nbsp;
{name}
</Button>
<Button color="inherit" onClick={UserAction.logout}>
<ExitToApp />
&nbsp;Logout
</Button>
</div> </div>
); );
} }

View File

@ -7,8 +7,8 @@ export default function LoadingSpinner() {
return ( return (
<DefaultPage title="" maxWidth={250} hideButton={true}> <DefaultPage title="" maxWidth={250} hideButton={true}>
<Grid item xs={12} style={{textAlign: 'center'}}> <Grid item xs={12} style={{textAlign: 'center'}}>
<CircularProgress size={150}/> <CircularProgress size={150} />
</Grid> </Grid>
</DefaultPage> </DefaultPage>
); );
}; }

View File

@ -1,4 +1,4 @@
import {WithStyles} from "material-ui"; import {WithStyles} from 'material-ui';
import Delete from 'material-ui-icons/Delete'; import Delete from 'material-ui-icons/Delete';
import IconButton from 'material-ui/IconButton'; import IconButton from 'material-ui/IconButton';
import {withStyles} from 'material-ui/styles'; import {withStyles} from 'material-ui/styles';
@ -32,14 +32,22 @@ const styles = () => ({
}, },
}); });
type Style = WithStyles<'header' | 'headerTitle' | 'trash' | 'wrapperPadding' | 'messageContentWrapper' | 'image' | 'imageWrapper'>; type Style = WithStyles<
| 'header'
| 'headerTitle'
| 'trash'
| 'wrapperPadding'
| 'messageContentWrapper'
| 'image'
| 'imageWrapper'
>;
interface IProps { interface IProps {
title: string title: string;
image?: string image?: string;
date: string date: string;
content: string content: string;
fDelete: VoidFunction fDelete: VoidFunction;
} }
function Message({fDelete, classes, title, date, content, image}: IProps & Style) { function Message({fDelete, classes, title, date, content, image}: IProps & Style) {
@ -47,7 +55,13 @@ function Message({fDelete, classes, title, date, content, image}: IProps & Style
<div className={classes.wrapperPadding}> <div className={classes.wrapperPadding}>
<Container style={{display: 'flex'}}> <Container style={{display: 'flex'}}>
<div className={classes.imageWrapper}> <div className={classes.imageWrapper}>
<img src={image} alt="app logo" width="70" height="70" className={classes.image}/> <img
src={image}
alt="app logo"
width="70"
height="70"
className={classes.image}
/>
</div> </div>
<div className={classes.messageContentWrapper}> <div className={classes.messageContentWrapper}>
<div className={classes.header}> <div className={classes.header}>
@ -55,9 +69,11 @@ function Message({fDelete, classes, title, date, content, image}: IProps & Style
{title} {title}
</Typography> </Typography>
<Typography variant="body1"> <Typography variant="body1">
<TimeAgo date={date}/> <TimeAgo date={date} />
</Typography> </Typography>
<IconButton onClick={fDelete} className={classes.trash}><Delete/></IconButton> <IconButton onClick={fDelete} className={classes.trash}>
<Delete />
</IconButton>
</div> </div>
<Typography component="p">{content}</Typography> <Typography component="p">{content}</Typography>
</div> </div>

View File

@ -1,4 +1,4 @@
import {Theme, WithStyles} from "material-ui"; import {Theme, WithStyles} from 'material-ui';
import Divider from 'material-ui/Divider'; import Divider from 'material-ui/Divider';
import Drawer from 'material-ui/Drawer'; import Drawer from 'material-ui/Drawer';
import {ListItem, ListItemText} from 'material-ui/List'; import {ListItem, ListItemText} from 'material-ui/List';
@ -20,14 +20,14 @@ const styles = (theme: Theme) => ({
}, },
}); });
type Styles = WithStyles<'drawerPaper' | 'toolbar' | 'link'> type Styles = WithStyles<'drawerPaper' | 'toolbar' | 'link'>;
interface IProps { interface IProps {
loggedIn: boolean loggedIn: boolean;
} }
interface IState { interface IState {
apps: IApplication[] apps: IApplication[];
} }
class Navigation extends Component<IProps & Styles, IState> { class Navigation extends Component<IProps & Styles, IState> {
@ -45,36 +45,39 @@ class Navigation extends Component<IProps & Styles, IState> {
const {classes, loggedIn} = this.props; const {classes, loggedIn} = this.props;
const {apps} = this.state; const {apps} = this.state;
const userApps = apps.length === 0 ? null : apps.map((app) => { const userApps =
return ( apps.length === 0
<Link className={classes.link} to={'/messages/' + app.id} key={app.id}> ? null
<ListItem button> : apps.map((app) => {
<ListItemText primary={app.name}/> return (
</ListItem> <Link className={classes.link} to={'/messages/' + app.id} key={app.id}>
</Link> <ListItem button>
); <ListItemText primary={app.name} />
}); </ListItem>
</Link>
);
});
const placeholderItems = [ const placeholderItems = [
<ListItem button disabled key={-1}> <ListItem button disabled key={-1}>
<ListItemText primary="Some Server"/> <ListItemText primary="Some Server" />
</ListItem>, </ListItem>,
<ListItem button disabled key={-2}> <ListItem button disabled key={-2}>
<ListItemText primary="A Raspberry PI"/> <ListItemText primary="A Raspberry PI" />
</ListItem>, </ListItem>,
]; ];
return ( return (
<Drawer variant="permanent" classes={{paper: classes.drawerPaper}}> <Drawer variant="permanent" classes={{paper: classes.drawerPaper}}>
<div className={classes.toolbar}/> <div className={classes.toolbar} />
<Link className={classes.link} to="/"> <Link className={classes.link} to="/">
<ListItem button disabled={!loggedIn}> <ListItem button disabled={!loggedIn}>
<ListItemText primary="All Messages"/> <ListItemText primary="All Messages" />
</ListItem> </ListItem>
</Link> </Link>
<Divider/> <Divider />
<div>{loggedIn ? userApps : placeholderItems}</div> <div>{loggedIn ? userApps : placeholderItems}</div>
<Divider/> <Divider />
</Drawer> </Drawer>
); );
} }
@ -82,4 +85,4 @@ class Navigation extends Component<IProps & Styles, IState> {
private updateApps = () => this.setState({apps: AppStore.get()}); private updateApps = () => this.setState({apps: AppStore.get()});
} }
export default withStyles(styles,{withTheme: true})<IProps>(Navigation); export default withStyles(styles, {withTheme: true})<IProps>(Navigation);

View File

@ -5,10 +5,12 @@ import React, {Component} from 'react';
class ScrollUpButton extends Component { class ScrollUpButton extends Component {
public render() { public render() {
return ( return (
<Button variant="fab" color="primary" <Button
style={{position: 'fixed', bottom: '30px', right: '30px', zIndex: 100000}} variant="fab"
onClick={this.scrollUp}> color="primary"
<KeyboardArrowUp/> style={{position: 'fixed', bottom: '30px', right: '30px', zIndex: 100000}}
onClick={this.scrollUp}>
<KeyboardArrowUp />
</Button> </Button>
); );
} }

View File

@ -6,11 +6,11 @@ import React, {ChangeEvent, Component} from 'react';
import * as UserAction from '../actions/UserAction'; import * as UserAction from '../actions/UserAction';
interface IState { interface IState {
pass: string pass: string;
} }
interface IProps { interface IProps {
fClose: VoidFunction fClose: VoidFunction;
} }
export default class SettingsDialog extends Component<IProps, IState> { export default class SettingsDialog extends Component<IProps, IState> {
@ -27,15 +27,25 @@ export default class SettingsDialog extends Component<IProps, IState> {
<Dialog open={true} onClose={fClose} aria-labelledby="form-dialog-title"> <Dialog open={true} onClose={fClose} aria-labelledby="form-dialog-title">
<DialogTitle id="form-dialog-title">Change Password</DialogTitle> <DialogTitle id="form-dialog-title">Change Password</DialogTitle>
<DialogContent> <DialogContent>
<TextField autoFocus margin="dense" type="password" label="New Pass *" value={pass} <TextField
onChange={this.handleChange.bind(this, 'pass')} fullWidth/> autoFocus
margin="dense"
type="password"
label="New Pass *"
value={pass}
onChange={this.handleChange.bind(this, 'pass')}
fullWidth
/>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={fClose}>Cancel</Button> <Button onClick={fClose}>Cancel</Button>
<Tooltip title={pass.length !== 0 ? '' : 'pass is required'}> <Tooltip title={pass.length !== 0 ? '' : 'pass is required'}>
<div> <div>
<Button disabled={pass.length === 0} onClick={submitAndClose} color="primary" <Button
variant="raised"> disabled={pass.length === 0}
onClick={submitAndClose}
color="primary"
variant="raised">
Change Change
</Button> </Button>
</div> </div>

View File

@ -4,12 +4,11 @@ import Snackbar from 'material-ui/Snackbar';
import React, {Component} from 'react'; import React, {Component} from 'react';
import SnackBarStore from '../stores/SnackBarStore'; import SnackBarStore from '../stores/SnackBarStore';
interface IState { interface IState {
current: string current: string;
hasNext: boolean hasNext: boolean;
open: boolean open: boolean;
openWhen: number openWhen: number;
} }
class SnackBarHandler extends Component<{}, IState> { class SnackBarHandler extends Component<{}, IState> {
@ -40,12 +39,18 @@ class SnackBarHandler extends Component<{}, IState> {
return ( return (
<Snackbar <Snackbar
anchorOrigin={{vertical: 'bottom', horizontal: 'left'}} anchorOrigin={{vertical: 'bottom', horizontal: 'left'}}
open={open} autoHideDuration={duration} open={open}
onClose={this.closeCurrentSnack} onExited={this.openNextSnack} autoHideDuration={duration}
onClose={this.closeCurrentSnack}
onExited={this.openNextSnack}
message={<span id="message-id">{current}</span>} message={<span id="message-id">{current}</span>}
action={ action={
<IconButton key="close" aria-label="Close" color="inherit" onClick={this.closeCurrentSnack}> <IconButton
<Close/> key="close"
aria-label="Close"
color="inherit"
onClick={this.closeCurrentSnack}>
<Close />
</IconButton> </IconButton>
} }
/> />
@ -64,7 +69,10 @@ class SnackBarHandler extends Component<{}, IState> {
if (snackOpenSince > SnackBarHandler.MIN_VISIBLE_SNACK_TIME_IN_MS) { if (snackOpenSince > SnackBarHandler.MIN_VISIBLE_SNACK_TIME_IN_MS) {
this.closeCurrentSnack(); this.closeCurrentSnack();
} else { } else {
setTimeout(this.closeCurrentSnack, SnackBarHandler.MIN_VISIBLE_SNACK_TIME_IN_MS - snackOpenSince); setTimeout(
this.closeCurrentSnack,
SnackBarHandler.MIN_VISIBLE_SNACK_TIME_IN_MS - snackOpenSince
);
} }
}; };

View File

@ -5,12 +5,12 @@ import Typography from 'material-ui/Typography';
import React, {Component} from 'react'; import React, {Component} from 'react';
interface IProps { interface IProps {
value: string value: string;
style?: object style?: object;
} }
interface IState { interface IState {
visible: boolean visible: boolean;
} }
class ToggleVisibility extends Component<IProps, IState> { class ToggleVisibility extends Component<IProps, IState> {
@ -22,11 +22,9 @@ class ToggleVisibility extends Component<IProps, IState> {
return ( return (
<div style={style}> <div style={style}>
<IconButton onClick={this.toggleVisibility}> <IconButton onClick={this.toggleVisibility}>
{this.state.visible ? <VisibilityOff/> : <Visibility/>} {this.state.visible ? <VisibilityOff /> : <Visibility />}
</IconButton> </IconButton>
<Typography style={{fontFamily: '\'Roboto Mono\', monospace'}}> <Typography style={{fontFamily: "'Roboto Mono', monospace"}}>{text}</Typography>
{text}
</Typography>
</div> </div>
); );
} }
@ -34,5 +32,4 @@ class ToggleVisibility extends Component<IProps, IState> {
private toggleVisibility = () => this.setState({visible: !this.state.visible}); private toggleVisibility = () => this.setState({visible: !this.state.visible});
} }
export default ToggleVisibility; export default ToggleVisibility;

View File

@ -1,5 +1,5 @@
export interface IConfig { export interface IConfig {
url: string url: string;
} }
let config: IConfig; let config: IConfig;
@ -8,6 +8,6 @@ export function set(c: IConfig) {
config = c; config = c;
} }
export function get(val: "url"): string { export function get(val: 'url'): string {
return config[val]; return config[val];
} }

View File

@ -3,7 +3,7 @@ import * as ReactDOM from 'react-dom';
import 'typeface-roboto'; import 'typeface-roboto';
import 'typeface-roboto-mono'; import 'typeface-roboto-mono';
import * as UserAction from './actions/UserAction'; import * as UserAction from './actions/UserAction';
import * as config from './config' import * as config from './config';
import Layout from './Layout'; import Layout from './Layout';
import registerServiceWorker from './registerServiceWorker'; import registerServiceWorker from './registerServiceWorker';
import * as Notifications from './stores/Notifications'; import * as Notifications from './stores/Notifications';
@ -36,6 +36,6 @@ declare global {
config.set(window.config || defaultDevConfig); config.set(window.config || defaultDevConfig);
} }
UserAction.checkIfAlreadyLoggedIn(); UserAction.checkIfAlreadyLoggedIn();
ReactDOM.render(<Layout/>, document.getElementById('root')); ReactDOM.render(<Layout />, document.getElementById('root'));
registerServiceWorker(); registerServiceWorker();
}()); })();

View File

@ -14,9 +14,9 @@ import AppStore from '../stores/AppStore';
import AddApplicationDialog from './dialog/AddApplicationDialog'; import AddApplicationDialog from './dialog/AddApplicationDialog';
interface IState { interface IState {
apps: IApplication[] apps: IApplication[];
createDialog: boolean createDialog: boolean;
deleteId: number deleteId: number;
} }
class Applications extends Component<{}, IState> { class Applications extends Component<{}, IState> {
@ -36,40 +36,61 @@ class Applications extends Component<{}, IState> {
public render() { public render() {
const {apps, createDialog, deleteId} = this.state; const {apps, createDialog, deleteId} = this.state;
return ( return (
<DefaultPage title="Applications" buttonTitle="Create Application" maxWidth={1000} <DefaultPage
fButton={this.showCreateDialog}> title="Applications"
buttonTitle="Create Application"
maxWidth={1000}
fButton={this.showCreateDialog}>
<Grid item xs={12}> <Grid item xs={12}>
<Paper elevation={6}> <Paper elevation={6}>
<Table> <Table>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell padding="checkbox" style={{width: 80}}/> <TableCell padding="checkbox" style={{width: 80}} />
<TableCell>Name</TableCell> <TableCell>Name</TableCell>
<TableCell>Token</TableCell> <TableCell>Token</TableCell>
<TableCell>Description</TableCell> <TableCell>Description</TableCell>
<TableCell/> <TableCell />
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{apps.map((app: IApplication) => { {apps.map((app: IApplication) => {
return ( return (
<Row key={app.id} description={app.description} image={app.image} <Row
name={app.name} value={app.token} fUpload={() => this.uploadImage(app.id)} key={app.id}
fDelete={() => this.showCloseDialog(app.id)}/> description={app.description}
image={app.image}
name={app.name}
value={app.token}
fUpload={() => this.uploadImage(app.id)}
fDelete={() => this.showCloseDialog(app.id)}
/>
); );
})} })}
</TableBody> </TableBody>
</Table> </Table>
<input ref={(upload) => this.upload = upload} type="file" style={{display: 'none'}} <input
onChange={this.onUploadImage}/> ref={(upload) => (this.upload = upload)}
type="file"
style={{display: 'none'}}
onChange={this.onUploadImage}
/>
</Paper> </Paper>
</Grid> </Grid>
{createDialog && <AddApplicationDialog fClose={this.hideCreateDialog} fOnSubmit={AppAction.createApp}/>} {createDialog && (
{deleteId !== -1 && <ConfirmDialog title="Confirm Delete" <AddApplicationDialog
text={'Delete ' + AppStore.getById(deleteId).name + '?'} fClose={this.hideCreateDialog}
fClose={this.hideCloseDialog} fOnSubmit={AppAction.createApp}
fOnSubmit={() => AppAction.deleteApp(deleteId)} />
/>} )}
{deleteId !== -1 && (
<ConfirmDialog
title="Confirm Delete"
text={'Delete ' + AppStore.getById(deleteId).name + '?'}
fClose={this.hideCloseDialog}
fOnSubmit={() => AppAction.deleteApp(deleteId)}
/>
)}
</DefaultPage> </DefaultPage>
); );
} }
@ -102,28 +123,33 @@ class Applications extends Component<{}, IState> {
} }
interface IRowProps { interface IRowProps {
name: string name: string;
value: string value: string;
description: string description: string;
fUpload: VoidFunction fUpload: VoidFunction;
image: string image: string;
fDelete: VoidFunction fDelete: VoidFunction;
} }
const Row: SFC<IRowProps> = ({name, value, description, fDelete, fUpload, image}) => ( const Row: SFC<IRowProps> = ({name, value, description, fDelete, fUpload, image}) => (
<TableRow> <TableRow>
<TableCell padding="checkbox"> <TableCell padding="checkbox">
<div style={{display: 'flex'}}> <div style={{display: 'flex'}}>
<Avatar src={image}/><IconButton onClick={fUpload} style={{height: 40}}><Edit/></IconButton> <Avatar src={image} />
<IconButton onClick={fUpload} style={{height: 40}}>
<Edit />
</IconButton>
</div> </div>
</TableCell> </TableCell>
<TableCell>{name}</TableCell> <TableCell>{name}</TableCell>
<TableCell> <TableCell>
<ToggleVisibility value={value} style={{display: 'flex', alignItems: 'center'}}/> <ToggleVisibility value={value} style={{display: 'flex', alignItems: 'center'}} />
</TableCell> </TableCell>
<TableCell>{description}</TableCell> <TableCell>{description}</TableCell>
<TableCell numeric padding="none"> <TableCell numeric padding="none">
<IconButton onClick={fDelete}><Delete/></IconButton> <IconButton onClick={fDelete}>
<Delete />
</IconButton>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );

View File

@ -12,9 +12,9 @@ import ClientStore from '../stores/ClientStore';
import AddClientDialog from './dialog/AddClientDialog'; import AddClientDialog from './dialog/AddClientDialog';
interface IState { interface IState {
clients: IClient[] clients: IClient[];
showDialog: boolean showDialog: boolean;
deleteId: number deleteId: number;
} }
class Clients extends Component<{}, IState> { class Clients extends Component<{}, IState> {
@ -32,7 +32,10 @@ class Clients extends Component<{}, IState> {
public render() { public render() {
const {clients, deleteId, showDialog} = this.state; const {clients, deleteId, showDialog} = this.state;
return ( return (
<DefaultPage title="Clients" buttonTitle="Create Client" fButton={this.showCreateDialog}> <DefaultPage
title="Clients"
buttonTitle="Create Client"
fButton={this.showCreateDialog}>
<Grid item xs={12}> <Grid item xs={12}>
<Paper elevation={6}> <Paper elevation={6}>
<Table> <Table>
@ -40,25 +43,38 @@ class Clients extends Component<{}, IState> {
<TableRow style={{textAlign: 'center'}}> <TableRow style={{textAlign: 'center'}}>
<TableCell>Name</TableCell> <TableCell>Name</TableCell>
<TableCell style={{width: 200}}>token</TableCell> <TableCell style={{width: 200}}>token</TableCell>
<TableCell/> <TableCell />
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{clients.map((client: IClient) => { {clients.map((client: IClient) => {
return ( return (
<Row key={client.id} name={client.name} <Row
value={client.token} fDelete={() => this.showDeleteDialog(client.id)}/> key={client.id}
name={client.name}
value={client.token}
fDelete={() => this.showDeleteDialog(client.id)}
/>
); );
})} })}
</TableBody> </TableBody>
</Table> </Table>
</Paper> </Paper>
</Grid> </Grid>
{showDialog && <AddClientDialog fClose={this.hideCreateDialog} fOnSubmit={ClientAction.createClient}/>} {showDialog && (
{deleteId !== -1 && <ConfirmDialog title="Confirm Delete" <AddClientDialog
text={'Delete ' + ClientStore.getById(this.state.deleteId).name + '?'} fClose={this.hideCreateDialog}
fClose={this.hideDeleteDelete} fOnSubmit={ClientAction.createClient}
fOnSubmit={this.deleteClient}/>} />
)}
{deleteId !== -1 && (
<ConfirmDialog
title="Confirm Delete"
text={'Delete ' + ClientStore.getById(this.state.deleteId).name + '?'}
fClose={this.hideDeleteDelete}
fOnSubmit={this.deleteClient}
/>
)}
</DefaultPage> </DefaultPage>
); );
} }
@ -75,22 +91,26 @@ class Clients extends Component<{}, IState> {
} }
interface IRowProps { interface IRowProps {
name: string name: string;
value: string value: string;
fDelete: VoidFunction fDelete: VoidFunction;
} }
const Row: SFC<IRowProps> = ({name, value, fDelete}) => ( const Row: SFC<IRowProps> = ({name, value, fDelete}) => (
<TableRow> <TableRow>
<TableCell>{name}</TableCell> <TableCell>{name}</TableCell>
<TableCell> <TableCell>
<ToggleVisibility value={value} style={{display: 'flex', alignItems: 'center', width: 200}}/> <ToggleVisibility
value={value}
style={{display: 'flex', alignItems: 'center', width: 200}}
/>
</TableCell> </TableCell>
<TableCell numeric padding="none"> <TableCell numeric padding="none">
<IconButton onClick={fDelete}><Delete/></IconButton> <IconButton onClick={fDelete}>
<Delete />
</IconButton>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
export default Clients; export default Clients;

View File

@ -7,8 +7,8 @@ import Container from '../component/Container';
import DefaultPage from '../component/DefaultPage'; import DefaultPage from '../component/DefaultPage';
interface IState { interface IState {
username: string username: string;
password: string password: string;
} }
class Login extends Component<{}, IState> { class Login extends Component<{}, IState> {
@ -21,12 +21,28 @@ class Login extends Component<{}, IState> {
<Grid item xs={12} style={{textAlign: 'center'}}> <Grid item xs={12} style={{textAlign: 'center'}}>
<Container> <Container>
<form onSubmit={this.preventDefault}> <form onSubmit={this.preventDefault}>
<TextField id="name" label="Username" margin="dense" value={username} <TextField
onChange={this.handleChange.bind(this, 'username')}/> id="name"
<TextField type="password" id="password" label="Password" margin="normal" label="Username"
value={password} onChange={this.handleChange.bind(this, 'password')}/> margin="dense"
<Button type="submit" variant="raised" size="large" color="primary" value={username}
style={{marginTop: 15, marginBottom: 5}} onClick={this.login}> onChange={this.handleChange.bind(this, 'username')}
/>
<TextField
type="password"
id="password"
label="Password"
margin="normal"
value={password}
onChange={this.handleChange.bind(this, 'password')}
/>
<Button
type="submit"
variant="raised"
size="large"
color="primary"
style={{marginTop: 15, marginBottom: 5}}
onClick={this.login}>
Login Login
</Button> </Button>
</form> </form>

View File

@ -2,7 +2,7 @@ import Grid from 'material-ui/Grid';
import {CircularProgress} from 'material-ui/Progress'; import {CircularProgress} from 'material-ui/Progress';
import Typography from 'material-ui/Typography'; import Typography from 'material-ui/Typography';
import React, {Component} from 'react'; import React, {Component} from 'react';
import {RouteComponentProps} from "react-router"; import {RouteComponentProps} from 'react-router';
import * as MessageAction from '../actions/MessageAction'; import * as MessageAction from '../actions/MessageAction';
import DefaultPage from '../component/DefaultPage'; import DefaultPage from '../component/DefaultPage';
import ReactList from '../component/FixedReactList'; import ReactList from '../component/FixedReactList';
@ -10,20 +10,18 @@ import Message from '../component/Message';
import AppStore from '../stores/AppStore'; import AppStore from '../stores/AppStore';
import MessageStore from '../stores/MessageStore'; import MessageStore from '../stores/MessageStore';
interface IProps extends RouteComponentProps<any> {}
interface IProps extends RouteComponentProps<any> {
}
interface IState { interface IState {
appId: number appId: number;
messages: IMessage[] messages: IMessage[];
name: string name: string;
hasMore: boolean hasMore: boolean;
nextSince?: number nextSince?: number;
id?: number id?: number;
} }
class Messages extends Component<IProps , IState> { class Messages extends Component<IProps, IState> {
private static appId(props: IProps) { private static appId(props: IProps) {
if (props === undefined) { if (props === undefined) {
return -1; return -1;
@ -57,25 +55,33 @@ class Messages extends Component<IProps , IState> {
const deleteMessages = () => MessageAction.deleteMessagesByApp(appId); const deleteMessages = () => MessageAction.deleteMessagesByApp(appId);
return ( return (
<DefaultPage title={name} buttonTitle="Delete All" fButton={deleteMessages} buttonDisabled={!hasMessages}> <DefaultPage
{hasMessages title={name}
? ( buttonTitle="Delete All"
<div style={{width: '100%'}}> fButton={deleteMessages}
<ReactList key={appId} buttonDisabled={!hasMessages}>
ref={(el: ReactList) => this.list = el} {hasMessages ? (
itemRenderer={this.renderMessage} <div style={{width: '100%'}}>
length={messages.length} <ReactList
threshold={1000} key={appId}
pageSize={30} ref={(el: ReactList) => (this.list = el)}
type='variable' itemRenderer={this.renderMessage}
/> length={messages.length}
{hasMore threshold={1000}
? <Grid item xs={12} style={{textAlign: 'center'}}><CircularProgress size={100}/></Grid> pageSize={30}
: this.label('You\'ve reached the end')} type="variable"
</div> />
) {hasMore ? (
: this.label('No messages') <Grid item xs={12} style={{textAlign: 'center'}}>
} <CircularProgress size={100} />
</Grid>
) : (
this.label("You've reached the end")
)}
</div>
) : (
this.label('No messages')
)}
</DefaultPage> </DefaultPage>
); );
} }
@ -102,12 +108,14 @@ class Messages extends Component<IProps , IState> {
this.checkIfLoadMore(); this.checkIfLoadMore();
const message: IMessage = this.state.messages[index]; const message: IMessage = this.state.messages[index];
return ( return (
<Message key={key} <Message
fDelete={this.deleteMessage(message)} key={key}
title={message.title} fDelete={this.deleteMessage(message)}
date={message.date} title={message.title}
content={message.message} date={message.date}
image={message.image}/> content={message.message}
image={message.image}
/>
); );
}; };
@ -115,14 +123,18 @@ class Messages extends Component<IProps , IState> {
const {hasMore, messages, appId} = this.state; const {hasMore, messages, appId} = this.state;
if (hasMore) { if (hasMore) {
const [, maxRenderedIndex] = (this.list && this.list.getVisibleRange()) || [0, 0]; const [, maxRenderedIndex] = (this.list && this.list.getVisibleRange()) || [0, 0];
if (maxRenderedIndex > (messages.length - 30)) { if (maxRenderedIndex > messages.length - 30) {
MessageStore.loadNext(appId); MessageStore.loadNext(appId);
} }
} }
} }
private label = (text: string) => ( private label = (text: string) => (
<Grid item xs={12}><Typography variant="caption" gutterBottom align="center">{text}</Typography></Grid> <Grid item xs={12}>
<Typography variant="caption" gutterBottom align="center">
{text}
</Typography>
</Grid>
); );
} }

View File

@ -1,4 +1,4 @@
import {WithStyles} from "material-ui"; import {WithStyles} from 'material-ui';
import Delete from 'material-ui-icons/Delete'; import Delete from 'material-ui-icons/Delete';
import Edit from 'material-ui-icons/Edit'; import Edit from 'material-ui-icons/Edit';
import Grid from 'material-ui/Grid'; import Grid from 'material-ui/Grid';
@ -11,7 +11,7 @@ import * as UserAction from '../actions/UserAction';
import ConfirmDialog from '../component/ConfirmDialog'; import ConfirmDialog from '../component/ConfirmDialog';
import DefaultPage from '../component/DefaultPage'; import DefaultPage from '../component/DefaultPage';
import UserStore from '../stores/UserStore'; import UserStore from '../stores/UserStore';
import AddEditDialog from "./dialog/AddEditUserDialog"; import AddEditDialog from './dialog/AddEditUserDialog';
const styles = () => ({ const styles = () => ({
wrapper: { wrapper: {
@ -21,10 +21,10 @@ const styles = () => ({
}); });
interface IRowProps { interface IRowProps {
name: string name: string;
admin: boolean admin: boolean;
fDelete: VoidFunction fDelete: VoidFunction;
fEdit: VoidFunction fEdit: VoidFunction;
} }
const UserRow: SFC<IRowProps> = ({name, admin, fDelete, fEdit}) => ( const UserRow: SFC<IRowProps> = ({name, admin, fDelete, fEdit}) => (
@ -32,17 +32,21 @@ const UserRow: SFC<IRowProps> = ({name, admin, fDelete, fEdit}) => (
<TableCell>{name}</TableCell> <TableCell>{name}</TableCell>
<TableCell>{admin ? 'Yes' : 'No'}</TableCell> <TableCell>{admin ? 'Yes' : 'No'}</TableCell>
<TableCell numeric padding="none"> <TableCell numeric padding="none">
<IconButton onClick={fEdit}><Edit/></IconButton> <IconButton onClick={fEdit}>
<IconButton onClick={fDelete}><Delete/></IconButton> <Edit />
</IconButton>
<IconButton onClick={fDelete}>
<Delete />
</IconButton>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
interface IState { interface IState {
users: IUser[] users: IUser[];
createDialog: boolean createDialog: boolean;
deleteId: number deleteId: number;
editId: number editId: number;
} }
class Users extends Component<WithStyles<'wrapper'>, IState> { class Users extends Component<WithStyles<'wrapper'>, IState> {
@ -68,33 +72,48 @@ class Users extends Component<WithStyles<'wrapper'>, IState> {
<TableRow style={{textAlign: 'center'}}> <TableRow style={{textAlign: 'center'}}>
<TableCell>Name</TableCell> <TableCell>Name</TableCell>
<TableCell>Admin</TableCell> <TableCell>Admin</TableCell>
<TableCell/> <TableCell />
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{users.map((user: IUser) => { {users.map((user: IUser) => {
return ( return (
<UserRow key={user.id} name={user.name} admin={user.admin} <UserRow
fDelete={() => this.showDeleteDialog(user.id)} key={user.id}
fEdit={() => this.showEditDialog(user.id)}/> name={user.name}
admin={user.admin}
fDelete={() => this.showDeleteDialog(user.id)}
fEdit={() => this.showEditDialog(user.id)}
/>
); );
})} })}
</TableBody> </TableBody>
</Table> </Table>
</Paper> </Paper>
</Grid> </Grid>
{this.state.createDialog && <AddEditDialog fClose={this.hideCreateDialog} {this.state.createDialog && (
fOnSubmit={UserAction.createUser}/>} <AddEditDialog
{editId !== -1 && <AddEditDialog fClose={this.hideEditDialog} fClose={this.hideCreateDialog}
fOnSubmit={UserAction.updateUser.bind(this, editId)} fOnSubmit={UserAction.createUser}
name={UserStore.getById(this.state.editId).name} />
admin={UserStore.getById(this.state.editId).admin} )}
isEdit={true}/>} {editId !== -1 && (
{deleteId !== -1 && <ConfirmDialog title="Confirm Delete" <AddEditDialog
text={'Delete ' + UserStore.getById(this.state.deleteId).name + '?'} fClose={this.hideEditDialog}
fClose={this.hideDeleteDialog} fOnSubmit={UserAction.updateUser.bind(this, editId)}
fOnSubmit={() => UserAction.deleteUser(this.state.deleteId)} name={UserStore.getById(this.state.editId).name}
/>} admin={UserStore.getById(this.state.editId).admin}
isEdit={true}
/>
)}
{deleteId !== -1 && (
<ConfirmDialog
title="Confirm Delete"
text={'Delete ' + UserStore.getById(this.state.deleteId).name + '?'}
fClose={this.hideDeleteDialog}
fOnSubmit={() => UserAction.deleteUser(this.state.deleteId)}
/>
)}
</DefaultPage> </DefaultPage>
); );
} }

View File

@ -1,17 +1,22 @@
import Button from 'material-ui/Button'; import Button from 'material-ui/Button';
import Dialog, {DialogActions, DialogContent, DialogContentText, DialogTitle} from 'material-ui/Dialog'; import Dialog, {
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from 'material-ui/Dialog';
import TextField from 'material-ui/TextField'; import TextField from 'material-ui/TextField';
import Tooltip from 'material-ui/Tooltip'; import Tooltip from 'material-ui/Tooltip';
import React, {Component} from 'react'; import React, {Component} from 'react';
interface IProps { interface IProps {
fClose: VoidFunction fClose: VoidFunction;
fOnSubmit: (name: string, description: string) => void fOnSubmit: (name: string, description: string) => void;
} }
interface IState { interface IState {
name: string name: string;
description: string description: string;
} }
export default class AddDialog extends Component<IProps, IState> { export default class AddDialog extends Component<IProps, IState> {
@ -29,17 +34,38 @@ export default class AddDialog extends Component<IProps, IState> {
<Dialog open={true} onClose={fClose} aria-labelledby="form-dialog-title"> <Dialog open={true} onClose={fClose} aria-labelledby="form-dialog-title">
<DialogTitle id="form-dialog-title">Create an application</DialogTitle> <DialogTitle id="form-dialog-title">Create an application</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText>An application is allowed to send messages.</DialogContentText> <DialogContentText>
<TextField autoFocus margin="dense" id="name" label="Name *" type="email" value={name} An application is allowed to send messages.
onChange={this.handleChange.bind(this, 'name')} fullWidth/> </DialogContentText>
<TextField margin="dense" id="description" label="Short Description" value={description} <TextField
onChange={this.handleChange.bind(this, 'description')} fullWidth multiline/> autoFocus
margin="dense"
id="name"
label="Name *"
type="email"
value={name}
onChange={this.handleChange.bind(this, 'name')}
fullWidth
/>
<TextField
margin="dense"
id="description"
label="Short Description"
value={description}
onChange={this.handleChange.bind(this, 'description')}
fullWidth
multiline
/>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={fClose}>Cancel</Button> <Button onClick={fClose}>Cancel</Button>
<Tooltip title={submitEnabled ? '' : 'name is required'}> <Tooltip title={submitEnabled ? '' : 'name is required'}>
<div> <div>
<Button disabled={!submitEnabled} onClick={submitAndClose} color="primary" variant="raised"> <Button
disabled={!submitEnabled}
onClick={submitAndClose}
color="primary"
variant="raised">
Create Create
</Button> </Button>
</div> </div>
@ -54,4 +80,4 @@ export default class AddDialog extends Component<IProps, IState> {
state[propertyName] = event.target.value; state[propertyName] = event.target.value;
this.setState(state); this.setState(state);
} }
} }

View File

@ -5,11 +5,11 @@ import Tooltip from 'material-ui/Tooltip';
import React, {Component} from 'react'; import React, {Component} from 'react';
interface IProps { interface IProps {
fClose: VoidFunction fClose: VoidFunction;
fOnSubmit: (name: string) => void fOnSubmit: (name: string) => void;
} }
export default class AddDialog extends Component<IProps, { name: string }> { export default class AddDialog extends Component<IProps, {name: string}> {
public state = {name: ''}; public state = {name: ''};
public render() { public render() {
@ -24,14 +24,28 @@ export default class AddDialog extends Component<IProps, { name: string }> {
<Dialog open={true} onClose={fClose} aria-labelledby="form-dialog-title"> <Dialog open={true} onClose={fClose} aria-labelledby="form-dialog-title">
<DialogTitle id="form-dialog-title">Create a client</DialogTitle> <DialogTitle id="form-dialog-title">Create a client</DialogTitle>
<DialogContent> <DialogContent>
<TextField autoFocus margin="dense" id="name" label="Name *" type="email" value={name} <TextField
onChange={this.handleChange.bind(this, 'name')} fullWidth/> autoFocus
margin="dense"
id="name"
label="Name *"
type="email"
value={name}
onChange={this.handleChange.bind(this, 'name')}
fullWidth
/>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={fClose}>Cancel</Button> <Button onClick={fClose}>Cancel</Button>
<Tooltip placement={'bottom-start'} title={submitEnabled ? '' : 'name is required'}> <Tooltip
placement={'bottom-start'}
title={submitEnabled ? '' : 'name is required'}>
<div> <div>
<Button disabled={!submitEnabled} onClick={submitAndClose} color="primary" variant="raised"> <Button
disabled={!submitEnabled}
onClick={submitAndClose}
color="primary"
variant="raised">
Create Create
</Button> </Button>
</div> </div>
@ -46,4 +60,4 @@ export default class AddDialog extends Component<IProps, { name: string }> {
state[propertyName] = event.target.value; state[propertyName] = event.target.value;
this.setState(state); this.setState(state);
} }
} }

View File

@ -7,17 +7,17 @@ import Tooltip from 'material-ui/Tooltip';
import React, {ChangeEvent, Component} from 'react'; import React, {ChangeEvent, Component} from 'react';
interface IProps { interface IProps {
name?: string name?: string;
admin?: boolean admin?: boolean;
fClose: VoidFunction fClose: VoidFunction;
fOnSubmit: (name: string, pass: string, admin: boolean) => void fOnSubmit: (name: string, pass: string, admin: boolean) => void;
isEdit?: boolean isEdit?: boolean;
} }
interface IState { interface IState {
name: string name: string;
pass: string pass: string;
admin: boolean admin: boolean;
} }
export default class AddEditDialog extends Component<IProps, IState> { export default class AddEditDialog extends Component<IProps, IState> {
@ -38,24 +38,57 @@ export default class AddEditDialog extends Component<IProps, IState> {
}; };
return ( return (
<Dialog open={true} onClose={fClose} aria-labelledby="form-dialog-title"> <Dialog open={true} onClose={fClose} aria-labelledby="form-dialog-title">
<DialogTitle id="form-dialog-title">{isEdit ? 'Edit ' + this.props.name : 'Add a user'}</DialogTitle> <DialogTitle id="form-dialog-title">
{isEdit ? 'Edit ' + this.props.name : 'Add a user'}
</DialogTitle>
<DialogContent> <DialogContent>
<TextField autoFocus margin="dense" id="name" label="Name *" type="email" value={name} <TextField
onChange={this.handleChange.bind(this, 'name')} fullWidth/> autoFocus
<TextField margin="dense" id="description" type="password" value={pass} fullWidth margin="dense"
label={isEdit ? 'Pass (empty if no change)' : 'Pass *'} id="name"
onChange={this.handleChange.bind(this, 'pass')}/> label="Name *"
type="email"
value={name}
onChange={this.handleChange.bind(this, 'name')}
fullWidth
/>
<TextField
margin="dense"
id="description"
type="password"
value={pass}
fullWidth
label={isEdit ? 'Pass (empty if no change)' : 'Pass *'}
onChange={this.handleChange.bind(this, 'pass')}
/>
<FormControlLabel <FormControlLabel
control={<Switch checked={admin} onChange={this.handleChecked.bind(this, 'admin')} control={
value="admin"/>} label="has administrator rights"/> <Switch
checked={admin}
onChange={this.handleChecked.bind(this, 'admin')}
value="admin"
/>
}
label="has administrator rights"
/>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={fClose}>Cancel</Button> <Button onClick={fClose}>Cancel</Button>
<Tooltip placement={'bottom-start'} <Tooltip
title={namePresent ? (passPresent ? '' : 'password is required') : 'name is required'}> placement={'bottom-start'}
title={
namePresent
? passPresent
? ''
: 'password is required'
: 'name is required'
}>
<div> <div>
<Button disabled={!passPresent || !namePresent} onClick={submitAndClose} <Button
color="primary" variant="raised"> disabled={!passPresent || !namePresent}
onClick={submitAndClose}
color="primary"
variant="raised">
{isEdit ? 'Save' : 'Create'} {isEdit ? 'Save' : 'Create'}
</Button> </Button>
</div> </div>
@ -81,4 +114,4 @@ export default class AddEditDialog extends Component<IProps, IState> {
state[propertyName] = event.target.checked; state[propertyName] = event.target.checked;
this.setState(state); this.setState(state);
} }
} }

View File

@ -11,21 +11,16 @@
const isLocalhost = Boolean( const isLocalhost = Boolean(
window.location.hostname === 'localhost' || window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address. // [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' || window.location.hostname === '[::1]' ||
// 127.0.0.1/8 is considered localhost for IPv4. // 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match( window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
); );
export default function register() { export default function register() {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW. // The URL constructor is available in all browsers that support SW.
const publicUrl = new URL( const publicUrl = new URL(process.env.PUBLIC_URL!, window.location.toString());
process.env.PUBLIC_URL!,
window.location.toString()
);
if (publicUrl.origin !== window.location.origin) { if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin // Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to // from what our page is served on. This might happen if a CDN is used to
@ -45,7 +40,7 @@ export default function register() {
navigator.serviceWorker.ready.then(() => { navigator.serviceWorker.ready.then(() => {
console.log( console.log(
'This web app is being served cache-first by a service ' + 'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://goo.gl/SC7cgQ' 'worker. To learn more, visit https://goo.gl/SC7cgQ'
); );
}); });
} else { } else {
@ -62,7 +57,7 @@ function registerValidSW(swUrl: string) {
} }
navigator.serviceWorker navigator.serviceWorker
.register(swUrl) .register(swUrl)
.then(registration => { .then((registration) => {
registration.onupdatefound = () => { registration.onupdatefound = () => {
const installingWorker = registration.installing; const installingWorker = registration.installing;
if (installingWorker) { if (installingWorker) {
@ -85,7 +80,7 @@ function registerValidSW(swUrl: string) {
} }
}; };
}) })
.catch(error => { .catch((error) => {
console.error('Error during service worker registration:', error); console.error('Error during service worker registration:', error);
}); });
} }
@ -93,14 +88,14 @@ function registerValidSW(swUrl: string) {
function checkValidServiceWorker(swUrl: string) { function checkValidServiceWorker(swUrl: string) {
// Check if the service worker can be found. If it can't reload the page. // Check if the service worker can be found. If it can't reload the page.
fetch(swUrl) fetch(swUrl)
.then(response => { .then((response) => {
// Ensure service worker exists, and that we really are getting a JS file. // Ensure service worker exists, and that we really are getting a JS file.
if ( if (
response.status === 404 || response.status === 404 ||
response.headers.get('content-type')!.indexOf('javascript') === -1 response.headers.get('content-type')!.indexOf('javascript') === -1
) { ) {
// No service worker found. Probably a different app. Reload the page. // No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => { navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => { registration.unregister().then(() => {
window.location.reload(); window.location.reload();
}); });
@ -111,15 +106,13 @@ function checkValidServiceWorker(swUrl: string) {
} }
}) })
.catch(() => { .catch(() => {
console.log( console.log('No internet connection found. App is running in offline mode.');
'No internet connection found. App is running in offline mode.'
);
}); });
} }
export function unregister() { export function unregister() {
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => { navigator.serviceWorker.ready.then((registration) => {
registration.unregister(); registration.unregister();
}); });
} }

View File

@ -11,7 +11,7 @@ class AppStore extends EventEmitter {
public getById(id: number): IApplication { public getById(id: number): IApplication {
const app = this.getByIdOrUndefined(id); const app = this.getByIdOrUndefined(id);
if (!app) { if (!app) {
throw new Error('app is required to exist') throw new Error('app is required to exist');
} }
return app; return app;
} }

View File

@ -11,7 +11,7 @@ class ClientStore extends EventEmitter {
public getById(id: number): IClient { public getById(id: number): IClient {
const client = this.clients.find((c) => c.id === id); const client = this.clients.find((c) => c.id === id);
if (!client) { if (!client) {
throw new Error('client is required to exist') throw new Error('client is required to exist');
} }
return client; return client;
} }
@ -29,7 +29,6 @@ class ClientStore extends EventEmitter {
} }
} }
const store = new ClientStore(); const store = new ClientStore();
dispatcher.register(store.handle.bind(store)); dispatcher.register(store.handle.bind(store));
export default store; export default store;

View File

@ -4,8 +4,7 @@ import AppStore from './AppStore';
import dispatcher, {IEvent} from './dispatcher'; import dispatcher, {IEvent} from './dispatcher';
class MessageStore extends EventEmitter { class MessageStore extends EventEmitter {
private appToMessages: {[appId: number]: IAppMessages} = {};
private appToMessages: { [appId: number]: IAppMessages } = {};
private reset: false | number = false; private reset: false | number = false;
private resetOnAll: false | number = false; private resetOnAll: false | number = false;
private loading = false; private loading = false;
@ -32,7 +31,9 @@ class MessageStore extends EventEmitter {
return; return;
} }
this.loading = true; this.loading = true;
MessageAction.fetchMessagesApp(id, this.get(id).nextSince).catch(() => this.loading = false); MessageAction.fetchMessagesApp(id, this.get(id).nextSince).catch(
() => (this.loading = false)
);
} }
public get(id: number): IAppMessages { public get(id: number): IAppMessages {
@ -87,7 +88,9 @@ class MessageStore extends EventEmitter {
private removeFromList(messages: IAppMessages, messageToDelete: IMessage): false | number { private removeFromList(messages: IAppMessages, messageToDelete: IMessage): false | number {
if (messages) { if (messages) {
const index = messages.messages.findIndex((message) => message.id === messageToDelete.id); const index = messages.messages.findIndex(
(message) => message.id === messageToDelete.id
);
if (index !== -1) { if (index !== -1) {
messages.messages.splice(index, 1); messages.messages.splice(index, 1);
return index; return index;
@ -97,11 +100,11 @@ class MessageStore extends EventEmitter {
} }
private updateApps = (): void => { private updateApps = (): void => {
const appToUrl: { [appId: number]: string } = {}; const appToUrl: {[appId: number]: string} = {};
AppStore.get().forEach((app) => appToUrl[app.id] = app.image); AppStore.get().forEach((app) => (appToUrl[app.id] = app.image));
Object.keys(this.appToMessages).forEach((key) => { Object.keys(this.appToMessages).forEach((key) => {
const appMessages: IAppMessages = this.appToMessages[key]; const appMessages: IAppMessages = this.appToMessages[key];
appMessages.messages.forEach((message) => message.image = appToUrl[message.appid]); appMessages.messages.forEach((message) => (message.image = appToUrl[message.appid]));
}); });
}; };
} }

View File

@ -3,8 +3,10 @@ import dispatcher, {IEvent} from './dispatcher';
export function requestPermission() { export function requestPermission() {
if (Notify.needsPermission && Notify.isSupported()) { if (Notify.needsPermission && Notify.isSupported()) {
Notify.requestPermission(() => console.log('granted notification permissions'), Notify.requestPermission(
() => console.log('notification permission denied')); () => console.log('granted notification permissions'),
() => console.log('notification permission denied')
);
} }
} }
@ -25,16 +27,18 @@ function closeAfterTimeout(event: Event) {
}, 5000); }, 5000);
} }
dispatcher.register((data: IEvent): void => { dispatcher.register(
if (data.type === 'ONE_MESSAGE') { (data: IEvent): void => {
const msg = data.payload; if (data.type === 'ONE_MESSAGE') {
const msg = data.payload;
const notify = new Notify(msg.title, { const notify = new Notify(msg.title, {
body: msg.message, body: msg.message,
icon: msg.image, icon: msg.image,
notifyClick: closeAndFocus, notifyClick: closeAndFocus,
notifyShow: closeAfterTimeout, notifyShow: closeAfterTimeout,
}); });
notify.show(); notify.show();
}
} }
}); );

View File

@ -6,7 +6,7 @@ class SnackBarStore extends EventEmitter {
public next(): string { public next(): string {
if (!this.hasNext()) { if (!this.hasNext()) {
throw new Error("no such element") throw new Error('no such element');
} }
return this.messages.shift() as string; return this.messages.shift() as string;
} }

View File

@ -1,8 +1,8 @@
import {Dispatcher} from 'flux'; import {Dispatcher} from 'flux';
export interface IEvent { export interface IEvent {
type: string type: string;
payload?: any payload?: any;
} }
export default new Dispatcher<IEvent>(); export default new Dispatcher<IEvent>();

View File

@ -1,3 +1,3 @@
declare module 'notifyjs' { declare module 'notifyjs' {
export default Notify; export default Notify;
} }

View File

@ -1,10 +1,9 @@
declare module 'react-timeago' { declare module 'react-timeago' {
import React from "react"; import React from 'react';
export interface ITimeAgoProps { export interface ITimeAgoProps {
date: string date: string;
} }
export default class TimeAgo extends React.Component<ITimeAgoProps, any> {} export default class TimeAgo extends React.Component<ITimeAgoProps, any> {}
} }

View File

@ -1,54 +1,54 @@
interface IApplication { interface IApplication {
id: number id: number;
token: string token: string;
name: string name: string;
description: string description: string;
image: string image: string;
} }
interface IClient { interface IClient {
id: number id: number;
token: string token: string;
name: string name: string;
} }
interface IMessage { interface IMessage {
id: number id: number;
appid: number appid: number;
message: string message: string;
title: string title: string;
priority: number priority: number;
date: string date: string;
image?: string image?: string;
} }
interface IPagedMessages { interface IPagedMessages {
paging: IPaging paging: IPaging;
messages: IMessage[] messages: IMessage[];
} }
interface IPaging { interface IPaging {
next?: string next?: string;
since?: number since?: number;
size: number size: number;
limit: number limit: number;
} }
interface IUser { interface IUser {
id: number id: number;
name: string name: string;
admin: boolean admin: boolean;
} }
interface IVersion { interface IVersion {
version: string version: string;
commit: string commit: string;
buildDate: string buildDate: string;
} }
interface IAppMessages { interface IAppMessages {
messages: IMessage[] messages: IMessage[];
hasMore: boolean hasMore: boolean;
nextSince: number, nextSince: number;
id?: number id?: number;
} }