diff --git a/app/autolinker/client/client.js b/app/autolinker/client/client.js index e743725b67e15..f18cd595e1600 100644 --- a/app/autolinker/client/client.js +++ b/app/autolinker/client/client.js @@ -7,6 +7,7 @@ import Autolinker from 'autolinker'; import { settings } from '../../settings'; import { callbacks } from '../../callbacks'; +import { escapeRegExp } from '../../../client/lib/escapeRegExp'; let config; @@ -33,7 +34,7 @@ const renderMessage = (message) => { let msgParts; let regexTokens; if (message.tokens && message.tokens.length) { - regexTokens = new RegExp(`(${ (message.tokens || []).map(({ token }) => RegExp.escape(token)) })`, 'g'); + regexTokens = new RegExp(`(${ (message.tokens || []).map(({ token }) => escapeRegExp(token)) })`, 'g'); msgParts = message.html.split(regexTokens); } else { msgParts = [message.html]; diff --git a/app/emoji-custom/client/lib/emojiCustom.js b/app/emoji-custom/client/lib/emojiCustom.js index f95d3160c6d07..afa5945d4bffe 100644 --- a/app/emoji-custom/client/lib/emojiCustom.js +++ b/app/emoji-custom/client/lib/emojiCustom.js @@ -7,6 +7,7 @@ import { RoomManager } from '../../../ui-utils/client'; import { emoji, EmojiPicker } from '../../../emoji/client'; import { CachedCollectionManager } from '../../../ui-cached-collection/client'; import { APIClient } from '../../../utils/client'; +import { escapeRegExp } from '../../../../client/lib/escapeRegExp'; export const getEmojiUrlFromName = function(name, extension) { Session.get; @@ -126,7 +127,7 @@ export const updateEmojiCustom = function(emojiData) { }; const customRender = (html) => { - const emojisMatchGroup = emoji.packages.emojiCustom.list.map(RegExp.escape).join('|'); + const emojisMatchGroup = emoji.packages.emojiCustom.list.map(escapeRegExp).join('|'); if (emojisMatchGroup !== emoji.packages.emojiCustom._regexpSignature) { emoji.packages.emojiCustom._regexpSignature = emojisMatchGroup; emoji.packages.emojiCustom._regexp = new RegExp(`]*>.*?<\/object>|]*>.*?<\/span>|<(?:object|embed|svg|img|div|span|p|a)[^>]*>|(${ emojisMatchGroup })`, 'gi'); diff --git a/app/emoji/client/emojiPicker.js b/app/emoji/client/emojiPicker.js index 86360cc475752..a96905631ebc5 100644 --- a/app/emoji/client/emojiPicker.js +++ b/app/emoji/client/emojiPicker.js @@ -4,12 +4,12 @@ import { ReactiveDict } from 'meteor/reactive-dict'; import { FlowRouter } from 'meteor/kadira:flow-router'; import { Template } from 'meteor/templating'; +import { escapeRegExp } from '../../../client/lib/escapeRegExp'; +import '../../theme/client/imports/components/emojiPicker.css'; import { t } from '../../utils/client'; import { EmojiPicker } from './lib/EmojiPicker'; import { emoji } from '../lib/rocketchat'; - import './emojiPicker.html'; -import '../../theme/client/imports/components/emojiPicker.css'; const ESCAPE = 27; @@ -50,7 +50,7 @@ function getEmojisBySearchTerm(searchTerm) { EmojiPicker.currentCategory.set(''); - const searchRegExp = new RegExp(RegExp.escape(searchTerm.replace(/:/g, '')), 'i'); + const searchRegExp = new RegExp(escapeRegExp(searchTerm.replace(/:/g, '')), 'i'); for (let current in emoji.list) { if (!emoji.list.hasOwnProperty(current)) { diff --git a/app/theme/client/index.js b/app/theme/client/index.js index 2dc7ee1fe082c..304c6de1954f4 100644 --- a/app/theme/client/index.js +++ b/app/theme/client/index.js @@ -1,3 +1,4 @@ +import 'toastr/build/toastr.min.css'; import './main.css'; import './vendor/photoswipe.css'; import './vendor/fontello/css/fontello.css'; diff --git a/app/ui-flextab/client/tabs/uploadedFilesList.js b/app/ui-flextab/client/tabs/uploadedFilesList.js index 6cd62c94cb700..71d4781a1b788 100644 --- a/app/ui-flextab/client/tabs/uploadedFilesList.js +++ b/app/ui-flextab/client/tabs/uploadedFilesList.js @@ -10,6 +10,7 @@ import { canDeleteMessage, getURL, handleError, t, APIClient } from '../../../ut import { popover, modal } from '../../../ui-utils/client'; import { Rooms, Messages } from '../../../models/client'; import { upsertMessageBulk } from '../../../ui-utils/client/lib/RoomHistoryManager'; +import { download } from '../../../../client/lib/download'; const LIST_SIZE = 50; const DEBOUNCE_TIME_TO_SEARCH_IN_MS = 500; @@ -255,13 +256,10 @@ Template.uploadedFilesList.events({ icon: 'download', name: t('Download'), action: () => { - const a = document.createElement('a'); - a.href = getURL(this.file.url); - a.download = this.file.name; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(this.file.url); - a.remove(); + const URL = window.webkitURL ?? window.URL; + const href = getURL(this.file.url); + download(href, this.file.name); + URL.revokeObjectURL(this.file.url); }, }, ], diff --git a/app/ui-message/client/popup/messagePopupConfig.js b/app/ui-message/client/popup/messagePopupConfig.js index f23c4bf5651f8..08b99a25bbdce 100644 --- a/app/ui-message/client/popup/messagePopupConfig.js +++ b/app/ui-message/client/popup/messagePopupConfig.js @@ -17,6 +17,7 @@ import { customMessagePopups } from './customMessagePopups'; import './messagePopupConfig.html'; import './messagePopupSlashCommand.html'; import './messagePopupUser.html'; +import { escapeRegExp } from '../../../../client/lib/escapeRegExp'; const reloadUsersFromRoomMessages = (rid, template) => { const user = Meteor.userId() && Meteor.users.findOne(Meteor.userId(), { fields: { username: 1 } }); @@ -144,7 +145,7 @@ const getEmojis = (collection, filter) => { return []; } - const regExp = new RegExp(RegExp.escape(filter), 'i'); + const regExp = new RegExp(escapeRegExp(filter), 'i'); const recents = EmojiPicker.getRecent().map((item) => `:${ item }:`); return Object.keys(collection) @@ -208,7 +209,7 @@ Template.messagePopupConfig.helpers({ getFilter: (collection, filter = '', cb) => { const { rid } = this; const filterText = filter.trim(); - const filterRegex = filterText !== '' && new RegExp(`${ RegExp.escape(filterText) }`, 'i'); + const filterRegex = filterText !== '' && new RegExp(`${ escapeRegExp(filterText) }`, 'i'); const items = template.usersFromRoomMessages .find( diff --git a/app/ui-sidenav/client/toolbar.js b/app/ui-sidenav/client/toolbar.js index df0995056d9d6..a2564bae629ba 100644 --- a/app/ui-sidenav/client/toolbar.js +++ b/app/ui-sidenav/client/toolbar.js @@ -12,6 +12,7 @@ import { Rooms, Subscriptions } from '../../models'; import { roomTypes } from '../../utils'; import { hasAtLeastOnePermission } from '../../authorization'; import { menu } from '../../ui-utils'; +import { escapeRegExp } from '../../../client/lib/escapeRegExp'; let filterText = ''; let usernamesFromClient; @@ -130,7 +131,7 @@ Template.toolbar.helpers({ query.t = 'd'; } - const searchQuery = new RegExp(RegExp.escape(filterText), 'i'); + const searchQuery = new RegExp(escapeRegExp(filterText), 'i'); query.$or = [ { name: searchQuery }, { fname: searchQuery }, diff --git a/client/account/AccountProfileForm.js b/client/account/AccountProfileForm.js index aff7951e87d71..9d067e264e3b8 100644 --- a/client/account/AccountProfileForm.js +++ b/client/account/AccountProfileForm.js @@ -6,7 +6,7 @@ import { useTranslation } from '../contexts/TranslationContext'; import { isEmail } from '../../app/utils/lib/isEmail.js'; import { useToastMessageDispatch } from '../contexts/ToastMessagesContext'; import { useMethod } from '../contexts/ServerContext'; -import { getUserEmailAddress } from '../helpers/getUserEmailAddress'; +import { getUserEmailAddress } from '../lib/getUserEmailAddress'; import { UserAvatarEditor } from '../components/basic/avatar/UserAvatarEditor'; import CustomFieldsForm from '../components/CustomFieldsForm'; import UserStatusMenu from '../components/basic/userStatus/UserStatusMenu'; diff --git a/client/account/AccountProfilePage.js b/client/account/AccountProfilePage.js index 722cd17380fde..2088d6ae79a52 100644 --- a/client/account/AccountProfilePage.js +++ b/client/account/AccountProfilePage.js @@ -13,7 +13,7 @@ import { useToastMessageDispatch } from '../contexts/ToastMessagesContext'; import { useMethod } from '../contexts/ServerContext'; import { useSetModal } from '../contexts/ModalContext'; import { useUpdateAvatar } from '../hooks/useUpdateAvatar'; -import { getUserEmailAddress } from '../helpers/getUserEmailAddress'; +import { getUserEmailAddress } from '../lib/getUserEmailAddress'; import ActionConfirmModal from './ActionConfirmModal'; const getInitialValues = (user) => ({ diff --git a/client/account/sidebarItems.js b/client/account/sidebarItems.js index 13a74fbbcba27..810ed0d35fecd 100644 --- a/client/account/sidebarItems.js +++ b/client/account/sidebarItems.js @@ -3,7 +3,7 @@ import { HTML } from 'meteor/htmljs'; import { hasPermission } from '../../app/authorization/client'; import { createTemplateForComponent } from '../reactAdapters'; import { settings } from '../../app/settings'; -import { createSidebarItems } from '../helpers/createSidebarItems'; +import { createSidebarItems } from '../lib/createSidebarItems'; createTemplateForComponent('accountFlex', () => import('./AccountSidebar'), { renderContainerView: () => HTML.DIV({ style: 'height: 100%; position: relative;' }), // eslint-disable-line new-cap diff --git a/client/admin/apps/AppSettings.js b/client/admin/apps/AppSettings.js index 9b273b084bfeb..a3ba095d95744 100644 --- a/client/admin/apps/AppSettings.js +++ b/client/admin/apps/AppSettings.js @@ -3,7 +3,7 @@ import { Box } from '@rocket.chat/fuselage'; import { useTranslation } from '../../contexts/TranslationContext'; import { MemoizedSetting } from '../settings/Setting'; -import { capitalize } from '../../helpers/capitalize'; +import { capitalize } from '../../lib/capitalize'; import { useRouteParameter } from '../../contexts/RouterContext'; import MarkdownText from '../../components/basic/MarkdownText'; diff --git a/client/admin/info/InformationRoute.js b/client/admin/info/InformationRoute.js index 4f3ae4aba0093..0a53a802398de 100644 --- a/client/admin/info/InformationRoute.js +++ b/client/admin/info/InformationRoute.js @@ -3,7 +3,7 @@ import React, { useState, useEffect } from 'react'; import { usePermission } from '../../contexts/AuthorizationContext'; import NotAuthorizedPage from '../../components/NotAuthorizedPage'; import { useMethod, useServerInformation, useEndpoint } from '../../contexts/ServerContext'; -import { downloadJsonAsAFile } from '../../helpers/download'; +import { downloadJsonAs } from '../../lib/download'; import InformationPage from './InformationPage'; const InformationRoute = React.memo(function InformationRoute() { @@ -61,7 +61,7 @@ const InformationRoute = React.memo(function InformationRoute() { if (isLoading) { return; } - downloadJsonAsAFile(statistics, 'statistics'); + downloadJsonAs(statistics, 'statistics'); }; if (canViewStatistics) { diff --git a/client/admin/routes.js b/client/admin/routes.js index 3ad629b07bca8..27fcdd669235e 100644 --- a/client/admin/routes.js +++ b/client/admin/routes.js @@ -1,6 +1,6 @@ import { Meteor } from 'meteor/meteor'; -import { createRouteGroup } from '../helpers/createRouteGroup'; +import { createRouteGroup } from '../lib/createRouteGroup'; export const registerAdminRoute = createRouteGroup('admin', '/admin', () => import('./AdministrationRouter')); diff --git a/client/admin/sidebarItems.js b/client/admin/sidebarItems.js index b6ddc658015c6..19d9f4a5a0767 100644 --- a/client/admin/sidebarItems.js +++ b/client/admin/sidebarItems.js @@ -3,7 +3,7 @@ import { Meteor } from 'meteor/meteor'; import { hasPermission, hasRole } from '../../app/authorization/client'; import { createTemplateForComponent } from '../reactAdapters'; -import { createSidebarItems } from '../helpers/createSidebarItems'; +import { createSidebarItems } from '../lib/createSidebarItems'; createTemplateForComponent('adminFlex', () => import('./sidebar/AdminSidebar'), { renderContainerView: () => HTML.DIV({ style: 'height: 100%; position: relative;' }), // eslint-disable-line new-cap diff --git a/client/admin/users/UsersTable.js b/client/admin/users/UsersTable.js index 2f9cf13c5acab..84478613b8046 100644 --- a/client/admin/users/UsersTable.js +++ b/client/admin/users/UsersTable.js @@ -4,7 +4,7 @@ import React, { useMemo, useCallback, useState } from 'react'; import UserAvatar from '../../components/basic/avatar/UserAvatar'; import GenericTable from '../../components/GenericTable'; -import { capitalize } from '../../helpers/capitalize'; +import { capitalize } from '../../lib/capitalize'; import { useTranslation } from '../../contexts/TranslationContext'; import { useRoute } from '../../contexts/RouterContext'; import { useEndpointData } from '../../hooks/useEndpointData'; diff --git a/client/components/CustomFieldsForm.js b/client/components/CustomFieldsForm.js index 8f83aa0adebe4..104dd77a20b6d 100644 --- a/client/components/CustomFieldsForm.js +++ b/client/components/CustomFieldsForm.js @@ -4,7 +4,7 @@ import { TextInput, Select, Field } from '@rocket.chat/fuselage'; import { useSetting } from '../contexts/SettingsContext'; import { useForm } from '../hooks/useForm'; import { useTranslation } from '../contexts/TranslationContext'; -import { capitalize } from '../helpers/capitalize'; +import { capitalize } from '../lib/capitalize'; const CustomTextInput = ({ name, required, minLength, maxLength, setState, state, className }) => { const t = useTranslation(); diff --git a/client/components/basic/userStatus/UserStatus.js b/client/components/basic/userStatus/UserStatus.js index 73fd3adc1ce32..e66217ac4b1b0 100644 --- a/client/components/basic/userStatus/UserStatus.js +++ b/client/components/basic/userStatus/UserStatus.js @@ -1,7 +1,7 @@ import React from 'react'; import { Box } from '@rocket.chat/fuselage'; -import statusColors from '../../../helpers/statusColors'; +import statusColors from '../../../lib/statusColors'; const UserStatus = React.memo(({ status, ...props }) => ); diff --git a/client/helpers/capitalize.js b/client/helpers/capitalize.js deleted file mode 100644 index e709a37503bff..0000000000000 --- a/client/helpers/capitalize.js +++ /dev/null @@ -1,4 +0,0 @@ -export const capitalize = (s) => { - if (typeof s !== 'string') { return ''; } - return s.charAt(0).toUpperCase() + s.slice(1); -}; diff --git a/client/helpers/createSidebarItems.js b/client/helpers/createSidebarItems.js deleted file mode 100644 index 8b8a8905890ff..0000000000000 --- a/client/helpers/createSidebarItems.js +++ /dev/null @@ -1,26 +0,0 @@ - -export const createSidebarItems = (initialItems = []) => { - const items = initialItems; - let updateCb = () => {}; - - const itemsSubscription = { - subscribe: (cb) => { - updateCb = cb; - return () => { - updateCb = () => {}; - }; - }, - getCurrentValue: () => items, - }; - const registerSidebarItem = (item) => { - items.push(item); - updateCb(); - }; - const unregisterSidebarItem = (label) => { - const index = items.findIndex(({ i18nLabel }) => i18nLabel === label); - delete items[index]; - updateCb(); - }; - - return { registerSidebarItem, unregisterSidebarItem, itemsSubscription }; -}; diff --git a/client/helpers/download.js b/client/helpers/download.js deleted file mode 100644 index 089f19cecf171..0000000000000 --- a/client/helpers/download.js +++ /dev/null @@ -1,15 +0,0 @@ -export const downloadJsonAsAFile = (jsonData, name = 'jsonfile') => { - const filename = `${ name }.json`; - const contentType = 'application/json;charset=utf-8;'; - if (window.navigator && window.navigator.msSaveOrOpenBlob) { - const blob = new Blob([decodeURIComponent(encodeURI(JSON.stringify(jsonData)))], { type: contentType }); - return navigator.msSaveOrOpenBlob(blob, filename); - } - const aElement = document.createElement('a'); - aElement.download = filename; - aElement.href = `data:${ contentType },${ encodeURIComponent(JSON.stringify(jsonData)) }`; - aElement.target = '_blank'; - document.body.appendChild(aElement); - aElement.click(); - document.body.removeChild(aElement); -}; diff --git a/client/helpers/getDateRange.js b/client/helpers/getDateRange.js deleted file mode 100644 index 84dd9ce73fccd..0000000000000 --- a/client/helpers/getDateRange.js +++ /dev/null @@ -1,9 +0,0 @@ -import moment from 'moment'; - -export const getDateRange = () => { - const today = moment(new Date()); - return { - start: `${ moment(new Date(today.year(), today.month(), today.date(), 0, 0, 0)).utc().format('YYYY-MM-DDTHH:mm:ss') }Z`, - end: `${ moment(new Date(today.year(), today.month(), today.date(), 23, 59, 59)).utc().format('YYYY-MM-DDTHH:mm:ss') }Z`, - }; -}; diff --git a/client/helpers/getUserEmailAddress.js b/client/helpers/getUserEmailAddress.js deleted file mode 100644 index 31d8b1f54403f..0000000000000 --- a/client/helpers/getUserEmailAddress.js +++ /dev/null @@ -1 +0,0 @@ -export const getUserEmailAddress = (user) => user.emails?.find(({ address }) => !!address)?.address; diff --git a/client/hooks/useForm.ts b/client/hooks/useForm.ts index fd28a00716f9b..7b890133ae340 100644 --- a/client/hooks/useForm.ts +++ b/client/hooks/useForm.ts @@ -1,6 +1,6 @@ import { useCallback, useReducer, useMemo, ChangeEvent } from 'react'; -import { capitalize } from '../helpers/capitalize'; +import { capitalize } from '../lib/capitalize'; type Field = { name: string; diff --git a/client/lib/capitalize.spec.ts b/client/lib/capitalize.spec.ts new file mode 100644 index 0000000000000..69d9b5abc7c59 --- /dev/null +++ b/client/lib/capitalize.spec.ts @@ -0,0 +1,43 @@ +import assert from 'assert'; + +import { describe, it } from 'mocha'; + +import { capitalize } from './capitalize'; + +describe('capitalize', () => { + it('should convert "xyz" to "Xyz"', () => { + assert.equal(capitalize('xyz'), 'Xyz'); + }); + + it('should convert "xyz xyz" to "Xyz xyz"', () => { + assert.equal(capitalize('xyz xyz'), 'Xyz xyz'); + }); + + it('should convert " xyz" to " xyz"', () => { + assert.equal(capitalize(' xyz'), ' xyz'); + }); + + it('should convert undefined to ""', () => { + assert.equal(capitalize(undefined as unknown as string), ''); + }); + + it('should convert null to ""', () => { + assert.equal(capitalize(null as unknown as string), ''); + }); + + it('should convert false to ""', () => { + assert.equal(capitalize(false as unknown as string), ''); + }); + + it('should convert true to ""', () => { + assert.equal(capitalize(true as unknown as string), ''); + }); + + it('should convert 0 to ""', () => { + assert.equal(capitalize(0 as unknown as string), ''); + }); + + it('should convert 1 to ""', () => { + assert.equal(capitalize(1 as unknown as string), ''); + }); +}); diff --git a/client/lib/capitalize.ts b/client/lib/capitalize.ts new file mode 100644 index 0000000000000..c3fc40dbfdb14 --- /dev/null +++ b/client/lib/capitalize.ts @@ -0,0 +1,7 @@ +export const capitalize = (s: string): string => { + if (typeof s !== 'string') { + return ''; + } + + return s.charAt(0).toUpperCase() + s.slice(1); +}; diff --git a/client/helpers/createRouteGroup.js b/client/lib/createRouteGroup.ts similarity index 51% rename from client/helpers/createRouteGroup.js rename to client/lib/createRouteGroup.ts index 89b28298aeaee..c25fc1abe81a5 100644 --- a/client/helpers/createRouteGroup.js +++ b/client/lib/createRouteGroup.ts @@ -1,14 +1,24 @@ import { FlowRouter } from 'meteor/kadira:flow-router'; +import type { ElementType } from 'react'; import { renderRouteComponent } from '../reactAdapters'; -export const createRouteGroup = (name, prefix, importRouter) => { +type RouteRegister = { + (path: string, params: { + name: string; + lazyRouteComponent: () => Promise; + props: Record; + action: (params?: Record, queryParams?: Record) => void; + }): void; +}; + +export const createRouteGroup = (name: string, prefix: string, importRouter: () => Promise): RouteRegister => { const routeGroup = FlowRouter.group({ name, prefix, }); - const registerRoute = (path, { lazyRouteComponent, props, action, ...options } = {}) => { + const registerRoute: RouteRegister = (path, { lazyRouteComponent, props, action, ...options }) => { routeGroup.route(path, { ...options, action: (params, queryParams) => { diff --git a/client/lib/createSidebarItems.ts b/client/lib/createSidebarItems.ts new file mode 100644 index 0000000000000..32cb8025111dd --- /dev/null +++ b/client/lib/createSidebarItems.ts @@ -0,0 +1,41 @@ +import type { Subscription } from 'use-subscription'; + +type SidebarItem = { + i18nLabel: string; +}; + +export const createSidebarItems = (initialItems: SidebarItem[] = []): ({ + registerSidebarItem: (item: SidebarItem) => void; + unregisterSidebarItem: (i18nLabel: SidebarItem['i18nLabel']) => void; + itemsSubscription: Subscription; +}) => { + const items = initialItems; + let updateCb: (() => void) = () => undefined; + + const itemsSubscription: Subscription = { + subscribe: (cb) => { + updateCb = cb; + return (): void => { + updateCb = (): void => undefined; + }; + }, + getCurrentValue: () => items, + }; + + const registerSidebarItem = (item: SidebarItem): void => { + items.push(item); + updateCb(); + }; + + const unregisterSidebarItem = (i18nLabel: SidebarItem['i18nLabel']): void => { + const index = items.findIndex((item) => item.i18nLabel === i18nLabel); + delete items[index]; + updateCb(); + }; + + return { + registerSidebarItem, + unregisterSidebarItem, + itemsSubscription, + }; +}; diff --git a/client/lib/download.spec.ts b/client/lib/download.spec.ts new file mode 100644 index 0000000000000..a50dbd9e3478c --- /dev/null +++ b/client/lib/download.spec.ts @@ -0,0 +1,90 @@ +import 'jsdom-global/register'; +import chai from 'chai'; +import chaiSpies from 'chai-spies'; +import { after, before, describe, it } from 'mocha'; + +import { download, downloadAs, downloadCsvAs, downloadJsonAs } from './download'; + +chai.use(chaiSpies); + +const withURL = (): void => { + let createObjectURL: typeof URL.createObjectURL; + let revokeObjectURL: typeof URL.revokeObjectURL; + + before(() => { + const blobs = new Map(); + + createObjectURL = window.URL.createObjectURL; + revokeObjectURL = window.URL.revokeObjectURL; + + window.URL.createObjectURL = (blob: Blob): string => { + const uuid = Math.random().toString(36).slice(2); + const url = `blob://${ uuid }`; + blobs.set(url, blob); + return url; + }; + + window.URL.revokeObjectURL = (url: string): void => { + blobs.delete(url); + }; + }); + + after(() => { + window.URL.createObjectURL = createObjectURL; + window.URL.revokeObjectURL = revokeObjectURL; + }); +}; + +describe('download', () => { + it('should work', () => { + const listener = chai.spy(); + document.addEventListener('click', listener, false); + + download('about:blank', 'blank'); + + document.removeEventListener('click', listener, false); + chai.expect(listener).to.have.been.called(); + }); +}); + +describe('downloadAs', () => { + withURL(); + + it('should work', () => { + const listener = chai.spy(); + document.addEventListener('click', listener, false); + + downloadAs({ data: [] }, 'blank'); + + document.removeEventListener('click', listener, false); + chai.expect(listener).to.have.been.called(); + }); +}); + +describe('downloadJsonAs', () => { + withURL(); + + it('should work', () => { + const listener = chai.spy(); + document.addEventListener('click', listener, false); + + downloadJsonAs({}, 'blank'); + + document.removeEventListener('click', listener, false); + chai.expect(listener).to.have.been.called(); + }); +}); + +describe('downloadCsvAs', () => { + withURL(); + + it('should work', () => { + const listener = chai.spy(); + document.addEventListener('click', listener, false); + + downloadCsvAs([[1, 2, 3], [4, 5, 6]], 'blank'); + + document.removeEventListener('click', listener, false); + chai.expect(listener).to.have.been.called(); + }); +}); diff --git a/client/lib/download.ts b/client/lib/download.ts new file mode 100644 index 0000000000000..61bfa7d843482 --- /dev/null +++ b/client/lib/download.ts @@ -0,0 +1,43 @@ +export const download = (href: string, filename: string): void => { + const anchorElement = document.createElement('a'); + anchorElement.download = filename; + anchorElement.href = href; + anchorElement.target = '_blank'; + document.body.appendChild(anchorElement); + anchorElement.click(); + document.body.removeChild(anchorElement); +}; + +export const downloadAs = ({ data, ...options }: { data: BlobPart[] } & BlobPropertyBag, filename: string): void => { + const blob = new Blob(data, options); + + if (navigator.msSaveOrOpenBlob) { + navigator.msSaveOrOpenBlob(blob); + return; + } + + const URL = window.webkitURL ?? window.URL; + const blobUrl = URL.createObjectURL(blob); + + download(blobUrl, filename); + + URL.revokeObjectURL(blobUrl); +}; + +export const downloadJsonAs = (jsonObject: unknown, basename: string): void => { + downloadAs({ + data: [decodeURIComponent(encodeURI(JSON.stringify(jsonObject, null, 2)))], + type: 'application/json;charset=utf-8', + }, `${ basename }.json`); +}; + +export const downloadCsvAs = (csvData: unknown[][], basename: string): void => { + const escapeCell = (cell: unknown): string => `"${ String(cell).replace(/"/g, '""') }"`; + const content = csvData.reduce((content, row) => `${ content + row.map(escapeCell).join(';') }\n`, ''); + + downloadAs({ + data: [decodeURIComponent(encodeURI(content))], + type: 'text/csv;charset=utf-8', + endings: 'native', + }, `${ basename }.csv`); +}; diff --git a/client/lib/escapeRegExp.spec.ts b/client/lib/escapeRegExp.spec.ts new file mode 100644 index 0000000000000..ba163debdcc53 --- /dev/null +++ b/client/lib/escapeRegExp.spec.ts @@ -0,0 +1,80 @@ +import assert from 'assert'; + +import { describe, it } from 'mocha'; + +import { escapeRegExp } from './escapeRegExp'; + +describe('escapeRegExp', () => { + it('should keep strings with letters only unchanged', () => { + assert.equal(escapeRegExp('word'), 'word'); + }); + + it('should escape slashes', () => { + assert.equal(escapeRegExp('/slashes/'), '\\/slashes\\/'); + assert.equal(escapeRegExp('\\backslashes\\'), '\\\\backslashes\\\\'); + assert.equal(escapeRegExp('\\border of word'), '\\\\border of word'); + }); + + it('should escape special group', () => { + assert.equal(escapeRegExp('(?:non-capturing)'), '\\(\\?:non\\-capturing\\)'); + assert.equal( + new RegExp(`${ escapeRegExp('(?:') }([^)]+)`).exec('(?:non-capturing)')?.[1], + 'non-capturing', + ); + + assert.equal(escapeRegExp('(?=positive-lookahead)'), '\\(\\?=positive\\-lookahead\\)'); + assert.equal( + new RegExp(`${ escapeRegExp('(?=') }([^)]+)`).exec('(?=positive-lookahead)')?.[1], + 'positive-lookahead', + ); + + assert.equal(escapeRegExp('(?<=positive-lookbehind)'), '\\(\\?<=positive\\-lookbehind\\)'); + assert.equal( + new RegExp(`${ escapeRegExp('(?<=') }([^)]+)`).exec('(?<=positive-lookbehind)')?.[1], + 'positive-lookbehind', + ); + + assert.equal(escapeRegExp('(?!negative-lookahead)'), '\\(\\?!negative\\-lookahead\\)'); + assert.equal( + new RegExp(`${ escapeRegExp('(?!') }([^)]+)`).exec('(?!negative-lookahead)')?.[1], + 'negative-lookahead', + ); + + assert.equal(escapeRegExp('(?')).exec('
')?.[0], '
'); + + assert.equal(escapeRegExp('{5,2}'), '\\{5,2\\}'); + + assert.equal( + escapeRegExp('/([.*+?^=!:${}()|[\\]\\/\\\\])/g'), + '\\/\\(\\[\\.\\*\\+\\?\\^=!:\\$\\{\\}\\(\\)\\|\\[\\\\\\]\\\\\\/\\\\\\\\\\]\\)\\/g', + ); + }); + + it('should not escape whitespace', () => { + assert.equal(escapeRegExp('\\n\\r\\t'), '\\\\n\\\\r\\\\t'); + assert.equal(escapeRegExp('\n\r\t'), '\n\r\t'); + }); + + it('throws an error for non-string argument', () => { + // @ts-ignore + assert.throws(() => escapeRegExp(false)); + + // @ts-ignore + assert.throws(() => escapeRegExp()); + + // @ts-ignore + assert.throws(() => escapeRegExp(null)); + + // @ts-ignore + assert.throws(() => escapeRegExp(42)); + }); +}); diff --git a/client/lib/escapeRegExp.ts b/client/lib/escapeRegExp.ts new file mode 100644 index 0000000000000..62a0fc5cc87d6 --- /dev/null +++ b/client/lib/escapeRegExp.ts @@ -0,0 +1,7 @@ +export const escapeRegExp = (input: string): string => { + if (typeof input !== 'string') { + throw new TypeError('string expected'); + } + + return input.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); +}; diff --git a/client/lib/getDateRange.ts b/client/lib/getDateRange.ts new file mode 100644 index 0000000000000..6a7cf3b296ceb --- /dev/null +++ b/client/lib/getDateRange.ts @@ -0,0 +1,15 @@ +import moment from 'moment'; + +export const getDateRange = (): { + start: string; + end: string; +} => { + const today = moment(new Date()); + const start = moment(new Date(today.year(), today.month(), today.date(), 0, 0, 0)); + const end = moment(new Date(today.year(), today.month(), today.date(), 23, 59, 59)); + + return { + start: start.toISOString(), + end: end.toISOString(), + }; +}; diff --git a/client/lib/getUserEmailAddress.ts b/client/lib/getUserEmailAddress.ts new file mode 100644 index 0000000000000..f257868484560 --- /dev/null +++ b/client/lib/getUserEmailAddress.ts @@ -0,0 +1,4 @@ +import type { IUser } from '../../definition/IUser'; + +export const getUserEmailAddress = (user: IUser): string | undefined => + user.emails?.find(({ address }) => !!address)?.address; diff --git a/client/lib/saveFile.js b/client/lib/saveFile.js deleted file mode 100644 index ff21605daaf27..0000000000000 --- a/client/lib/saveFile.js +++ /dev/null @@ -1,9 +0,0 @@ -export const saveFile = (content, name = 'download') => { - const blob = new Blob([content], { type: 'text/plain' }); - const anchor = document.createElement('a'); - - anchor.download = name; - anchor.href = (window.webkitURL || window.URL).createObjectURL(blob); - anchor.dataset.downloadurl = ['text/plain', anchor.download, anchor.href].join(':'); - anchor.click(); -}; diff --git a/client/helpers/statusColors.js b/client/lib/statusColors.ts similarity index 100% rename from client/helpers/statusColors.js rename to client/lib/statusColors.ts diff --git a/client/lib/toastr.js b/client/lib/toastr.js deleted file mode 100644 index 80846fac990da..0000000000000 --- a/client/lib/toastr.js +++ /dev/null @@ -1 +0,0 @@ -import 'toastr/build/toastr.min.css'; diff --git a/client/lib/userData.js b/client/lib/userData.js deleted file mode 100644 index 6a3c5da44b84f..0000000000000 --- a/client/lib/userData.js +++ /dev/null @@ -1,41 +0,0 @@ -import { ReactiveVar } from 'meteor/reactive-var'; -import { Meteor } from 'meteor/meteor'; - -import { APIClient } from '../../app/utils/client'; -import { Users } from '../../app/models/client'; -import { Notifications } from '../../app/notifications/client'; - -export const isSyncReady = new ReactiveVar(false); - -function updateUser(userData) { - const user = Users.findOne({ _id: userData._id }); - if (!user || !user._updatedAt || userData._updatedAt > user._updatedAt.toISOString()) { - userData._updatedAt = new Date(userData._updatedAt); - return Meteor.users.upsert({ _id: userData._id }, userData); - } - // delete data already on user's collection as those are newer - Object.keys(user).forEach((key) => delete userData[key]); - Meteor.users.update({ _id: user._id }, { $set: userData }); -} - -const onUserEvents = { - inserted: (_id, data) => Meteor.users.insert(data), - updated: (_id, { diff }) => Meteor.users.upsert({ _id }, { $set: diff }), - removed: (_id) => Meteor.users.remove({ _id }), -}; - -export const syncUserdata = async (uid) => { - if (!uid) { - return; - } - - await Notifications.onUser('userData', ({ type, id, ...data }) => onUserEvents[type](uid, data)); - - const userData = await APIClient.v1.get('me'); - if (userData) { - updateUser(userData); - } - isSyncReady.set(true); - - return userData; -}; diff --git a/client/lib/userData.ts b/client/lib/userData.ts new file mode 100644 index 0000000000000..2596af5b6519f --- /dev/null +++ b/client/lib/userData.ts @@ -0,0 +1,71 @@ +import { ReactiveVar } from 'meteor/reactive-var'; +import { Meteor } from 'meteor/meteor'; + +import { APIClient } from '../../app/utils/client'; +import { Users } from '../../app/models/client'; +import { Notifications } from '../../app/notifications/client'; +import type { IUser } from '../../definition/IUser'; + +export const isSyncReady = new ReactiveVar(false); + +type RawUserData = Omit & { + _updatedAt: string; +}; + +const updateUser = (userData: IUser & { _updatedAt: Date }): void => { + const user: IUser = Users.findOne({ _id: userData._id }); + + if (!user || !user._updatedAt || (user._updatedAt.getTime() < userData._updatedAt.getTime())) { + Meteor.users.upsert({ _id: userData._id }, userData); + return; + } + + // delete data already on user's collection as those are newer + Object.keys(user).forEach((key) => { + delete userData[key as keyof IUser]; + }); + Meteor.users.update({ _id: user._id }, { $set: userData }); +}; + +type UserDataNotification = { + id: unknown; +} +& ( + ({ type: 'inserted' } & Meteor.User) + | ({ type: 'updated' } & Partial) + | ({ type: 'removed' }) +) + +export const synchronizeUserData = async (uid: Meteor.User['_id']): Promise => { + if (!uid) { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + await Notifications.onUser('userData', ({ type, id, ...data }: UserDataNotification) => { + switch (type) { + case 'inserted': + Meteor.users.insert(data); + break; + + case 'updated': + Meteor.users.upsert({ _id: uid }, { $set: data }); + break; + + case 'removed': + Meteor.users.remove({ _id: uid }); + break; + } + }); + + const userData: RawUserData = await APIClient.v1.get('me'); + if (userData) { + updateUser({ + ...userData, + _updatedAt: new Date(userData._updatedAt), + }); + } + isSyncReady.set(true); + + return userData; +}; diff --git a/client/main.js b/client/main.js index 6fad840df323c..45632e26a53d8 100644 --- a/client/main.js +++ b/client/main.js @@ -7,7 +7,6 @@ import '../imports/startup/client'; import '../lib/RegExp'; import '../ee/client'; -import './lib/toastr'; import './templateHelpers'; import './methods/deleteMessage'; import './methods/hideRoom'; diff --git a/client/omnichannel/agents/AgentEdit.js b/client/omnichannel/agents/AgentEdit.js index 4654e77a5c21f..6d10f77349260 100644 --- a/client/omnichannel/agents/AgentEdit.js +++ b/client/omnichannel/agents/AgentEdit.js @@ -11,7 +11,7 @@ import { UserInfo } from '../../components/basic/UserInfo'; import { useEndpointDataExperimental, ENDPOINT_STATES } from '../../hooks/useEndpointDataExperimental'; import { FormSkeleton } from './Skeleton'; import { useForm } from '../../hooks/useForm'; -import { getUserEmailAddress } from '../../helpers/getUserEmailAddress'; +import { getUserEmailAddress } from '../../lib/getUserEmailAddress'; import { useRoute } from '../../contexts/RouterContext'; import { formsSubscription } from '../additionalForms'; diff --git a/client/omnichannel/realTimeMonitoring/RealTimeMonitoringPage.js b/client/omnichannel/realTimeMonitoring/RealTimeMonitoringPage.js index 8de043577b441..3a14841f7e502 100644 --- a/client/omnichannel/realTimeMonitoring/RealTimeMonitoringPage.js +++ b/client/omnichannel/realTimeMonitoring/RealTimeMonitoringPage.js @@ -14,7 +14,7 @@ import AgentsOverview from './overviews/AgentsOverview'; import ChatsOverview from './overviews/ChatsOverview'; import ProductivityOverview from './overviews/ProductivityOverview'; import DepartmentAutoComplete from '../DepartmentAutoComplete'; -import { getDateRange } from '../../helpers/getDateRange'; +import { getDateRange } from '../../lib/getDateRange'; import { useTranslation } from '../../contexts/TranslationContext'; const dateRange = getDateRange(); diff --git a/client/omnichannel/routes.js b/client/omnichannel/routes.js index 27dce1f438326..0ced0524f5ed0 100644 --- a/client/omnichannel/routes.js +++ b/client/omnichannel/routes.js @@ -1,7 +1,7 @@ import { HTML } from 'meteor/htmljs'; import { createTemplateForComponent } from '../reactAdapters'; -import { createRouteGroup } from '../helpers/createRouteGroup'; +import { createRouteGroup } from '../lib/createRouteGroup'; createTemplateForComponent('omnichannelFlex', () => import('./sidebar/OmnichannelSidebar'), { renderContainerView: () => HTML.DIV({ style: 'height: 100%; position: relative;' }), // eslint-disable-line new-cap diff --git a/client/omnichannel/sidebarItems.js b/client/omnichannel/sidebarItems.js index 56925ee6d967f..ffc91f63a390e 100644 --- a/client/omnichannel/sidebarItems.js +++ b/client/omnichannel/sidebarItems.js @@ -1,5 +1,5 @@ import { hasPermission } from '../../app/authorization/client'; -import { createSidebarItems } from '../helpers/createSidebarItems'; +import { createSidebarItems } from '../lib/createSidebarItems'; export const { registerSidebarItem: registerOmnichannelSidebarItem, diff --git a/client/startup/startup.js b/client/startup/startup.js index ae86ac6940386..5e55d084c208e 100644 --- a/client/startup/startup.js +++ b/client/startup/startup.js @@ -11,7 +11,7 @@ import hljs from '../../app/markdown/lib/hljs'; import { fireGlobalEvent, alerts } from '../../app/ui-utils'; import { getUserPreference, t } from '../../app/utils'; import 'highlight.js/styles/github.css'; -import { syncUserdata } from '../lib/userData'; +import { synchronizeUserData } from '../lib/userData'; hljs.initHighlightingOnLoad(); @@ -42,7 +42,7 @@ Meteor.startup(function() { return; } - const user = await syncUserdata(uid); + const user = await synchronizeUserData(uid); if (!user) { return; } diff --git a/ee/app/engagement-dashboard/client/components/ChannelsTab/TableSection.js b/ee/app/engagement-dashboard/client/components/ChannelsTab/TableSection.js index 443a2a4abd8e6..ab9937795cab2 100644 --- a/ee/app/engagement-dashboard/client/components/ChannelsTab/TableSection.js +++ b/ee/app/engagement-dashboard/client/components/ChannelsTab/TableSection.js @@ -7,10 +7,7 @@ import { useEndpointData } from '../../../../../../client/hooks/useEndpointData' import Growth from '../../../../../../client/components/data/Growth'; import { Section } from '../Section'; import { ActionButton } from '../../../../../../client/components/basic/Buttons/ActionButton'; -import { saveFile } from '../../../../../../client/lib/saveFile'; - -const convertDataToCSV = (data) => `// type, name, messagesCount, updatedAt, createdAt -${ data.map(({ createdAt, messagesCount, name, t, updatedAt }) => `${ t }, ${ name }, ${ messagesCount }, ${ updatedAt }, ${ createdAt }`).join('\n') }`; +import { downloadCsvAs } from '../../../../../../client/lib/download'; export function TableSection() { const t = useTranslation(); @@ -79,7 +76,14 @@ export function TableSection() { }, [data]); const downloadData = () => { - saveFile(convertDataToCSV(channels), `Channels_start_${ params.start }_end_${ params.end }.csv`); + const data = channels.map(({ + createdAt, + messagesCount, + name, + t, + updatedAt, + }) => [t, name, messagesCount, updatedAt, createdAt]); + downloadCsvAs(data, `Channels_start_${ params.start }_end_${ params.end }`); }; return