feat: [videoconf] mobile ringing (#30131)

pull/30177/head^2
Pierre Lehnen 2 years ago committed by GitHub
parent 22cf158c43
commit 239a34e877
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 7
      .changeset/bright-carpets-fly.md
  2. 2
      apps/meteor/app/push/server/apn.ts
  3. 1
      apps/meteor/app/push/server/definition.ts
  4. 23
      apps/meteor/app/push/server/push.ts
  5. 1
      apps/meteor/client/views/account/preferences/AccountPreferencesPage.tsx
  6. 17
      apps/meteor/client/views/account/preferences/PreferencesNotificationsSection.tsx
  7. 3
      apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json
  8. 2
      apps/meteor/server/methods/saveUserPreferences.ts
  9. 74
      apps/meteor/server/services/video-conference/service.ts
  10. 6
      apps/meteor/server/settings/accounts.ts
  11. 9
      apps/meteor/server/settings/video-conference.ts
  12. 1
      apps/meteor/tests/data/user.ts
  13. 1
      apps/meteor/tests/end-to-end/api/00-miscellaneous.js
  14. 1
      packages/core-typings/src/IPushNotificationConfig.ts
  15. 5
      packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts

@ -0,0 +1,7 @@
---
'@rocket.chat/core-typings': minor
'@rocket.chat/rest-typings': minor
'@rocket.chat/meteor': minor
---
new: ring mobile users on direct conference calls

@ -65,7 +65,7 @@ export const sendAPN = ({
note.payload.messageFrom = notification.from;
note.priority = priority;
note.topic = notification.topic;
note.topic = `${notification.topic}${notification.apn?.topicSuffix || ''}`;
note.mutableContent = true;
void apnConnection.send(note, userToken).then((response) => {

@ -26,6 +26,7 @@ export type PendingPushNotification = {
notId?: number;
apn?: {
category?: string;
topicSuffix?: string;
};
gcm?: {
style?: string;

@ -200,6 +200,17 @@ class PushClass {
}
}
private getGatewayNotificationData(notification: PendingPushNotification): Omit<GatewayNotification, 'uniqueId'> {
// Gateway accepts every attribute from the PendingPushNotification type, except for the priority and apn.topicSuffix
const { priority: _priority, apn, ...notifData } = notification;
const { topicSuffix: _topicSuffix, ...apnData } = apn || ({} as RequiredField<PendingPushNotification, 'apn'>['apn']);
return {
...notifData,
...(notification.apn ? { apn: { ...apnData } } : {}),
};
}
private async sendNotificationGateway(
app: IAppsTokens,
notification: PendingPushNotification,
@ -210,20 +221,21 @@ class PushClass {
return;
}
// Gateway accepts every attribute from the PendingPushNotification type, except for the priority
const { priority: _priority, ...notifData } = notification;
const { topicSuffix = '' } = notification.apn || {};
const gatewayNotification = this.getGatewayNotificationData(notification);
for (const gateway of this.options.gateways) {
logger.debug('send to token', app.token);
if ('apn' in app.token && app.token.apn) {
countApn.push(app._id);
return this.sendGatewayPush(gateway, 'apn', app.token.apn, { topic: app.appName, ...notifData });
return this.sendGatewayPush(gateway, 'apn', app.token.apn, { topic: `${app.appName}${topicSuffix}`, ...gatewayNotification });
}
if ('gcm' in app.token && app.token.gcm) {
countGcm.push(app._id);
return this.sendGatewayPush(gateway, 'gcm', app.token.gcm, notifData);
return this.sendGatewayPush(gateway, 'gcm', app.token.gcm, gatewayNotification);
}
}
}
@ -306,6 +318,7 @@ class PushClass {
contentAvailable: Match.Optional(Match.Integer),
apn: Match.Optional({
category: Match.Optional(String),
topicSuffix: Match.Optional(String),
}),
gcm: Match.Optional({
image: Match.Optional(String),
@ -344,7 +357,7 @@ class PushClass {
...(this.hasApnOptions(options)
? {
apn: {
...pick(options.apn, 'category'),
...pick(options.apn, 'category', 'topicSuffix'),
},
}
: {}),

@ -47,6 +47,7 @@ type CurrentData = {
receiveLoginDetectionEmail: boolean;
dontAskAgainList: [action: string, label: string][];
notifyCalendarEvents: boolean;
enableMobileRinging: boolean;
};
export type FormSectionProps = {

@ -31,6 +31,7 @@ const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }: Form
const userEmailNotificationMode = useUserPreference('emailNotificationMode') as keyof typeof emailNotificationOptionsLabelMap;
const userReceiveLoginDetectionEmail = useUserPreference('receiveLoginDetectionEmail');
const userNotifyCalendarEvents = useUserPreference('notifyCalendarEvents');
const userEnableMobileRinging = useUserPreference('enableMobileRinging');
const defaultDesktopNotifications = useSetting(
'Accounts_Default_User_Preferences_desktopNotifications',
@ -44,6 +45,7 @@ const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }: Form
const allowLoginEmailPreference = useSetting('Device_Management_Allow_Login_Email_preference');
const showNewLoginEmailPreference = loginEmailEnabled && allowLoginEmailPreference;
const showCalendarPreference = useSetting('Outlook_Calendar_Enabled');
const showMobileRinging = useSetting('VideoConf_Mobile_Ringing');
const { values, handlers, commit } = useForm(
{
@ -53,6 +55,7 @@ const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }: Form
emailNotificationMode: userEmailNotificationMode,
receiveLoginDetectionEmail: userReceiveLoginDetectionEmail,
notifyCalendarEvents: userNotifyCalendarEvents,
enableMobileRinging: userEnableMobileRinging,
},
onChange,
);
@ -64,6 +67,7 @@ const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }: Form
emailNotificationMode,
receiveLoginDetectionEmail,
notifyCalendarEvents,
enableMobileRinging,
} = values as {
desktopNotificationRequireInteraction: boolean;
desktopNotifications: string;
@ -71,6 +75,7 @@ const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }: Form
emailNotificationMode: string;
receiveLoginDetectionEmail: boolean;
notifyCalendarEvents: boolean;
enableMobileRinging: boolean;
};
const {
@ -80,6 +85,7 @@ const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }: Form
handleEmailNotificationMode,
handleReceiveLoginDetectionEmail,
handleNotifyCalendarEvents,
handleEnableMobileRinging,
} = handlers;
useEffect(() => setNotificationsPermission(window.Notification && Notification.permission), []);
@ -203,6 +209,17 @@ const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }: Form
</Box>
</Field>
)}
{showMobileRinging && (
<Field>
<Box display='flex' flexDirection='row' justifyContent='space-between' flexGrow={1}>
<Field.Label>{t('VideoConf_Mobile_Ringing')}</Field.Label>
<Field.Row>
<ToggleSwitch checked={enableMobileRinging} onChange={handleEnableMobileRinging} />
</Field.Row>
</Box>
</Field>
)}
</FieldGroup>
</Accordion.Item>
);

@ -5442,6 +5442,9 @@
"VideoConf_Enable_Groups": "Enable in private channels",
"VideoConf_Enable_DMs": "Enable in direct messages",
"VideoConf_Enable_Teams": "Enable in teams",
"VideoConf_Mobile_Ringing": "Enable mobile ringing",
"VideoConf_Mobile_Ringing_Description": "When enabled, direct calls to mobile users will ring their device as a phone call.",
"VideoConf_Mobile_Ringing_Alert": "This feature is currently in an experimental stage and may not yet be fully supported by the mobile app. When enabled it will send additional Push Notifications to users.",
"videoconf-ring-users": "Ring other users when calling",
"videoconf-ring-users_description": "Permission to ring other users when calling",
"Video_record": "Video record",

@ -40,6 +40,7 @@ type UserPreferences = {
fontSize?: FontSize;
receiveLoginDetectionEmail: boolean;
notifyCalendarEvents: boolean;
enableMobileRinging: boolean;
};
declare module '@rocket.chat/ui-contexts' {
@ -85,6 +86,7 @@ export const saveUserPreferences = async (settings: Partial<UserPreferences>, us
omnichannelTranscriptEmail: Match.Optional(Boolean),
omnichannelTranscriptPDF: Match.Optional(Boolean),
notifyCalendarEvents: Match.Optional(Boolean),
enableMobileRinging: Match.Optional(Boolean),
};
check(settings, Match.ObjectIncluding(keys));
const user = await Users.findOneById(userId);

@ -32,10 +32,16 @@ import type { PaginatedResult } from '@rocket.chat/rest-typings';
import type { MessageSurfaceLayout } from '@rocket.chat/ui-kit';
import { MongoInternals } from 'meteor/mongo';
import { RocketChatAssets } from '../../../app/assets/server';
import { canAccessRoomIdAsync } from '../../../app/authorization/server/functions/canAccessRoom';
import { sendMessage } from '../../../app/lib/server/functions/sendMessage';
import { metrics } from '../../../app/metrics/server/lib/metrics';
import PushNotification from '../../../app/push-notifications/server/lib/PushNotification';
import { Push } from '../../../app/push/server/push';
import { settings } from '../../../app/settings/server';
import { updateCounter } from '../../../app/statistics/server/functions/updateStatsCounter';
import { getUserAvatarURL } from '../../../app/utils/server/getUserAvatarURL';
import { getUserPreference } from '../../../app/utils/server/lib/getUserPreference';
import { Apps } from '../../../ee/server/apps';
import { callbacks } from '../../../lib/callbacks';
import { availabilityErrors } from '../../../lib/videoConference/constants';
@ -199,6 +205,8 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
await this.runVideoConferenceChangedEvent(callId);
this.notifyVideoConfUpdate(call.rid, call._id);
await this.sendAllPushNotifications(call._id);
}
public async get(callId: VideoConference['_id']): Promise<Omit<VideoConference, 'providerData'> | null> {
@ -583,6 +591,69 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
};
}
private async sendPushNotification(
call: AtLeast<IDirectVideoConference, 'createdBy' | 'rid' | '_id' | 'status'>,
calleeId: IUser['_id'],
): Promise<void> {
if (
settings.get('Push_enable') !== true ||
settings.get('VideoConf_Mobile_Ringing') !== true ||
!(await getUserPreference(calleeId, 'enableMobileRinging'))
) {
return;
}
metrics.notificationsSent.inc({ notification_type: 'mobile' });
await Push.send({
from: 'push',
badge: 0,
sound: 'default',
priority: 10,
title: `@${call.createdBy.username}`,
text: i18n.t('Video_Conference'),
payload: {
host: Meteor.absoluteUrl(),
rid: call.rid,
notificationType: 'videoconf',
caller: call.createdBy,
avatar: getUserAvatarURL(call.createdBy.username),
status: call.status,
},
userId: calleeId,
notId: PushNotification.getNotificationId(`${call.rid}|${call._id}`),
gcm: {
style: 'inbox',
image: RocketChatAssets.getURL('Assets_favicon_192'),
},
apn: {
category: 'MESSAGE_NOREPLY',
topicSuffix: '.voip',
},
});
}
private async sendAllPushNotifications(callId: VideoConference['_id']): Promise<void> {
if (settings.get('Push_enable') !== true || settings.get('VideoConf_Mobile_Ringing') !== true) {
return;
}
const call = await VideoConferenceModel.findOneById<Pick<VideoConference, 'createdBy' | 'rid' | '_id' | 'users' | 'status'>>(callId, {
projection: { createdBy: 1, rid: 1, users: 1, status: 1 },
});
if (!call) {
return;
}
const subscriptions = Subscriptions.findByRoomIdAndNotUserId(call.rid, call.createdBy._id, {
projection: { 'u._id': 1, '_id': 0 },
});
for await (const subscription of subscriptions) {
await this.sendPushNotification(call, subscription.u._id);
}
}
private async startDirect(
providerName: string,
user: IUser,
@ -637,6 +708,8 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
}
}, 40000);
await this.sendPushNotification(call, calleeId);
return {
type: 'direct',
callId,
@ -949,5 +1022,6 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
this.notifyVideoConfUpdate(call.rid, call._id);
await this.runVideoConferenceChangedEvent(call._id);
await this.sendAllPushNotifications(call._id);
}
}

