[NEW] Engagement Dashboard (#16960)

pull/17004/head^2
Tasso Evangelista 6 years ago committed by GitHub
parent 7ee5473189
commit 80c69c0fb8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      .storybook/config.js
  2. 11
      app/models/server/models/Analytics.js
  3. 145
      app/models/server/raw/Analytics.js
  4. 103
      app/models/server/raw/Rooms.js
  5. 251
      app/models/server/raw/Sessions.js
  6. 3
      client/main.js
  7. 146
      ee/app/engagement-dashboard/client/components/ChannelsTab/TableSection.js
  8. 9
      ee/app/engagement-dashboard/client/components/ChannelsTab/index.js
  9. 14
      ee/app/engagement-dashboard/client/components/ChannelsTab/index.stories.js
  10. 35
      ee/app/engagement-dashboard/client/components/EngagementDashboardPage.js
  11. 11
      ee/app/engagement-dashboard/client/components/EngagementDashboardPage.stories.js
  12. 24
      ee/app/engagement-dashboard/client/components/EngagementDashboardRoute.js
  13. 217
      ee/app/engagement-dashboard/client/components/MessagesTab/MessagesPerChannelSection.js
  14. 169
      ee/app/engagement-dashboard/client/components/MessagesTab/MessagesSentSection.js
  15. 13
      ee/app/engagement-dashboard/client/components/MessagesTab/index.js
  16. 14
      ee/app/engagement-dashboard/client/components/MessagesTab/index.stories.js
  17. 24
      ee/app/engagement-dashboard/client/components/Section.js
  18. 223
      ee/app/engagement-dashboard/client/components/UsersTab/ActiveUsersSection.js
  19. 250
      ee/app/engagement-dashboard/client/components/UsersTab/BusiestChatTimesSection.js
  20. 169
      ee/app/engagement-dashboard/client/components/UsersTab/NewUsersSection.js
  21. 166
      ee/app/engagement-dashboard/client/components/UsersTab/UsersByTimeOfTheDaySection.js
  22. 32
      ee/app/engagement-dashboard/client/components/UsersTab/index.js
  23. 14
      ee/app/engagement-dashboard/client/components/UsersTab/index.stories.js
  24. 24
      ee/app/engagement-dashboard/client/components/data/Counter.js
  25. 16
      ee/app/engagement-dashboard/client/components/data/Counter.stories.js
  26. 16
      ee/app/engagement-dashboard/client/components/data/CounterSet.js
  27. 16
      ee/app/engagement-dashboard/client/components/data/CounterSet.stories.js
  28. 16
      ee/app/engagement-dashboard/client/components/data/Growth.js
  29. 23
      ee/app/engagement-dashboard/client/components/data/Growth.stories.js
  30. 85
      ee/app/engagement-dashboard/client/components/data/Histogram.js
  31. 16
      ee/app/engagement-dashboard/client/components/data/Histogram.stories.js
  32. 19
      ee/app/engagement-dashboard/client/components/data/LegendSymbol.js
  33. 25
      ee/app/engagement-dashboard/client/components/data/LegendSymbol.stories.js
  34. 26
      ee/app/engagement-dashboard/client/components/data/NegativeGrowthSymbol.js
  35. 16
      ee/app/engagement-dashboard/client/components/data/NegativeGrowthSymbol.stories.js
  36. 26
      ee/app/engagement-dashboard/client/components/data/PositiveGrowthSymbol.js
  37. 16
      ee/app/engagement-dashboard/client/components/data/PositiveGrowthSymbol.stories.js
  38. 2
      ee/app/engagement-dashboard/client/components/data/colors.js
  39. 52
      ee/app/engagement-dashboard/client/hooks/useEndpointData.js
  40. 1
      ee/app/engagement-dashboard/client/index.js
  41. 71
      ee/app/engagement-dashboard/client/routes.js
  42. 26
      ee/app/engagement-dashboard/server/api/channels.js
  43. 14
      ee/app/engagement-dashboard/server/api/helpers/date.js
  44. 3
      ee/app/engagement-dashboard/server/api/index.js
  45. 41
      ee/app/engagement-dashboard/server/api/messages.js
  46. 67
      ee/app/engagement-dashboard/server/api/users.js
  47. 6
      ee/app/engagement-dashboard/server/index.js
  48. 27
      ee/app/engagement-dashboard/server/lib/channels.js
  49. 11
      ee/app/engagement-dashboard/server/lib/date.js
  50. 85
      ee/app/engagement-dashboard/server/lib/messages.js
  51. 109
      ee/app/engagement-dashboard/server/lib/users.js
  52. 2
      ee/app/engagement-dashboard/server/listeners/index.js
  53. 5
      ee/app/engagement-dashboard/server/listeners/messages.js
  54. 4
      ee/app/engagement-dashboard/server/listeners/users.js
  55. 1
      ee/app/license/server/bundles.js
  56. 1
      ee/client/index.js
  57. 1
      ee/server/index.js
  58. 675
      package-lock.json
  59. 10
      package.json
  60. 28
      packages/rocketchat-i18n/i18n/en.i18n.json
  61. 3
      server/main.js

@ -20,4 +20,5 @@ addDecorator(withKnobs);
configure([
require.context('../app', true, /\.stories\.js$/),
require.context('../client', true, /\.stories\.js$/),
require.context('../ee/app', true, /\.stories\.js$/),
], module);

@ -0,0 +1,11 @@
import { Base } from './_Base';
export class Analytics extends Base {
constructor() {
super('analytics');
this.tryEnsureIndex({ date: 1 });
this.tryEnsureIndex({ 'room._id': 1, date: 1 }, { unique: true });
}
}
export default new Analytics();

@ -0,0 +1,145 @@
import { Random } from 'meteor/random';
import { BaseRaw } from './BaseRaw';
import Analytics from '../models/Analytics';
export class AnalyticsRaw extends BaseRaw {
saveMessageSent({ room, date }) {
return this.update({ date, 'room._id': room._id, type: 'messages' }, {
$set: {
room: { _id: room._id, name: room.fname || room.name, t: room.t, usernames: room.usernames || [] },
},
$setOnInsert: {
_id: Random.id(),
date,
type: 'messages',
},
$inc: { messages: 1 },
}, { upsert: true });
}
saveUserData({ date }) {
return this.update({ date, type: 'users' }, {
$setOnInsert: {
_id: Random.id(),
date,
type: 'users',
},
$inc: { users: 1 },
}, { upsert: true });
}
saveMessageDeleted({ room, date }) {
return this.update({ date, 'room._id': room._id }, {
$inc: { messages: -1 },
});
}
getMessagesSentTotalByDate({ start, end, options = {} }) {
const params = [
{
$match: {
type: 'messages',
date: { $gte: start, $lte: end },
},
},
{
$group: {
_id: '$date',
messages: { $sum: '$messages' },
},
},
];
if (options.sort) {
params.push({ $sort: options.sort });
}
if (options.count) {
params.push({ $limit: options.count });
}
return this.col.aggregate(params).toArray();
}
getMessagesOrigin({ start, end }) {
const params = [
{
$match: {
type: 'messages',
date: { $gte: start, $lte: end },
},
},
{
$group: {
_id: { t: '$room.t' },
messages: { $sum: '$messages' },
},
},
{
$project: {
_id: 0,
t: '$_id.t',
messages: 1,
},
},
];
return this.col.aggregate(params).toArray();
}
getMostPopularChannelsByMessagesSentQuantity({ start, end, options = {} }) {
const params = [
{
$match: {
type: 'messages',
date: { $gte: start, $lte: end },
},
},
{
$group: {
_id: { t: '$room.t', name: '$room.name', usernames: '$room.usernames' },
messages: { $sum: '$messages' },
},
},
{
$project: {
_id: 0,
t: '$_id.t',
name: '$_id.name',
usernames: '$_id.usernames',
messages: 1,
},
},
];
if (options.sort) {
params.push({ $sort: options.sort });
}
if (options.count) {
params.push({ $limit: options.count });
}
return this.col.aggregate(params).toArray();
}
getTotalOfRegisteredUsersByDate({ start, end, options = {} }) {
const params = [
{
$match: {
type: 'users',
date: { $gte: start, $lte: end },
},
},
{
$group: {
_id: '$date',
users: { $sum: '$users' },
},
},
];
if (options.sort) {
params.push({ $sort: options.sort });
}
if (options.count) {
params.push({ $limit: options.count });
}
return this.col.aggregate(params).toArray();
}
}
export default new AnalyticsRaw(Analytics.model.rawCollection());

@ -89,4 +89,107 @@ export class RoomsRaw extends BaseRaw {
return this.find(query, options);
}
findChannelsWithNumberOfMessagesBetweenDate({ start, end, startOfLastWeek, endOfLastWeek, onlyCount = false, options = {} }) {
const lookup = {
$lookup: {
from: 'rocketchat_analytics',
localField: '_id',
foreignField: 'room._id',
as: 'messages',
},
};
const messagesProject = {
$project: {
room: '$$ROOT',
messages: {
$filter: {
input: '$messages',
as: 'message',
cond: {
$and: [
{ $gte: ['$$message.date', start] },
{ $lte: ['$$message.date', end] },
],
},
},
},
lastWeekMessages: {
$filter: {
input: '$messages',
as: 'message',
cond: {
$and: [
{ $gte: ['$$message.date', startOfLastWeek] },
{ $lte: ['$$message.date', endOfLastWeek] },
],
},
},
},
},
};
const messagesUnwind = {
$unwind: {
path: '$messages',
preserveNullAndEmptyArrays: true,
},
};
const messagesGroup = {
$group: {
_id: {
_id: '$room._id',
},
room: { $first: '$room' },
messages: { $sum: '$messages.messages' },
lastWeekMessages: { $first: '$lastWeekMessages' },
},
};
const lastWeekMessagesUnwind = {
$unwind: {
path: '$lastWeekMessages',
preserveNullAndEmptyArrays: true,
},
};
const lastWeekMessagesGroup = {
$group: {
_id: {
_id: '$room._id',
},
room: { $first: '$room' },
messages: { $first: '$messages' },
lastWeekMessages: { $sum: '$lastWeekMessages.messages' },
},
};
const presentationProject = {
$project: {
_id: 0,
room: {
_id: '$_id._id',
name: { $ifNull: ['$room.name', '$room.fname'] },
ts: '$room.ts',
t: '$room.t',
_updatedAt: '$room._updatedAt',
usernames: '$room.usernames',
},
messages: '$messages',
lastWeekMessages: '$lastWeekMessages',
diffFromLastWeek: { $subtract: ['$messages', '$lastWeekMessages'] },
},
};
const firstParams = [lookup, messagesProject, messagesUnwind, messagesGroup, lastWeekMessagesUnwind, lastWeekMessagesGroup, presentationProject];
const sort = { $sort: options.sort || { messages: -1 } };
const params = [...firstParams, sort];
if (onlyCount) {
params.push({ $count: 'total' });
return this.col.aggregate(params);
}
if (options.offset) {
params.push({ $skip: options.offset });
}
if (options.count) {
params.push({ $limit: options.count });
}
return this.col.aggregate(params).toArray();
}
}

