Skip to content

Commit

Permalink
feat: Outlook Calendar Integration (#27922)
Browse files Browse the repository at this point in the history
  • Loading branch information
dougfabris authored Jul 4, 2023
1 parent 3e2d700 commit c10137e
Show file tree
Hide file tree
Showing 70 changed files with 2,385 additions and 46 deletions.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"typescript.tsdk": "./node_modules/typescript/lib",
"cSpell.words": [
"autotranslate",
"Contextualbar",
"fname",
"Gazzodown",
"katex",
Expand Down
1 change: 1 addition & 0 deletions apps/meteor/app/api/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import './helpers/isUserFromParams';
import './helpers/parseJsonQuery';
import './default/info';
import './v1/assets';
import './v1/calendar';
import './v1/channels';
import './v1/chat';
import './v1/cloud';
Expand Down
139 changes: 139 additions & 0 deletions apps/meteor/app/api/server/v1/calendar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import {
isCalendarEventListProps,
isCalendarEventCreateProps,
isCalendarEventImportProps,
isCalendarEventInfoProps,
isCalendarEventUpdateProps,
isCalendarEventDeleteProps,
} from '@rocket.chat/rest-typings';
import { Calendar } from '@rocket.chat/core-services';

import { API } from '../api';

API.v1.addRoute(
'calendar-events.list',
{ authRequired: true, validateParams: isCalendarEventListProps, rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 } },
{
async get() {
const { userId } = this;
const { date } = this.queryParams;

const data = await Calendar.list(userId, new Date(date));

return API.v1.success({ data });
},
},
);

API.v1.addRoute(
'calendar-events.info',
{ authRequired: true, validateParams: isCalendarEventInfoProps, rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 } },
{
async get() {
const { userId } = this;
const { id } = this.queryParams;

const event = await Calendar.get(id);

if (!event || event.uid !== userId) {
return API.v1.failure();
}

return API.v1.success({ event });
},
},
);

API.v1.addRoute(
'calendar-events.create',
{ authRequired: true, validateParams: isCalendarEventCreateProps },
{
async post() {
const { userId: uid } = this;
const { startTime, externalId, subject, description, meetingUrl, reminderMinutesBeforeStart } = this.bodyParams;

const id = await Calendar.create({
uid,
startTime: new Date(startTime),
externalId,
subject,
description,
meetingUrl,
reminderMinutesBeforeStart,
});

return API.v1.success({ id });
},
},
);

API.v1.addRoute(
'calendar-events.import',
{ authRequired: true, validateParams: isCalendarEventImportProps },
{
async post() {
const { userId: uid } = this;
const { startTime, externalId, subject, description, meetingUrl, reminderMinutesBeforeStart } = this.bodyParams;

const id = await Calendar.import({
uid,
startTime: new Date(startTime),
externalId,
subject,
description,
meetingUrl,
reminderMinutesBeforeStart,
});

return API.v1.success({ id });
},
},
);

API.v1.addRoute(
'calendar-events.update',
{ authRequired: true, validateParams: isCalendarEventUpdateProps },
{
async post() {
const { userId } = this;
const { eventId, startTime, subject, description, meetingUrl, reminderMinutesBeforeStart } = this.bodyParams;

const event = await Calendar.get(eventId);

if (!event || event.uid !== userId) {
throw new Error('invalid-calendar-event');
}

await Calendar.update(eventId, {
startTime: new Date(startTime),
subject,
description,
meetingUrl,
reminderMinutesBeforeStart,
});

return API.v1.success();
},
},
);

