diff --git a/app/apps/server/bridges/livechat.ts b/app/apps/server/bridges/livechat.ts index 9d219698798..4d133c3c43e 100644 --- a/app/apps/server/bridges/livechat.ts +++ b/app/apps/server/bridges/livechat.ts @@ -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 { + protected async createRoom(visitor: IVisitor, agent: IUser, appId: string, extraParams?: IExtraRoomParams): Promise { 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, diff --git a/app/apps/server/converters/rooms.js b/app/apps/server/converters/rooms.js index 6333058a25b..61d79c264e4 100644 --- a/app/apps/server/converters/rooms.js +++ b/app/apps/server/converters/rooms.js @@ -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; diff --git a/client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelAppSourceRoomIcon.tsx b/client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelAppSourceRoomIcon.tsx new file mode 100644 index 00000000000..9ffd5166906 --- /dev/null +++ b/client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelAppSourceRoomIcon.tsx @@ -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['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 ; + } + return ( + + + + ); +}; diff --git a/client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelCoreSourceRoomIcon.tsx b/client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelCoreSourceRoomIcon.tsx new file mode 100644 index 00000000000..5a53d7d89f6 --- /dev/null +++ b/client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelCoreSourceRoomIcon.tsx @@ -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['size']; +}): ReactElement => { + const icon = iconMap[room.source.type] || 'headset'; + return ; +}; diff --git a/client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelRoomIcon.tsx b/client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelRoomIcon.tsx new file mode 100644 index 00000000000..3fd6307c53a --- /dev/null +++ b/client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelRoomIcon.tsx @@ -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['size']; + placement: 'sidebar' | 'default'; +}): ReactElement => { + if (isOmnichannelRoomFromAppSource(room)) { + return ; + } + return ; +}; diff --git a/client/components/RoomIcon/OmnichannelRoomIcon/context/OmnichannelRoomIconContext.tsx b/client/components/RoomIcon/OmnichannelRoomIcon/context/OmnichannelRoomIconContext.tsx new file mode 100644 index 00000000000..7c825e1d54f --- /dev/null +++ b/client/components/RoomIcon/OmnichannelRoomIcon/context/OmnichannelRoomIconContext.tsx @@ -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; + subscribe: (callback: () => void) => Unsubscribe; + }; +}; + +export const OmnichannelRoomIconContext = createContext({ + queryIcon: () => ({ + getCurrentValue: (): AsyncState => ({ + phase: AsyncStatePhase.LOADING, + value: undefined, + error: undefined, + }), + subscribe: (): Unsubscribe => (): void => undefined, + }), +}); + +export const useOmnichannelRoomIcon = (app: string, icon: string): AsyncState => { + const { queryIcon } = useContext(OmnichannelRoomIconContext); + return useSubscription(useMemo(() => queryIcon(app, icon), [app, queryIcon, icon])); +}; diff --git a/client/components/RoomIcon/OmnichannelRoomIcon/index.tsx b/client/components/RoomIcon/OmnichannelRoomIcon/index.tsx new file mode 100644 index 00000000000..64267073c60 --- /dev/null +++ b/client/components/RoomIcon/OmnichannelRoomIcon/index.tsx @@ -0,0 +1 @@ +export * from './OmnichannelRoomIcon'; diff --git a/client/components/RoomIcon/OmnichannelRoomIcon/lib/OmnichannelRoomIcon.ts b/client/components/RoomIcon/OmnichannelRoomIcon/lib/OmnichannelRoomIcon.ts new file mode 100644 index 00000000000..0567502ff31 --- /dev/null +++ b/client/components/RoomIcon/OmnichannelRoomIcon/lib/OmnichannelRoomIcon.ts @@ -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(); + + 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(``, ''), + ); + this.emit('change'); + this.emit(`${appId}-${icon}`); + }); + } +})(); + +export default OmnichannelRoomIcon; diff --git a/client/components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider.tsx b/client/components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider.tsx new file mode 100644 index 00000000000..237dab6931f --- /dev/null +++ b/client/components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider.tsx @@ -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 ( + ({ + queryIcon: (app: string, iconName: string): Subscription> => ({ + getCurrentValue: (): AsyncState => { + 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( + , + document.body, + 'custom-icons', + )} + {children} + + ); +}; diff --git a/client/components/RoomIcon/RoomIcon.tsx b/client/components/RoomIcon/RoomIcon.tsx new file mode 100644 index 00000000000..007ca6cc75b --- /dev/null +++ b/client/components/RoomIcon/RoomIcon.tsx @@ -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['size']; + highlighted?: boolean; + placement: 'sidebar' | 'default'; +}> = ({ room, size = 'x16', placement }) => { + if (room.prid) { + return ; + } + + if (room.teamMain) { + return ; + } + + if (isOmnichannelRoom(room)) { + return ; + } + if (isDirectMessageRoom(room)) { + if (room.uids && room.uids.length > 2) { + return ; + } + if (room.uids && room.uids.length > 0) { + return ( + uid !== room.u._id)[0] || room.u._id} /> + ); + } + return ; + } + + switch (room.t) { + case 'p': + return ; + case 'c': + return ; + default: + return null; + } +}; diff --git a/client/components/RoomIcon/index.tsx b/client/components/RoomIcon/index.tsx new file mode 100644 index 00000000000..45e5aa0c3a3 --- /dev/null +++ b/client/components/RoomIcon/index.tsx @@ -0,0 +1 @@ +export * from './RoomIcon'; diff --git a/client/components/UserStatus/ReactiveUserStatus.tsx b/client/components/UserStatus/ReactiveUserStatus.tsx index 8fceb19bff7..fcaf410bee4 100644 --- a/client/components/UserStatus/ReactiveUserStatus.tsx +++ b/client/components/UserStatus/ReactiveUserStatus.tsx @@ -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 ; }; diff --git a/client/components/UserStatus/UserStatus.tsx b/client/components/UserStatus/UserStatus.tsx index abb04963347..e9049dd4bbc 100644 --- a/client/components/UserStatus/UserStatus.tsx +++ b/client/components/UserStatus/UserStatus.tsx @@ -3,7 +3,7 @@ import React, { memo, ComponentProps, ReactElement } from 'react'; import { useTranslation } from '../../contexts/TranslationContext'; -type UserStatusProps = { +export type UserStatusProps = { small?: boolean; } & ComponentProps; diff --git a/client/hooks/useRoomIcon.tsx b/client/hooks/useRoomIcon.tsx index 4f3f5f9ea93..3fb8ffbddc3 100644 --- a/client/hooks/useRoomIcon.tsx +++ b/client/hooks/useRoomIcon.tsx @@ -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 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 ( - uid !== room.u._id)[0] || room.u._id, - } as any)} - /> - ); - } - return { name: 'at' }; default: return null; } diff --git a/client/sidebar/RoomList/SideBarItemTemplateWithData.js b/client/sidebar/RoomList/SideBarItemTemplateWithData.js index a335de67ba1..bb2e9f7b00e 100644 --- a/client/sidebar/RoomList/SideBarItemTemplateWithData.js +++ b/client/sidebar/RoomList/SideBarItemTemplateWithData.js @@ -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 = ( - + + + ); const isQueued = room.status === 'queued'; diff --git a/client/sidebar/RoomList/SidebarIcon.js b/client/sidebar/RoomList/SidebarIcon.js deleted file mode 100644 index c7e231ec4ce..00000000000 --- a/client/sidebar/RoomList/SidebarIcon.js +++ /dev/null @@ -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 ( - - {!icon.name && icon} - - ); -}; - -export default SidebarIcon; diff --git a/client/views/root/AppRoot.tsx b/client/views/root/AppRoot.tsx index 3aa3453203a..75fb0c2fd08 100644 --- a/client/views/root/AppRoot.tsx +++ b/client/views/root/AppRoot.tsx @@ -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 = () => ( }> - - - - + + + + + + diff --git a/definition/IRoom.ts b/definition/IRoom.ts index 0fe6b44da65..e190d0fa8f7 100644 --- a/definition/IRoom.ts +++ b/definition/IRoom.ts @@ -128,6 +128,10 @@ export interface IOmnichannelRoom extends Omit room.t === 'l'; + +export const isOmnichannelRoomFromAppSource = (room: IRoom): room is IOmnichannelRoomFromAppSource => { + if (!isOmnichannelRoom(room)) { + return false; + } + + return room.source.type === OmnichannelSourceType.APP; +}; diff --git a/package-lock.json b/package-lock.json index d07af93e156..3cb3b7d2104 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 90f2170da12..281325d4f8f 100644 --- a/package.json +++ b/package.json @@ -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",