@ -697,6 +697,12 @@ export const createAccountSettings = () =>
public: true,
i18nLabel: 'Notify_Calendar_Events',
});
await this.add('Accounts_Default_User_Preferences_enableMobileRinging', true, {
type: 'boolean',
public: true,
i18nLabel: 'VideoConf_Mobile_Ringing',
});
});
await this.section('Avatar', async function () {

@ -8,6 +8,15 @@ export const createVConfSettings = () =>
public: true,
});
await this.add('VideoConf_Mobile_Ringing', false, {
type: 'boolean',
public: true,
enterprise: true,
modules: ['videoconference-enterprise'],
invalidValue: false,
alert: 'VideoConf_Mobile_Ringing_Alert',
});
// #ToDo: Those should probably be handled by the apps themselves
await this.add('Jitsi_Click_To_Join_Count', 0, {
type: 'int',

@ -33,6 +33,7 @@ export const preferences = {
sendOnEnter: 'normal',
idleTimeLimit: 3600,
notifyCalendarEvents: false,
enableMobileRinging: false,
},
};

@ -176,6 +176,7 @@ describe('miscellaneous', function () {
'sidebarGroupByType',
'muteFocusedConversations',
'notifyCalendarEvents',
'enableMobileRinging',
].filter((p) => Boolean(p));
expect(res.body).to.have.property('success', true);

@ -14,5 +14,6 @@ export interface IPushNotificationConfig {
};
apn?: {
category: string;
topicSuffix?: string;
};
}

@ -49,6 +49,7 @@ export type UsersSetPreferencesParamsPOST = {
idleTimeLimit?: number;
omnichannelTranscriptEmail?: boolean;
omnichannelTranscriptPDF?: boolean;
enableMobileRinging?: boolean;
};
};
@ -240,6 +241,10 @@ const UsersSetPreferencesParamsPostSchema = {
type: 'boolean',
nullable: true,
},
enableMobileRinging: {
type: 'boolean',
nullable: true,
},
},
required: [],
additionalProperties: false,

Loading…
Cancel
Save