Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion app/apps/server/bridges/livechat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '@rocket.chat/apps-engine/definition/livechat';
import { IUser } from '@rocket.chat/apps-engine/definition/users';
import { IMessage } from '@rocket.chat/apps-engine/definition/messages';
import { IExtraRoomParams } from '@rocket.chat/apps-engine/definition/accessors/ILivechatCreator';

import { getRoom } from '../../../livechat/server/api/lib/livechat';
import { Livechat } from '../../../livechat/server/lib/Livechat';
Expand Down Expand Up @@ -75,9 +76,13 @@ export class AppLivechatBridge extends LivechatBridge {
Livechat.updateMessage(data);
}

protected async createRoom(visitor: IVisitor, agent: IUser, appId: string): Promise<ILivechatRoom> {
protected async createRoom(visitor: IVisitor, agent: IUser, appId: string, extraParams?: IExtraRoomParams): Promise<ILivechatRoom> {
this.orch.debugLog(`The App ${ appId } is creating a livechat room.`);

const { source } = extraParams || {};
// `source` will likely have the properties below, so we tell TS it's alright
const { sidebarIcon, defaultIcon } = (source || {}) as { sidebarIcon?: string; defaultIcon?: string };

let agentRoom;
if (agent?.id) {
const user = Users.getAgentInfo(agent.id);
Expand All @@ -93,6 +98,8 @@ export class AppLivechatBridge extends LivechatBridge {
type: OmnichannelSourceType.APP,
id: appId,
alias: this.orch.getManager()?.getOneById(appId)?.getNameSlug(),
sidebarIcon,
defaultIcon,
},
},
extraParams: undefined,
Expand Down
6 changes: 6 additions & 0 deletions app/apps/server/converters/rooms.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ export class AppRoomsConverter {
livechatData: room.livechatData,
prid: typeof room.parentRoom === 'undefined' ? undefined : room.parentRoom.id,
...room._USERNAMES && { _USERNAMES: room._USERNAMES },
...room.source && {
source: {
...room.source,
},
},
};

return Object.assign(newRoom, room._unmappedProperties_);
Expand Down Expand Up @@ -121,6 +126,7 @@ export class AppRoomsConverter {
isOpen: 'open',
_USERNAMES: '_USERNAMES',
description: 'description',
source: 'source',
isDefault: (room) => {
const result = !!room.default;
delete room.default;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Icon, Box } from '@rocket.chat/fuselage';
import React, { ComponentProps, ReactElement } from 'react';

import { IOmnichannelRoomFromAppSource } from '../../../../definition/IRoom';
import { AsyncStatePhase } from '../../../lib/asyncState/AsyncStatePhase';
import { useOmnichannelRoomIcon } from './context/OmnichannelRoomIconContext';

export const colors = {
busy: 'danger-500',
away: 'warning-600',
online: 'success-500',
offline: 'neutral-600',
};

export const OmnichannelAppSourceRoomIcon = ({
room,
size = 'x16',
placement = 'default',
}: {
room: IOmnichannelRoomFromAppSource;
size: ComponentProps<typeof Icon>['size'];
placement: 'sidebar' | 'default';
}): ReactElement => {
const color = colors[room.v.status || 'offline'];
const icon = (placement === 'sidebar' && room.source.sidebarIcon) || room.source.defaultIcon;
const { phase, value } = useOmnichannelRoomIcon(room.source.id, icon || '');
if ([AsyncStatePhase.REJECTED, AsyncStatePhase.LOADING].includes(phase)) {
return <Icon name='headset' size={size} color={color} />;
}
return (
<Box color={color}>
<svg className='rc-icon rc-input__icon-svg rc-input__icon-svg--plus' aria-hidden='true'>
<use href={`#${value}`} />
</svg>
</Box>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Icon } from '@rocket.chat/fuselage';
import React, { ComponentProps, ReactElement } from 'react';

import { IOmnichannelRoom } from '../../../../definition/IRoom';

const colors = {
busy: 'danger-500',
away: 'warning-600',
online: 'success-500',
offline: 'neutral-600',
};

const iconMap = {
widget: 'livechat',
email: 'mail',
sms: 'sms',
app: 'headset',
api: 'headset',
other: 'headset',
};

export const OmnichannelCoreSourceRoomIcon = ({
room,
size = 'x16',
}: {
room: IOmnichannelRoom;
size: ComponentProps<typeof Icon>['size'];
}): ReactElement => {
const icon = iconMap[room.source.type] || 'headset';
return <Icon name={icon} size={size} color={colors[room.v.status || 'offline']} />;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Icon } from '@rocket.chat/fuselage';
import React, { ComponentProps, ReactElement } from 'react';

import { IOmnichannelRoom, isOmnichannelRoomFromAppSource } from '../../../../definition/IRoom';
import { OmnichannelAppSourceRoomIcon } from './OmnichannelAppSourceRoomIcon';
import { OmnichannelCoreSourceRoomIcon } from './OmnichannelCoreSourceRoomIcon';

export const OmnichannelRoomIcon = ({
room,
size = 'x16',
placement = 'default',
}: {
room: IOmnichannelRoom;
size: ComponentProps<typeof Icon>['size'];
placement: 'sidebar' | 'default';
}): ReactElement => {
if (isOmnichannelRoomFromAppSource(room)) {
return <OmnichannelAppSourceRoomIcon placement={placement} room={room} size={size} />;
}
return <OmnichannelCoreSourceRoomIcon room={room} size={size} />;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { createContext, useMemo, useContext } from 'react';
import { useSubscription, Unsubscribe } from 'use-subscription';

import { AsyncState } from '../../../../lib/asyncState/AsyncState';
import { AsyncStatePhase } from '../../../../lib/asyncState/AsyncStatePhase';

type IOmnichannelRoomIconContext = {
queryIcon(
app: string,
icon: string,
): {
getCurrentValue: () => AsyncState<string>;
subscribe: (callback: () => void) => Unsubscribe;
};
};

export const OmnichannelRoomIconContext = createContext<IOmnichannelRoomIconContext>({
queryIcon: () => ({
getCurrentValue: (): AsyncState<string> => ({
phase: AsyncStatePhase.LOADING,
value: undefined,
error: undefined,
}),
subscribe: (): Unsubscribe => (): void => undefined,
}),
});

export const useOmnichannelRoomIcon = (app: string, icon: string): AsyncState<string> => {
const { queryIcon } = useContext(OmnichannelRoomIconContext);
return useSubscription(useMemo(() => queryIcon(app, icon), [app, queryIcon, icon]));
};
1 change: 1 addition & 0 deletions client/components/RoomIcon/OmnichannelRoomIcon/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './OmnichannelRoomIcon';
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Emitter } from '@rocket.chat/emitter';
import DOMPurify from 'dompurify';

import { APIClient } from '../../../../../app/utils/client';

const OmnichannelRoomIcon = new (class extends Emitter {
icons = new Map<string, string>();

constructor() {
super();
}

public get(appId: string, icon: string): string | undefined {
if (!appId || !icon) {
return;
}
if (this.icons.has(`${appId}-${icon}`)) {
return `${appId}-${icon}`;
}
APIClient.get(`apps/public/${appId}/get-sidebar-icon`, { icon }).then((response) => {
this.icons.set(
`${appId}-${icon}`,
DOMPurify.sanitize(response, {
FORBID_ATTR: ['id'],
NAMESPACE: 'http://www.w3.org/2000/svg',
USE_PROFILES: { svg: true, svgFilters: true },
})
.replace(`<svg`, `<symbol id="${appId}-${icon}"`)
.replace(`</svg>`, '</symbol>'),
);
this.emit('change');
this.emit(`${appId}-${icon}`);
});
}
})();

export default OmnichannelRoomIcon;
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React, { FC, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useSubscription, Subscription } from 'use-subscription';

import { AsyncState } from '../../../../lib/asyncState/AsyncState';
import { AsyncStatePhase } from '../../../../lib/asyncState/AsyncStatePhase';
import { OmnichannelRoomIconContext } from '../context/OmnichannelRoomIconContext';
import OmnichannelRoomIcon from '../lib/OmnichannelRoomIcon';

export const OmnichannelRoomIconProvider: FC = ({ children }) => {
const svgIcons = useSubscription(
useMemo(
() => ({
getCurrentValue: (): string[] => Array.from(OmnichannelRoomIcon.icons.values()),
subscribe: (callback): (() => void) => OmnichannelRoomIcon.on('change', callback),
}),
[],
),
);
return (
<OmnichannelRoomIconContext.Provider
value={useMemo(
() => ({
queryIcon: (app: string, iconName: string): Subscription<AsyncState<string>> => ({
getCurrentValue: (): AsyncState<string> => {
const icon = OmnichannelRoomIcon.get(app, iconName);

if (!icon) {
return {
phase: AsyncStatePhase.LOADING,
value: undefined,
error: undefined,
};
}

return {
phase: AsyncStatePhase.RESOLVED,
value: icon,
error: undefined,
};
},
subscribe: (callback): (() => void) =>
OmnichannelRoomIcon.on(`${app}-${iconName}`, callback),
}),
}),
[],
)}
>
{createPortal(
<svg
xmlns='http://www.w3.org/2000/svg'
xmlnsXlink='http://www.w3.org/1999/xlink'
style={{ display: 'none' }}
dangerouslySetInnerHTML={{ __html: svgIcons.join('') }}
/>,
document.body,
'custom-icons',
)}
{children}
</OmnichannelRoomIconContext.Provider>
);
};
45 changes: 45 additions & 0 deletions client/components/RoomIcon/RoomIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Icon } from '@rocket.chat/fuselage';
import React, { ComponentProps, FC } from 'react';

import { IRoom, isDirectMessageRoom, isOmnichannelRoom } from '../../../definition/IRoom';
import { ReactiveUserStatus } from '../UserStatus';
import { OmnichannelRoomIcon } from './OmnichannelRoomIcon';

export const RoomIcon: FC<{
room: IRoom;
size: ComponentProps<typeof Icon>['size'];
highlighted?: boolean;
placement: 'sidebar' | 'default';
}> = ({ room, size = 'x16', placement }) => {
if (room.prid) {
return <Icon name='baloons' size={size} />;
}

if (room.teamMain) {
return <Icon name={room.t === 'p' ? 'team-lock' : 'team'} size={size} />;
}

if (isOmnichannelRoom(room)) {
return <OmnichannelRoomIcon placement={placement} room={room} size={size} />;
}
if (isDirectMessageRoom(room)) {
if (room.uids && room.uids.length > 2) {
return <Icon name='balloon' size={size} />;
}
if (room.uids && room.uids.length > 0) {
return (
<ReactiveUserStatus uid={room.uids.filter((uid) => uid !== room.u._id)[0] || room.u._id} />
);
}
return <Icon name='at' size={size} />;
}

switch (room.t) {
case 'p':
return <Icon name='hashtag-lock' size={size} />;
case 'c':
return <Icon name='hash' size={size} />;
default:
return null;
}
};
1 change: 1 addition & 0 deletions client/components/RoomIcon/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './RoomIcon';
5 changes: 2 additions & 3 deletions client/components/UserStatus/ReactiveUserStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@ import React, { memo, ReactElement } from 'react';

import { IUser } from '../../../definition/IUser';
import { usePresence } from '../../hooks/usePresence';
import UserStatus from './UserStatus';
import UserStatus, { UserStatusProps } from './UserStatus';

const ReactiveUserStatus = ({
uid,
...props
}: {
uid: IUser['_id'];
props: typeof UserStatus;
}): ReactElement => {
} & UserStatusProps): ReactElement => {
const status = usePresence(uid)?.status;
return <UserStatus status={status} {...props} />;
};
Expand Down
2 changes: 1 addition & 1 deletion client/components/UserStatus/UserStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import React, { memo, ComponentProps, ReactElement } from 'react';

import { useTranslation } from '../../contexts/TranslationContext';

type UserStatusProps = {
export type UserStatusProps = {
small?: boolean;
} & ComponentProps<typeof StatusBullet>;

Expand Down
Loading