@ -0,0 +1,251 @@
import { BaseRaw } from './BaseRaw';
import Sessions from '../models/Sessions';
const matchBasedOnDate = (start, end) => {
if (start.year === end.year && start.month === end.month) {
return {
year: start.year,
month: start.month,
day: { $gte: start.day, $lte: end.day },
};
}
if (start.year === end.year) {
return {
year: start.year,
$and: [{
$or: [{
month: { $gt: start.month },
}, {
month: start.month,
day: { $gte: start.day },
}],
}, {
$or: [{
month: { $lt: end.month },
}, {
month: end.month,
day: { $lte: end.day },
}],
}],
};
}
return {
$and: [{
$or: [{
year: { $gt: start.year },
}, {
year: start.year,
month: { $gt: start.month },
}, {
year: start.year,
month: start.month,
day: { $gte: start.day },
}],
}, {
$or: [{
year: { $lt: end.year },
}, {
year: end.year,
month: { $lt: end.month },
}, {
year: end.year,
month: end.month,
day: { $lte: end.day },
}],
}],
};
};
const getGroupSessionsByHour = (_id) => {
const isOpenSession = { $not: ['$session.closedAt'] };
const isAfterLoginAt = { $gte: ['$range', { $hour: '$session.loginAt' }] };
const isBeforeClosedAt = { $lte: ['$range', { $hour: '$session.closedAt' }] };
return {
$group: {
_id,
users: {
$sum: {
$cond: [
{
$or: [
{ $and: [isOpenSession, isAfterLoginAt] },
{ $and: [isAfterLoginAt, isBeforeClosedAt] },
],
},
1,
0,
],
},
},
},
};
};
const getSortByFullDate = () => ({
year: -1,
month: -1,
day: -1,
});
const getProjectionByFullDate = () => ({
day: '$_id.day',
month: '$_id.month',
year: '$_id.year',
});
export class SessionsRaw extends BaseRaw {
getActiveUsersBetweenDates({ start, end }) {
return this.col.aggregate([
{
$match: {
...matchBasedOnDate(start, end),
type: 'user_daily',
},
},
{
$group: {
_id: '$userId',
},
},
]).toArray();
}
getActiveUsersOfPeriodByDayBetweenDates({ start, end }) {
return this.col.aggregate([
{
$match: {
...matchBasedOnDate(start, end),
type: 'user_daily',
},
},
{
$group: {
_id: {
day: '$day',
month: '$month',
year: '$year',
},
users: { $sum: 1 },
},
},
{
$project: {
_id: 0,
...getProjectionByFullDate(),
users: 1,
},
},
{
$sort: {
...getSortByFullDate(),
},
},
]).toArray();
}
getBusiestTimeWithinHoursPeriod({ start, end }) {
const match = {
$match: {
type: 'computed-session',
loginAt: { $gte: start, $lte: end },
},
};
const rangeProject = {
$project: {
range: {
$range: [0, 24],
},
session: '$$ROOT',
},
};
const unwind = {
$unwind: '$range',
};
const group = getGroupSessionsByHour('$range');
const presentationProject = {
$project: {
_id: 0,
hour: '$_id',
users: 1,
},
};
const sort = {
$sort: {
hour: -1,
},
};
return this.col.aggregate([match, rangeProject, unwind, group, presentationProject, sort]).toArray();
}
getTotalOfSessionsByDayBetweenDates({ start, end }) {
return this.col.aggregate([
{
$match: {
...matchBasedOnDate(start, end),
type: 'user_daily',
},
},
{
$group: {
_id: { year: '$year', month: '$month', day: '$day' },
users: { $sum: '$sessions' },
},
},
{
$project: {
_id: 0,
...getProjectionByFullDate(),
users: 1,
},
},
{
$sort: {
...getSortByFullDate(),
},
},
]).toArray();
}
getTotalOfSessionByHourAndDayBetweenDates({ start, end }) {
const match = {
$match: {
type: 'computed-session',
loginAt: { $gte: start, $lte: end },
},
};
const rangeProject = {
$project: {
range: {
$range: [
{ $hour: '$loginAt' },
{ $sum: [{ $ifNull: [{ $hour: '$closedAt' }, 23] }, 1] }],
},
session: '$$ROOT',
},
};
const unwind = {
$unwind: '$range',
};
const group = getGroupSessionsByHour({ range: '$range', day: '$session.day', month: '$session.month', year: '$session.year' });
const presentationProject = {
$project: {
_id: 0,
hour: '$_id.range',
...getProjectionByFullDate(),
users: 1,
},
};
const sort = {
$sort: {
...getSortByFullDate(),
hour: -1,
},
};
return this.col.aggregate([match, rangeProject, unwind, group, presentationProject, sort]).toArray();
}
}
export default new SessionsRaw(Sessions.model.rawCollection());

@ -6,6 +6,7 @@ import '../imports/startup/client';
import '../lib/RegExp';
import '../ee/client';
import './lib/toastr';
import './templateHelpers';
import './methods/deleteMessage';
@ -27,5 +28,3 @@ import './startup/startup';
import './startup/unread';
import './startup/userSetUtcOffset';
import './startup/usersObserve';
import '../ee/client';

@ -0,0 +1,146 @@
import { Box, Icon, Margins, Pagination, Select, Skeleton, Table, Tile } from '@rocket.chat/fuselage';
import moment from 'moment';
import React, { useMemo, useState } from 'react';
import { useTranslation } from '../../../../../../client/contexts/TranslationContext';
import { Growth } from '../data/Growth';
import { Section } from '../Section';
import { useEndpointData } from '../../hooks/useEndpointData';
export function TableSection() {
const t = useTranslation();
const periodOptions = useMemo(() => [
['last 7 days', t('Last_7_days')],
['last 30 days', t('Last_30_days')],
['last 90 days', t('Last_90_days')],
], [t]);
const [periodId, setPeriodId] = useState('last 7 days');
const period = useMemo(() => {
switch (periodId) {
case 'last 7 days':
return {
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(7, 'days'),
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1),
};
case 'last 30 days':
return {
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(30, 'days'),
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1),
};
case 'last 90 days':
return {
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(90, 'days'),
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1),
};
}
}, [periodId]);
const handlePeriodChange = (periodId) => setPeriodId(periodId);
const [current, setCurrent] = useState(0);
const [itemsPerPage, setItemsPerPage] = useState(25);
const params = useMemo(() => ({
start: period.start.toISOString(),
end: period.end.toISOString(),
offset: current,
count: itemsPerPage,
}), [period, current, itemsPerPage]);
const data = useEndpointData('GET', 'engagement-dashboard/channels/list', params);
const channels = useMemo(() => {
if (!data) {
return;
}
return data.channels.map(({
room: { t, name, usernames, ts, _updatedAt },
messages,
diffFromLastWeek,
}) => ({
t,
name: name || usernames.join(' × '),
createdAt: ts,
updatedAt: _updatedAt,
messagesCount: messages,
messagesVariation: diffFromLastWeek,
}));
}, [data]);
return <Section filter={<Select options={periodOptions} value={periodId} onChange={handlePeriodChange} />}>
<Box>
{channels && !channels.length && <Tile textStyle='p1' textColor='info' style={{ textAlign: 'center' }}>
{t('No_data_found')}
</Tile>}
{(!channels || channels.length)
&& <Table>
<Table.Head>
<Table.Row>
<Table.Cell>{'#'}</Table.Cell>
<Table.Cell>{t('Channel')}</Table.Cell>
<Table.Cell>{t('Created')}</Table.Cell>
<Table.Cell>{t('Last_active')}</Table.Cell>
<Table.Cell>{t('Messages_sent')}</Table.Cell>
</Table.Row>
</Table.Head>
<Table.Body>
{channels && channels.map(({ t, name, createdAt, updatedAt, messagesCount, messagesVariation }, i) =>
<Table.Row key={i}>
<Table.Cell>{i + 1}.</Table.Cell>
<Table.Cell>
<Margins inlineEnd='x4'>
{(t === 'd' && <Icon name='at' />)
|| (t === 'c' && <Icon name='lock' />)
|| (t === 'p' && <Icon name='hashtag' />)}
</Margins>
{name}
</Table.Cell>
<Table.Cell>
{moment(createdAt).format('L')}
</Table.Cell>
<Table.Cell>
{moment(updatedAt).format('L')}
</Table.Cell>
<Table.Cell>
{messagesCount} <Growth>{messagesVariation}</Growth>
</Table.Cell>
</Table.Row>)}
{!channels && Array.from({ length: 5 }, (_, i) =>
<Table.Row key={i}>
<Table.Cell>
<Skeleton width='100%' />
</Table.Cell>
<Table.Cell>
<Skeleton width='100%' />
</Table.Cell>
<Table.Cell>
<Skeleton width='100%' />
</Table.Cell>
<Table.Cell>
<Skeleton width='100%' />
</Table.Cell>
<Table.Cell>
<Skeleton width='100%' />
</Table.Cell>
</Table.Row>)}
</Table.Body>
</Table>}
<Pagination
current={current}
itemsPerPage={itemsPerPage}
itemsPerPageLabel={() => t('Items_per_page:')}
showingResultsLabel={({ count, current, itemsPerPage }) =>
t('Showing results %s - %s of %s', current + 1, Math.min(current + itemsPerPage, count), count)}
count={(data && data.total) || 0}
onSetItemsPerPage={setItemsPerPage}
onSetCurrent={setCurrent}
/>
</Box>
</Section>;
}

@ -0,0 +1,9 @@
import React from 'react';
import { TableSection } from './TableSection';
export function ChannelsTab() {
return <>
<TableSection />
</>;
}

@ -0,0 +1,14 @@
import { Margins } from '@rocket.chat/fuselage';
import React from 'react';
import { ChannelsTab } from '.';
export default {
title: 'admin/engagement/ChannelsTab',
component: ChannelsTab,
decorators: [
(fn) => <Margins children={fn()} all='x24' />,
],
};
export const _default = () => <ChannelsTab />;

@ -0,0 +1,35 @@
import { Box, Margins, Tabs } from '@rocket.chat/fuselage';
import React from 'react';
import { useTranslation } from '../../../../../client/contexts/TranslationContext';
import { Page } from '../../../../../client/components/basic/Page';
import { UsersTab } from './UsersTab';
import { MessagesTab } from './MessagesTab';
import { ChannelsTab } from './ChannelsTab';
export function EngagementDashboardPage({
tab = 'users',
onSelectTab,
}) {
const t = useTranslation();
const handleTabClick = onSelectTab ? (tab) => () => onSelectTab(tab) : () => undefined;
return <Page>
<Page.Header title={t('Engagement_Dashboard')} />
<Tabs>
<Tabs.Item selected={tab === 'users'} onClick={handleTabClick('users')}>{t('Users')}</Tabs.Item>
<Tabs.Item selected={tab === 'messages'} onClick={handleTabClick('messages')}>{t('Messages')}</Tabs.Item>
<Tabs.Item selected={tab === 'channels'} onClick={handleTabClick('channels')}>{t('Channels')}</Tabs.Item>
</Tabs>
<Page.Content style={{ padding: 0 }}>
<Margins all='x24'>
<Box>
{(tab === 'users' && <UsersTab />)
|| (tab === 'messages' && <MessagesTab />)
|| (tab === 'channels' && <ChannelsTab />)}
</Box>
</Margins>
</Page.Content>
</Page>;
}

@ -0,0 +1,11 @@
import React from 'react';
import { EngagementDashboardPage } from './EngagementDashboardPage';
export default {
title: 'admin/engagement/EngagementDashboardPage',
component: EngagementDashboardPage,
decorators: [(fn) => <div children={fn()} style={{ height: '100vh' }} />],
};
export const _default = () => <EngagementDashboardPage />;

@ -0,0 +1,24 @@
import React, { useEffect } from 'react';
import { useRoute, useRouteParameter } from '../../../../../client/contexts/RouterContext';
import { useAdminSideNav } from '../../../../../client/hooks/useAdminSideNav';
import { EngagementDashboardPage } from './EngagementDashboardPage';
export function EngagementDashboardRoute() {
useAdminSideNav();
const goToEngagementDashboard = useRoute('engagement-dashboard');
const tab = useRouteParameter('tab');
useEffect(() => {
if (!tab) {
goToEngagementDashboard.replacingState({ tab: 'users' });
}
}, [tab]);
return <EngagementDashboardPage
tab={tab}
onSelectTab={(tab) => goToEngagementDashboard({ tab })}
/>;
}