API.v1.addRoute(
'calendar-events.delete',
{ authRequired: true, validateParams: isCalendarEventDeleteProps },
{
async post() {
const { userId } = this;
const { eventId } = this.bodyParams;

const event = await Calendar.get(eventId);

if (!event || event.uid !== userId) {
throw new Error('invalid-calendar-event');
}

await Calendar.delete(eventId);

return API.v1.success();
},
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ const GenericModal: FC<GenericModalProps> = ({
{confirmText ?? t('Ok')}
</Button>
)}
{!wrapperFunction && (
{!wrapperFunction && onConfirm && (
<Button {...getButtonProps(variant)} onClick={onConfirm} disabled={confirmDisabled}>
{confirmText ?? t('Ok')}
</Button>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Skeleton } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import type { ComponentProps } from 'react';
import React from 'react';

import GenericModal from './GenericModal';

const GenericModalSkeleton = ({ onClose, ...props }: ComponentProps<typeof GenericModal>) => {
const t = useTranslation();

return (
<GenericModal
{...props}
variant='warning'
onClose={onClose}
title={<Skeleton width='50%' />}
confirmText={t('Cancel')}
onConfirm={onClose}
>
<Skeleton width='full' />
</GenericModal>
);
};

export default GenericModalSkeleton;
2 changes: 2 additions & 0 deletions apps/meteor/client/components/GenericModal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './GenericModal';
export { default } from './GenericModal';
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useUserPreference, useTranslation, useEndpoint } from '@rocket.chat/ui-
import type { FC, ReactElement, ComponentType } from 'react';
import React, { useState } from 'react';

import type { DontAskAgainList } from '../hooks/useDontAskAgain';
import type { DontAskAgainList } from '../../hooks/useDontAskAgain';

type DoNotAskAgainProps = {
onConfirm: (...args: any) => Promise<void> | void;
Expand Down
14 changes: 14 additions & 0 deletions apps/meteor/client/lib/utils/window.RocketChatDesktop.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
type OutlookEventsResponse = { status: 'success' | 'canceled' };

// eslint-disable-next-line @typescript-eslint/naming-convention
interface Window {
RocketChatDesktop:
| {
openInternalVideoChatWindow?: (url: string, options: undefined) => void;
getOutlookEvents?: (date: Date) => Promise<OutlookEventsResponse>;
setOutlookExchangeUrl?: (url: string, userId: string) => Promise<void>;
hasOutlookCredentials?: () => Promise<boolean>;
clearOutlookCredentials?: () => void;
}
| undefined;
}
1 change: 1 addition & 0 deletions apps/meteor/client/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ import './views/admin';
import './views/marketplace';
import './views/account';
import './views/teams';
import './views/outlookCalendar';
4 changes: 2 additions & 2 deletions apps/meteor/client/providers/VideoConfProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import { VideoConfContext } from '../contexts/VideoConfContext';
import type { DirectCallParams, ProviderCapabilities, CallPreferences } from '../lib/VideoConfManager';
import { VideoConfManager } from '../lib/VideoConfManager';
import VideoConfPopups from '../views/room/contextualBar/VideoConference/VideoConfPopups';
import { useVideoOpenCall } from '../views/room/contextualBar/VideoConference/hooks/useVideoConfOpenCall';
import { useVideoConfOpenCall } from '../views/room/contextualBar/VideoConference/hooks/useVideoConfOpenCall';

const VideoConfContextProvider = ({ children }: { children: ReactNode }): ReactElement => {
const [outgoing, setOutgoing] = useState<VideoConfPopupPayload | undefined>();
const handleOpenCall = useVideoOpenCall();
const handleOpenCall = useVideoConfOpenCall();

useEffect(
() =>
Expand Down
40 changes: 38 additions & 2 deletions apps/meteor/client/startup/notifications/konchatNotifications.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import type { AtLeast, ISubscription, IUser } from '@rocket.chat/core-typings';
import type { AtLeast, ISubscription, IUser, ICalendarNotification } from '@rocket.chat/core-typings';
import { FlowRouter } from 'meteor/kadira:flow-router';
import { Meteor } from 'meteor/meteor';
import { Tracker } from 'meteor/tracker';
import { lazy } from 'react';

import { CachedChatSubscription } from '../../../app/models/client';
import { Notifications } from '../../../app/notifications/client';
import { settings } from '../../../app/settings/client';
import { readMessage } from '../../../app/ui-utils/client';
import { KonchatNotification } from '../../../app/ui/client/lib/KonchatNotification';
import { getUserPreference } from '../../../app/utils/client';
import { RoomManager } from '../../lib/RoomManager';
import { imperativeModal } from '../../lib/imperativeModal';
import { fireGlobalEvent } from '../../lib/utils/fireGlobalEvent';
import { isLayoutEmbedded } from '../../lib/utils/isLayoutEmbedded';

const OutlookCalendarEventModal = lazy(() => import('../../views/outlookCalendar/OutlookCalendarEventModal'));

const notifyNewRoom = async (sub: AtLeast<ISubscription, 'rid'>): Promise<void> => {
const user = Meteor.user() as IUser | null;
if (!user || user.status === 'busy') {
Expand Down Expand Up @@ -41,11 +46,42 @@ function notifyNewMessageAudio(rid?: string): void {
}

Meteor.startup(() => {
const notifyUserCalendar = async function (notification: ICalendarNotification): Promise<void> {
const user = Meteor.user() as IUser | null;
if (!user || user.status === 'busy') {
return;
}

const requireInteraction = getUserPreference<boolean>(Meteor.userId(), 'desktopNotificationRequireInteraction');

const n = new Notification(notification.title, {
body: notification.text,
tag: notification.payload._id,
silent: true,
requireInteraction,
} as NotificationOptions);

n.onclick = function () {
this.close();
window.focus();
imperativeModal.open({
component: OutlookCalendarEventModal,
props: { id: notification.payload._id, onClose: imperativeModal.close, onCancel: imperativeModal.close },
});
};
};
Tracker.autorun(() => {
if (!Meteor.userId() || !settings.get('Outlook_Calendar_Enabled')) {
return Notifications.unUser('calendar');
}

Notifications.onUser('calendar', notifyUserCalendar);
});

Tracker.autorun(() => {
if (!Meteor.userId()) {
return;
}

Notifications.onUser('notification', (notification) => {
const openedRoomId = ['channel', 'group', 'direct'].includes(FlowRouter.getRouteName()) ? RoomManager.opened : undefined;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ type CurrentData = {
muteFocusedConversations: boolean;
receiveLoginDetectionEmail: boolean;
dontAskAgainList: [action: string, label: string][];
notifyCalendarEvents: boolean;
};

export type FormSectionProps = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }: Form
const userMobileNotifications = useUserPreference('pushNotifications');
const userEmailNotificationMode = useUserPreference('emailNotificationMode') as keyof typeof emailNotificationOptionsLabelMap;
const userReceiveLoginDetectionEmail = useUserPreference('receiveLoginDetectionEmail');
const userNotifyCalendarEvents = useUserPreference('notifyCalendarEvents');

const defaultDesktopNotifications = useSetting(
'Accounts_Default_User_Preferences_desktopNotifications',
Expand All @@ -42,6 +43,7 @@ const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }: Form
const loginEmailEnabled = useSetting('Device_Management_Enable_Login_Emails');
const allowLoginEmailPreference = useSetting('Device_Management_Allow_Login_Email_preference');
const showNewLoginEmailPreference = loginEmailEnabled && allowLoginEmailPreference;
const showCalendarPreference = useSetting('Outlook_Calendar_Enabled');

const { values, handlers, commit } = useForm(
{
Expand All @@ -50,6 +52,7 @@ const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }: Form
pushNotifications: userMobileNotifications,
emailNotificationMode: userEmailNotificationMode,
receiveLoginDetectionEmail: userReceiveLoginDetectionEmail,
notifyCalendarEvents: userNotifyCalendarEvents,
},
onChange,
);
Expand All @@ -60,12 +63,14 @@ const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }: Form
pushNotifications,
emailNotificationMode,
receiveLoginDetectionEmail,
notifyCalendarEvents,
} = values as {
desktopNotificationRequireInteraction: boolean;
desktopNotifications: string;
pushNotifications: string;
emailNotificationMode: string;
receiveLoginDetectionEmail: boolean;
notifyCalendarEvents: boolean;
};

const {
Expand All @@ -74,6 +79,7 @@ const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }: Form
handlePushNotifications,
handleEmailNotificationMode,
handleReceiveLoginDetectionEmail,
handleNotifyCalendarEvents,
} = handlers;

useEffect(() => setNotificationsPermission(window.Notification && Notification.permission), []);
Expand Down Expand Up @@ -186,6 +192,17 @@ const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }: Form
<Field.Hint>{t('Receive_Login_Detection_Emails_Description')}</Field.Hint>
</Field>
)}

{showCalendarPreference && (
<Field>
<Box display='flex' flexDirection='row' justifyContent='space-between' flexGrow={1}>
<Field.Label>{t('Notify_Calendar_Events')}</Field.Label>
<Field.Row>
<ToggleSwitch checked={notifyCalendarEvents} onChange={handleNotifyCalendarEvents} />
</Field.Row>
</Box>
</Field>
)}
</FieldGroup>
</Accordion.Item>
);
Expand Down
4 changes: 2 additions & 2 deletions apps/meteor/client/views/conference/ConferencePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ReactElement } from 'react';
import React, { useEffect } from 'react';

import { useUserDisplayName } from '../../hooks/useUserDisplayName';
import { useVideoOpenCall } from '../room/contextualBar/VideoConference/hooks/useVideoConfOpenCall';
import { useVideoConfOpenCall } from '../room/contextualBar/VideoConference/hooks/useVideoConfOpenCall';
import PageLoading from '../root/PageLoading';
import ConferencePageError from './ConferencePageError';

Expand All @@ -19,7 +19,7 @@ const ConferencePage = (): ReactElement => {
const user = useUser();
const defaultRoute = useRoute('/');
const setModal = useSetModal();
const handleOpenCall = useVideoOpenCall();
const handleOpenCall = useVideoConfOpenCall();
const userDisplayName = useUserDisplayName({ name: user?.name, username: user?.username });

const { callUrlParam } = getQueryParams();
Expand Down
Loading

0 comments on commit c10137e

Please sign in to comment.