[NEW] Show Omnichannel room icon based on source definition (#23912)

* feat: adding sidebar icon definition

* fix: fixing unit tests and object logic

* Fix review

* feat: adding source on the create livechat room

* A few refactors

* Update Apps-Engine version

* Added arcane logic to handle icons

* chore: removing unused var

* Fix merge develop

* Update client/hooks/useRoomIcon.tsx

* Update Apps-Engine

* Change source attribution

* Fix prettier error

Co-authored-by: Guilherme Gazzo <guilhermegazzo@gmail.com>
Co-authored-by: Douglas Gubert <douglas.gubert@gmail.com>
pull/23875/head^2
Allan RIbeiro 3 years ago committed by GitHub
parent 1e2e0257d8
commit 0e2c91bbff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      app/apps/server/bridges/livechat.ts
  2. 6
      app/apps/server/converters/rooms.js
  3. 37
      client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelAppSourceRoomIcon.tsx
  4. 31
      client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelCoreSourceRoomIcon.tsx
  5. 21
      client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelRoomIcon.tsx
  6. 31
      client/components/RoomIcon/OmnichannelRoomIcon/context/OmnichannelRoomIconContext.tsx
  7. 1
      client/components/RoomIcon/OmnichannelRoomIcon/index.tsx
  8. 37
      client/components/RoomIcon/OmnichannelRoomIcon/lib/OmnichannelRoomIcon.ts
  9. 62
      client/components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider.tsx
  10. 45
      client/components/RoomIcon/RoomIcon.tsx
  11. 1
      client/components/RoomIcon/index.tsx
  12. 5
      client/components/UserStatus/ReactiveUserStatus.tsx
  13. 2
      client/components/UserStatus/UserStatus.tsx
  14. 60
      client/hooks/useRoomIcon.tsx
  15. 10
      client/sidebar/RoomList/SideBarItemTemplateWithData.js
  16. 16
      client/sidebar/RoomList/SidebarIcon.js
  17. 11
      client/views/root/AppRoot.tsx
  18. 22
      definition/IRoom.ts
  19. 6
      package-lock.json
  20. 2
      package.json

@ -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';
@ -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);
@ -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,

@ -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_);
@ -121,6 +126,7 @@ export class AppRoomsConverter {
isOpen: 'open',
_USERNAMES: '_USERNAMES',
description: 'description',
source: 'source',
isDefault: (room) => {
const result = !!room.default;
delete room.default;

@ -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>
);
};

@ -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']} />;
};

@ -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} />;
};

@ -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]));
};

@ -0,0 +1 @@
export * from './OmnichannelRoomIcon';

@ -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;

@ -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>
);
};

@ -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;
}
};

@ -0,0 +1 @@
export * from './RoomIcon';

@ -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} />;
};

@ -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>;