@ -0,0 +1,217 @@
import { ResponsivePie } from '@nivo/pie';
import { Box, Flex, Icon, Margins, Select, Skeleton, Table, Tile } from '@rocket.chat/fuselage';
import moment from 'moment';
import React, { useMemo, useState } from 'react';
import { useTranslation } from '../../../../../../client/contexts/TranslationContext';
import { LegendSymbol } from '../data/LegendSymbol';
import { useEndpointData } from '../../hooks/useEndpointData';
import { Section } from '../Section';
export function MessagesPerChannelSection() {
const t = useTranslation();
const periodOptions = useMemo(() => [
['last 7 days', t('Last_7_days')],
['last 30 days', t('Last_30_days')],
['last 90 days', t('Last_90_days')],
], [t]);
const [periodId, setPeriodId] = useState('last 7 days');
const period = useMemo(() => {
switch (periodId) {
case 'last 7 days':
return {
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(7, 'days'),
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1),
};
case 'last 30 days':
return {
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(30, 'days'),
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1),
};
case 'last 90 days':
return {
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(90, 'days'),
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1),
};
}
}, [periodId]);
const handlePeriodChange = (periodId) => setPeriodId(periodId);
const params = useMemo(() => ({
start: period.start.toISOString(),
end: period.end.toISOString(),
}), [period]);
const pieData = useEndpointData('GET', 'engagement-dashboard/messages/origin', params);
const tableData = useEndpointData('GET', 'engagement-dashboard/messages/top-five-popular-channels', params);
const [pie, table] = useMemo(() => {
if (!pieData || !tableData) {
return [];
}
const pie = pieData.origins.reduce((obj, { messages, t }) => ({ ...obj, [t]: messages }), {});
const table = tableData.channels.reduce((entries, { t, messages, name, usernames }, i) =>
[...entries, { i, t, name: name || usernames.join(' × '), messages }], []);
return [pie, table];
}, [period, pieData, tableData]);
return <Section
title={t('Where_are_the_messages_being_sent?')}
filter={<Select options={periodOptions} value={periodId} onChange={handlePeriodChange} />}
>
<Flex.Container>
<Margins inline='neg-x12'>
<Box>
<Margins inline='x12'>
<Flex.Item grow={1} shrink={0} basis='0'>
<Box>
<Flex.Container alignItems='center' wrap='no-wrap'>
{pie
? <Box>
<Flex.Item grow={1} shrink={1}>
<Margins inline='x24'>
<Box style={{ position: 'relative', height: 300 }}>
<Box style={{ position: 'absolute', width: '100%', height: '100%' }}>
<ResponsivePie
data={[
{
id: 'd',
label: t('Private_Chats'),
value: pie.d,
color: '#FFD031',
},
{
id: 'c',
label: t('Private_Channels'),
value: pie.c,
color: '#2DE0A5',
},
{
id: 'p',
label: t('Public_Channels'),
value: pie.p,
color: '#1D74F5',
},
]}
innerRadius={0.6}
colors={['#FFD031', '#2DE0A5', '#1D74F5']}
enableRadialLabels={false}
enableSlicesLabels={false}
animate={true}
motionStiffness={90}
motionDamping={15}
theme={{
// TODO: Get it from theme
axis: {
ticks: {
text: {
fill: '#9EA2A8',
fontFamily: 'Inter, -apple-system, system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Meiryo UI", Arial, sans-serif',
fontSize: 10,
fontStyle: 'normal',
fontWeight: '600',
letterSpacing: '0.2px',
lineHeight: '12px',
},
},
},
tooltip: {
container: {
backgroundColor: '#1F2329',
boxShadow: '0px 0px 12px rgba(47, 52, 61, 0.12), 0px 0px 2px rgba(47, 52, 61, 0.08)',
borderRadius: 2,
},
},
}}
tooltip={({ value }) => <Box textStyle='p2' textColor='alternative'>
{t('Value_messages', { value })}
</Box>}
/>
</Box>
</Box>
</Margins>
</Flex.Item>
<Flex.Item basis='auto'>
<Margins block='neg-x4'>
<Box>
<Margins block='x4'>
<Box textColor='info' textStyle='p1'>
<LegendSymbol color='#FFD031' />
{t('Private_Chats')}
</Box>
<Box textColor='info' textStyle='p1'>
<LegendSymbol color='#2DE0A5' />
{t('Private_Channels')}
</Box>
<Box textColor='info' textStyle='p1'>
<LegendSymbol color='#1D74F5' />
{t('Public_Channels')}
</Box>
</Margins>
</Box>
</Margins>
</Flex.Item>
</Box>
: <Skeleton variant='rect' height={300} />}
</Flex.Container>
</Box>
</Flex.Item>
<Flex.Item grow={1} shrink={0} basis='0'>
<Box>
<Margins blockEnd='x16'>
{table ? <Box textStyle='p1'>{t('Most_popular_channels_top_5')}</Box> : <Skeleton width='50%' />}
</Margins>
{table && !table.length && <Tile textStyle='p1' textColor='info' style={{ textAlign: 'center' }}>
{t('Not_enough_data')}
</Tile>}
{(!table || !!table.length) && <Table>
<Table.Head>
<Table.Row>
<Table.Cell>{'#'}</Table.Cell>
<Table.Cell>{t('Channel')}</Table.Cell>
<Table.Cell align='end'>{t('Number_of_messages')}</Table.Cell>
</Table.Row>
</Table.Head>
<Table.Body>
{table && table.map(({ i, t, name, messages }) => <Table.Row key={i}>
<Table.Cell>{i + 1}.</Table.Cell>
<Table.Cell>
<Margins inlineEnd='x4'>
{(t === 'd' && <Icon name='at' />)
|| (t === 'c' && <Icon name='lock' />)
|| (t === 'p' && <Icon name='hashtag' />)}
</Margins>
{name}
</Table.Cell>
<Table.Cell align='end'>{messages}</Table.Cell>
</Table.Row>)}
{!table && Array.from({ length: 5 }, (_, i) => <Table.Row key={i}>
<Table.Cell>
<Skeleton width='100%' />
</Table.Cell>
<Table.Cell>
<Skeleton width='100%' />
</Table.Cell>
<Table.Cell align='end'>
<Skeleton width='100%' />
</Table.Cell>
</Table.Row>)}
</Table.Body>
</Table>}
</Box>
</Flex.Item>
</Margins>
</Box>
</Margins>
</Flex.Container>
</Section>;
}

@ -0,0 +1,169 @@
import { ResponsiveBar } from '@nivo/bar';
import { Box, Flex, Select, Skeleton } from '@rocket.chat/fuselage';
import moment from 'moment';
import React, { useMemo, useState } from 'react';
import { useTranslation } from '../../../../../../client/contexts/TranslationContext';
import { CounterSet } from '../data/CounterSet';
import { Section } from '../Section';
import { useEndpointData } from '../../hooks/useEndpointData';
export function MessagesSentSection() {
const t = useTranslation();
const periodOptions = useMemo(() => [
['last 7 days', t('Last_7_days')],
['last 30 days', t('Last_30_days')],
['last 90 days', t('Last_90_days')],
], [t]);
const [periodId, setPeriodId] = useState('last 7 days');
const period = useMemo(() => {
switch (periodId) {
case 'last 7 days':
return {
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(7, 'days'),
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1),
};
case 'last 30 days':
return {
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(30, 'days'),
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1),
};
case 'last 90 days':
return {
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(90, 'days'),
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1),
};
}
}, [periodId]);
const handlePeriodChange = (periodId) => setPeriodId(periodId);
const params = useMemo(() => ({
start: period.start.toISOString(),
end: period.end.toISOString(),
}), [period]);
const data = useEndpointData('GET', 'engagement-dashboard/messages/messages-sent', params);
const [
countFromPeriod,
variatonFromPeriod,
countFromYesterday,
variationFromYesterday,
values,
] = useMemo(() => {
if (!data) {
return [];
}
const values = Array.from({ length: moment(period.end).diff(period.start, 'days') + 1 }, (_, i) => ({
date: moment(period.start).add(i, 'days').toISOString(),
newUsers: 0,
}));
for (const { day, users } of data.days) {
const i = moment(day).diff(period.start, 'days');
values[i].newUsers += users;
}
return [
data.period.count,
data.period.variation,
data.yesterday.count,
data.yesterday.variation,
values,
];
}, [data, period]);
return <Section
title={t('Messages_sent')}
filter={<Select options={periodOptions} value={periodId} onChange={handlePeriodChange} />}
>
<CounterSet
counters={[
{
count: data ? countFromPeriod : <Skeleton variant='rect' width='3ex' height='1em' />,
variation: data ? variatonFromPeriod : 0,
description: periodOptions.find(([id]) => id === periodId)[1],
},
{
count: data ? countFromYesterday : <Skeleton variant='rect' width='3ex' height='1em' />,
variation: data ? variationFromYesterday : 0,
description: t('Yesterday'),
},
]}
/>
<Flex.Container>
{data
? <Box style={{ height: 240 }}>
<Flex.Item align='stretch' grow={1} shrink={0}>
<Box style={{ position: 'relative' }}>
<Box style={{ position: 'absolute', width: '100%', height: '100%' }}>
<ResponsiveBar
data={values}
indexBy='date'
keys={['newUsers']}
groupMode='grouped'
padding={0.25}
margin={{
// TODO: Get it from theme
bottom: 20,
}}
colors={[
// TODO: Get it from theme
'#1d74f5',
]}
enableLabel={false}
enableGridY={false}
axisTop={null}
axisRight={null}
axisBottom={(values.length === 7 && {
tickSize: 0,
// TODO: Get it from theme
tickPadding: 4,
tickRotation: 0,
format: (date) => moment(date).format('dddd'),
}) || null }
axisLeft={null}
animate={true}
motionStiffness={90}
motionDamping={15}
theme={{
// TODO: Get it from theme
axis: {
ticks: {
text: {
fill: '#9EA2A8',
fontFamily: 'Inter, -apple-system, system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Meiryo UI", Arial, sans-serif',
fontSize: '10px',
fontStyle: 'normal',
fontWeight: '600',
letterSpacing: '0.2px',
lineHeight: '12px',
},
},
},
tooltip: {
container: {
backgroundColor: '#1F2329',
boxShadow: '0px 0px 12px rgba(47, 52, 61, 0.12), 0px 0px 2px rgba(47, 52, 61, 0.08)',
borderRadius: 2,
},
},
}}
tooltip={({ value }) => <Box textStyle='p2' textColor='alternative'>
{t('Value_users', { value })}
</Box>}
/>
</Box>
</Box>
</Flex.Item>
</Box>
: <Skeleton variant='rect' height={240} />}
</Flex.Container>
</Section>;
}

@ -0,0 +1,13 @@
import { Divider } from '@rocket.chat/fuselage';
import React from 'react';
import { MessagesSentSection } from './MessagesSentSection';
import { MessagesPerChannelSection } from './MessagesPerChannelSection';
export function MessagesTab() {
return <>
<MessagesSentSection />
<Divider />
<MessagesPerChannelSection />
</>;
}

@ -0,0 +1,14 @@
import { Margins } from '@rocket.chat/fuselage';
import React from 'react';
import { MessagesTab } from '.';
export default {
title: 'admin/engagement/MessagesTab',
component: MessagesTab,
decorators: [
(fn) => <Margins children={fn()} all='x24' />,
],
};
export const _default = () => <MessagesTab />;

@ -0,0 +1,24 @@
import { Box, Flex, InputBox, Margins } from '@rocket.chat/fuselage';
import React from 'react';
export function Section({
children,
title,
filter = <InputBox.Skeleton />,
}) {
return <Box>
<Margins block='x16'>
<Flex.Container alignItems='center' wrap='no-wrap'>
<Box>
<Flex.Item grow={1}>
<Box textStyle='s2' textColor='default'>{title}</Box>
</Flex.Item>
{filter && <Flex.Item grow={0}>
{filter}
</Flex.Item>}
</Box>
</Flex.Container>
{children}
</Margins>
</Box>;
}

