[NEW] Banner system and NPS (#20221)

Co-authored-by: Tasso Evangelista <tasso.evangelista@rocket.chat>
Co-authored-by: Guilherme Gazzo <guilherme@gazzo.xyz>
pull/20253/head
Diego Sampaio 4 years ago committed by GitHub
parent 818d707a2b
commit b50175e182
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      app/api/server/index.js
  2. 50
      app/api/server/v1/banners.ts
  3. 328
      app/apps/server/communication/uikit.js
  4. 3
      app/cloud/server/functions/buildRegistrationData.js
  5. 27
      app/cloud/server/functions/syncWorkspace.js
  6. 9
      app/e2e/client/rocketchat.e2e.js
  7. 7
      app/lib/server/startup/settings.js
  8. 35
      app/models/server/raw/Banners.ts
  9. 27
      app/models/server/raw/BannersDismiss.ts
  10. 90
      app/models/server/raw/Nps.ts
  11. 100
      app/models/server/raw/NpsVote.ts
  12. 105
      app/theme/client/imports/components/alerts.css
  13. 1
      app/theme/client/main.css
  14. 1
      app/ui-master/client/main.html
  15. 51
      app/ui-message/client/ActionManager.js
  16. 1
      app/ui-utils/client/index.js
  17. 29
      app/ui-utils/client/lib/alerts.html
  18. 73
      app/ui-utils/client/lib/alerts.js
  19. 4
      app/version-check/client/index.js
  20. 24
      client/UIKit/hooks/useUIKitHandleAction.tsx
  21. 35
      client/UIKit/hooks/useUIKitHandleClose.tsx
  22. 34
      client/UIKit/hooks/useUIKitStateManager.tsx
  23. 18
      client/components/AppRoot.js
  24. 13
      client/components/PortalWrapper.js
  25. 75
      client/lib/banners.ts
  26. 20
      client/lib/createValueSubscription.ts
  27. 84
      client/reactAdapters.js
  28. 63
      client/startup/banners.ts
  29. 1
      client/startup/index.js
  30. 5
      client/startup/startup.js
  31. 37
      client/types/fuselage-ui-kit.d.ts
  32. 8
      client/types/fuselage.d.ts
  33. 22
      client/views/banners/BannerRegion.tsx
  34. 63
      client/views/banners/LegacyBanner.tsx
  35. 50
      client/views/banners/UiKitBanner.tsx
  36. 24
      definition/IBanner.ts
  37. 35
      definition/INps.ts
  38. 59
      definition/UIKit.ts
  39. 96
      package-lock.json
  40. 16
      package.json
  41. 6
      packages/rocketchat-i18n/i18n/en.i18n.json
  42. 5
      packages/rocketchat-i18n/i18n/pt-BR.i18n.json
  43. 22
      server/cron/nps.js
  44. 11
      server/cron/oembed.js
  45. 64
      server/cron/statistics.js
  46. 1
      server/main.js
  47. 22
      server/modules/core-apps/banner.module.ts
  48. 70
      server/modules/core-apps/nps.module.ts
  49. 95
      server/modules/core-apps/nps/createModal.ts
  50. 4
      server/modules/listeners/listeners.module.ts
  51. 6
      server/sdk/index.ts
  52. 1
      server/sdk/lib/Events.ts
  53. 7
      server/sdk/types/IBannerService.ts
  54. 22
      server/sdk/types/INPSService.ts
  55. 16
      server/sdk/types/IUiKitCoreApp.ts
  56. 111
      server/services/banner/service.ts
  57. 42
      server/services/nps/getBannerForAdmins.ts
  58. 31
      server/services/nps/sendToCloud.ts
  59. 196
      server/services/nps/service.ts
  60. 10
      server/services/startup.ts
  61. 59
      server/services/uikit-core-app/service.ts
  62. 6
      server/startup/coreApps.ts
  63. 74
      server/startup/cron.js

@ -38,6 +38,7 @@ import './v1/oauthapps';
import './v1/custom-sounds';
import './v1/custom-user-status';
import './v1/instances';
import './v1/banners';
import './v1/email-inbox';
export { API, APIClass, defaultRateLimiterOptions } from './api';

@ -0,0 +1,50 @@
import { Promise } from 'meteor/promise';
import { Meteor } from 'meteor/meteor';
import { Match, check } from 'meteor/check';
import { API } from '../api';
import { Banner } from '../../../../server/sdk';
import { BannerPlatform } from '../../../../definition/IBanner';
API.v1.addRoute('banners.getNew', { authRequired: true }, {
get() {
check(this.queryParams, Match.ObjectIncluding({
platform: String,
bid: Match.Maybe(String),
}));
const { platform, bid: bannerId } = this.queryParams;
if (!platform) {
throw new Meteor.Error('error-missing-param', 'The required "platform" param is missing.');
}
if (!Object.values(BannerPlatform).includes(platform)) {
throw new Meteor.Error('error-unknown-platform', 'Platform is unknown.');
}
const banners = Promise.await(Banner.getNewBannersForUser(this.userId, platform, bannerId));
return API.v1.success({ banners });
},
});
API.v1.addRoute('banners.dismiss', { authRequired: true }, {
post() {
check(this.bodyParams, Match.ObjectIncluding({
bannerId: String,
}));
const { bannerId } = this.bodyParams;
if (!bannerId || !bannerId.trim()) {
throw new Meteor.Error('error-missing-param', 'The required "bannerId" param is missing.');
}
try {
Promise.await(Banner.dismiss(this.userId, bannerId));
return API.v1.success();
} catch (e) {
return API.v1.failure();
}
},
});

@ -8,6 +8,7 @@ import { AppInterface } from '@rocket.chat/apps-engine/definition/metadata';
import { Users } from '../../../models/server';
import { settings } from '../../../settings/server';
import { Apps } from '../orchestrator';
import { UiKitCoreApp } from '../../../../server/sdk';
const apiServer = express();
@ -60,126 +61,231 @@ router.use((req, res, next) => {
apiServer.use('/api/apps/ui.interaction/', router);
export class AppUIKitInteractionApi {
constructor(orch) {
this.orch = orch;
const getPayloadForType = (type, req) => {
if (type === UIKitIncomingInteractionType.BLOCK) {
const {
type,
actionId,
triggerId,
mid,
rid,
payload,
container,
} = req.body;
const { visitor, user } = req;
const room = rid; // orch.getConverters().get('rooms').convertById(rid);
const message = mid;
return {
type,
container,
actionId,
message,
triggerId,
payload,
user,
visitor,
room,
};
}
if (type === UIKitIncomingInteractionType.VIEW_CLOSED) {
const {
type,
actionId,
payload: {
view,
isCleared,
},
} = req.body;
const { user } = req;
return {
type,
actionId,
user,
payload: {
view,
isCleared,
},
};
}
if (type === UIKitIncomingInteractionType.VIEW_SUBMIT) {
const {
type,
actionId,
triggerId,
payload,
} = req.body;
const { user } = req;
return {
type,
actionId,
triggerId,
payload,
user,
};
}
throw new Error('Type not supported');
};
router.post('/:appId', async (req, res, next) => {
const {
appId,
} = req.params;
const isCore = await UiKitCoreApp.isRegistered(appId);
if (!isCore) {
return next();
}
const {
type,
} = req.body;
try {
const payload = {
...getPayloadForType(type, req),
appId,
};
const result = await UiKitCoreApp[type](payload);
res.send(result);
} catch (e) {
console.error('ops', e);
res.status(500).send({ error: e.message });
}
});
const appsRoutes = (orch) => (req, res) => {
const {
appId,
} = req.params;
const {
type,
} = req.body;
router.post('/:appId', (req, res) => {
switch (type) {
case UIKitIncomingInteractionType.BLOCK: {
const {
type,
actionId,
triggerId,
mid,
rid,
payload,
container,
} = req.body;
const { visitor } = req;
const room = orch.getConverters().get('rooms').convertById(rid);
const user = orch.getConverters().get('users').convertToApp(req.user);
const message = mid && orch.getConverters().get('messages').convertById(mid);
const action = {
type,
container,
appId,
actionId,
message,
triggerId,
payload,
user,
visitor,
room,
};
try {
const eventInterface = !visitor ? AppInterface.IUIKitInteractionHandler : AppInterface.IUIKitLivechatInteractionHandler;
const result = Promise.await(orch.triggerEvent(eventInterface, action));
res.send(result);
} catch (e) {
res.status(500).send(e.message);
}
break;
}
case UIKitIncomingInteractionType.VIEW_CLOSED: {
const {
type,
actionId,
payload: {
view,
isCleared,
},
} = req.body;
const user = orch.getConverters().get('users').convertToApp(req.user);
const action = {
type,
appId,
} = req.params;
actionId,
user,
payload: {
view,
isCleared,
},
};
try {
Promise.await(orch.triggerEvent('IUIKitInteractionHandler', action));
res.sendStatus(200);
} catch (e) {
console.error(e);
res.status(500).send(e.message);
}
break;
}
case UIKitIncomingInteractionType.VIEW_SUBMIT: {
const {
type,
actionId,
triggerId,
payload,
} = req.body;
switch (type) {
case UIKitIncomingInteractionType.BLOCK: {
const {
type,
actionId,
triggerId,
mid,
rid,
payload,
container,
} = req.body;
const { visitor } = req;
const room = this.orch.getConverters().get('rooms').convertById(rid);
const user = this.orch.getConverters().get('users').convertToApp(req.user);
const message = mid && this.orch.getConverters().get('messages').convertById(mid);
const action = {
type,
container,
appId,
actionId,
message,
triggerId,
payload,
user,
visitor,
room,
};
try {
const eventInterface = !visitor ? AppInterface.IUIKitInteractionHandler : AppInterface.IUIKitLivechatInteractionHandler;
const result = Promise.await(this.orch.triggerEvent(eventInterface, action));
res.send(result);
} catch (e) {
res.status(500).send(e.message);
}
break;
}
case UIKitIncomingInteractionType.VIEW_CLOSED: {
const {
type,
actionId,
payload: {
view,
isCleared,
},
} = req.body;
const user = this.orch.getConverters().get('users').convertToApp(req.user);
const action = {
type,
appId,
actionId,
user,
payload: {
view,
isCleared,
},
};
try {
Promise.await(this.orch.triggerEvent('IUIKitInteractionHandler', action));
res.sendStatus(200);
} catch (e) {
console.log(e);
res.status(500).send(e.message);
}
break;
}
case UIKitIncomingInteractionType.VIEW_SUBMIT: {
const {
type,
actionId,
triggerId,
payload,
} = req.body;
const user = this.orch.getConverters().get('users').convertToApp(req.user);
const action = {
type,
appId,
actionId,
triggerId,
payload,
user,
};
try {
const result = Promise.await(this.orch.triggerEvent('IUIKitInteractionHandler', action));
res.send(result);
} catch (e) {
res.status(500).send(e.message);
}
break;
}
const user = orch.getConverters().get('users').convertToApp(req.user);
const action = {
type,
appId,
actionId,
triggerId,
payload,
user,
};
try {
const result = Promise.await(orch.triggerEvent('IUIKitInteractionHandler', action));
res.send(result);
} catch (e) {
res.status(500).send(e.message);
}
break;
}
}
// TODO: validate payloads per type
};
export class AppUIKitInteractionApi {
constructor(orch) {
this.orch = orch;
// TODO: validate payloads per type
});
router.post('/:appId', appsRoutes(orch));
}
}

@ -26,6 +26,8 @@ export function buildWorkspaceRegistrationData() {
const website = settings.get('Website');
const npsEnabled = settings.get('NPS_survey_enabled');
const agreePrivacyTerms = settings.get('Cloud_Service_Agree_PrivacyTerms');
const { organizationType, industry, size: orgSize, country, language, serverType: workspaceType } = stats.wizard;
@ -53,5 +55,6 @@ export function buildWorkspaceRegistrationData() {
licenseVersion: LICENSE_VERSION,
enterpriseReady: true,
setupComplete: settings.get('Show_Setup_Wizard') === 'completed',
npsEnabled,
};
}

@ -6,6 +6,7 @@ import { getWorkspaceAccessToken } from './getWorkspaceAccessToken';
import { getWorkspaceLicense } from './getWorkspaceLicense';
import { Settings } from '../../../models';
import { settings } from '../../../settings';
import { NPS, Banner } from '../../../../server/sdk';
export function syncWorkspace(reconnectCheck = false) {
const { workspaceRegistered, connectToCloud } = retrieveRegistrationStatus();
@ -45,10 +46,34 @@ export function syncWorkspace(reconnectCheck = false) {
}
const { data } = result;
if (!data) {
return true;
}
if (data && data.publicKey) {
if (data.publicKey) {
Settings.updateValueById('Cloud_Workspace_PublicKey', data.publicKey);
}
if (data.nps) {
const {
id: npsId,
startAt,
expireAt,
} = data.nps;
Promise.await(NPS.create({
npsId,
startAt,
expireAt,
}));
}
// add banners
if (data.banners) {
for (const banner of data.banners) {
Promise.await(Banner.create(banner));
}
}
return true;
}

@ -25,7 +25,8 @@ import { Rooms, Subscriptions, Messages } from '../../models';
import { promises } from '../../promises/client';
import { settings } from '../../settings';
import { Notifications } from '../../notifications/client';
import { Layout, call, modal, alerts } from '../../ui-utils';
import { Layout, call, modal } from '../../ui-utils';
import * as banners from '../../../client/lib/banners';
import './events.js';
import './tabbar';
@ -198,7 +199,7 @@ class E2E {
this.log('-> Stop Client');
// This flag is used to avoid closing unrelated alerts.
if (showingE2EAlert) {
alerts.close();
banners.close();
}
Meteor._localStorage.removeItem('public_key');
@ -428,12 +429,12 @@ class E2E {
openAlert(config) {
showingE2EAlert = true;
alerts.open(config);
banners.open(config);
}
closeAlert() {
if (showingE2EAlert) {
alerts.close();
banners.close();
}
showingE2EAlert = false;
}

@ -979,11 +979,16 @@ settings.addGroup('General', function() {
public: true,
});
});
return this.section('Stream_Cast', function() {
this.section('Stream_Cast', function() {
return this.add('Stream_Cast_Address', '', {
type: 'string',
});
});
this.section('NPS', function() {
this.add('NPS_survey_enabled', true, {
type: 'boolean',
});
});
});
settings.addGroup('Message', function() {

@ -0,0 +1,35 @@
import { Collection, Cursor, FindOneOptions } from 'mongodb';
import { BannerPlatform, IBanner } from '../../../../definition/IBanner';
import { BaseRaw } from './BaseRaw';
type T = IBanner;
export class BannersRaw extends BaseRaw<T> {
constructor(
public readonly col: Collection<T>,
public readonly trash?: Collection<T>,
) {
super(col, trash);
this.col.createIndexes([
{ key: { platform: 1, startAt: 1, expireAt: 1 } },
]);
}
findActiveByRoleOrId(roles: string[], platform: BannerPlatform, bannerId?: string, options?: FindOneOptions<T>): Cursor<T> {
const today = new Date();
const query = {
...bannerId && { _id: bannerId },
platform,
startAt: { $lte: today },
expireAt: { $gte: today },
$or: [
{ roles: { $in: roles } },
{ roles: { $exists: false } },
],
};
return this.col.find(query, options);
}
}

@ -0,0 +1,27 @@
import { Collection, Cursor, FindOneOptions } from 'mongodb';
import { IBannerDismiss } from '../../../../definition/IBanner';
import { BaseRaw } from './BaseRaw';
type T = IBannerDismiss;
export class BannersDismissRaw extends BaseRaw<T> {
constructor(
public readonly col: Collection<T>,
public readonly trash?: Collection<T>,
) {
super(col, trash);
this.col.createIndexes([
{ key: { userId: 1, bannerId: 1 } },
]);
}
findByUserIdAndBannerId(userId: string, bannerIds: string[], options?: FindOneOptions<T>): Cursor<T> {
const query = {
userId,
bannerId: { $in: bannerIds },
};
return this.col.find(query, options);
}
}

@ -0,0 +1,90 @@
import { UpdateWriteOpResult, Collection } from 'mongodb';
import { BaseRaw } from './BaseRaw';
import { INps, NPSStatus } from '../../../../definition/INps';
type T = INps;
export class NpsRaw extends BaseRaw<T> {
constructor(
public readonly col: Collection<T>,
public readonly trash?: Collection<T>,
) {
super(col, trash);
this.col.createIndexes([
{ key: { status: 1, expireAt: 1 } },
]);
}
// get expired surveys still in progress
async getOpenExpiredAndStartSending(): Promise<INps | undefined> {
const today = new Date();
const query = {
status: NPSStatus.OPEN,
expireAt: { $lte: today },
};
const update = {
$set: {
status: NPSStatus.SENDING,
},
};
const { value } = await this.col.findOneAndUpdate(query, update, { sort: { expireAt: 1 } });
return value;
}
// get expired surveys already sending results
async getOpenExpiredAlreadySending(): Promise<INps | null> {
const today = new Date();
const query = {
status: NPSStatus.SENDING,
expireAt: { $lte: today },
};
return this.col.findOne(query);
}
updateStatusById(_id: INps['_id'], status: INps['status']): Promise<UpdateWriteOpResult> {
const update = {
$set: {
status,
},
};
return this.col.updateOne({ _id }, update);
}
save({ _id, startAt, expireAt, createdBy, status }: Pick<INps, '_id' | 'startAt' | 'expireAt' | 'createdBy' | 'status'>): Promise<UpdateWriteOpResult> {
return this.col.updateOne({
_id,
}, {
$set: {
startAt,
_updatedAt: new Date(),
},
$setOnInsert: {
expireAt,
createdBy,
createdAt: new Date(),
status,
},
}, {
upsert: true,
});
}
closeAllByStatus(status: NPSStatus): Promise<UpdateWriteOpResult> {
const query = {
status,
};
const update = {
$set: {
status: NPSStatus.CLOSED,
},
};
return this.col.updateMany(query, update);
}
}

@ -0,0 +1,100 @@
import { ObjectId, Collection, Cursor, FindOneOptions, UpdateWriteOpResult } from 'mongodb';
import { INpsVote, INpsVoteStatus } from '../../../../definition/INps';
import { BaseRaw } from './BaseRaw';
type T = INpsVote;
export class NpsVoteRaw extends BaseRaw<T> {
constructor(
public readonly col: Collection<T>,
public readonly trash?: Collection<T>,
) {
super(col, trash);
this.col.createIndexes([
{ key: { npsId: 1, status: 1, sentAt: 1 } },
{ key: { npsId: 1, identifier: 1 } },
]);
}
findNotSentByNpsId(npsId: string, options?: FindOneOptions<T>): Cursor<T> {
const query = {
npsId,
status: INpsVoteStatus.NEW,
};
return this.col
.find(query, options)
.sort({ ts: 1 })
.limit(1000);
}
findByNpsIdAndStatus(npsId: string, status: INpsVoteStatus, options?: FindOneOptions<T>): Cursor<T> {
const query = {
npsId,
status,
};
return this.col.find(query, options);
}
findByNpsId(npsId: string, options?: FindOneOptions<T>): Cursor<T> {
const query = {
npsId,
};
return this.col.find(query, options);
}
save(vote: Omit<T, '_id' | '_updatedAt'>): Promise<UpdateWriteOpResult> {
const {
npsId,
identifier,
} = vote;
const query = {
npsId,
identifier,
};
const update = {
$set: {
...vote,
_updatedAt: new Date(),
},
$setOnInsert: {
_id: new ObjectId().toHexString(),
},
};
return this.col.updateOne(query, update, { upsert: true });
}
updateVotesToSent(voteIds: string[]): Promise<UpdateWriteOpResult> {
const query = {
_id: { $in: voteIds },
};
const update = {
$set: {
status: INpsVoteStatus.SENT,
},
};
return this.col.updateMany(query, update);
}
updateOldSendingToNewByNpsId(npsId: string): Promise<UpdateWriteOpResult> {
const fiveMinutes = new Date();
fiveMinutes.setMinutes(fiveMinutes.getMinutes() - 5);
const query = {
npsId,
status: INpsVoteStatus.SENDING,
sentAt: { $lt: fiveMinutes },
};
const update = {
$set: {
status: INpsVoteStatus.NEW,
},
$unset: {
sentAt: 1 as 1, // why do you do this to me TypeScript?
},
};
return this.col.updateMany(query, update);
}
}

@ -1,105 +0,0 @@
.rc-alerts {
position: relative;
z-index: 10;
display: flex;
padding: var(--alerts-padding-vertical) var(--alerts-padding);
animation-name: dropdown-show;
animation-duration: 0.3s;
text-align: center;
color: var(--alerts-color);
background: var(--alerts-background);
font-size: var(--alerts-font-size);
align-items: center;
flex-grow: 0;
&--danger {
background: var(--rc-color-error);
}
&--alert {
background: var(--rc-color-alert);
}
&--large > &__content {
flex-direction: column;
text-align: start;
}
&--has-action {
cursor: pointer;
}
&__icon {
cursor: pointer;
color: inherit;
flex-grow: 0;
&--close {
transform: rotate(45deg);
}
}
&__title {
font-weight: bold;
}
&__title,
&__line {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
flex-grow: 0;
}
&__content > &__title + &__line {
margin: 0 var(--alerts-padding-vertical);
}
&--large > &__content > &__title + &__line {
margin: 0;
}
&--large > &__icon {
font-size: 30px;
}
&--large > &__big-icon {
width: 40px;
height: 40px;
font-size: 40px;
}
&--large {
padding: var(--alerts-padding-vertical-large) var(--alerts-padding);
justify-content: start;
}
&__content {
display: flex;
overflow: hidden;
flex-direction: row;
flex: 1;
margin: 0 var(--alerts-padding-vertical);
justify-content: center;
}
}

@ -32,7 +32,6 @@
@import 'imports/components/avatar.css';
@import 'imports/components/badge.css';
@import 'imports/components/popover.css';
@import 'imports/components/alerts.css';
@import 'imports/components/popout.css';
@import 'imports/components/modal.css';
@import 'imports/components/chip.css';

@ -1,5 +1,4 @@
<body class="color-primary-font-color">
<div id='alert-anchor'></div>
</body>
<template name="main">

@ -1,4 +1,4 @@
import { UIKitInteractionType, UIKitIncomingInteractionType } from '@rocket.chat/apps-engine/definition/uikit';
import { UIKitIncomingInteractionType } from '@rocket.chat/apps-engine/definition/uikit';
import { Meteor } from 'meteor/meteor';
import { Random } from 'meteor/random';
import { Emitter } from '@rocket.chat/emitter';
@ -7,6 +7,8 @@ import Notifications from '../../notifications/client/lib/Notifications';
import { CachedCollectionManager } from '../../ui-cached-collection';
import { modal } from '../../ui-utils/client/lib/modal';
import { APIClient } from '../../utils';
import { UIKitInteractionTypes } from '../../../definition/UIKit';
import * as banners from '../../../client/lib/banners';
const events = new Emitter();
@ -37,6 +39,7 @@ export const generateTriggerId = (appId) => {
return triggerId;
};
const handlePayloadUserInteraction = (type, { /* appId,*/ triggerId, ...data }) => {
if (!triggersId.has(triggerId)) {
return;
@ -57,7 +60,7 @@ const handlePayloadUserInteraction = (type, { /* appId,*/ triggerId, ...data })
return;
}
if ([UIKitInteractionType.ERRORS].includes(type)) {
if ([UIKitInteractionTypes.ERRORS].includes(type)) {
events.emit(viewId, {
type,
triggerId,
@ -65,10 +68,10 @@ const handlePayloadUserInteraction = (type, { /* appId,*/ triggerId, ...data })
appId,
...data,
});
return UIKitInteractionType.ERRORS;
return UIKitInteractionTypes.ERRORS;
}
if ([UIKitInteractionType.MODAL_UPDATE].includes(type)) {
if ([UIKitInteractionTypes.BANNER_UPDATE, UIKitInteractionTypes.MODAL_UPDATE].includes(type)) {
events.emit(viewId, {
type,
triggerId,
@ -76,10 +79,10 @@ const handlePayloadUserInteraction = (type, { /* appId,*/ triggerId, ...data })
appId,
...data,
});
return UIKitInteractionType.MODAL_UPDATE;
return type;
}
if ([UIKitInteractionType.MODAL_OPEN].includes(type)) {
if ([UIKitInteractionTypes.MODAL_OPEN].includes(type)) {
const instance = modal.push({
template: 'ModalBlock',
modifier: 'uikit',
@ -91,11 +94,35 @@ const handlePayloadUserInteraction = (type, { /* appId,*/ triggerId, ...data })
...data,
},
});
instances.set(viewId, instance);
return UIKitInteractionType.MODAL_OPEN;
instances.set(viewId, {
close() {
instance.close();
instances.delete(viewId);
},
});
return UIKitInteractionTypes.MODAL_OPEN;
}
if ([UIKitInteractionTypes.BANNER_OPEN].includes(type)) {
banners.open(data);
instances.set(viewId, {
close() {
banners.closeById(viewId);
},
});
return UIKitInteractionTypes.BANNER_OPEN;
}
if ([UIKitIncomingInteractionType.BANNER_CLOSE].includes(type)) {
const instance = instances.get(viewId);
if (instance) {
instance.close();
}
return UIKitIncomingInteractionType.BANNER_CLOSE;
}
return UIKitInteractionType.MODAL_ClOSE;
return UIKitInteractionTypes.MODAL_ClOSE;
};
export const triggerAction = async ({ type, actionId, appId, rid, mid, viewId, container, ...rest }) => new Promise(async (resolve, reject) => {
@ -114,25 +141,26 @@ export const triggerAction = async ({ type, actionId, appId, rid, mid, viewId, c
});
export const triggerBlockAction = (options) => triggerAction({ type: UIKitIncomingInteractionType.BLOCK, ...options });
export const triggerSubmitView = async ({ viewId, ...options }) => {
const close = () => {
const instance = instances.get(viewId);
if (instance) {
instance.close();
instances.delete(viewId);
}
};
try {
const result = await triggerAction({ type: UIKitIncomingInteractionType.VIEW_SUBMIT, viewId, ...options });
if (!result || UIKitInteractionType.MODAL_CLOSE === result) {
if (!result || UIKitInteractionTypes.MODAL_CLOSE === result) {
close();
}
} catch {
close();
}
};
export const triggerCancel = async ({ view, ...options }) => {
const instance = instances.get(view.id);
try {
@ -140,7 +168,6 @@ export const triggerCancel = async ({ view, ...options }) => {
} finally {
if (instance) {
instance.close();
instances.delete(view.id);
}
}
};

@ -18,7 +18,6 @@ export { getAvatarAsPng } from './lib/avatar';
export { popout } from './lib/popout';
export { messageProperties } from '../lib/MessageProperties';
export { MessageTypes } from '../lib/MessageTypes';
export { alerts } from './lib/alerts';
export { Message } from '../lib/Message';
export { openRoom } from './lib/openRoom';
export { warnUserDeletionMayRemoveRooms } from './lib/warnUserDeletionMayRemoveRooms';

@ -1,29 +0,0 @@
<template name="alerts">
<div class="rc-alerts {{modifiers}} {{customclass}}" data-popover="popover">
{{#if icon}}
<div class="rc-alerts__big-icon rc-alerts__icon {{hasAction}} js-action">
{{> icon icon=icon }}
</div>
{{/if}}
<div class="rc-alerts__content {{hasAction}} js-action">
{{#if template}}
{{> Template.dynamic template=template data=data}}
{{else}}
{{#if title}}
<div class="rc-alerts__title">
{{title}}
</div>
{{/if}}
<div class="rc-alerts__line">
{{text}}
</div>
{{{html}}}
{{/if}}
</div>
{{#if closable}}
<div class="rc-alerts__icon js-close" aria-label="{{_ "Close"}}">
{{> icon block="rc-alerts__icon rc-alerts__icon--close" icon='plus'}}
</div>
{{/if}}
</div>
</template>

@ -1,73 +0,0 @@
import './alerts.html';
import { Blaze } from 'meteor/blaze';
import { Template } from 'meteor/templating';
export const alerts = {
renderedAlert: null,
open(config) {
this.close(false);
config.closable = typeof config.closable === typeof true ? config.closable : true;
if (config.timer) {
this.timer = setTimeout(() => this.close(), config.timer);
}
this.renderedAlert = Blaze.renderWithData(Template.alerts, config, document.body, document.body.querySelector('#alert-anchor'));
},
close(dismiss = true) {
if (this.timer) {
clearTimeout(this.timer);
delete this.timer;
}
if (!this.renderedAlert) {
return false;
}
Blaze.remove(this.renderedAlert);
const { activeElement } = this.renderedAlert.dataVar.curValue;
if (activeElement) {
$(activeElement).removeClass('active');
}
dismiss && this.renderedAlert.dataVar.curValue.onClose && this.renderedAlert.dataVar.curValue.onClose();
},
};
Template.alerts.helpers({
hasAction() {
return Template.instance().data.action ? 'rc-alerts--has-action' : '';
},
modifiers() {
return (Template.instance().data.modifiers || []).map((mod) => `rc-alerts--${ mod }`).join(' ');
},
});
Template.alerts.onRendered(function() {
if (this.data.onRendered) {
this.data.onRendered();
}
});
Template.alerts.onDestroyed(function() {
if (this.data.onDestroyed) {
this.data.onDestroyed();
}
});
Template.alerts.events({
'click .js-action'(e, instance) {
if (!this.action) {
return;
}
this.action.call(this, e, instance.data.data);
},
'click .js-close'() {
alerts.close();
},
});
Template.alerts.helpers({
isSafariIos: /iP(ad|hone|od).+Version\/[\d\.]+.*Safari/i.test(navigator.userAgent),
});

@ -2,7 +2,7 @@ import { Meteor } from 'meteor/meteor';
import { Tracker } from 'meteor/tracker';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import { alerts } from '../../ui-utils';
import * as banners from '../../../client/lib/banners';
Meteor.startup(function() {
Tracker.autorun(() => {
@ -17,7 +17,7 @@ Meteor.startup(function() {
firstBanner.textArguments = firstBanner.textArguments || [];
alerts.open({
banners.open({
title: TAPi18n.__(firstBanner.title),
text: TAPi18n.__(firstBanner.text, ...firstBanner.textArguments),
modifiers: firstBanner.modifiers,

@ -0,0 +1,24 @@
import { UIKitIncomingInteractionContainerType } from '@rocket.chat/apps-engine/definition/uikit/UIKitIncomingInteractionContainer';
/* eslint-disable new-cap */
// import { Banner, Icon } from '@rocket.chat/fuselage';
// import { kitContext, UiKitBanner as renderUiKitBannerBlocks } from '@rocket.chat/fuselage-ui-kit';
// import React, { Context, FC, useMemo } from 'react';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
// import { UIKitIncomingInteractionContainerType } from '@rocket.chat/apps-engine/definition/uikit/UIKitIncomingInteractionContainer';
// import { useEndpoint } from '../../contexts/ServerContext';
import * as ActionManager from '../../../app/ui-message/client/ActionManager';
import { UiKitPayload, UIKitActionEvent } from '../../../definition/UIKit';
const useUIKitHandleAction = <S extends UiKitPayload>(state: S): (event: UIKitActionEvent) => Promise<void> => useMutableCallback(async ({ blockId, value, appId, actionId }) => ActionManager.triggerBlockAction({
container: {
type: UIKitIncomingInteractionContainerType.VIEW,
id: state.viewId || state.appId,
},
actionId,
appId,
value,
blockId,
}));
export { useUIKitHandleAction };

@ -0,0 +1,35 @@
import { UIKitInteractionType } from '@rocket.chat/apps-engine/definition/uikit';
/* eslint-disable new-cap */
// import { Banner, Icon } from '@rocket.chat/fuselage';
// import { kitContext, UiKitBanner as renderUiKitBannerBlocks } from '@rocket.chat/fuselage-ui-kit';
// import React, { Context, FC, useMemo } from 'react';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
// import { UIKitIncomingInteractionContainerType } from '@rocket.chat/apps-engine/definition/uikit/UIKitIncomingInteractionContainer';
// import { useEndpoint } from '../../contexts/ServerContext';
import { useToastMessageDispatch } from '../../contexts/ToastMessagesContext';
import * as ActionManager from '../../../app/ui-message/client/ActionManager';
import { UiKitPayload } from '../../../definition/UIKit';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const emptyFn = (_error: any, _result: UIKitInteractionType | void): void => undefined;
const useUIKitHandleClose = <S extends UiKitPayload>(state: S, fn = emptyFn): () => Promise<void | UIKitInteractionType> => {
const dispatchToastMessage = useToastMessageDispatch();
return useMutableCallback(() => ActionManager.triggerCancel({
appId: state.appId,
viewId: state.viewId,
view: {
...state,
id: state.viewId,
// state: groupStateByBlockId(values),
},
isCleared: true,
}).then((result) => fn(undefined, result)).catch((error) => {
dispatchToastMessage({ type: 'error', message: error });
fn(error, undefined);
return Promise.reject(error);
}));
};
export { useUIKitHandleClose };

@ -0,0 +1,34 @@
import { useEffect, useState } from 'react';
import { isErrorType, UIKitUserInteractionResult, UiKitPayload } from '../../../definition/UIKit';
import * as ActionManager from '../../../app/ui-message/client/ActionManager';
const useUIKitStateManager = <S extends UiKitPayload>(initialState: S): S => {
const [state, setState] = useState<S>(initialState);
const { viewId } = state;
useEffect(() => {
const handleUpdate = ({ ...data }: UIKitUserInteractionResult): void => {
if (isErrorType(data)) {
const { errors } = data;
setState((state) => ({ ...state, errors }));
return;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { type, ...rest } = data;
setState(rest as any);
};
ActionManager.on(viewId, handleUpdate);
return (): void => {
ActionManager.off(viewId, handleUpdate);
};
}, [viewId]);
return state;
};
export { useUIKitStateManager };

@ -0,0 +1,18 @@
import React from 'react';
import { useSubscription } from 'use-subscription';
import MeteorProvider from '../providers/MeteorProvider';
import { portalsSubscription } from '../reactAdapters';
import BannerRegion from '../views/banners/BannerRegion';
import PortalWrapper from './PortalWrapper';
const AppRoot = () => {
const portals = useSubscription(portalsSubscription);
return <MeteorProvider>
<BannerRegion />
{portals.map(({ key, portal }) => <PortalWrapper key={key} portal={portal} />)}
</MeteorProvider>;
};
export default AppRoot;

@ -0,0 +1,13 @@
import { PureComponent } from 'react';
class PortalWrapper extends PureComponent {
state = { errored: false }
static getDerivedStateFromError = () => ({ errored: true })
componentDidCatch = () => {}
render = () => (this.state.errored ? null : this.props.portal)
}
export default PortalWrapper;

@ -0,0 +1,75 @@
import { Emitter } from '@rocket.chat/emitter';
import { Subscription } from 'use-subscription';
import { mountRoot } from '../reactAdapters';
import { UiKitBannerPayload } from '../../definition/UIKit';
export type LegacyBannerPayload = {
closable?: boolean;
title?: string;
text?: string;
html?: string;
icon?: string;
modifiers?: ('large' | 'danger')[];
timer?: number;
action?: () => void;
onClose?: () => void;
};
type BannerPayload = LegacyBannerPayload | UiKitBannerPayload;
export const isLegacyPayload = (payload: BannerPayload): payload is LegacyBannerPayload => !('blocks' in payload);
const queue: BannerPayload[] = [];
const emitter = new Emitter();
export const firstSubscription: Subscription<BannerPayload | null> = {
getCurrentValue: () => queue[0] ?? null,
subscribe: (callback) => emitter.on('update-first', callback),
};
export const open = (payload: BannerPayload): void => {
mountRoot();
let index = -1;
if (!isLegacyPayload(payload)) {
index = queue.findIndex((_payload) => !isLegacyPayload(_payload) && _payload.viewId === payload.viewId);
}
if (index < 0) {
index = queue.length;
}
queue[index] = payload;
emitter.emit('update');
if (index === 0) {
emitter.emit('update-first');
}
};
export const closeById = (viewId: string): void => {
const index = queue.findIndex((banner) => !isLegacyPayload(banner) && banner.viewId === viewId);
if (index < 0) {
return;
}
queue.splice(index, 1);
emitter.emit('update');
emitter.emit('update-first');
};
export const close = (): void => {
queue.shift();
emitter.emit('update');
emitter.emit('update-first');
};
export const clear = (): void => {
queue.splice(0, queue.length);
emitter.emit('update');
emitter.emit('update-first');
};

@ -0,0 +1,20 @@
import { Emitter } from '@rocket.chat/emitter';
import { Subscription, Unsubscribe } from 'use-subscription';
type ValueSubscription<T> = Subscription<T> & {
setCurrentValue: (value: T) => void;
};
export const createValueSubscription = <T>(initialValue: T): ValueSubscription<T> => {
let value: T = initialValue;
const emitter = new Emitter();
return {
getCurrentValue: (): T => value,
setCurrentValue: (_value: T): void => {
value = _value;
emitter.emit('update');
},
subscribe: (callback): Unsubscribe => emitter.on('update', callback),
};
};

@ -1,3 +1,4 @@
import { Emitter } from '@rocket.chat/emitter';
import { Blaze } from 'meteor/blaze';
import { HTML } from 'meteor/htmljs';
import { Random } from 'meteor/random';
@ -7,80 +8,65 @@ import { ReactiveVar } from 'meteor/reactive-var';
import { Template } from 'meteor/templating';
import { Tracker } from 'meteor/tracker';
const createPortalsSubscription = () => {
const portalsMap = new Map();
const emitter = new Emitter();
return {
getCurrentValue: () => Array.from(portalsMap.values()),
subscribe: (callback) => emitter.on('update', callback),
delete: (key) => {
portalsMap.delete(key);
emitter.emit('update');
},
set: (key, portal) => {
portalsMap.set(key, { portal, key: Random.id() });
emitter.emit('update');
},
has: (key) => portalsMap.has(key),
};
};
export const portalsSubscription = createPortalsSubscription();
let rootNode;
let invalidatePortals = () => {};
const portalsMap = new Map();
const mountRoot = async () => {
export const mountRoot = async () => {
if (rootNode) {
return;
}
rootNode = document.getElementById('react-root');
if (!rootNode) {
rootNode = document.createElement('div');
rootNode.id = 'react-root';
document.body.appendChild(rootNode);
document.body.insertBefore(rootNode, document.body.firstChild);
}
const [
{ PureComponent, Suspense, createElement, lazy, useLayoutEffect, useState },
{ Suspense, createElement, lazy },
{ render },
] = await Promise.all([
import('react'),
import('react-dom'),
import('@rocket.chat/fuselage-hooks'),
]);
const LazyMeteorProvider = lazy(() => import('./providers/MeteorProvider'));
class PortalWrapper extends PureComponent {
state = { errored: false }
static getDerivedStateFromError = () => ({ errored: true })
componentDidCatch = () => {}
render = () => (this.state.errored ? null : this.props.portal)
}
function AppRoot() {
const [portals, setPortals] = useState(() => Tracker.nonreactive(() => Array.from(portalsMap.values())));
const LazyAppRoot = lazy(() => import('./components/AppRoot'));
useLayoutEffect(() => {
invalidatePortals = () => {
setPortals(Array.from(portalsMap.values()));
};
invalidatePortals();
return () => {
invalidatePortals = () => {};
};
}, []);
return createElement(Suspense, { fallback: null },
createElement(LazyMeteorProvider, {},
portals.map(({ key, portal }) => createElement(PortalWrapper, { key, portal })),
),
);
}
render(createElement(AppRoot), rootNode);
render(createElement(Suspense, { fallback: null }, createElement(LazyAppRoot)), rootNode);
};
const unregisterPortal = (key) => {
portalsMap.delete(key);
invalidatePortals();
portalsSubscription.delete(key);
};
export const registerPortal = (key, portal) => {
if (!rootNode) {
mountRoot();
}
portalsMap.set(key, { portal, key: Random.id() });
invalidatePortals();
mountRoot();
portalsSubscription.set(key, portal);
return () => unregisterPortal(key);
};
const createLazyElement = async (importFn, propsFn) => {
const { createElement, lazy, useEffect, useState, memo, Suspense } = await import('react');
const LazyComponent = lazy(importFn);
@ -163,7 +149,7 @@ export const renderRouteComponent = (importFn, {
} = {}) => {
const routeName = FlowRouter.getRouteName();
if (portalsMap.has(routeName)) {
if (portalsSubscription.has(routeName)) {
return;
}

@ -0,0 +1,63 @@
import { Meteor } from 'meteor/meteor';
import { Tracker } from 'meteor/tracker';
import * as banners from '../lib/banners';
import { APIClient } from '../../app/utils/client';
import { IBanner, BannerPlatform } from '../../definition/IBanner';
import { Notifications } from '../../app/notifications/client';
const fetchInitialBanners = async (): Promise<void> => {
const response = await APIClient.get('v1/banners.getNew', {
platform: BannerPlatform.Web,
}) as {
banners: IBanner[];
};
for (const banner of response.banners) {
banners.open({
...banner.view,
viewId: banner.view.viewId || banner._id,
});
}
};
const handleNewBanner = async (event: { bannerId: string }): Promise<void> => {
const response = await APIClient.get('v1/banners.getNew', {
platform: BannerPlatform.Web,
bid: event.bannerId,
}) as {
banners: IBanner[];
};
for (const banner of response.banners) {
banners.open({
...banner.view,
viewId: banner.view.viewId || banner._id,
});
}
};
const watchBanners = (): (() => void) => {
fetchInitialBanners();
Notifications.onLogged('new-banner', handleNewBanner);
return (): void => {
Notifications.unLogged(handleNewBanner);
banners.clear();
};
};
Meteor.startup(() => {
let unwatchBanners: () => void | undefined;
Tracker.autorun(() => {
unwatchBanners?.();
if (!Meteor.userId()) {
return;
}
unwatchBanners = Tracker.nonreactive(watchBanners);
});
});

@ -1,3 +1,4 @@
import './banners';
import './contextualBar';
import './emailVerification';
import './i18n';

@ -8,11 +8,12 @@ import toastr from 'toastr';
import hljs from '../../app/markdown/lib/hljs';
import { fireGlobalEvent, alerts } from '../../app/ui-utils';
import { fireGlobalEvent } from '../../app/ui-utils';
import { getUserPreference, t } from '../../app/utils';
import { hasPermission } from '../../app/authorization/client';
import 'highlight.js/styles/github.css';
import { synchronizeUserData } from '../lib/userData';
import * as banners from '../lib/banners';
hljs.initHighlightingOnLoad();
@ -83,7 +84,7 @@ Meteor.startup(function() {
autoRunHandler.stop();
const { connectToCloud = false, workspaceRegistered = false } = data;
if (connectToCloud === true && workspaceRegistered !== true) {
alerts.open({
banners.open({
title: t('Cloud_registration_pending_title'),
html: t('Cloud_registration_pending_html'),
modifiers: ['large', 'danger'],

@ -0,0 +1,37 @@
declare module '@rocket.chat/fuselage-ui-kit' {
import { IBlock } from '@rocket.chat/ui-kit';
import { Context, FC, ReactChildren } from 'react';
import { IDividerBlock, ISectionBlock, IActionsBlock, IContextBlock, IInputBlock } from '@rocket.chat/apps-engine/definition/uikit/blocks/Blocks';
export const kitContext: Context<{
action: (action: {
blockId: string;
appId: string;
actionId: string;
value: unknown;
viewId: string;
}) => void | Promise<void>;
state?: (state: {
blockId: string;
appId: string;
actionId: string;
value: unknown;
}) => void | Promise<void>;
appId: string;
errors?: {
[fieldName: string]: string;
};
}>;
type UiKitComponentProps = {
render: (blocks: IBlock[]) => ReactChildren;
blocks: IBlock[];
};
export const UiKitComponent: FC<UiKitComponentProps>;
type BannerBlocks = IDividerBlock | ISectionBlock | IActionsBlock | IContextBlock | IInputBlock;
export const UiKitBanner: (blocks: BannerBlocks[], conditions?: { [param: string]: unknown }) => ReactChildren;
export const UiKitMessage: (blocks: IBlock[], conditions?: { [param: string]: unknown }) => ReactChildren;
export const UiKitModal: (blocks: IBlock[], conditions?: { [param: string]: unknown }) => ReactChildren;
}

@ -173,7 +173,13 @@ declare module '@rocket.chat/fuselage' {
Item: ForwardRefExoticComponent<AccordionItemProps>;
};
type AvatarProps = BoxProps;
type AvatarProps = Omit<BoxProps, 'title' | 'size'> & {
title?: string;
size?: 'x16' | 'x18' | 'x20' | 'x24' | 'x28' | 'x32' | 'x36' | 'x40' | 'x48' | 'x124' | 'x200' | 'x332';
rounded?: boolean;
objectFit?: boolean;
url: string;
};
export const Avatar: ForwardRefExoticComponent<AvatarProps> & {
Context: Context<{
baseUrl: string;

@ -0,0 +1,22 @@
import React, { FC } from 'react';
import { useSubscription } from 'use-subscription';
import * as banners from '../../lib/banners';
import LegacyBanner from './LegacyBanner';
import UiKitBanner from './UiKitBanner';
const BannerRegion: FC = () => {
const payload = useSubscription(banners.firstSubscription);
if (!payload) {
return null;
}
if (banners.isLegacyPayload(payload)) {
return <LegacyBanner config={payload} />;
}
return <UiKitBanner payload={payload} />;
};
export default BannerRegion;

@ -0,0 +1,63 @@
import { Banner, Icon } from '@rocket.chat/fuselage';
import React, { FC, useCallback, useEffect } from 'react';
import { LegacyBannerPayload } from '../../lib/banners';
import * as banners from '../../lib/banners';
type LegacyBannerProps = {
config: LegacyBannerPayload;
};
const LegacyBanner: FC<LegacyBannerProps> = ({ config }) => {
const {
closable = true,
title,
text,
html,
icon,
modifiers,
} = config;
const inline = !modifiers?.includes('large');
const variant = modifiers?.includes('danger') ? 'danger' : 'info';
useEffect(() => {
if (!config.timer) {
return;
}
const timer = setTimeout(() => {
config.onClose?.call(undefined);
banners.close();
}, config.timer);
return (): void => {
clearTimeout(timer);
};
}, [config.onClose, config.timer]);
const handleAction = useCallback(() => {
config.action?.call(undefined);
}, [config.action]);
const handleClose = useCallback(() => {
config.onClose?.call(undefined);
banners.close();
}, [config.onClose]);
return <Banner
inline={inline}
actionable={!!config.action}
closeable={closable}
icon={icon ? <Icon name={icon} size={20} /> : undefined}
title={title}
variant={variant}
onAction={handleAction}
onClose={handleClose}
>
{text}
{html && <div dangerouslySetInnerHTML={{ __html: html }} />}
</Banner>;
};
export default LegacyBanner;

@ -0,0 +1,50 @@
/* eslint-disable new-cap */
import { Banner, Icon } from '@rocket.chat/fuselage';
import { kitContext, UiKitBanner as renderUiKitBannerBlocks } from '@rocket.chat/fuselage-ui-kit';
import React, { Context, FC, useMemo } from 'react';
import * as banners from '../../lib/banners';
// import { useEndpoint } from '../../contexts/ServerContext';
import { UiKitBannerProps, UiKitBannerPayload } from '../../../definition/UIKit';
import { useUIKitStateManager } from '../../UIKit/hooks/useUIKitStateManager';
import { useUIKitHandleClose } from '../../UIKit/hooks/useUIKitHandleClose';
import { useUIKitHandleAction } from '../../UIKit/hooks/useUIKitHandleAction';
const UiKitBanner: FC<UiKitBannerProps> = ({ payload }) => {
const state = useUIKitStateManager<UiKitBannerPayload>(payload);
const icon = useMemo(() => {
if (state.icon) {
return <Icon name={state.icon} size={20} />;
}
return null;
}, [state.icon]);
const handleClose = useUIKitHandleClose(state, () => banners.close());
const action = useUIKitHandleAction(state);
const contextValue = useMemo<typeof kitContext extends Context<infer V> ? V : never>(() => ({
action: async (...args) => {
await action(...args);
banners.closeById(state.viewId);
},
appId: state.appId,
}), [action, state.appId, state.viewId]);
return <Banner
closeable
icon={icon}
inline={state.inline}
title={state.title}
variant={state.variant}
onClose={handleClose}
>
<kitContext.Provider value={contextValue}>
{renderUiKitBannerBlocks(state.blocks, { engine: 'rocket.chat' })}
</kitContext.Provider>
</Banner>;
};
export default UiKitBanner;

@ -0,0 +1,24 @@
import { IRocketChatRecord } from './IRocketChatRecord';
import { IUser } from './IUser';
import { UiKitBannerPayload } from './UIKit';
export enum BannerPlatform {
Web = 'web',
Mobile = 'mobile',
}
export interface IBanner extends IRocketChatRecord {
platform: BannerPlatform[]; // pĺatforms a banner could be shown
expireAt: Date; // date when banner should not be shown anymore
startAt: Date; // start date a banner should be presented
roles?: string[]; // only show the banner to this roles
createdBy: Pick<IUser, '_id' | 'username' >;
createdAt: Date;
view: UiKitBannerPayload;
}
export interface IBannerDismiss extends IRocketChatRecord {
userId: IUser['_id']; // user receiving the banner dismissed
bannerId: IBanner['_id']; // banner dismissed
dismissedAt: Date; // when is was dismissed
dismissedBy: Pick<IUser, '_id' | 'username' >; // who dismissed (usually the same as userId)
}

@ -0,0 +1,35 @@
import { IRocketChatRecord } from './IRocketChatRecord';
import { IUser } from './IUser';
export enum NPSStatus {
OPEN = 'open',
SENDING = 'sending',
SENT = 'sent',
CLOSED = 'closed',
}
export interface INps extends IRocketChatRecord {
startAt: Date; // start date a banner should be presented
expireAt: Date; // date when banner should not be shown anymore
createdBy: Pick<IUser, '_id' | 'username'>;
createdAt: Date;
status: NPSStatus;
}
export enum INpsVoteStatus {
NEW = 'new',
SENDING = 'sending',
SENT = 'sent',
}
export interface INpsVote extends IRocketChatRecord {
_id: string;
npsId: INps['_id'];
ts: Date;
identifier: string; // voter identifier
roles: IUser['roles']; // voter roles
score: number;
comment: string;
status: INpsVoteStatus;
sentAt?: Date;
}

@ -0,0 +1,59 @@
import { UIKitInteractionType as UIKitInteractionTypeApi } from '@rocket.chat/apps-engine/definition/uikit';
import { IDividerBlock, ISectionBlock, IActionsBlock, IContextBlock, IInputBlock } from '@rocket.chat/apps-engine/definition/uikit/blocks/Blocks';
enum UIKitInteractionTypeExtended {
BANNER_OPEN = 'banner.open',
BANNER_UPDATE = 'banner.update',
BANNER_CLOSE = 'banner.close',
}
export type UIKitInteractionType = UIKitInteractionTypeApi | UIKitInteractionTypeExtended;
export const UIKitInteractionTypes = {
...UIKitInteractionTypeApi,
...UIKitInteractionTypeExtended,
};
export type UiKitPayload = {
viewId: string;
appId: string;
blocks: (IDividerBlock | ISectionBlock | IActionsBlock | IContextBlock | IInputBlock)[];
}
export type UiKitBannerPayload = UiKitPayload & {
inline?: boolean;
variant?: 'neutral' | 'info' | 'success' | 'warning' | 'danger';
icon?: string;
title?: string;
}
export type UIKitUserInteraction = {
type: UIKitInteractionType;
} & UiKitPayload;
export type UiKitBannerProps = {
payload: UiKitBannerPayload;
};
export type UIKitUserInteractionResult = UIKitUserInteractionResultError | UIKitUserInteraction;
type UIKitUserInteractionResultError = UIKitUserInteraction & {
type: UIKitInteractionTypeApi.ERRORS;
errors?: Array<{[key: string]: string}>;
};
export const isErrorType = (result: UIKitUserInteractionResult): result is UIKitUserInteractionResultError => result.type === UIKitInteractionTypeApi.ERRORS;
export type UIKitActionEvent = {
blockId: string;
value?: unknown;
appId: string;
actionId: string;
viewId: string;
}

96
package-lock.json generated

@ -5920,12 +5920,12 @@
}
},
"@rocket.chat/css-in-js": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@rocket.chat/css-in-js/-/css-in-js-0.20.1.tgz",
"integrity": "sha512-0bgLOKs7+GbuDfI6kdW4eNohkpXmjpSUW8deA6FCsI+Mstatt5uJl/kBm+leyMCJHJk7jDUL37yr5snDsM/t1w==",
"version": "0.6.3-dev.169",
"resolved": "https://registry.npmjs.org/@rocket.chat/css-in-js/-/css-in-js-0.6.3-dev.169.tgz",
"integrity": "sha512-jK60ysCqPXmSjgBHm5S8MZB3kKTFOr56GENL7yc1ETp8aScJaWWisRehBiwXbW22HH/2nYhhvUmp/ek5bB9qVA==",
"requires": {
"@emotion/hash": "^0.8.0",
"stylis": "^4.0.3"
"stylis": "^4.0.6"
},
"dependencies": {
"stylis": {
@ -5936,9 +5936,9 @@
}
},
"@rocket.chat/emitter": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@rocket.chat/emitter/-/emitter-0.20.1.tgz",
"integrity": "sha512-BeY0z6jbYuE7VRv147dUY9BhsLI6qR7/T6x/clCi534jUHRG+hB0Wt9dehtieCiClU3buICgZIl6V/ZQbhjEdw=="
"version": "0.6.3-dev.169",
"resolved": "https://registry.npmjs.org/@rocket.chat/emitter/-/emitter-0.6.3-dev.169.tgz",
"integrity": "sha512-aM8rie5D1qidsHXmwqkh8IJTOB1q2GEX+ieOGlNeGp2A5Bo7Qq80NLjcqGMlCw7zZfFGjlMio1MqQT7LaEorVg=="
},
"@rocket.chat/eslint-config": {
"version": "0.3.0",
@ -5950,9 +5950,9 @@
}
},
"@rocket.chat/fuselage": {
"version": "0.6.3-dev.164",
"resolved": "https://registry.npmjs.org/@rocket.chat/fuselage/-/fuselage-0.6.3-dev.164.tgz",
"integrity": "sha512-e6sbkVm4R+E6Dq7xWbNoyhr/1cTjk7duMiZdLvZ1qa9AasBhTpXtYXgtN0SA+qSNH3l907YpoKUJU+qVWbXl6w==",
"version": "0.6.3-dev.171",
"resolved": "https://registry.npmjs.org/@rocket.chat/fuselage/-/fuselage-0.6.3-dev.171.tgz",
"integrity": "sha512-YWLQeEwRl6LMhUNZILEFOiwCEiA8gzDInQogEX1DNAyLDK17QCKC0JG8iJIWrpBUFt5qg2he3+eRQzk3Mcc+5w==",
"requires": {
"@rocket.chat/css-in-js": "^0.20.1",
"@rocket.chat/fuselage-tokens": "^0.20.1",
@ -5960,38 +5960,52 @@
"react-keyed-flatten-children": "^1.2.0"
},
"dependencies": {
"@rocket.chat/css-in-js": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@rocket.chat/css-in-js/-/css-in-js-0.20.1.tgz",
"integrity": "sha512-0bgLOKs7+GbuDfI6kdW4eNohkpXmjpSUW8deA6FCsI+Mstatt5uJl/kBm+leyMCJHJk7jDUL37yr5snDsM/t1w==",
"requires": {
"@emotion/hash": "^0.8.0",
"stylis": "^4.0.3"
}
},
"@rocket.chat/fuselage-tokens": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-tokens/-/fuselage-tokens-0.20.1.tgz",
"integrity": "sha512-tRzWNvdb9T7nU3U9ZbMue84yvs21aP44tVsV/Gz+UNjWG1cEmpnB73dIj+52orzM+VcU7YSJ+Tv+K8Z87fHCeA=="
},
"stylis": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.0.6.tgz",
"integrity": "sha512-1igcUEmYFBEO14uQHAJhCUelTR5jPztfdVKrYxRnDa5D5Dn3w0NxXupJNPr/VV/yRfZYEAco8sTIRZzH3sRYKg=="
}
}
},
"@rocket.chat/fuselage-hooks": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-hooks/-/fuselage-hooks-0.20.1.tgz",
"integrity": "sha512-GrK7S2zEmWqm+Ia/e/3b4wmPQIL47ZaC5puLL1xeyQ236jIBPKVuI73cz8+HC0ZkyY2IkO+yhAHm4x+bJztfQA==",
"version": "0.6.3-dev.169",
"resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-hooks/-/fuselage-hooks-0.6.3-dev.169.tgz",
"integrity": "sha512-k+uEQypRQXQExjDOfQoBPCyCqg1D01QP0S3sy/25+hScTRAD+BMfDXKehpxLsfZMb6l2AdnJLdYA4hL3aPlcpQ==",
"requires": {
"@rocket.chat/fuselage-tokens": "^0.20.1",
"@rocket.chat/fuselage-tokens": "^0.6.3-dev.169+b3ad66cf",
"use-subscription": "^1.4.1"
},
"dependencies": {
"@rocket.chat/fuselage-tokens": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-tokens/-/fuselage-tokens-0.20.1.tgz",
"integrity": "sha512-tRzWNvdb9T7nU3U9ZbMue84yvs21aP44tVsV/Gz+UNjWG1cEmpnB73dIj+52orzM+VcU7YSJ+Tv+K8Z87fHCeA=="
"version": "0.6.3-dev.169",
"resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-tokens/-/fuselage-tokens-0.6.3-dev.169.tgz",
"integrity": "sha512-iIoHPlRFYK/zNJxk2zHEk8DzUZJdRIOp9aBo/Ql0CXErcxdDx4XL+hlqHyR17aj1NjgHpn8Elt072MFeXatc/g=="
}
}
},
"@rocket.chat/fuselage-polyfills": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-polyfills/-/fuselage-polyfills-0.20.1.tgz",
"integrity": "sha512-SU3vQSbrRsuMyMeu8i6eZetNty5yguemS+T3nqY+XF+HXyPKZkkw2CYxBJxQQwtAjk4tjp2zZ9BesdGcBw2NAA==",
"version": "0.6.3-dev.161",
"resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-polyfills/-/fuselage-polyfills-0.6.3-dev.161.tgz",
"integrity": "sha512-DxT/szII10zoWjj+6283waf2wyHrAFqBPiMmwTaAdlEqmSYDmmEPj6+9e77nKzytZMWnNvE39J1vtb/k/t54LQ==",
"requires": {
"@juggle/resize-observer": "^3.1.2",
"@juggle/resize-observer": "^3.2.0",
"clipboard-polyfill": "^3.0.1",
"element-closest-polyfill": "^1.0.2",
"focus-visible": "^5.0.2",
"focus-visible": "^5.2.0",
"focus-within-polyfill": "^5.1.0"
}
},
@ -6001,11 +6015,25 @@
"integrity": "sha512-qHOiBuvquTaiv6qc9yDUbmFdRQf32BJ4SdAG7sQjNk+Qpvl3vuYYXID3zIRrhSpHHEKbspIimU8pdJu+kWl2UA=="
},
"@rocket.chat/fuselage-ui-kit": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-ui-kit/-/fuselage-ui-kit-0.20.1.tgz",
"integrity": "sha512-TW0h4E+6W0z1qb4QeTQQxYWbHnFIv2nA+NJlU3IX4zHkpjptPA8afgDlf4QDQSZ5XNmcEFLl3dfnMpZgiqWVxw==",
"version": "0.6.3-dev.171",
"resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-ui-kit/-/fuselage-ui-kit-0.6.3-dev.171.tgz",
"integrity": "sha512-gTXNvEffBWKHn1QnduA9KPaTR/R5Z8f76EM/WsXc3vqOhTjcuHg67tFsnycV1F8Kms3bk8EjF5++rMLn7M9MdA==",
"requires": {
"@rocket.chat/fuselage-polyfills": "^0.20.1"
},
"dependencies": {
"@rocket.chat/fuselage-polyfills": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-polyfills/-/fuselage-polyfills-0.20.1.tgz",
"integrity": "sha512-SU3vQSbrRsuMyMeu8i6eZetNty5yguemS+T3nqY+XF+HXyPKZkkw2CYxBJxQQwtAjk4tjp2zZ9BesdGcBw2NAA==",
"requires": {
"@juggle/resize-observer": "^3.1.2",
"clipboard-polyfill": "^3.0.1",
"element-closest-polyfill": "^1.0.2",
"focus-visible": "^5.0.2",
"focus-within-polyfill": "^5.1.0"
}
}
}
},
"@rocket.chat/icons": {
@ -6064,9 +6092,9 @@
}
},
"@rocket.chat/mp3-encoder": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@rocket.chat/mp3-encoder/-/mp3-encoder-0.20.1.tgz",
"integrity": "sha512-rcbgiPntCnIIGQpIF0ckiLUI+5fujWEWJWIaQrRH0sJaXb28Oica0PXss1jjLd7dBbjgQ607ZGcOJqvxrARJxA==",
"version": "0.6.3-dev.169",
"resolved": "https://registry.npmjs.org/@rocket.chat/mp3-encoder/-/mp3-encoder-0.6.3-dev.169.tgz",
"integrity": "sha512-prWrERTXMpzGEEDAloZpEnNoA3zJcSy16VE+1vS+xlKIOJ1l1weI+ygxNJ0+xIK+SdO2cjFYdxhzBZWhrEpymw==",
"requires": {
"lamejs": "git+https://github.com/zhuker/lamejs.git"
}
@ -6114,9 +6142,9 @@
}
},
"@rocket.chat/ui-kit": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@rocket.chat/ui-kit/-/ui-kit-0.20.1.tgz",
"integrity": "sha512-HddN7y/8nXPZbYwDJpG/lVlozAomvShhkTKYvsRwQ3i8Tx04iMVmuNawhSITjlxouiNLDQfoNwebOdY8gB6+/A=="
"version": "0.6.3-dev.169",
"resolved": "https://registry.npmjs.org/@rocket.chat/ui-kit/-/ui-kit-0.6.3-dev.169.tgz",
"integrity": "sha512-wyoiGmqMm0oDpUQFaYlyJEf1w1LUgqALbHTYhRbL5te0MGjI1NmRnh+84TwsE1bnpx35osc/jn6eR7ZhnyTFvA=="
},
"@samverschueren/stream-to-observable": {
"version": "0.3.1",
@ -16029,9 +16057,9 @@
}
},
"clipboard-polyfill": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/clipboard-polyfill/-/clipboard-polyfill-3.0.1.tgz",
"integrity": "sha512-R/uxBa8apxLJArzpFpuTLqavUcnEX8bezZKSuqkwz7Kny2BmxyKDslYGdrKiasKuD+mU1noF7Lkt/p5pyDqFoQ=="
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/clipboard-polyfill/-/clipboard-polyfill-3.0.2.tgz",
"integrity": "sha512-1DTdwGicGBAhK4/VjOwFYG3fAu1aj1n/jC/00qkRyDobKSB/PF8mhJkbAlDGbNEc8hIrPElv5j96OE5PwHMUfA=="
},
"cliui": {
"version": "5.0.0",

@ -136,16 +136,16 @@
"@nivo/line": "^0.61.1",
"@nivo/pie": "^0.61.1",
"@rocket.chat/apps-engine": "1.22.0-alpha.4469",
"@rocket.chat/css-in-js": "^0.20.1",
"@rocket.chat/emitter": "^0.20.1",
"@rocket.chat/fuselage": "^0.6.3-dev.164",
"@rocket.chat/fuselage-hooks": "^0.20.1",
"@rocket.chat/fuselage-polyfills": "^0.20.1",
"@rocket.chat/css-in-js": "^0.6.3-dev.169",
"@rocket.chat/emitter": "^0.6.3-dev.169",
"@rocket.chat/fuselage": "^0.6.3-dev.171",
"@rocket.chat/fuselage-hooks": "^0.6.3-dev.169",
"@rocket.chat/fuselage-polyfills": "^0.6.3-dev.161",
"@rocket.chat/fuselage-tokens": "^0.6.3-dev.161",
"@rocket.chat/fuselage-ui-kit": "^0.20.1",
"@rocket.chat/fuselage-ui-kit": "^0.6.3-dev.171",
"@rocket.chat/icons": "^0.6.3-dev.162",
"@rocket.chat/mp3-encoder": "^0.20.1",
"@rocket.chat/ui-kit": "^0.20.1",
"@rocket.chat/mp3-encoder": "^0.6.3-dev.169",
"@rocket.chat/ui-kit": "^0.6.3-dev.169",
"@slack/client": "^4.12.0",
"@types/fibers": "^3.1.0",
"@types/imap": "^0.8.33",

@ -2857,6 +2857,9 @@
"Notifications_Sound_Volume": "Notifications sound volume",
"Notify_active_in_this_room": "Notify active users in this room",
"Notify_all_in_this_room": "Notify all in this room",
"NPS_survey_enabled": "Enable NPS Survey",
"NPS_survey_enabled_Description": "Allow NPS survey run for all users. Admins will receive an alert 2 months upfront the survey is launched",
"NPS_survey_is_scheduled_to-run-at__date__for_all_users": "NPS survey is scheduled to run at __date__ for all users. It's possible to turn off the survey on 'Admin > General > NPS'?",
"Num_Agents": "# Agents",
"Number_in_seconds": "Number in seconds",
"Number_of_events": "Number of events",
@ -3382,6 +3385,7 @@
"Scan_QR_code": "Using an authenticator app like Google Authenticator, Authy or Duo, scan the QR code. It will display a 6 digit code which you need to enter below.",
"Scan_QR_code_alternative_s": "If you can't scan the QR code, you may enter code manually instead:",
"Scope": "Scope",
"Score": "Score",
"Screen_Lock": "Screen Lock",
"Screen_Share": "Screen Share",
"Script_Enabled": "Script Enabled",
@ -4150,6 +4154,7 @@
"Warning": "Warning",
"Warnings": "Warnings",
"WAU_value": "WAU __value__",
"We_appreciate_your_feedback": "We appreciate your feedback",
"We_are_offline_Sorry_for_the_inconvenience": "We are offline. Sorry for the inconvenience.",
"We_have_sent_password_email": "We have sent you an email with password reset instructions. If you do not receive an email shortly, please come back and try again.",
"We_have_sent_registration_email": "We have sent you an email to confirm your registration. If you do not receive an email shortly, please come back and try again.",
@ -4185,6 +4190,7 @@
"When_a_line_starts_with_one_of_there_words_post_to_the_URLs_below": "When a line starts with one of these words, post to the URL(s) below",
"When_is_the_chat_busier?": "When is the chat busier?",
"Where_are_the_messages_being_sent?": "Where are the messages being sent?",
"Why_did_you_chose__score__": "Why did you chose __score__?",
"Why_do_you_want_to_report_question_mark": "Why do you want to report?",
"Will_Appear_In_From": "Will appear in the From: header of emails you send.",
"will_be_able_to": "will be able to",

@ -2884,6 +2884,7 @@
"Scan_QR_code": "Usando um aplicativo autenticador como o Google Authenticator, Authy ou Duo, analise o código QR. Ele exibirá um código de 6 dígitos que você precisa inserir abaixo.",
"Scan_QR_code_alternative_s": "Se você não pode digitalizar o código QR, pode digitar o código manualmente em vez disso:",
"Scope": "Escopo",
"Score": "Valor",
"Screen_Lock": "Bloqueio de Tela",
"Screen_Share": "Compartilhamento de Tela",
"Script_Enabled": "Script Ativado",
@ -3543,6 +3544,7 @@
"Waiting_queue_message": "Mensagem de fila de espera",
"Waiting_queue_message_description": "Mensagem que será exibida aos visitantes quando eles entrarem na fila de espera",
"Warnings": "Avisos",
"We_appreciate_your_feedback": "Agradecemos o seu feedback",
"We_are_offline_Sorry_for_the_inconvenience": "Estamos offline. Desculpe pela inconveniência.",
"We_have_sent_password_email": "Nós lhe enviamos um e-mail com instruções para redefinir sua senha. Se você não receber um e-mail em breve, por favor retorne e tente novamente.",
"We_have_sent_registration_email": "Nós lhe enviamos um e-mail para confirmar o seu registro. Se você não receber um e-mail em breve, por favor retorne e tente novamente.",
@ -3571,6 +3573,7 @@
"Welcome": "Seja bem-vindo <em>%s</em>.",
"Welcome_to": "Bem-vindo ao __Site_Name__",
"Welcome_to_the": "Bem-vindo ao",
"Why_did_you_chose__score__": "Por que você escolheu __score__?",
"Why_do_you_want_to_report_question_mark": "Por que você quer denunciar?",
"Will_Appear_In_From": "Aparecerá no cabeçalho dos e-mails que você enviar.",
"will_be_able_to": "poderá",
@ -3635,4 +3638,4 @@
"Your_question": "A sua pergunta",
"Your_server_link": "O link do seu servidor",
"Your_workspace_is_ready": "O seu espaço de trabalho está pronto a usar 🎉"
}
}

@ -0,0 +1,22 @@
import { settings } from '../../app/settings';
import { NPS } from '../sdk';
function runNPS() {
// if NPS is disabled close any pending scheduled survey
const enabled = settings.get('NPS_survey_enabled');
if (!enabled) {
Promise.await(NPS.closeOpenSurveys());
return;
}
Promise.await(NPS.sendResults());
}
export function npsCron(SyncedCron) {
SyncedCron.add({
name: 'NPS',
schedule(parser) {
return parser.cron('9 3 * * *');
},
job: runNPS,
});
}

@ -0,0 +1,11 @@
import { Meteor } from 'meteor/meteor';
export function oembedCron(SyncedCron) {
SyncedCron.add({
name: 'Cleanup OEmbed cache',
schedule(parser) {
return parser.cron('24 2 * * *');
},
job: () => Meteor.call('OEmbedCacheCleanup'),
});
}

@ -0,0 +1,64 @@
import { Meteor } from 'meteor/meteor';
import { HTTP } from 'meteor/http';
import { getWorkspaceAccessToken } from '../../app/cloud/server';
import { statistics } from '../../app/statistics';
import { settings } from '../../app/settings';
function generateStatistics(logger) {
const cronStatistics = statistics.save();
cronStatistics.host = Meteor.absoluteUrl();
if (!settings.get('Statistics_reporting')) {
return;
}
try {
const headers = {};
const token = getWorkspaceAccessToken();
if (token) {
headers.Authorization = `Bearer ${ token }`;
}
HTTP.post('https://collector.rocket.chat/', {
data: cronStatistics,
headers,
});
} catch (error) {
/* error*/
logger.warn('Failed to send usage report');
}
}
export function statsCron(SyncedCron, logger) {
if (settings.get('Troubleshoot_Disable_Statistics_Generator')) {
return;
}
const name = 'Generate and save statistics';
let previousValue;
settings.get('Troubleshoot_Disable_Statistics_Generator', (key, value) => {
if (value === previousValue) {
return;
}
previousValue = value;
if (value) {
SyncedCron.remove(name);
return;
}
generateStatistics();
SyncedCron.add({
name,
schedule(parser) {
return parser.cron('12 * * * *');
},
job: () => generateStatistics(logger),
});
});
}

@ -13,6 +13,7 @@ import './startup/initialData';
import './startup/instance';
import './startup/presence';
import './startup/serverRunning';
import './startup/coreApps';
import './configuration/accounts_meld';
import './methods/OEmbedCacheCleanup';
import './methods/addAllUserToRoom';

@ -0,0 +1,22 @@
import { IUiKitCoreApp } from '../../sdk/types/IUiKitCoreApp';
import { Banner } from '../../sdk';
export class BannerModule implements IUiKitCoreApp {
appId = 'banner-core';
// when banner view is closed we need to dissmiss that banner for that user
async viewClosed(payload: any): Promise<any> {
const {
payload: {
view: {
viewId: bannerId,
},
},
user: {
_id: userId,
},
} = payload;
return Banner.dismiss(userId, bannerId);
}
}

@ -0,0 +1,70 @@
import { IUiKitCoreApp } from '../../sdk/types/IUiKitCoreApp';
import { Banner, NPS } from '../../sdk';
import { createModal } from './nps/createModal';
export class Nps implements IUiKitCoreApp {
appId = 'nps-core';
async blockAction(payload: any): Promise<any> {
const {
triggerId,
container: {
id: bannerId,
},
payload: {
value: score,
blockId: npsId,
},
user,
} = payload;
return createModal({
appId: this.appId,
npsId,
bannerId,
triggerId,
score,
user,
});
}
async viewSubmit(payload: any): Promise<any> {
if (!payload.payload?.view?.state) {
throw new Error('Invalid payload');
}
const {
payload: {
view: {
state,
id: bannerId,
},
},
user: {
_id: userId,
roles,
},
} = payload;
const [npsId] = Object.keys(state);
const {
[npsId]: {
score,
comment,
},
} = state;
await NPS.vote({
npsId,
userId,
comment,
roles,
score,
});
await Banner.dismiss(userId, bannerId);
return true;
}
}

@ -0,0 +1,95 @@
import { Meteor } from 'meteor/meteor';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import { settings } from '../../../../app/settings/server';
import { IUser } from '../../../../definition/IUser';
export type ModalParams = {
appId: string;
npsId: string;
bannerId: string;
triggerId: string;
score: string;
user: IUser;
}
export const createModal = Meteor.bindEnvironment(({ appId, npsId, bannerId, triggerId, score, user }: ModalParams): any => {
const language = user.language || settings.get('Language') || 'en';
const options = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10'].map((score) => ({
text: {
type: 'plain_text',
text: score,
emoji: true,
},
value: score,
}));
return {
type: 'modal.open',
triggerId,
appId,
view: {
appId,
type: 'modal',
id: bannerId,
title: {
type: 'plain_text',
text: TAPi18n.__('We_appreciate_your_feedback', { lng: language }),
emoji: false,
},
submit: {
type: 'button',
text: {
type: 'plain_text',
text: TAPi18n.__('Send', { lng: language }),
emoji: false,
},
actionId: 'send-vote',
},
close: {
type: 'button',
text: {
type: 'plain_text',
text: TAPi18n.__('Cancel', { lng: language }),
emoji: false,
},
actionId: 'cancel',
},
blocks: [{
blockId: npsId,
type: 'input',
element: {
type: 'static_select',
placeholder: {
type: 'plain_text',
text: TAPi18n.__('Score', { lng: language }),
emoji: false,
},
initialValue: score,
options,
actionId: 'score',
},
label: {
type: 'plain_text',
text: TAPi18n.__('Score', { lng: language }),
emoji: false,
},
},
{
blockId: npsId,
type: 'input',
element: {
type: 'plain_text_input',
multiline: true,
actionId: 'comment',
},
label: {
type: 'plain_text',
text: TAPi18n.__('Why_did_you_chose__score__', { score, lng: language }),
emoji: false,
},
}],
},
};
});

@ -254,5 +254,9 @@ export class ListenersModule {
...data,
});
});
service.onEvent('banner.new', (bannerId): void => {
notifications.notifyLoggedInThisInstance('new-banner', { bannerId });
});
}
}

@ -7,7 +7,10 @@ import { IPresence } from './types/IPresence';
import { IAccount } from './types/IAccount';
import { ILicense } from './types/ILicense';
import { IMeteor } from './types/IMeteor';
import { IUiKitCoreAppService } from './types/IUiKitCoreApp';
import { IEnterpriseSettings } from './types/IEnterpriseSettings';
import { IBannerService } from './types/IBannerService';
import { INPSService } from './types/INPSService';
// TODO think in a way to not have to pass the service name to proxify here as well
export const Authorization = proxifyWithWait<IAuthorization>('authorization');
@ -15,6 +18,9 @@ export const Presence = proxifyWithWait<IPresence>('presence');
export const Account = proxifyWithWait<IAccount>('accounts');
export const License = proxifyWithWait<ILicense>('license');
export const MeteorService = proxifyWithWait<IMeteor>('meteor');
export const Banner = proxifyWithWait<IBannerService>('banner');
export const UiKitCoreApp = proxifyWithWait<IUiKitCoreAppService>('uikit-core-app');
export const NPS = proxifyWithWait<INPSService>('nps');
// Calls without wait. Means that the service is optional and the result may be an error
// of service/method not available

@ -17,6 +17,7 @@ import { IIntegration } from '../../../definition/IIntegration';
import { IEmailInbox } from '../../../definition/IEmailInbox';
export type EventSignatures = {
'banner.new'(bannerId: string): void;
'emoji.deleteCustom'(emoji: IEmoji): void;
'emoji.updateCustom'(emoji: IEmoji): void;
'license.module'(data: { module: string; valid: boolean }): void;

@ -0,0 +1,7 @@
import { BannerPlatform, IBanner } from '../../../definition/IBanner';
export interface IBannerService {
getNewBannersForUser(userId: string, platform: BannerPlatform, bannerId?: string): Promise<IBanner[]>;
create(banner: Omit<IBanner, '_id'>): Promise<IBanner>;
dismiss(userId: string, bannerId: string): Promise<boolean>;
}

@ -0,0 +1,22 @@
import { IUser } from '../../../definition/IUser';
export type NPSVotePayload = {
userId: string;
npsId: string;
roles: string[];
score: number;
comment: string;
};
export type NPSCreatePayload = {
npsId: string;
startAt: Date;
expireAt: Date;
createdBy: Pick<IUser, '_id' | 'username'>;
};
export interface INPSService {
create(nps: NPSCreatePayload): Promise<boolean>;
vote(vote: NPSVotePayload): Promise<void>;
sendResults(): Promise<void>;
closeOpenSurveys(): Promise<void>;
}

@ -0,0 +1,16 @@
import { IServiceClass } from './ServiceClass';
export interface IUiKitCoreApp {
appId: string;
blockAction?(payload: any): Promise<any>;
viewClosed?(payload: any): Promise<any>;
viewSubmit?(payload: any): Promise<any>;
}
export interface IUiKitCoreAppService extends IServiceClass {
isRegistered(appId: string): Promise<boolean>;
blockAction(payload: any): Promise<any>;
viewClosed(payload: any): Promise<any>;
viewSubmit(payload: any): Promise<any>;
}

@ -0,0 +1,111 @@
import { Db } from 'mongodb';
import { v4 as uuidv4 } from 'uuid';
import { ServiceClass } from '../../sdk/types/ServiceClass';
import { BannersRaw } from '../../../app/models/server/raw/Banners';
import { BannersDismissRaw } from '../../../app/models/server/raw/BannersDismiss';
import { UsersRaw } from '../../../app/models/server/raw/Users';
import { IBannerService } from '../../sdk/types/IBannerService';
import { BannerPlatform, IBanner } from '../../../definition/IBanner';
import { api } from '../../sdk/api';
export class BannerService extends ServiceClass implements IBannerService {
protected name = 'banner';
private Banners: BannersRaw;
private BannersDismiss: BannersDismissRaw;
private Users: UsersRaw;
constructor(db: Db) {
super();
this.Banners = new BannersRaw(db.collection('rocketchat_banner'));
this.BannersDismiss = new BannersDismissRaw(db.collection('rocketchat_banner_dismiss'));
this.Users = new UsersRaw(db.collection('users'));
}
async create(doc: Omit<IBanner, '_id'>): Promise<IBanner> {
const bannerId = uuidv4();
doc.view.appId = 'banner-core';
doc.view.viewId = bannerId;
const invalidPlatform = doc.platform?.some((platform) => !Object.values(BannerPlatform).includes(platform));
if (invalidPlatform) {
throw new Error('Invalid platform');
}
if (doc.startAt > doc.expireAt) {
throw new Error('Start date cannot be later than expire date');
}
if (doc.expireAt < new Date()) {
throw new Error('Cannot create banner already expired');
}
await this.Banners.insertOne({
_id: bannerId,
...doc,
});
const banner = await this.Banners.findOneById(bannerId);
if (!banner) {
throw new Error('error-creating-banner');
}
api.broadcast('banner.new', banner._id);
return banner;
}
async getNewBannersForUser(userId: string, platform: BannerPlatform, bannerId?: string): Promise<IBanner[]> {
const { roles } = await this.Users.findOneById(userId, { projection: { roles: 1 } });
const banners = await this.Banners.findActiveByRoleOrId(roles, platform, bannerId).toArray();
const bannerIds = banners.map(({ _id }) => _id);
const result = await this.BannersDismiss.findByUserIdAndBannerId(userId, bannerIds, { projection: { bannerId: 1, _id: 0 } }).toArray();
const dismissed = new Set(result.map(({ bannerId }) => bannerId));
return banners.filter((banner) => !dismissed.has(banner._id));
}
async dismiss(userId: string, bannerId: string): Promise<boolean> {
if (!userId || !bannerId) {
throw new Error('Invalid params');
}
const banner = await this.Banners.findOneById(bannerId);
if (!banner) {
throw new Error('Banner not found');
}
const user = await this.Users.findOneById(userId, { projection: { username: 1 } });
if (!user) {
throw new Error('User not found');
}
const dismissedBy = {
_id: user._id,
username: user.username,
};
const today = new Date();
const doc = {
userId,
bannerId,
dismissedBy,
dismissedAt: today,
_updatedAt: today,
};
await this.BannersDismiss.insertOne(doc);
return true;
}
}

@ -0,0 +1,42 @@
import { BlockType } from '@rocket.chat/apps-engine/definition/uikit/blocks/Blocks';
import { TextObjectType } from '@rocket.chat/apps-engine/definition/uikit/blocks/Objects';
import { Meteor } from 'meteor/meteor';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import moment from 'moment';
import { settings } from '../../../app/settings/server';
import { IBanner, BannerPlatform } from '../../../definition/IBanner';
export const getBannerForAdmins = Meteor.bindEnvironment((): Omit<IBanner, '_id'> => {
const lng = settings.get('Language') || 'en';
const today = new Date();
const inTwoMonths = new Date();
inTwoMonths.setMonth(inTwoMonths.getMonth() + 2);
return {
platform: [BannerPlatform.Web],
createdAt: today,
expireAt: inTwoMonths,
startAt: today,
roles: ['admin'],
createdBy: {
_id: 'rocket.cat',
username: 'rocket.cat',
},
_updatedAt: new Date(),
view: {
viewId: '',
appId: '',
blocks: [{
type: BlockType.SECTION,
blockId: 'attention',
text: {
type: TextObjectType.PLAINTEXT,
text: TAPi18n.__('NPS_survey_is_scheduled_to-run-at__date__for_all_users', { date: moment(inTwoMonths).format('YYYY-MM-DD'), lng }),
emoji: false,
},
}],
},
};
});

@ -0,0 +1,31 @@
import { HTTP } from 'meteor/http';
import { Meteor } from 'meteor/meteor';
import { settings } from '../../../app/settings/server';
import { getWorkspaceAccessToken } from '../../../app/cloud/server';
import { INpsVote } from '../../../definition/INps';
type NPSResultPayload = {
total: number;
votes: INpsVote[];
}
export const sendToCloud = Meteor.bindEnvironment(function sendToCloud(npsId: string, data: NPSResultPayload) {
const token: string = getWorkspaceAccessToken(true);
if (!token) {
return false;
}
const cloudUrl = settings.get('Cloud_Url');
try {
return HTTP.post(`${ cloudUrl }/v1/nps/surveys/${ npsId }/results`, {
headers: {
Authorization: `Bearer ${ token }`,
},
data,
});
} catch (e) {
throw new Error(e);
}
});

@ -0,0 +1,196 @@
import { createHash } from 'crypto';
import { Db } from 'mongodb';
import { NpsRaw } from '../../../app/models/server/raw/Nps';
import { NpsVoteRaw } from '../../../app/models/server/raw/NpsVote';
import { SettingsRaw } from '../../../app/models/server/raw/Settings';
import { NPSStatus, INpsVoteStatus, INpsVote } from '../../../definition/INps';
import { INPSService, NPSVotePayload, NPSCreatePayload } from '../../sdk/types/INPSService';
import { ServiceClass } from '../../sdk/types/ServiceClass';
import { Banner, NPS } from '../../sdk';
import { sendToCloud } from './sendToCloud';
import { getBannerForAdmins } from './getBannerForAdmins';
export class NPSService extends ServiceClass implements INPSService {
protected name = 'nps';
private Nps: NpsRaw;
private Settings: SettingsRaw;
private NpsVote: NpsVoteRaw;
constructor(db: Db) {
super();
this.Nps = new NpsRaw(db.collection('rocketchat_nps'));
this.NpsVote = new NpsVoteRaw(db.collection('rocketchat_nps_vote'));
this.Settings = new SettingsRaw(db.collection('rocketchat_settings'));
}
async create(nps: NPSCreatePayload): Promise<boolean> {
const npsEnabled = await this.Settings.getValueById('NPS_survey_enabled');
if (!npsEnabled) {
throw new Error('Server opted-out for NPS surveys');
}
const any = await this.Nps.findOne({}, { projection: { _id: 1 } });
if (!any) {
Banner.create(getBannerForAdmins());
}
const {
npsId,
startAt,
expireAt,
createdBy,
} = nps;
const { result } = await this.Nps.save({
_id: npsId,
startAt,
expireAt,
createdBy,
status: NPSStatus.OPEN,
});
if (!result) {
throw new Error('Error creating NPS');
}
return true;
}
async sendResults(): Promise<void> {
const npsEnabled = await this.Settings.getValueById('NPS_survey_enabled');
if (!npsEnabled) {
return;
}
const npsSending = await this.Nps.getOpenExpiredAlreadySending();
const nps = npsSending || await this.Nps.getOpenExpiredAndStartSending();
if (!nps) {
return;
}
const total = await this.NpsVote.findByNpsId(nps._id).count();
const votesToSend = await this.NpsVote.findNotSentByNpsId(nps._id).toArray();
// if there is nothing to sent, check if something gone wrong
if (votesToSend.length === 0) {
// check if still has votes left to send
const totalSent = await this.NpsVote.findByNpsIdAndStatus(nps._id, INpsVoteStatus.SENT).count();
if (totalSent === total) {
await this.Nps.updateStatusById(nps._id, NPSStatus.SENT);
return;
}
// update old votes (sent 5 minutes ago or more) in 'sending' status back to 'new'
await this.NpsVote.updateOldSendingToNewByNpsId(nps._id);
// try again in 5 minutes
setTimeout(() => NPS.sendResults(), 5 * 60 * 1000);
return;
}
const today = new Date();
const sending = await Promise.all(votesToSend.map(async (vote) => {
const { value } = await this.NpsVote.col.findOneAndUpdate({
_id: vote._id,
status: INpsVoteStatus.NEW,
}, {
$set: {
status: INpsVoteStatus.SENDING,
sentAt: today,
},
}, {
projection: {
identifier: 1,
roles: 1,
score: 1,
comment: 1,
},
});
return value;
}));
const votes = sending.filter(Boolean) as INpsVote[];
if (votes.length > 0) {
const voteIds = votes.map(({ _id }) => _id);
const payload = {
total,
votes,
};
sendToCloud(nps._id, payload);
this.NpsVote.updateVotesToSent(voteIds);
}
const totalSent = await this.NpsVote.findByNpsIdAndStatus(nps._id, INpsVoteStatus.SENT).count();
if (totalSent < total) {
// send more in five minutes
setTimeout(() => NPS.sendResults(), 5 * 60 * 1000);
return;
}
await this.Nps.updateStatusById(nps._id, NPSStatus.SENT);
}
async vote({
userId,
npsId,
roles,
score,
comment,
}: NPSVotePayload): Promise<void> {
const npsEnabled = await this.Settings.getValueById('NPS_survey_enabled');
if (!npsEnabled) {
return;
}
if (!npsId || typeof npsId !== 'string') {
throw new Error('Invalid NPS id');
}
const nps = await this.Nps.findOneById(npsId, { projection: { status: 1, startAt: 1, expireAt: 1 } });
if (!nps) {
return;
}
if (nps.status !== NPSStatus.OPEN) {
throw new Error('NPS not open for votes');
}
const today = new Date();
if (today > nps.expireAt) {
throw new Error('NPS expired');
}
if (today < nps.startAt) {
throw new Error('NPS survey not started');
}
const identifier = createHash('sha256').update(`${ userId }${ npsId }`).digest('hex');
const result = await this.NpsVote.save({
ts: new Date(),
npsId,
identifier,
roles,
score,
comment,
status: INpsVoteStatus.NEW,
});
if (!result) {
throw new Error('Error saving NPS vote');
}
}
async closeOpenSurveys(): Promise<void> {
await this.Nps.closeAllByStatus(NPSStatus.OPEN);
}
}

@ -2,7 +2,15 @@ import { MongoInternals } from 'meteor/mongo';
import { api } from '../sdk/api';
import { Authorization } from './authorization/service';
import { BannerService } from './banner/service';
import { MeteorService } from './meteor/service';
import { NPSService } from './nps/service';
import { UiKitCoreApp } from './uikit-core-app/service';
api.registerService(new Authorization(MongoInternals.defaultRemoteCollectionDriver().mongo.db));
const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo;
api.registerService(new Authorization(db));
api.registerService(new BannerService(db));
api.registerService(new MeteorService());
api.registerService(new UiKitCoreApp());
api.registerService(new NPSService(db));

@ -0,0 +1,59 @@
import { ServiceClass } from '../../sdk/types/ServiceClass';
import { IUiKitCoreApp, IUiKitCoreAppService } from '../../sdk/types/IUiKitCoreApp';
const registeredApps = new Map();
const getAppModule = (appId: string): any => {
const module = registeredApps.get(appId);
if (typeof module === 'undefined') {
throw new Error('invalid service name');
}
return module;
};
export const registerCoreApp = (module: IUiKitCoreApp): void => {
registeredApps.set(module.appId, module);
};
export class UiKitCoreApp extends ServiceClass implements IUiKitCoreAppService {
protected name = 'uikit-core-app';
async isRegistered(appId: string): Promise<boolean> {
return registeredApps.has(appId);
}
async blockAction(payload: any): Promise<any> {
const { appId } = payload;
const service = getAppModule(appId);
if (!service) {
return;
}
return service.blockAction?.(payload);
}
async viewClosed(payload: any): Promise<any> {
const { appId } = payload;
const service = getAppModule(appId);
if (!service) {
return;
}
return service.viewClosed?.(payload);
}
async viewSubmit(payload: any): Promise<any> {
const { appId } = payload;
const service = getAppModule(appId);
if (!service) {
return;
}
return service.viewSubmit?.(payload);
}
}

@ -0,0 +1,6 @@
import { Nps } from '../modules/core-apps/nps.module';
import { BannerModule } from '../modules/core-apps/banner.module';
import { registerCoreApp } from '../services/uikit-core-app/service';
registerCoreApp(new Nps());
registerCoreApp(new BannerModule());

@ -1,11 +1,10 @@
import { Meteor } from 'meteor/meteor';
import { HTTP } from 'meteor/http';
import { SyncedCron } from 'meteor/littledata:synced-cron';
import { Logger } from '../../app/logger';
import { getWorkspaceAccessToken } from '../../app/cloud/server';
import { statistics } from '../../app/statistics';
import { settings } from '../../app/settings';
import { oembedCron } from '../cron/oembed';
import { statsCron } from '../cron/statistics';
import { npsCron } from '../cron/nps';
const logger = new Logger('SyncedCron');
@ -16,67 +15,10 @@ SyncedCron.config({
collectionName: 'rocketchat_cron_history',
});
function generateStatistics() {
const cronStatistics = statistics.save();
Meteor.defer(function() {
oembedCron(SyncedCron);
statsCron(SyncedCron, logger);
npsCron(SyncedCron);
cronStatistics.host = Meteor.absoluteUrl();
if (settings.get('Statistics_reporting')) {
try {
const headers = {};
const token = getWorkspaceAccessToken();
if (token) {
headers.Authorization = `Bearer ${ token }`;
}
HTTP.post('https://collector.rocket.chat/', {
data: cronStatistics,
headers,
});
} catch (error) {
/* error*/
logger.warn('Failed to send usage report');
}
}
}
function cleanupOEmbedCache() {
return Meteor.call('OEmbedCacheCleanup');
}
const name = 'Generate and save statistics';
Meteor.startup(function() {
return Meteor.defer(function() {
let TroubleshootDisableStatisticsGenerator;
settings.get('Troubleshoot_Disable_Statistics_Generator', (key, value) => {
if (TroubleshootDisableStatisticsGenerator === value) { return; }
TroubleshootDisableStatisticsGenerator = value;
if (value) {
return SyncedCron.remove(name);
}
generateStatistics();
SyncedCron.add({
name,
schedule(parser) {
return parser.cron('12 * * * *');
},
job: generateStatistics,
});
});
SyncedCron.add({
name: 'Cleanup OEmbed cache',
schedule(parser) {
return parser.cron('24 2 * * *');
},
job: cleanupOEmbedCache,
});
return SyncedCron.start();
});
SyncedCron.start();
});

Loading…
Cancel
Save