@ -1,6 +1,6 @@
import React, { ReactNode } from 'react';
import { IRoom, IOmnichannelRoom, IDirectMessageRoom } from '../../definition/IRoom';
import { IRoom, isOmnichannelRoom, isDirectMessageRoom } from '../../definition/IRoom';
import { ReactiveUserStatus } from '../components/UserStatus';
export const colors = {
@ -19,43 +19,37 @@ export const useRoomIcon = (room: IRoom): ReactNode | { name: string; color?: st
return { name: room.t === 'p' ? 'team-lock' : 'team' };
}
if (isOmnichannelRoom(room)) {
const icon =
{
widget: 'livechat',
email: 'mail',
sms: 'sms',
app: 'headset', // TODO: use app icon
api: 'headset', // TODO: use api icon
other: 'headset',
}[room.source.type] || 'headset';
return {
name: icon,
color: colors[room.v.status || 'offline'],
};
}
if (isDirectMessageRoom(room)) {
if (room.uids && room.uids.length > 2) {
return { name: 'balloon' };
}
if (room.uids && room.uids.length > 0) {
return <ReactiveUserStatus uid={room.uids.find((uid) => uid !== room.u._id) || room.u._id} />;
}
return { name: 'at' };
}
switch (room.t) {
case 'p':
return { name: 'hashtag-lock' };
case 'c':
return { name: 'hash' };
case 'l':
const omnichannelRoom = room as IOmnichannelRoom;
const icon =
{
widget: 'livechat',
email: 'mail',
sms: 'sms',
app: 'headset', // TODO: use app icon
api: 'headset', // TODO: use api icon
other: 'headset',
}[omnichannelRoom.source?.type as string] || 'headset';
return {
name: icon,
color: colors[(room as unknown as IOmnichannelRoom)?.v?.status || 'offline'],
};
case 'd':
const direct = room as unknown as IDirectMessageRoom;
if (direct.uids && direct.uids.length > 2) {
return { name: 'balloon' };
}
if (direct.uids && direct.uids.length > 0) {
return (
<ReactiveUserStatus
{...({
uid: direct.uids.filter((uid) => uid !== room.u._id)[0] || room.u._id,
} as any)}
/>
);
}
return { name: 'at' };
default:
return null;
}

@ -1,10 +1,10 @@
import { Badge } from '@rocket.chat/fuselage';
import { Badge, Sidebar } from '@rocket.chat/fuselage';
import React, { memo } from 'react';
import { roomTypes } from '../../../app/utils/client';
import { RoomIcon } from '../../components/RoomIcon';
import { useLayout } from '../../contexts/LayoutContext';
import RoomMenu from '../RoomMenu';
import SidebarIcon from './SidebarIcon';
import { normalizeSidebarMessage } from './normalizeSidebarMessage';
const getMessage = (room, lastMessage, t) => {
@ -35,7 +35,7 @@ function SideBarItemTemplateWithData({
AvatarTemplate,
t,
style,
sidebarViewMode,
// sidebarViewMode,
isAnonymous,
}) {
const { sidebar } = useLayout();
@ -59,7 +59,9 @@ function SideBarItemTemplateWithData({
const highlighted = !hideUnreadStatus && (alert || unread);
const icon = (
<SidebarIcon highlighted={highlighted} room={room} small={sidebarViewMode !== 'medium'} />
<Sidebar.Item.Icon highlighted={highlighted}>
<RoomIcon highlighted={highlighted} room={room} placement='sidebar' />
</Sidebar.Item.Icon>
);
const isQueued = room.status === 'queued';

@ -1,16 +0,0 @@
import { Sidebar } from '@rocket.chat/fuselage';
import React from 'react';
import { useRoomIcon } from '../../hooks/useRoomIcon';
const SidebarIcon = ({ room, highlighted }) => {
const icon = useRoomIcon(room);
return (
<Sidebar.Item.Icon highlighted={highlighted} {...(icon.name && icon)}>
{!icon.name && icon}
</Sidebar.Item.Icon>
);
};
export default SidebarIcon;

@ -1,6 +1,7 @@
import React, { FC, lazy, Suspense } from 'react';
import { QueryClientProvider } from 'react-query';
import { OmnichannelRoomIconProvider } from '../../components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider';
import { queryClient } from '../../lib/queryClient';
import PageLoading from './PageLoading';
@ -16,10 +17,12 @@ const AppRoot: FC = () => (
<Suspense fallback={<PageLoading />}>
<MeteorProvider>
<QueryClientProvider client={queryClient}>
<ConnectionStatusBar />
<BannerRegion />
<AppLayout />
<PortalsWrapper />
<OmnichannelRoomIconProvider>
<ConnectionStatusBar />
<BannerRegion />
<AppLayout />
<PortalsWrapper />
</OmnichannelRoomIconProvider>
</QueryClientProvider>
</MeteorProvider>
</Suspense>

@ -128,6 +128,10 @@ export interface IOmnichannelRoom extends Omit<IRoom, 'default' | 'featured' | '
id?: string;
// A human readable alias that goes with the ID, for post analytical purposes
alias?: string;
// The sidebar icon
sidebarIcon?: string;
// The default sidebar icon
defaultIcon?: string;
};
transcriptRequest?: IRequestTranscript;
servedBy?: {
@ -154,4 +158,22 @@ export interface IOmnichannelRoom extends Omit<IRoom, 'default' | 'featured' | '
crmData?: unknown;
}
export interface IOmnichannelRoomFromAppSource extends IOmnichannelRoom {
source: {
type: OmnichannelSourceType.APP;
id: string;
alias?: string;
sidebarIcon?: string;
defaultIcon?: string;
};
}
export const isOmnichannelRoom = (room: IRoom): room is IOmnichannelRoom & IRoom => room.t === 'l';
export const isOmnichannelRoomFromAppSource = (room: IRoom): room is IOmnichannelRoomFromAppSource => {
if (!isOmnichannelRoom(room)) {
return false;
}
return room.source.type === OmnichannelSourceType.APP;
};

6
package-lock.json generated

@ -5324,9 +5324,9 @@
}
},
"@rocket.chat/apps-engine": {
"version": "1.29.0-alpha.0.5706",
"resolved": "https://registry.npmjs.org/@rocket.chat/apps-engine/-/apps-engine-1.29.0-alpha.0.5706.tgz",
"integrity": "sha512-ML+B8yv47tvwqmw8ictaq93EI9DTVefD9tyMvBYWrjMwNtt1MEmh0PWWRgHp9yM3SxsExOqguqJDGLv7Muuakw==",
"version": "1.29.0-alpha.0.5711",
"resolved": "https://registry.npmjs.org/@rocket.chat/apps-engine/-/apps-engine-1.29.0-alpha.0.5711.tgz",
"integrity": "sha512-nbIFOcMypYLemX3kNnR/mPr1/Kgj9Uqk/TGGBp6SAsI27YHKkVbSgqAIKyZu4p9kwg6o06bdRr2VLt5k4YL4Og==",
"requires": {
"adm-zip": "^0.4.9",
"cryptiles": "^4.1.3",

@ -173,7 +173,7 @@
"@nivo/heatmap": "0.73.0",
"@nivo/line": "0.62.0",
"@nivo/pie": "0.73.0",
"@rocket.chat/apps-engine": "^1.29.0-alpha.0.5706",
"@rocket.chat/apps-engine": "^1.29.0-alpha.0.5711",
"@rocket.chat/css-in-js": "^0.30.1",
"@rocket.chat/emitter": "^0.30.1",
"@rocket.chat/fuselage": "^0.6.3-dev.368",

Loading…
Cancel
Save