@ -0,0 +1,223 @@
import { ResponsiveLine } from '@nivo/line';
import { Box, Flex, Skeleton, Tile } from '@rocket.chat/fuselage';
import moment from 'moment';
import React, { useMemo } from 'react';
import { useTranslation } from '../../../../../../client/contexts/TranslationContext';
import { CounterSet } from '../data/CounterSet';
import { LegendSymbol } from '../data/LegendSymbol';
import { useEndpointData } from '../../hooks/useEndpointData';
import { Section } from '../Section';
export function ActiveUsersSection() {
const t = useTranslation();
const period = useMemo(() => ({
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(30, 'days'),
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1),
}), []);
const params = useMemo(() => ({
start: period.start.clone().subtract(30, 'days').toISOString(),
end: period.end.toISOString(),
}), [period]);
const data = useEndpointData('GET', 'engagement-dashboard/users/active-users', params);
const [
countDailyActiveUsers,
diffDailyActiveUsers,
countWeeklyActiveUsers,
diffWeeklyActiveUsers,
countMonthlyActiveUsers,
diffMonthlyActiveUsers,
dauValues,
wauValues,
mauValues,
] = useMemo(() => {
if (!data) {
return [];
}
const createPoint = (i) => ({
x: moment(period.start).add(i, 'days').toDate(),
y: 0,
});
const createPoints = () => Array.from({ length: moment(period.end).diff(period.start, 'days') + 1 }, (_, i) => createPoint(i));
const distributeValueOverPoints = (value, i, T, array, prev) => {
for (let j = 0; j < T; ++j) {
const k = i + j;
if (k >= array.length) {
continue;
}
if (k >= 0) {
array[k].y += value;
}
if (k === -1) {
prev.y += value;
}
}
};
const dauValues = createPoints();
const prevDauValue = createPoint(-1);
const wauValues = createPoints();
const prevWauValue = createPoint(-1);
const mauValues = createPoints();
const prevMauValue = createPoint(-1);
for (const { users, day, month, year } of data.month) {
const i = moment.utc([year, month - 1, day, 0, 0, 0, 0]).diff(period.start, 'days');
distributeValueOverPoints(users, i, 1, dauValues, prevDauValue);
distributeValueOverPoints(users, i, 7, wauValues, prevWauValue);
distributeValueOverPoints(users, i, 30, mauValues, prevMauValue);
}
return [
dauValues[dauValues.length - 1].y,
dauValues[dauValues.length - 1].y - prevDauValue.y,
wauValues[wauValues.length - 1].y,
wauValues[wauValues.length - 1].y - prevWauValue.y,
mauValues[mauValues.length - 1].y,
mauValues[mauValues.length - 1].y - prevMauValue.y,
dauValues,
wauValues,
mauValues,
];
}, [period, data]);
return <Section title={t('Active_users')} filter={null}>
<CounterSet
counters={[
{
count: data ? countDailyActiveUsers : <Skeleton variant='rect' width='3ex' height='1em' />,
variation: data ? diffDailyActiveUsers : 0,
description: <><LegendSymbol color='#D1EBFE' /> {t('Daily_Active_Users')}</>,
},
{
count: data ? countWeeklyActiveUsers : <Skeleton variant='rect' width='3ex' height='1em' />,
variation: data ? diffWeeklyActiveUsers : 0,
description: <><LegendSymbol color='#76B7FC' /> {t('Weekly_Active_Users')}</>,
},
{
count: data ? countMonthlyActiveUsers : <Skeleton variant='rect' width='3ex' height='1em' />,
variation: data ? diffMonthlyActiveUsers : 0,
description: <><LegendSymbol color='#1D74F5' /> {t('Monthly_Active_Users')}</>,
},
]}
/>
<Flex.Container>
{data
? <Box style={{ height: 240 }}>
<Flex.Item align='stretch' grow={1} shrink={0}>
<Box style={{ position: 'relative' }}>
<Box style={{ position: 'absolute', width: '100%', height: '100%' }}>
<ResponsiveLine
data={[
{
id: 'dau',
data: dauValues,
},
{
id: 'wau',
data: wauValues,
},
{
id: 'mau',
data: mauValues,
},
]}
xScale={{
type: 'time',
format: 'native',
precision: 'day',
}}
xFormat='time:%Y-%m-%d'
yScale={{
type: 'linear',
stacked: true,
}}
enableGridX={false}
enableGridY={false}
enablePoints={false}
useMesh
enableArea
areaOpacity={1}
enableCrosshair
crosshairType='bottom'
margin={{
// TODO: Get it from theme
top: 0,
bottom: 20,
right: 0,
left: 40,
}}
colors={[
'#D1EBFE',
'#76B7FC',
'#1D74F5',
]}
axisLeft={{
// TODO: Get it from theme
tickSize: 0,
tickPadding: 4,
tickRotation: 0,
tickValues: 3,
}}
axisBottom={{
// TODO: Get it from theme
tickSize: 0,
tickPadding: 4,
tickRotation: 0,
tickValues: 'every 3 days',
format: (date) => moment(date).format(dauValues.length === 7 ? 'dddd' : 'L'),
}}
animate={true}
motionStiffness={90}
motionDamping={15}
theme={{
// TODO: Get it from theme
axis: {
ticks: {
text: {
fill: '#9EA2A8',
fontFamily: 'Inter, -apple-system, system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Meiryo UI", Arial, sans-serif',
fontSize: '10px',
fontStyle: 'normal',
fontWeight: '600',
letterSpacing: '0.2px',
lineHeight: '12px',
},
},
},
tooltip: {
container: {
backgroundColor: '#1F2329',
boxShadow: '0px 0px 12px rgba(47, 52, 61, 0.12), 0px 0px 2px rgba(47, 52, 61, 0.08)',
borderRadius: 2,
},
},
}}
enableSlices='x'
sliceTooltip={({ slice: { points } }) => <Tile elevation='2'>
{points.map(({ serieId, data: { y: activeUsers } }) =>
<Box key={serieId} textStyle='p2'>
{(serieId === 'dau' && t('DAU_value', { value: activeUsers }))
|| (serieId === 'wau' && t('WAU_value', { value: activeUsers }))
|| (serieId === 'mau' && t('MAU_value', { value: activeUsers }))}
</Box>)}
</Tile>}
/>
</Box>
</Box>
</Flex.Item>
</Box>
: <Skeleton variant='rect' height={240} />}
</Flex.Container>
</Section>;
}

@ -0,0 +1,250 @@
import { ResponsiveBar } from '@nivo/bar';
import { Box, Button, Chevron, Flex, Margins, Select, Skeleton } from '@rocket.chat/fuselage';
import moment from 'moment';
import React, { useMemo, useState } from 'react';
import { useTranslation } from '../../../../../../client/contexts/TranslationContext';
import { Section } from '../Section';
import { useEndpointData } from '../../hooks/useEndpointData';
function ContentForHours({ displacement, onPreviousDateClick, onNextDateClick }) {
const t = useTranslation();
const currentDate = useMemo(() =>
moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 })
.subtract(1).subtract(displacement, 'days'), [displacement]);
const params = useMemo(() => ({ start: currentDate.toISOString() }), [currentDate]);
const data = useEndpointData('GET', 'engagement-dashboard/users/chat-busier/hourly-data', params);
const values = useMemo(() => {
if (!data) {
return [];
}
const divider = 2;
const values = Array.from({ length: 24 / divider }, (_, i) => ({
hour: divider * i,
users: 0,
}));
for (const { hour, users } of data.hours) {
const i = Math.floor(hour / divider);
values[i] = values[i] || { hour: divider * i, users: 0 };
values[i].users += users;
}
return values;
}, [data]);
return <>
<Flex.Container alignItems='center' justifyContent='center'>
<Box>
<Button ghost square small onClick={onPreviousDateClick}>
<Chevron left size='20' style={{ verticalAlign: 'middle' }} />
</Button>
<Flex.Item basis='25%'>
<Margins inline='x8'>
<Box is='span' style={{ textAlign: 'center' }}>
{currentDate.format(displacement < 7 ? 'dddd' : 'L')}
</Box>
</Margins>
</Flex.Item>
<Button ghost square small disabled={displacement === 0} onClick={onNextDateClick}>
<Chevron right size='20' style={{ verticalAlign: 'middle' }} />
</Button>
</Box>
</Flex.Container>
<Flex.Container>
{data
? <Box style={{ height: 196 }}>
<Flex.Item align='stretch' grow={1} shrink={0}>
<Box style={{ position: 'relative' }}>
<Box style={{ position: 'absolute', width: '100%', height: '100%' }}>
<ResponsiveBar
data={values}
indexBy='hour'
keys={['users']}
groupMode='grouped'
padding={0.25}
margin={{
// TODO: Get it from theme
bottom: 20,
}}
colors={[
// TODO: Get it from theme
'#1d74f5',
]}
enableLabel={false}
enableGridY={false}
axisTop={null}
axisRight={null}
axisBottom={{
tickSize: 0,
// TODO: Get it from theme
tickPadding: 4,
tickRotation: 0,
tickValues: 'every 2 hours',
format: (hour) => moment().set({ hour, minute: 0, second: 0 }).format('LT'),
}}
axisLeft={null}
animate={true}
motionStiffness={90}
motionDamping={15}
theme={{
// TODO: Get it from theme
axis: {
ticks: {
text: {
fill: '#9EA2A8',
fontFamily: 'Inter, -apple-system, system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Meiryo UI", Arial, sans-serif',
fontSize: '10px',
fontStyle: 'normal',
fontWeight: '600',
letterSpacing: '0.2px',
lineHeight: '12px',
},
},
},
tooltip: {
container: {
backgroundColor: '#1F2329',
boxShadow: '0px 0px 12px rgba(47, 52, 61, 0.12), 0px 0px 2px rgba(47, 52, 61, 0.08)',
borderRadius: 2,
},
},
}}
tooltip={({ value }) => <Box textStyle='p2' textColor='alternative'>
{t('Value_users', { value })}
</Box>}
/>
</Box>
</Box>
</Flex.Item>
</Box>
: <Skeleton variant='rect' height={196} />}
</Flex.Container>
</>;
}
function ContentForDays({ displacement, onPreviousDateClick, onNextDateClick }) {
const currentDate = useMemo(() => moment.utc().subtract(displacement, 'weeks'), [displacement]);
const formattedCurrentDate = useMemo(() => {
const startOfWeekDate = currentDate.clone().subtract(6, 'days');
return `${ startOfWeekDate.format('L') } - ${ currentDate.format('L') }`;
}, [currentDate]);
const params = useMemo(() => ({ start: currentDate.toISOString() }), [currentDate]);
const data = useEndpointData('GET', 'engagement-dashboard/users/chat-busier/weekly-data', params);
const values = useMemo(() => (data ? data.month.map(({ users, day, month, year }) => ({
users,
day: moment.utc([year, month - 1, day, 0, 0, 0]).valueOf(),
})).sort(({ day: a }, { day: b }) => a - b) : []), [data]);
return <>
<Flex.Container alignItems='center' justifyContent='center'>
<Box>
<Button ghost square small onClick={onPreviousDateClick}>
<Chevron left size='20' style={{ verticalAlign: 'middle' }} />
</Button>
<Flex.Item basis='50%'>
<Margins inline='x8'>
<Box is='span' style={{ textAlign: 'center' }}>
{formattedCurrentDate}
</Box>
</Margins>
</Flex.Item>
<Button ghost square small disabled={displacement === 0} onClick={onNextDateClick}>
<Chevron right size='20' style={{ verticalAlign: 'middle' }} />
</Button>
</Box>
</Flex.Container>
<Flex.Container>
{data
? <Box style={{ height: 196 }}>
<Flex.Item align='stretch' grow={1} shrink={0}>
<Box style={{ position: 'relative' }}>
<Box style={{ position: 'absolute', width: '100%', height: '100%' }}>
<ResponsiveBar
data={values}
indexBy='day'
keys={['users']}
groupMode='grouped'
padding={0.25}
margin={{
// TODO: Get it from theme
bottom: 20,
}}
colors={[
// TODO: Get it from theme
'#1d74f5',
]}
enableLabel={false}
enableGridY={false}
axisTop={null}
axisRight={null}
axisBottom={{
tickSize: 0,
// TODO: Get it from theme
tickPadding: 4,
tickRotation: 0,
tickValues: 'every 3 days',
format: (timestamp) => moment(timestamp).format('L'),
}}
axisLeft={null}
animate={true}
motionStiffness={90}
motionDamping={15}
theme={{
// TODO: Get it from theme
axis: {
ticks: {
text: {
fill: '#9EA2A8',
fontFamily: 'Inter, -apple-system, system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Meiryo UI", Arial, sans-serif',
fontSize: '10px',
fontStyle: 'normal',
fontWeight: '600',
letterSpacing: '0.2px',
lineHeight: '12px',
},
},
},
}}
/>
</Box>
</Box>
</Flex.Item>
</Box>
: <Skeleton variant='rect' height={196} />}
</Flex.Container>
</>;
}
export function BusiestChatTimesSection() {
const t = useTranslation();
const [timeUnit, setTimeUnit] = useState('hours');
const timeUnitOptions = useMemo(() => [
['hours', t('Hours')],
['days', t('Days')],
], [t]);
const handleTimeUnitChange = (timeUnit) => {
setTimeUnit(timeUnit);
};
const [displacement, setDisplacement] = useState(0);
const handlePreviousDateClick = () => setDisplacement((displacement) => displacement + 1);
const handleNextDateClick = () => setDisplacement((displacement) => displacement - 1);
const Content = (timeUnit === 'hours' && ContentForHours) || (timeUnit === 'days' && ContentForDays);
return <Section
title={t('When_is_the_chat_busier?')}
filter={<Select options={timeUnitOptions} value={timeUnit} onChange={handleTimeUnitChange} />}
>
<Content
displacement={displacement}
onPreviousDateClick={handlePreviousDateClick}
onNextDateClick={handleNextDateClick}
/>
</Section>;
}

@ -0,0 +1,169 @@
import { ResponsiveBar } from '@nivo/bar';
import { Box, Flex, Select, Skeleton } from '@rocket.chat/fuselage';
import moment from 'moment';
import React, { useMemo, useState } from 'react';
import { useTranslation } from '../../../../../../client/contexts/TranslationContext';
import { useEndpointData } from '../../hooks/useEndpointData';
import { CounterSet } from '../data/CounterSet';
import { Section } from '../Section';
export function NewUsersSection() {
const t = useTranslation();
const periodOptions = useMemo(() => [
['last 7 days', t('Last_7_days')],
['last 30 days', t('Last_30_days')],
['last 90 days', t('Last_90_days')],
], [t]);
const [periodId, setPeriodId] = useState('last 7 days');
const period = useMemo(() => {
switch (periodId) {
case 'last 7 days':
return {
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(7, 'days'),
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1),
};
case 'last 30 days':
return {
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(30, 'days'),
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1),
};
case 'last 90 days':
return {
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(90, 'days'),
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1),
};
}
}, [periodId]);
const handlePeriodChange = (periodId) => setPeriodId(periodId);
const params = useMemo(() => ({
start: period.start.toISOString(),
end: period.end.toISOString(),
}), [period]);
const data = useEndpointData('GET', 'engagement-dashboard/users/new-users', params);
const [
countFromPeriod,
variatonFromPeriod,
countFromYesterday,
variationFromYesterday,
values,
] = useMemo(() => {
if (!data) {
return [];
}
const values = Array.from({ length: moment(period.end).diff(period.start, 'days') + 1 }, (_, i) => ({
date: moment(period.start).add(i, 'days').toISOString(),
newUsers: 0,
}));
for (const { day, users } of data.days) {
const i = moment(day).diff(period.start, 'days');
values[i].newUsers += users;
}
return [
data.period.count,
data.period.variation,
data.yesterday.count,
data.yesterday.variation,
values,
];
}, [data, period]);
return <Section
title={t('New_users')}
filter={<Select options={periodOptions} value={periodId} onChange={handlePeriodChange} />}
>
<CounterSet
counters={[
{
count: data ? countFromPeriod : <Skeleton variant='rect' width='3ex' height='1em' />,
variation: data ? variatonFromPeriod : 0,
description: periodOptions.find(([id]) => id === periodId)[1],
},
{
count: data ? countFromYesterday : <Skeleton variant='rect' width='3ex' height='1em' />,
variation: data ? variationFromYesterday : 0,
description: t('Yesterday'),
},
]}
/>
<Flex.Container>
{data
? <Box style={{ height: 240 }}>
<Flex.Item align='stretch' grow={1} shrink={0}>
<Box style={{ position: 'relative' }}>
<Box style={{ position: 'absolute', width: '100%', height: '100%' }}>
<ResponsiveBar
data={values}
indexBy='date'
keys={['newUsers']}
groupMode='grouped'
padding={0.25}
margin={{
// TODO: Get it from theme
bottom: 20,
}}
colors={[
// TODO: Get it from theme
'#1d74f5',
]}
enableLabel={false}
enableGridY={false}
axisTop={null}
axisRight={null}
axisBottom={(values.length === 7 && {
tickSize: 0,
// TODO: Get it from theme
tickPadding: 4,
tickRotation: 0,
format: (date) => moment(date).format('dddd'),
}) || null }
axisLeft={null}
animate={true}
motionStiffness={90}
motionDamping={15}
theme={{
// TODO: Get it from theme
axis: {
ticks: {
text: {
fill: '#9EA2A8',
fontFamily: 'Inter, -apple-system, system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Meiryo UI", Arial, sans-serif',
fontSize: '10px',
fontStyle: 'normal',
fontWeight: '600',
letterSpacing: '0.2px',
lineHeight: '12px',
},
},
},
tooltip: {
container: {
backgroundColor: '#1F2329',
boxShadow: '0px 0px 12px rgba(47, 52, 61, 0.12), 0px 0px 2px rgba(47, 52, 61, 0.08)',
borderRadius: 2,
},
},
}}
tooltip={({ value }) => <Box textStyle='p2' textColor='alternative'>
{t('Value_users', { value })}
</Box>}
/>
</Box>
</Box>
</Flex.Item>
</Box>
: <Skeleton variant='rect' height={240} />}
</Flex.Container>
</Section>;
}

@ -0,0 +1,166 @@
import { ResponsiveHeatMap } from '@nivo/heatmap';
import { Box, Flex, Select, Skeleton } from '@rocket.chat/fuselage';
import moment from 'moment';
import React, { useMemo, useState } from 'react';
import { useTranslation } from '../../../../../../client/contexts/TranslationContext';
import { Section } from '../Section';
import { useEndpointData } from '../../hooks/useEndpointData';
export function UsersByTimeOfTheDaySection() {
const t = useTranslation();
const periodOptions = useMemo(() => [
['last 7 days', t('Last_7_days')],
['last 30 days', t('Last_30_days')],
['last 90 days', t('Last_90_days')],
], [t]);
const [periodId, setPeriodId] = useState('last 7 days');
const period = useMemo(() => {
switch (periodId) {
case 'last 7 days':
return {
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(7, 'days'),
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1),
};
case 'last 30 days':
return {
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(30, 'days'),
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1),
};
case 'last 90 days':
return {
start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(90, 'days'),
end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1),
};
}
}, [periodId]);
const handlePeriodChange = (periodId) => setPeriodId(periodId);
const params = useMemo(() => ({
start: period.start.toISOString(),
end: period.end.toISOString(),
}), [period]);
const data = useEndpointData('GET', 'engagement-dashboard/users/users-by-time-of-the-day-in-a-week', params);
const [
dates,
values,
] = useMemo(() => {
if (!data) {
return [];
}
const dates = Array.from({ length: moment(period.end).diff(period.start, 'days') + 1 },
(_, i) => moment(period.start).add(i, 'days'));
const values = Array.from({ length: 24 }, (_, hour) => ({
hour: String(hour),
...dates.map((date) => ({ [date.toISOString()]: 0 }))
.reduce((obj, elem) => ({ ...obj, ...elem }), {}),
}));
for (const { users, hour, day, month, year } of data.week) {
const date = moment([year, month - 1, day, 0, 0, 0, 0]).toISOString();
values[hour][date] += users;
}
return [
dates.map((date) => date.toISOString()),
values,
];
}, [data]);
return <Section
title={t('Users_by_time_of_day')}
filter={<Select options={periodOptions} value={periodId} onChange={handlePeriodChange} />}
>
<Flex.Container>
{data
? <Box style={{ height: 696 }}>
<Flex.Item align='stretch' grow={1} shrink={0}>
<Box style={{ position: 'relative' }}>
<Box style={{ position: 'absolute', width: '100%', height: '100%' }}>
<ResponsiveHeatMap
data={values}
indexBy='hour'
keys={dates}
padding={4}
margin={{
// TODO: Get it from theme
left: 40,
bottom: 20,
}}
colors={[
// TODO: Get it from theme
'#E8F2FF',
'#D1EBFE',
'#A4D3FE',
'#76B7FC',
'#549DF9',
'#1D74F5',
'#10529E',
]}
cellOpacity={1}
enableLabels={false}
axisTop={null}
axisRight={null}
axisBottom={{
// TODO: Get it from theme
tickSize: 0,
tickPadding: 4,
tickRotation: 0,
format: (isoString) => (dates.length === 7 ? moment(isoString).format('dddd') : ''),
}}
axisLeft={{
// TODO: Get it from theme
tickSize: 0,
tickPadding: 4,
tickRotation: 0,
format: (hour) => moment().set({ hour: parseInt(hour, 10), minute: 0, second: 0 }).format('LT'),
}}
hoverTarget='cell'
animate={dates.length <= 7}
motionStiffness={90}
motionDamping={15}
theme={{
// TODO: Get it from theme
axis: {
ticks: {
text: {
fill: '#9EA2A8',
fontFamily: 'Inter, -apple-system, system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Meiryo UI", Arial, sans-serif',
fontSize: 10,
fontStyle: 'normal',
fontWeight: '600',
letterSpacing: '0.2px',
lineHeight: '12px',
},
},
},
tooltip: {
container: {
backgroundColor: '#1F2329',
boxShadow: '0px 0px 12px rgba(47, 52, 61, 0.12), 0px 0px 2px rgba(47, 52, 61, 0.08)',
borderRadius: 2,
},
},
}}
tooltip={({ value }) => <Box textStyle='p2' textColor='alternative'>
{t('Value_users', { value })}
</Box>}
/>
</Box>
</Box>
</Flex.Item>
</Box>
: <Skeleton variant='rect' height={696} />}
</Flex.Container>
</Section>;
}

@ -0,0 +1,32 @@
import { Box, Divider, Flex, Margins } from '@rocket.chat/fuselage';
import React from 'react';
import { NewUsersSection } from './NewUsersSection';
import { ActiveUsersSection } from './ActiveUsersSection';
import { UsersByTimeOfTheDaySection } from './UsersByTimeOfTheDaySection';
import { BusiestChatTimesSection } from './BusiestChatTimesSection';
export function UsersTab() {
return <>
<NewUsersSection />
<Divider />
<ActiveUsersSection />
<Divider />
<Flex.Container>
<Margins inline='x12'>
<Box>
<Margins inline='x12'>
<Flex.Item grow={1} shrink={0} basis='0'>
<UsersByTimeOfTheDaySection />
</Flex.Item>
<Flex.Item grow={1} shrink={0} basis='0'>
<Box>
<BusiestChatTimesSection />
</Box>
</Flex.Item>
</Margins>
</Box>
</Margins>
</Flex.Container>
</>;
}

@ -0,0 +1,14 @@
import { Margins } from '@rocket.chat/fuselage';
import React from 'react';
import { UsersTab } from '.';
export default {
title: 'admin/engagement/UsersTab',
component: UsersTab,
decorators: [
(fn) => <Margins children={fn()} all='x24' />,
],
};
export const _default = () => <UsersTab />;

@ -0,0 +1,24 @@
import { Box, Flex, Margins } from '@rocket.chat/fuselage';
import React from 'react';
import { Growth } from './Growth';
export function Counter({ count, variation = 0, description }) {
return <>
<Flex.Container alignItems='end'>
<Box>
<Box is='span' textColor='default' textStyle='h1' style={{ fontSize: '3em', lineHeight: 1 }}>
{count}
</Box>
<Growth textStyle='s1'>{variation}</Growth>
</Box>
</Flex.Container>
<Margins block='x12'>
<Flex.Container alignItems='center'>
<Box textStyle='p1' textColor='hint'>
{description}
</Box>
</Flex.Container>
</Margins>
</>;
}

@ -0,0 +1,16 @@
import React from 'react';
import { Counter } from './Counter';
export default {
title: 'admin/engagement/data/Counter',
component: Counter,
};
export const _default = () => <Counter count={123} />;
export const withPositiveVariation = () => <Counter count={123} variation={4} />;
export const withNegativeVariation = () => <Counter count={123} variation={-4} />;
export const withDescription = () => <Counter count={123} description='Description' />;

@ -0,0 +1,16 @@
import { Grid } from '@rocket.chat/fuselage';
import React from 'react';
import { Counter } from './Counter';
export function CounterSet({ counters = [] }) {
return <Grid>
{counters.map(({ count, variation, description }, i) => <Grid.Item key={i}>
<Counter
count={count}
variation={variation}
description={description}
/>
</Grid.Item>)}
</Grid>;
}

@ -0,0 +1,16 @@
import React from 'react';
import { CounterSet } from './CounterSet';
export default {
title: 'admin/engagement/data/CounterSet',
component: CounterSet,
};
export const _default = () => <CounterSet
counters={[
{ count: 123, variation: 0 },
{ count: 456, variation: 7 },
{ count: 789, variation: -1, description: 'Description' },
]}
/>;

@ -0,0 +1,16 @@
import { Box } from '@rocket.chat/fuselage';
import React from 'react';
import { NegativeGrowthSymbol } from './NegativeGrowthSymbol';
import { PositiveGrowthSymbol } from './PositiveGrowthSymbol';
export function Growth({ children, ...props }) {
if (children === 0) {
return null;
}
return <Box is='span' textColor={children < 0 ? 'danger' : 'success'} {...props}>
{children < 0 ? <NegativeGrowthSymbol /> : <PositiveGrowthSymbol />}
{String(Math.abs(children))}
</Box>;
}

@ -0,0 +1,23 @@
import { Box, Margins } from '@rocket.chat/fuselage';
import React from 'react';
import { Growth } from './Growth';
export default {
title: 'admin/engagement/data/Growth',
component: Growth,
decorators: [(fn) => <Margins children={fn()} all='x16' />],
};
export const positive = () => <Growth>{3}</Growth>;
export const zero = () => <Growth>{0}</Growth>;
export const negative = () => <Growth>{-3}</Growth>;
export const withTextStyle = () =>
['h1', 's1', 'c1', 'micro']
.map((textStyle) => <Box key={textStyle}>
<Growth textStyle={textStyle}>{3}</Growth>
<Growth textStyle={textStyle}>{-3}</Growth>
</Box>);

@ -0,0 +1,85 @@
import { ResponsiveBar } from '@nivo/bar';
import { Box, Flex } from '@rocket.chat/fuselage';
import React from 'react';
import { polychromaticColors } from './colors';
export function Histogram() {
return <Flex.Item align='stretch' grow={1} shrink={0}>
<Box style={{ position: 'relative' }}>
<Box style={{ position: 'absolute', width: '100%', height: '100%' }}>
<ResponsiveBar
data={[
{
utc: '-3',
users: Math.round(100 * Math.random()),
},
{
utc: '-5',
users: Math.round(100 * Math.random()),
},
{
utc: '+2',
users: Math.round(100 * Math.random()),
},
{
utc: '+8',
users: Math.round(100 * Math.random()),
},
{
utc: '-6',
users: Math.round(100 * Math.random()),
},
{
utc: '16',
users: Math.round(100 * Math.random()),
},
]}
indexBy='utc'
keys={['users']}
groupMode='grouped'
layout='horizontal'
padding={0.25}
margin={{ left: 64, bottom: 20 }}
colors={[polychromaticColors[2]]}
enableLabel={false}
enableGridX
enableGridY={false}
axisTop={null}
axisRight={null}
axisBottom={{
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
format: (users) => `${ users }%`,
}}
axisLeft={{
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
format: (utc) => `UTF ${ utc }`,
}}
animate={true}
motionStiffness={90}
motionDamping={15}
theme={{
font: 'inherit',
fontStyle: 'normal',
fontWeight: 600,
fontSize: 10,
lineHeight: 12,
letterSpacing: 0.2,
color: '#9EA2A8',
grid: {
line: {
stroke: '#CBCED1',
strokeWidth: 1,
strokeDasharray: '4 1.5',
},
},
}}
/>
</Box>
</Box>
</Flex.Item>;
}

@ -0,0 +1,16 @@
import { Box, Flex, Margins } from '@rocket.chat/fuselage';
import React from 'react';
import { Histogram } from './Histogram';
export default {
title: 'admin/engagement/data/Histogram',
component: Histogram,
decorators: [(fn) => <Margins all='x16'>
<Flex.Container>
<Box children={fn()} style={{ height: 240 }} />
</Flex.Container>
</Margins>],
};
export const _default = () => <Histogram />;

@ -0,0 +1,19 @@
import { Box, Margins } from '@rocket.chat/fuselage';
import React from 'react';
export function LegendSymbol({ color = 'currentColor' }) {
return <Margins inlineEnd='x8'>
<Box
is='span'
aria-hidden='true'
style={{
display: 'inline-block',
width: 12,
height: 12,
borderRadius: 2,
backgroundColor: color,
verticalAlign: 'baseline',
}}
/>
</Margins>;
}

@ -0,0 +1,25 @@
import { Box, Margins } from '@rocket.chat/fuselage';
import React from 'react';
import { LegendSymbol } from './LegendSymbol';
import { monochromaticColors, polychromaticColors } from './colors';
export default {
title: 'admin/engagement/data/LegendSymbol',
component: LegendSymbol,
decorators: [(fn) => <Margins children={fn()} all='x16' />],
};
export const _default = () => <Box>
<LegendSymbol />
Legend text
</Box>;
export const withColor = () => <>
{monochromaticColors.map((color) => <Box key={color}>
<LegendSymbol color={color} /> {color}
</Box>)}
{polychromaticColors.map((color) => <Box key={color}>
<LegendSymbol color={color} /> {color}
</Box>)}
</>;

@ -0,0 +1,26 @@
import React from 'react';
const style = { width: '1.5em', height: '1.5em', verticalAlign: '-0.5em' };
export const NegativeGrowthSymbol = (props) =>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 25 24'
style={style}
{...props}
>
<path
clipRule='evenodd'
fill='currentColor'
fillRule='evenodd'
d={`M4.70712 8.50049L8.55757 12.3509C9.14335 12.9367 10.0931 12.9367
10.6789 12.3509L11.9282 11.1016L15.9295 15.1029L14.2054 15.1029C13.6531
15.1029 13.2054 15.5506 13.2054 16.1029C13.2054 16.6551 13.6531 17.1029
14.2054 17.1029L18.3437 17.1029C18.896 17.1029 19.3437 16.6551 19.3437
16.1029L19.3437 11.9645C19.3437 11.4123 18.896 10.9645 18.3437 10.9645
C17.7914 10.9645 17.3437 11.4123 17.3437 11.9645L17.3437 13.6886L12.9889
9.33382C12.4031 8.74803 11.4534 8.74803 10.8676 9.33382L9.61823 10.5832
L6.12133 7.08627C5.73081 6.69575 5.09765 6.69575 4.70712 7.08628C4.3166
7.4768 4.3166 8.10996 4.70712 8.50049Z`}
/>
</svg>;

@ -0,0 +1,16 @@
import { Box, Margins } from '@rocket.chat/fuselage';
import React from 'react';
import { NegativeGrowthSymbol } from './NegativeGrowthSymbol';
export default {
title: 'admin/engagement/data/NegativeGrowthSymbol',
component: NegativeGrowthSymbol,
decorators: [(fn) => <Margins children={fn()} all='x16' />],
};
export const _default = () => <NegativeGrowthSymbol />;
export const withColor = () => <Box textColor='danger'>
<NegativeGrowthSymbol />
</Box>;

@ -0,0 +1,26 @@
import React from 'react';
const style = { width: '1.5em', height: '1.5em', verticalAlign: '-0.5em' };
export const PositiveGrowthSymbol = (props) =>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
style={style}
{...props}
>
<path
clipRule='evenodd'
fill='currentColor'
fillRule='evenodd'
d={`M4.70712 15.3265L8.55757 11.4761C9.14335 10.8903 10.0931 10.8903
10.6789 11.4761L11.9282 12.7254L15.9295 8.72417L14.2054 8.72417C13.6531
8.72417 13.2054 8.27646 13.2054 7.72417C13.2054 7.17189 13.6531 6.72417
14.2054 6.72417L18.3437 6.72418C18.896 6.72417 19.3437 7.17189 19.3437
7.72418L19.3437 11.8625C19.3437 12.4148 18.896 12.8625 18.3437 12.8625
C17.7914 12.8625 17.3437 12.4148 17.3437 11.8625L17.3437 10.1384L12.9889
14.4932C12.4031 15.079 11.4534 15.079 10.8676 14.4932L9.61823 13.2439
L6.12133 16.7408C5.73081 17.1313 5.09765 17.1313 4.70712 16.7408C4.3166
16.3502 4.3166 15.7171 4.70712 15.3265Z`}
/>
</svg>;

@ -0,0 +1,16 @@
import { Box, Margins } from '@rocket.chat/fuselage';
import React from 'react';
import { PositiveGrowthSymbol } from './PositiveGrowthSymbol';
export default {
title: 'admin/engagement/data/PositiveGrowthSymbol',
component: PositiveGrowthSymbol,
decorators: [(fn) => <Margins children={fn()} all='x16' />],
};
export const _default = () => <PositiveGrowthSymbol />;
export const withColor = () => <Box textColor='success'>
<PositiveGrowthSymbol />
</Box>;

@ -0,0 +1,2 @@
export const monochromaticColors = ['#E8F2FF', '#D1EBFE', '#A4D3FE', '#76B7FC', '#549DF9', '#1D74F5', '#10529E'];
export const polychromaticColors = ['#FFD031', '#2DE0A5', '#1D74F5'];

@ -0,0 +1,52 @@
import { useEffect, useState } from 'react';
import { useEndpoint } from '../../../../../client/contexts/ServerContext';
import { useToastMessageDispatch } from '../../../../../client/contexts/ToastMessagesContext';
export const useEndpointData = (httpMethod, endpoint, params = {}) => {
const [data, setData] = useState(null);
const getData = useEndpoint(httpMethod, endpoint);
const dispatchToastMessage = useToastMessageDispatch();
useEffect(() => {
let mounted = true;
const fetchData = async () => {
try {
const timer = setTimeout(() => {
if (!mounted) {
return;
}
setData(null);
}, 3000);
const data = await getData(params);
clearTimeout(timer);
if (!data.success) {
throw new Error(data.status);
}
if (!mounted) {
return;
}
setData(data);
} catch (error) {
console.error(error);
dispatchToastMessage({ type: 'error', message: error });
}
};
fetchData();
return () => {
mounted = false;
};
}, [getData, params]);
return data;
};

@ -0,0 +1,71 @@
import { Blaze } from 'meteor/blaze';
import { HTML } from 'meteor/htmljs';
import { FlowRouter } from 'meteor/kadira:flow-router';
import { BlazeLayout } from 'meteor/kadira:blaze-layout';
import { Template } from 'meteor/templating';
import { hasAllPermission } from '../../../../app/authorization';
import { AdminBox } from '../../../../app/ui-utils';
import { hasLicense } from '../../license/client';
Template.EngagementDashboardRoute = new Blaze.Template('EngagementDashboardRoute',
() => HTML.DIV.call(null, { style: 'overflow: hidden; flex: 1 1 auto; height: 1%;' }));
Template.EngagementDashboardRoute.onRendered(async function() {
const [
{ createElement },
{ render, unmountComponentAtNode },
{ MeteorProvider },
{ EngagementDashboardRoute },
] = await Promise.all([
import('react'),
import('react-dom'),
import('../../../../client/providers/MeteorProvider'),
import('./components/EngagementDashboardRoute'),
]);
const container = this.firstNode;
if (!container) {
return;
}
this.autorun(() => {
const routeName = FlowRouter.getRouteName();
if (routeName !== 'engagement-dashboard') {
unmountComponentAtNode(container);
}
});
render(createElement(MeteorProvider, { children: createElement(EngagementDashboardRoute) }), container);
});
let licensed = false;
FlowRouter.route('/admin/engagement-dashboard/:tab?', {
name: 'engagement-dashboard',
action: () => {
if (!licensed) {
return;
}
BlazeLayout.render('main', { center: 'EngagementDashboardRoute' });
},
});
hasLicense('engagement-dashboard').then((enabled) => {
if (!enabled) {
return;
}
licensed = true;
AdminBox.addOption({
href: 'engagement-dashboard',
i18nLabel: 'Engagement Dashboard',
icon: 'file-keynote',
permissionGranted: () => hasAllPermission('view-statistics'),
});
}).catch((error) => {
console.error('Error checking license.', error);
});

@ -0,0 +1,26 @@
import { check } from 'meteor/check';
import { API } from '../../../../../app/api';
import { findAllChannelsWithNumberOfMessages } from '../lib/channels';
import { transformDatesForAPI } from './helpers/date';
API.v1.addRoute('engagement-dashboard/channels/list', { authRequired: true }, {
get() {
const { start, end } = this.requestParams();
const { offset, count } = this.getPaginationItems();
check(start, String);
check(end, String);
const { channels, total } = Promise.await(findAllChannelsWithNumberOfMessages({
...transformDatesForAPI(start, end),
options: { offset, count },
}));
return API.v1.success({
channels,
count: channels.length,
offset,
total,
});
},
});

@ -0,0 +1,14 @@
export const transformDatesForAPI = (start, end) => {
if (isNaN(Date.parse(start))) {
throw new Error('The "start" query parameter must be a valid date.');
}
if (end && isNaN(Date.parse(end))) {
throw new Error('The "end" query parameter must be a valid date.');
}
start = new Date(start);
end = new Date(end);
return {
start,
end,
};
};

@ -0,0 +1,3 @@
import './messages';
import './channels';
import './users';

@ -0,0 +1,41 @@
import { check } from 'meteor/check';
import { API } from '../../../../../app/api';
import { findWeeklyMessagesSentData, findMessagesSentOrigin, findTopFivePopularChannelsByMessageSentQuantity } from '../lib/messages';
import { transformDatesForAPI } from './helpers/date';
API.v1.addRoute('engagement-dashboard/messages/messages-sent', { authRequired: true }, {
get() {
const { start, end } = this.requestParams();
check(start, String);
check(end, String);
const data = Promise.await(findWeeklyMessagesSentData(transformDatesForAPI(start, end)));
return API.v1.success(data);
},
});
API.v1.addRoute('engagement-dashboard/messages/origin', { authRequired: true }, {
get() {
const { start, end } = this.requestParams();
check(start, String);
check(end, String);
const data = Promise.await(findMessagesSentOrigin(transformDatesForAPI(start, end)));
return API.v1.success(data);
},
});
API.v1.addRoute('engagement-dashboard/messages/top-five-popular-channels', { authRequired: true }, {
get() {
const { start, end } = this.requestParams();
check(start, String);
check(end, String);
const data = Promise.await(findTopFivePopularChannelsByMessageSentQuantity(transformDatesForAPI(start, end)));
return API.v1.success(data);
},
});

@ -0,0 +1,67 @@
import { check } from 'meteor/check';
import { API } from '../../../../../app/api';
import {
findWeeklyUsersRegisteredData,
findActiveUsersMonthlyData,
findBusiestsChatsInADayByHours,
findBusiestsChatsWithinAWeek,
findUserSessionsByHourWithinAWeek,
} from '../lib/users';
import { transformDatesForAPI } from './helpers/date';
API.v1.addRoute('engagement-dashboard/users/new-users', { authRequired: true }, {
get() {
const { start, end } = this.requestParams();
check(start, String);
check(end, String);
const data = Promise.await(findWeeklyUsersRegisteredData(transformDatesForAPI(start, end)));
return API.v1.success(data);
},
});
API.v1.addRoute('engagement-dashboard/users/active-users', { authRequired: true }, {
get() {
const { start, end } = this.requestParams();
check(start, String);
check(end, String);
const data = Promise.await(findActiveUsersMonthlyData(transformDatesForAPI(start, end)));
return API.v1.success(data);
},
});
API.v1.addRoute('engagement-dashboard/users/chat-busier/hourly-data', { authRequired: true }, {
get() {
const { start } = this.requestParams();
const data = Promise.await(findBusiestsChatsInADayByHours(transformDatesForAPI(start)));
return API.v1.success(data);
},
});
API.v1.addRoute('engagement-dashboard/users/chat-busier/weekly-data', { authRequired: true }, {
get() {
const { start } = this.requestParams();
check(start, String);
const data = Promise.await(findBusiestsChatsWithinAWeek(transformDatesForAPI(start)));
return API.v1.success(data);
},
});
API.v1.addRoute('engagement-dashboard/users/users-by-time-of-the-day-in-a-week', { authRequired: true }, {
get() {
const { start, end } = this.requestParams();
check(start, String);
check(end, String);
const data = Promise.await(findUserSessionsByHourWithinAWeek(transformDatesForAPI(start, end)));
return API.v1.success(data);
},
});

@ -0,0 +1,6 @@
import { onLicense } from '../../license/server';
onLicense('engagement-dashboard', async () => {
await import('./listeners');
await import('./api');
});

@ -0,0 +1,27 @@
import moment from 'moment';
import { Rooms } from '../../../../../app/models/server/raw';
import { convertDateToInt, diffBetweenDaysInclusive } from './date';
export const findAllChannelsWithNumberOfMessages = async ({ start, end, options = {} }) => {
const daysBetweenDates = diffBetweenDaysInclusive(end, start);
const endOfLastWeek = moment(start).clone().subtract(1, 'days').toDate();
const startOfLastWeek = moment(endOfLastWeek).clone().subtract(daysBetweenDates, 'days').toDate();
const total = await Rooms.findChannelsWithNumberOfMessagesBetweenDate({
start: convertDateToInt(start),
end: convertDateToInt(end),
startOfLastWeek: convertDateToInt(startOfLastWeek),
endOfLastWeek: convertDateToInt(endOfLastWeek),
onlyCount: true,
}).toArray();
return {
channels: await Rooms.findChannelsWithNumberOfMessagesBetweenDate({
start: convertDateToInt(start),
end: convertDateToInt(end),
startOfLastWeek: convertDateToInt(startOfLastWeek),
endOfLastWeek: convertDateToInt(endOfLastWeek),
options,
}),
total: total.length ? total[0].total : 0,
};
};

@ -0,0 +1,11 @@
import moment from 'moment';
export const convertDateToInt = (date) => parseInt(moment(date).clone().format('YYYYMMDD'));
export const convertIntToDate = (intValue) => moment(intValue, 'YYYYMMDD').clone().toDate();
export const diffBetweenDays = (start, end) => moment(new Date(start)).clone().diff(new Date(end), 'days');
export const diffBetweenDaysInclusive = (start, end) => diffBetweenDays(start, end) + 1;
export const getTotalOfWeekItems = (weekItems, property) => weekItems.reduce((acc, item) => {
acc += item[property];
return acc;
}, 0);

@ -0,0 +1,85 @@
import moment from 'moment';
import Analytics from '../../../../../app/models/server/raw/Analytics';
import { roomTypes } from '../../../../../app/utils';
import { convertDateToInt, diffBetweenDaysInclusive, convertIntToDate, getTotalOfWeekItems } from './date';
export const handleMessagesSent = (message, room) => {
const roomTypesToShow = roomTypes.getTypesToShowOnDashboard();
if (!roomTypesToShow.includes(room.t)) {
return;
}
Promise.await(Analytics.saveMessageSent({
date: convertDateToInt(message.ts),
room,
}));
return message;
};
export const handleMessagesDeleted = (message, room) => {
const roomTypesToShow = roomTypes.getTypesToShowOnDashboard();
if (!roomTypesToShow.includes(room.t)) {
return;
}
Promise.await(Analytics.saveMessageDeleted({
date: convertDateToInt(message.ts),
room,
}));
return message;
};
export const findWeeklyMessagesSentData = async ({ start, end }) => {
const daysBetweenDates = diffBetweenDaysInclusive(end, start);
const endOfLastWeek = moment(start).clone().subtract(1, 'days').toDate();
const startOfLastWeek = moment(endOfLastWeek).clone().subtract(daysBetweenDates, 'days').toDate();
const today = convertDateToInt(end);
const yesterday = convertDateToInt(moment(end).clone().subtract(1, 'days').toDate());
const currentPeriodMessages = await Analytics.getMessagesSentTotalByDate({
start: convertDateToInt(start),
end: convertDateToInt(end),
options: { count: daysBetweenDates, sort: { _id: -1 } },
});
const lastPeriodMessages = await Analytics.getMessagesSentTotalByDate({
start: convertDateToInt(startOfLastWeek),
end: convertDateToInt(endOfLastWeek),
options: { count: daysBetweenDates, sort: { _id: -1 } },
});
const yesterdayMessages = (currentPeriodMessages.find((item) => item._id === yesterday) || {}).messages || 0;
const todayMessages = (currentPeriodMessages.find((item) => item._id === today) || {}).messages || 0;
const currentPeriodTotalOfMessages = getTotalOfWeekItems(currentPeriodMessages, 'messages');
const lastPeriodTotalOfMessages = getTotalOfWeekItems(lastPeriodMessages, 'messages');
return {
days: currentPeriodMessages.map((day) => ({ day: convertIntToDate(day._id), messages: day.messages })),
period: {
count: currentPeriodTotalOfMessages,
variation: currentPeriodTotalOfMessages - lastPeriodTotalOfMessages,
},
yesterday: {
count: yesterdayMessages,
variation: todayMessages - yesterdayMessages,
},
};
};
export const findMessagesSentOrigin = async ({ start, end }) => {
const origins = await Analytics.getMessagesOrigin({
start: convertDateToInt(start),
end: convertDateToInt(end),
});
const roomTypesToShow = roomTypes.getTypesToShowOnDashboard();
const responseTypes = origins.map((origin) => origin.t);
const missingTypes = roomTypesToShow.filter((type) => !responseTypes.includes(type));
if (missingTypes.length) {
missingTypes.forEach((type) => origins.push({ messages: 0, t: type }));
}
return { origins };
};
export const findTopFivePopularChannelsByMessageSentQuantity = async ({ start, end }) => {
const channels = await Analytics.getMostPopularChannelsByMessagesSentQuantity({
start: convertDateToInt(start),
end: convertDateToInt(end),
options: { count: 5, sort: { messages: -1 } },
});
return { channels };
};

@ -0,0 +1,109 @@
import moment from 'moment';
import Analytics from '../../../../../app/models/server/raw/Analytics';
import Sessions from '../../../../../app/models/server/raw/Sessions';
import { convertDateToInt, diffBetweenDaysInclusive, getTotalOfWeekItems, convertIntToDate } from './date';
export const handleUserCreated = (user) => {
Promise.await(Analytics.saveUserData({
date: convertDateToInt(user.ts),
user,
}));
return user;
};
export const findWeeklyUsersRegisteredData = async ({ start, end }) => {
const daysBetweenDates = diffBetweenDaysInclusive(end, start);
const endOfLastWeek = moment(start).clone().subtract(1, 'days').toDate();
const startOfLastWeek = moment(endOfLastWeek).clone().subtract(daysBetweenDates, 'days').toDate();
const today = convertDateToInt(end);
const yesterday = convertDateToInt(moment(end).clone().subtract(1, 'days').toDate());
const currentPeriodUsers = await Analytics.getTotalOfRegisteredUsersByDate({
start: convertDateToInt(start),
end: convertDateToInt(end),
options: { count: daysBetweenDates, sort: { _id: -1 } },
});
const lastPeriodUsers = await Analytics.getTotalOfRegisteredUsersByDate({
start: convertDateToInt(startOfLastWeek),
end: convertDateToInt(endOfLastWeek),
options: { count: daysBetweenDates, sort: { _id: -1 } },
});
const yesterdayUsers = (currentPeriodUsers.find((item) => item._id === yesterday) || {}).users || 0;
const todayUsers = (currentPeriodUsers.find((item) => item._id === today) || {}).users || 0;
const currentPeriodTotalUsers = getTotalOfWeekItems(currentPeriodUsers, 'users');
const lastPeriodTotalUsers = getTotalOfWeekItems(lastPeriodUsers, 'users');
return {
days: currentPeriodUsers.map((day) => ({ day: convertIntToDate(day._id), users: day.users })),
period: {
count: currentPeriodTotalUsers,
variation: currentPeriodTotalUsers - lastPeriodTotalUsers,
},
yesterday: {
count: yesterdayUsers,
variation: todayUsers - yesterdayUsers,
},
};
};
export const findActiveUsersMonthlyData = async ({ start, end }) => {
const startOfPeriod = moment(start);
const endOfPeriod = moment(end);
return {
month: await Sessions.getActiveUsersOfPeriodByDayBetweenDates({
start: {
year: startOfPeriod.year(),
month: startOfPeriod.month() + 1,
day: startOfPeriod.date(),
},
end: {
year: endOfPeriod.year(),
month: endOfPeriod.month() + 1,
day: endOfPeriod.date(),
},
}),
};
};
export const findBusiestsChatsInADayByHours = async ({ start }) => {
const now = moment(start);
const yesterday = moment(now).clone().subtract(24, 'hours');
return {
hours: await Sessions.getBusiestTimeWithinHoursPeriod({
start: yesterday.toDate(),
end: now.toDate(),
}),
};
};
export const findBusiestsChatsWithinAWeek = async ({ start }) => {
const today = moment(start);
const startOfCurrentWeek = moment(today).clone().subtract(7, 'days');
return {
month: await Sessions.getTotalOfSessionsByDayBetweenDates({
start: {
year: startOfCurrentWeek.year(),
month: startOfCurrentWeek.month() + 1,
day: startOfCurrentWeek.date(),
},
end: {
year: today.year(),
month: today.month() + 1,
day: today.date(),
},
}),
};
};
export const findUserSessionsByHourWithinAWeek = async ({ start, end }) => {
const startOfPeriod = moment(start);
const endOfPeriod = moment(end);
return {
week: await Sessions.getTotalOfSessionByHourAndDayBetweenDates({
start: startOfPeriod.toDate(),
end: endOfPeriod.toDate(),
}),
};
};

@ -0,0 +1,2 @@
import './messages';
import './users';

@ -0,0 +1,5 @@
import { callbacks } from '../../../../../app/callbacks/server';
import { handleMessagesSent, handleMessagesDeleted } from '../lib/messages';
callbacks.add('afterSaveMessage', handleMessagesSent);
callbacks.add('afterDeleteMessage', handleMessagesDeleted);

@ -0,0 +1,4 @@
import { callbacks } from '../../../../../app/callbacks/server';
import { handleUserCreated } from '../lib/users';
callbacks.add('afterCreateUser', handleUserCreated);

@ -4,6 +4,7 @@ const bundles = {
'canned-responses',
'ldap-enterprise',
'livechat-enterprise',
'engagement-dashboard',
],
pro: [
],

@ -1,4 +1,5 @@
import '../app/auditing/client/index';
import '../app/canned-responses/client/index';
import '../app/engagement-dashboard/client/index';
import '../app/license/client/index';
import '../app/livechat-enterprise/client/index';

@ -2,5 +2,6 @@ import '../app/models';
import '../app/api-enterprise/server/index';
import '../app/auditing/server/index';
import '../app/canned-responses/server/index';
import '../app/engagement-dashboard/server/index';
import '../app/ldap-enterprise/server/index';
import '../app/livechat-enterprise/server/index';

675
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -124,10 +124,14 @@
"@google-cloud/language": "^3.7.0",
"@google-cloud/storage": "^2.3.1",
"@google-cloud/vision": "^1.8.0",
"@nivo/bar": "^0.61.1",
"@nivo/heatmap": "^0.61.0",
"@nivo/line": "^0.61.1",
"@nivo/pie": "^0.61.1",
"@rocket.chat/apps-engine": "^1.13.0-beta.2857",
"@rocket.chat/fuselage": "^0.3.0",
"@rocket.chat/fuselage-hooks": "^0.3.0",
"@rocket.chat/fuselage-ui-kit": "^0.3.0",
"@rocket.chat/fuselage": "^0.6.0",
"@rocket.chat/fuselage-hooks": "^0.6.0",
"@rocket.chat/fuselage-ui-kit": "^0.6.0",
"@rocket.chat/icons": "^0.3.0",
"@rocket.chat/ui-kit": "^0.3.0",
"@slack/client": "^4.8.0",

@ -223,6 +223,13 @@
"Accounts_UserAddedEmailSubject_Default": "You have been added to [Site_Name]",
"Accounts_UserAddedEmail_Description": "You may use the following placeholders: <br/><ul><li>[name], [fname], [lname] for the user's full name, first name or last name, respectively.</li><li>[email] for the user's email.</li><li>[password] for the user's password.</li><li>[Site_Name] and [Site_URL] for the Application Name and URL respectively.</li></ul>",
"Activate": "Activate",
"Active_users": "Active users",
"Daily_Active_Users": "Daily Active Users",
"Weekly_Active_Users": "Weekly Active Users",
"Monthly_Active_Users": "Monthly Active Users",
"DAU_value": "DAU __value__",
"WAU_value": "WAU __value__",
"MAU_value": "MAU __value__",
"Activity": "Activity",
"Add": "Add",
"add-oauth-service": "Add Oauth Service",
@ -1011,6 +1018,7 @@
"Create_A_New_Channel": "Create a New Channel",
"Create_new": "Create new",
"Create_unique_rules_for_this_channel": "Create unique rules for this channel",
"Created": "Created",
"Created_at": "Created at",
"Created_at_s_by_s": "Created at <strong>%s</strong> by <strong>%s</strong>",
"Created_at_s_by_s_triggered_by_s": "Created at <strong>%s</strong> by <strong>%s</strong> triggered by <strong>%s</strong>",
@ -1074,6 +1082,7 @@
"Date_From": "From",
"Date_to": "to",
"days": "days",
"Days": "Days",
"DB_Migration": "Database Migration",
"DB_Migration_Date": "Database Migration Date",
"DDP_Rate_Limit_IP_Enabled": "Limit by IP: enabled",
@ -1271,6 +1280,7 @@
"Emoji_provided_by_JoyPixels": "Emoji provided by <strong>JoyPixels</strong>",
"EmojiCustomFilesystem": "Custom Emoji Filesystem",
"Empty_title": "Empty title",
"Engagement_Dashboard": "Engagement Dashboard",
"Enable": "Enable",
"Enable_Auto_Away": "Enable Auto Away",
"Enable_Desktop_Notifications": "Enable Desktop Notifications",
@ -1890,6 +1900,7 @@
"IssueLinks_Incompatible": "Warning: do not enable this and the 'Hex Color Preview' at the same time.",
"IssueLinks_LinkTemplate": "Template for issue links",
"IssueLinks_LinkTemplate_Description": "Template for issue links; %s will be replaced by the issue number.",
"Items_per_page:": "Items per page:",
"It_works": "It works",
"italic": "Italic",
"italics": "italics",
@ -1903,6 +1914,7 @@
"Mobex_sms_gateway_restful_address": "Mobex SMS REST API Address",
"Mobex_sms_gateway_restful_address_desc": "IP or Host of your Mobex REST API. E.g. `http://192.168.1.1:8080` or `https://www.example.com:8080`",
"Mobex_sms_gateway_username": "Username",
"Most_popular_channels_top_5": "Most popular channels (Top 5)",
"Jitsi_Chrome_Extension": "Chrome Extension Id",
"Jitsi_Enabled_TokenAuth": "Enable JWT auth",
"Jitsi_Application_ID": "Application ID (iss)",
@ -1961,6 +1973,7 @@
"Language_Russian": "Russian",
"Language_Spanish": "Spanish",
"Language_Version": "English Version",
"Last_active": "Last active",
"Last_login": "Last login",
"Last_Message_At": "Last Message At",
"Last_seen": "Last seen",
@ -1968,6 +1981,9 @@
"Last_Message": "Last Message",
"Last_Status": "Last Status",
"Last_Updated": "Last Updated",
"Last_7_days": "Last 7 Days",
"Last_30_days": "Last 30 Days",
"Last_90_days": "Last 90 Days",
"Launched_successfully": "Launched successfully",
"Layout": "Layout",
"Layout_Home_Body": "Home Body",
@ -2339,6 +2355,7 @@
"Message": "Message",
"messages": "messages",
"Messages": "Messages",
"Messages_sent": "Messages sent",
"Messages_that_are_sent_to_the_Incoming_WebHook_will_be_posted_here": "Messages that are sent to the Incoming WebHook will be posted here.",
"Meta": "Meta",
"Meta_custom": "Custom Meta Tags",
@ -2398,6 +2415,7 @@
"Name_optional": "Name (optional)",
"Name_Placeholder": "Please enter your name...",
"Navigation_History": "Navigation History",
"New_users": "New users",
"New_Application": "New Application",
"New_chat_transfer": "New Chat Transfer: __transfer__",
"New_Custom_Field": "New Custom Field",
@ -2447,11 +2465,13 @@
"No_discussions_yet": "No discussions yet",
"No_Threads": "No threads found",
"No_user_with_username_%s_was_found": "No user with username <strong>\"%s\"</strong> was found!",
"No_data_found": "No data found",
"Nobody_available": "Nobody available",
"Node_version": "Node Version",
"None": "None",
"Nonprofit": "Nonprofit",
"Normal": "Normal",
"Not_enough_data": "Not enough data",
"Not_authorized": "Not authorized",
"Not_Available": "Not Available",
"Not_found_or_not_allowed": "Not Found or Not Allowed",
@ -2634,6 +2654,8 @@
"Privacy_Policy": "Privacy Policy",
"Private": "Private",
"Private_Channel": "Private Channel",
"Private_Channels": "Private Channels",
"Private_Chats": "Private Chats",
"Private_Group": "Private Group",
"Private_Groups": "Private Groups",
"Private_Groups_list": "List of Private Groups",
@ -2661,6 +2683,7 @@
"files_pruned": "files pruned",
"Public": "Public",
"Public_Channel": "Public Channel",
"Public_Channels": "Public Channels",
"Public_Community": "Public Community",
"Public_Relations": "Public Relations",
"Public_URL": "Public URL",
@ -3493,6 +3516,7 @@
"Username_wants_to_start_otr_Do_you_want_to_accept": "__username__ wants to start OTR. Do you want to accept?",
"Users": "Users",
"Users_added": "The users have been added",
"Users_by_time_of_day": "Users by time of day",
"Users_in_role": "Users in role",
"Users must use Two Factor Authentication": "Users must use Two Factor Authentication",
"Leave_the_description_field_blank_if_you_dont_want_to_show_the_role": "Leave the description field blank if you don't want to show the role",
@ -3501,6 +3525,8 @@
"UTF8_Names_Slugify": "UTF8 Names Slugify",
"UTF8_Names_Validation": "UTF8 Names Validation",
"UTF8_Names_Validation_Description": "RegExp that will be used to validate usernames and channel names",
"Value_messages": "__value__ messages",
"Value_users": "__value__ users",
"Validate_email_address": "Validate Email Address",
"Verification_email_body": "You have succesfully created an account on [Site_Name]. Please, click on the button below to confirm your email address and finish registration.",
"Verification": "Verification",
@ -3603,6 +3629,8 @@
"Welcome": "Welcome <em>%s</em>.",
"Welcome_to": "Welcome to __Site_Name__",
"Welcome_to_the": "Welcome to the",
"Where_are_the_messages_being_sent?": "Where are the messages being sent?",
"When_is_the_chat_busier?": "When is the chat busier?",
"Why_do_you_want_to_report_question_mark": "Why do you want to report?",
"will_be_able_to": "will be able to",
"Worldwide": "Worldwide",

@ -3,6 +3,7 @@ import '../imports/startup/server';
import '../lib/RegExp';
import '../ee/server';
import './lib/accounts';
import './lib/cordova';
import './lib/roomFiles';
@ -74,5 +75,3 @@ import './routes/avatar';
import './stream/messages';
import './stream/rooms';
import './stream/streamBroadcast';
import '../ee/server';

Loading…
Cancel
Save