From 8ce8aab35e4c39b077d6c21769a5f2c6325b2723 Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Tue, 26 Mar 2024 23:28:22 +0530 Subject: [PATCH 001/131] fix: prevent duplicate api calls in forwarding livechat rooms (#32021) --- .changeset/strong-bananas-flash.md | 5 +++++ .../Omnichannel/modals/ForwardChatModal.tsx | 14 +++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 .changeset/strong-bananas-flash.md diff --git a/.changeset/strong-bananas-flash.md b/.changeset/strong-bananas-flash.md new file mode 100644 index 000000000000..d41697836d11 --- /dev/null +++ b/.changeset/strong-bananas-flash.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixed duplicate API calls during livechat room forwarding by adding loading state for submit button diff --git a/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx b/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx index 5e65c43a957c..977e298e638e 100644 --- a/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx +++ b/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx @@ -36,7 +36,15 @@ const ForwardChatModal = ({ const getUserData = useEndpoint('GET', '/v1/users.info'); const idleAgentsAllowedForForwarding = useSetting('Livechat_enabled_when_agent_idle') as boolean; - const { getValues, handleSubmit, register, setFocus, setValue, watch } = useForm(); + const { + getValues, + handleSubmit, + register, + setFocus, + setValue, + watch, + formState: { isSubmitting }, + } = useForm(); useEffect(() => { setFocus('comment'); @@ -71,7 +79,7 @@ const ForwardChatModal = ({ uid = user?._id; } - onForward(departmentId, uid, comment); + await onForward(departmentId, uid, comment); }, [getUserData, onForward], ); @@ -146,7 +154,7 @@ const ForwardChatModal = ({ - From 550a13ac0d0e71e47d85b9dfa6865eac4327bc25 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 27 Mar 2024 11:09:47 -0300 Subject: [PATCH 002/131] ci: Remove unnecessary condition skiping yarn install (#32078) --- .github/actions/setup-node/action.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/actions/setup-node/action.yml b/.github/actions/setup-node/action.yml index d3a463492cbb..0e921e81f1f3 100644 --- a/.github/actions/setup-node/action.yml +++ b/.github/actions/setup-node/action.yml @@ -41,6 +41,5 @@ runs: cache: 'yarn' - name: yarn install - if: steps.cache-node-modules.outputs.cache-hit != 'true' shell: bash run: yarn From 5b7623dfe45a450c7d18094617d176117ccc1e39 Mon Sep 17 00:00:00 2001 From: Martin Schoeler Date: Wed, 27 Mar 2024 11:24:35 -0300 Subject: [PATCH 003/131] test(Livechat): `registerguest()` with different guests (#32072) --- .../omnichannel-livechat-api.spec.ts | 50 +++++++++++++++++++ .../omnichannel-livechat-embedded.ts | 2 +- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts index 1c6cf2404fe4..2800709ef9ba 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts @@ -405,6 +405,56 @@ test.describe('OC - Livechat API', () => { }); }); + test('OC - Livechat API - registerGuest different guests', async () => { + const registerGuestVisitor1 = { + name: faker.person.firstName(), + email: faker.internet.email(), + token: faker.string.uuid(), + }; + + const registerGuestVisitor2 = { + name: faker.person.firstName(), + email: faker.internet.email(), + token: faker.string.uuid(), + }; + + await test.step('Expect registerGuest to create guest 1', async () => { + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.maximizeWidget()); + await expect(poLiveChat.page.frameLocator('#rocketchat-iframe').getByText('Start Chat')).toBeVisible(); + + await poLiveChat.page.evaluate( + (registerGuestVisitor1) => window.RocketChat.livechat.registerGuest(registerGuestVisitor1), + registerGuestVisitor1, + ); + + await expect(poLiveChat.page.frameLocator('#rocketchat-iframe').getByText('Start Chat')).not.toBeVisible(); + + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor_1'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await expect(poLiveChat.txtChatMessage('this_a_test_message_from_visitor_1')).toBeVisible(); + + }); + + await test.step('Expect registerGuest to create guest 2', async () => { + await poLiveChat.page.evaluate( + (registerGuestVisitor2) => window.RocketChat.livechat.registerGuest(registerGuestVisitor2), + registerGuestVisitor2, + ); + + await poLiveChat.page.frameLocator('#rocketchat-iframe').getByText('this_a_test_message_from_visitor').waitFor({ state: 'hidden' }); + + await expect(poLiveChat.page.frameLocator('#rocketchat-iframe').getByText('Start Chat')).not.toBeVisible(); + + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor_2'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await poLiveChat.txtChatMessage('this_a_test_message_from_visitor_2').waitFor({ state: 'visible' }); + await expect(poLiveChat.txtChatMessage('this_a_test_message_from_visitor_2')).toBeVisible(); + + }); + }); + test('OC - Livechat API - registerGuest multiple times', async () => { const registerGuestVisitor = { name: faker.person.firstName(), diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel-livechat-embedded.ts b/apps/meteor/tests/e2e/page-objects/omnichannel-livechat-embedded.ts index 8d5fff57412b..db4993eaaac6 100644 --- a/apps/meteor/tests/e2e/page-objects/omnichannel-livechat-embedded.ts +++ b/apps/meteor/tests/e2e/page-objects/omnichannel-livechat-embedded.ts @@ -40,7 +40,7 @@ export class OmnichannelLiveChatEmbedded { } txtChatMessage(message: string): Locator { - return this.page.frameLocator('#rocketchat-iframe').locator(`text="${message}"`); + return this.page.frameLocator('#rocketchat-iframe').locator(`li >> text="${message}"`); } async closeChat(): Promise { From c9a92e6ea2379d86258a0ee892335541ed7ac1b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Jaeger=20Foresti?= <60678893+juliajforesti@users.noreply.github.com> Date: Thu, 28 Mar 2024 10:08:49 -0300 Subject: [PATCH 004/131] feat: `ConnectionStatusBar` redesign (#32055) --- .changeset/two-suns-marry.md | 6 ++ .../connectionStatus/ConnectionStatusBar.tsx | 75 ++++++++++++------- packages/i18n/src/locales/en.i18n.json | 11 +-- 3 files changed, 60 insertions(+), 32 deletions(-) create mode 100644 .changeset/two-suns-marry.md diff --git a/.changeset/two-suns-marry.md b/.changeset/two-suns-marry.md new file mode 100644 index 000000000000..3eae6383a62f --- /dev/null +++ b/.changeset/two-suns-marry.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +feat: `ConnectionStatusBar` redesign diff --git a/apps/meteor/client/components/connectionStatus/ConnectionStatusBar.tsx b/apps/meteor/client/components/connectionStatus/ConnectionStatusBar.tsx index 6046aecc0bae..5a26392b60b9 100644 --- a/apps/meteor/client/components/connectionStatus/ConnectionStatusBar.tsx +++ b/apps/meteor/client/components/connectionStatus/ConnectionStatusBar.tsx @@ -1,11 +1,37 @@ -import { Box, Icon } from '@rocket.chat/fuselage'; +import { css } from '@rocket.chat/css-in-js'; +import { Box, Button, Icon, Palette } from '@rocket.chat/fuselage'; import { useConnectionStatus } from '@rocket.chat/ui-contexts'; -import type { MouseEvent } from 'react'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { useReconnectCountdown } from './useReconnectCountdown'; +const connectionStatusBarStyle = css` + color: ${Palette.statusColor['status-font-on-warning']}; + background-color: ${Palette.surface['surface-tint']}; + border-color: ${Palette.statusColor['status-font-on-warning']}; + + position: fixed; + z-index: 1000000; + + display: flex; + justify-content: space-between; + align-items: center; + + .rcx-connection-status-bar--wrapper { + display: flex; + align-items: center; + column-gap: 8px; + } + .rcx-connection-status-bar--content { + display: flex; + align-items: center; + column-gap: 8px; + } + .rcx-connection-status-bar--info { + color: ${Palette.text['font-default']}; + } +`; function ConnectionStatusBar() { const { connected, retryTime, status, reconnect } = useConnectionStatus(); const reconnectCountdown = useReconnectCountdown(retryTime, status); @@ -15,37 +41,32 @@ function ConnectionStatusBar() { return null; } - const handleRetryClick = (event: MouseEvent) => { - event.preventDefault(); - reconnect?.(); - }; - return ( - {' '} - - {t('meteor_status', { context: status })} - {status === 'waiting' && <> {t('meteor_status_reconnect_in', { count: reconnectCountdown })}} - {['waiting', 'offline'].includes(status) && ( - <> - {' '} - - {t('meteor_status_try_now', { context: status })} - - - )} - + + + + {t('meteor_status', { context: status })} + {['waiting', 'failed', 'offline'].includes(status) && ( + + {status === 'waiting' ? t('meteor_status_reconnect_in', { count: reconnectCountdown }) : t('meteor_status_try_again_later')} + + )} + + + ); } diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index ab9eaf4779e1..8b9ab62fe910 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -3592,13 +3592,14 @@ "Meta_robots": "Robots", "meteor_status_connected": "Connected", "meteor_status_connecting": "Connecting...", - "meteor_status_failed": "The server connection failed", - "meteor_status_offline": "Offline mode.", - "meteor_status_reconnect_in_one": "trying again in 1 second...", - "meteor_status_reconnect_in_other": "trying again in {{count}} seconds...", + "meteor_status_failed": "Connection attempt failed", + "meteor_status_offline": "You’re offline", + "meteor_status_reconnect_in_one": "Trying to reconnect in 1 second", + "meteor_status_reconnect_in_other": "Trying to reconnect in {{count}} seconds", + "meteor_status_try_again_later": "Please try again later or ask your workspace admin for assistance", "meteor_status_try_now_offline": "Connect again", "meteor_status_try_now_waiting": "Try now", - "meteor_status_waiting": "Waiting for server connection,", + "meteor_status_waiting": "You’re offline", "Method": "Method", "Mic_on": "Mic On", "Microphone": "Microphone", From c3fe00736370db6e54d88e930a4817d37493a709 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Thu, 28 Mar 2024 14:19:00 -0300 Subject: [PATCH 005/131] chore: register a dummy Read Receipts model in CE (#32022) --- apps/meteor/server/models/ReadReceipts.ts | 5 + apps/meteor/server/models/dummy/BaseDummy.ts | 196 ++++++++++++++++++ .../server/models/dummy/ReadReceipts.ts | 58 ++++++ apps/meteor/server/models/startup.ts | 1 + packages/models/src/proxify.ts | 10 +- 5 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 apps/meteor/server/models/ReadReceipts.ts create mode 100644 apps/meteor/server/models/dummy/BaseDummy.ts create mode 100644 apps/meteor/server/models/dummy/ReadReceipts.ts diff --git a/apps/meteor/server/models/ReadReceipts.ts b/apps/meteor/server/models/ReadReceipts.ts new file mode 100644 index 000000000000..5faf90c33546 --- /dev/null +++ b/apps/meteor/server/models/ReadReceipts.ts @@ -0,0 +1,5 @@ +import { registerModel } from '@rocket.chat/models'; + +import { ReadReceiptsDummy } from './dummy/ReadReceipts'; + +registerModel('IReadReceiptsModel', new ReadReceiptsDummy(), false); diff --git a/apps/meteor/server/models/dummy/BaseDummy.ts b/apps/meteor/server/models/dummy/BaseDummy.ts new file mode 100644 index 000000000000..c417213f5a36 --- /dev/null +++ b/apps/meteor/server/models/dummy/BaseDummy.ts @@ -0,0 +1,196 @@ +import type { RocketChatRecordDeleted } from '@rocket.chat/core-typings'; +import type { DefaultFields, FindPaginated, IBaseModel, InsertionModel, ResultFields } from '@rocket.chat/model-typings'; +import { getCollectionName } from '@rocket.chat/models'; +import type { + BulkWriteOptions, + ChangeStream, + Collection, + DeleteOptions, + DeleteResult, + Document, + Filter, + FindCursor, + FindOptions, + InsertManyResult, + InsertOneOptions, + InsertOneResult, + ModifyResult, + UpdateFilter, + UpdateOptions, + UpdateResult, + WithId, +} from 'mongodb'; + +export class BaseDummy< + T extends { _id: string }, + C extends DefaultFields = undefined, + TDeleted extends RocketChatRecordDeleted = RocketChatRecordDeleted, +> implements IBaseModel +{ + public readonly col: Collection; + + private collectionName: string; + + constructor(protected name: string) { + this.collectionName = getCollectionName(name); + this.col = undefined as any; + } + + public async createIndexes(): Promise { + // nothing to do + } + + getCollectionName(): string { + return this.collectionName; + } + + async findOneAndUpdate(): Promise> { + return { + value: null, + ok: 1, + }; + } + + findOneById(_id: T['_id'], options?: FindOptions | undefined): Promise; + + findOneById

(_id: T['_id'], options?: FindOptions

): Promise

; + + async findOneById(_id: T['_id'], _options?: any): Promise { + return null; + } + + findOne(query?: Filter | T['_id'], options?: undefined): Promise; + + findOne

(query: Filter | T['_id'], options: FindOptions

): Promise

; + + async findOne

(_query: Filter | T['_id'], _options?: any): Promise | WithId

| null> { + return null; + } + + find(query?: Filter): FindCursor>; + + find

(query: Filter, options: FindOptions

): FindCursor

; + + find

( + _query: Filter | undefined, + _options?: FindOptions

, + ): FindCursor> | FindCursor> { + return undefined as any; + } + + findPaginated

(query: Filter, options?: FindOptions

): FindPaginated>>; + + findPaginated(_query: Filter, _options?: any): FindPaginated>> { + return { + cursor: undefined as any, + totalCount: Promise.resolve(0), + }; + } + + async update( + filter: Filter, + update: UpdateFilter | Partial, + options?: UpdateOptions & { multi?: true }, + ): Promise { + return this.updateOne(filter, update, options); + } + + async updateOne(_filter: Filter, _update: UpdateFilter | Partial, _options?: UpdateOptions): Promise { + return { + acknowledged: true, + matchedCount: 0, + modifiedCount: 0, + upsertedCount: 0, + upsertedId: '' as any, + }; + } + + async updateMany(filter: Filter, update: UpdateFilter | Partial, options?: UpdateOptions): Promise { + return this.updateOne(filter, update, options); + } + + async insertMany(_docs: InsertionModel[], _options?: BulkWriteOptions): Promise> { + return { + acknowledged: true, + insertedCount: 0, + insertedIds: {}, + }; + } + + async insertOne(_doc: InsertionModel, _options?: InsertOneOptions): Promise> { + return { + acknowledged: true, + insertedId: '' as any, + }; + } + + async removeById(_id: T['_id']): Promise { + return { + acknowledged: true, + deletedCount: 0, + }; + } + + async deleteOne(filter: Filter, options?: DeleteOptions & { bypassDocumentValidation?: boolean }): Promise { + return this.deleteMany(filter, options); + } + + async deleteMany(_filter: Filter, _options?: DeleteOptions): Promise { + return { + acknowledged: true, + deletedCount: 0, + }; + } + + // Trash + trashFind

( + _query: Filter, + _options?: FindOptions

, + ): FindCursor> | undefined { + return undefined as any; + } + + trashFindOneById(_id: TDeleted['_id']): Promise; + + trashFindOneById

(_id: TDeleted['_id'], options: FindOptions

): Promise

; + + async trashFindOneById

( + _id: TDeleted['_id'], + _options?: FindOptions

, + ): Promise | TDeleted> | null> { + return null; + } + + trashFindDeletedAfter(deletedAt: Date): FindCursor>; + + trashFindDeletedAfter

( + _deletedAt: Date, + _query?: Filter, + _options?: FindOptions

, + ): FindCursor> { + return undefined as any; + } + + trashFindPaginatedDeletedAfter

( + _deletedAt: Date, + _query?: Filter, + _options?: FindOptions

, + ): FindPaginated>> { + return { + cursor: undefined as any, + totalCount: Promise.resolve(0), + }; + } + + watch(_pipeline?: object[]): ChangeStream { + return undefined as any; + } + + async countDocuments(): Promise { + return 0; + } + + async estimatedDocumentCount(): Promise { + return 0; + } +} diff --git a/apps/meteor/server/models/dummy/ReadReceipts.ts b/apps/meteor/server/models/dummy/ReadReceipts.ts new file mode 100644 index 000000000000..90a5cbdf900f --- /dev/null +++ b/apps/meteor/server/models/dummy/ReadReceipts.ts @@ -0,0 +1,58 @@ +import type { IUser, IMessage, ReadReceipt } from '@rocket.chat/core-typings'; +import type { IReadReceiptsModel } from '@rocket.chat/model-typings'; +import type { FindCursor, DeleteResult, Filter, UpdateResult, Document } from 'mongodb'; + +import { BaseDummy } from './BaseDummy'; + +export class ReadReceiptsDummy extends BaseDummy implements IReadReceiptsModel { + constructor() { + super('read_receipts'); + } + + findByMessageId(_messageId: string): FindCursor { + return this.find({}); + } + + removeByUserId(_userId: string): Promise { + return this.deleteMany({}); + } + + removeByRoomId(_roomId: string): Promise { + return this.deleteMany({}); + } + + removeByRoomIds(_roomIds: string[]): Promise { + return this.deleteMany({}); + } + + removeByMessageId(_messageId: string): Promise { + return this.deleteMany({}); + } + + removeByMessageIds(_messageIds: string[]): Promise { + return this.deleteMany({}); + } + + removeOTRReceiptsUntilDate(_roomId: string, _until: Date): Promise { + return this.deleteMany({}); + } + + async removeByIdPinnedTimestampLimitAndUsers( + _roomId: string, + _ignorePinned: boolean, + _ignoreDiscussion: boolean, + _ts: Filter['ts'], + _users: IUser['_id'][], + _ignoreThreads: boolean, + ): Promise { + return this.deleteMany({}); + } + + setPinnedByMessageId(_messageId: string, _pinned = true): Promise { + return this.updateMany({}, {}); + } + + setAsThreadById(_messageId: string): Promise { + return this.updateMany({}, {}); + } +} diff --git a/apps/meteor/server/models/startup.ts b/apps/meteor/server/models/startup.ts index 14b26e0f188f..3d6dc6066689 100644 --- a/apps/meteor/server/models/startup.ts +++ b/apps/meteor/server/models/startup.ts @@ -68,3 +68,4 @@ import './Imports'; import './AppsTokens'; import './CronHistory'; import './Migrations'; +import './ReadReceipts'; diff --git a/packages/models/src/proxify.ts b/packages/models/src/proxify.ts index 54ff914f23f6..5cd2a4fb9bd5 100644 --- a/packages/models/src/proxify.ts +++ b/packages/models/src/proxify.ts @@ -38,7 +38,15 @@ function handler(namespace: string): ProxyHandler { }; } -export function registerModel>(name: string, instance: TModel | (() => TModel)): void { +export function registerModel>( + name: string, + instance: TModel | (() => TModel), + overwriteExisting = true, +): void { + if (!overwriteExisting && (lazyModels.has(name) || models.has(name))) { + return; + } + if (typeof instance === 'function') { lazyModels.set(name, instance); } else { From 65324bc6ba3de9f7e60c49c72d6da07a456bf2fa Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 28 Mar 2024 15:29:15 -0300 Subject: [PATCH 006/131] fix!: api login should not suggest which credential is wrong (#32086) --- .changeset/fuzzy-cherries-buy.md | 7 +++++++ .../lib/server/lib/loginErrorMessageOverride.js | 14 -------------- .../lib/server/lib/loginErrorMessageOverride.ts | 16 ++++++++++++++++ .../client/meteorOverrides/login/google.ts | 10 ---------- .../externals/meteor/accounts-base.d.ts | 10 +++++++++- .../end-to-end/api/31-failed-login-attempts.ts | 2 +- 6 files changed, 33 insertions(+), 26 deletions(-) create mode 100644 .changeset/fuzzy-cherries-buy.md delete mode 100644 apps/meteor/app/lib/server/lib/loginErrorMessageOverride.js create mode 100644 apps/meteor/app/lib/server/lib/loginErrorMessageOverride.ts diff --git a/.changeset/fuzzy-cherries-buy.md b/.changeset/fuzzy-cherries-buy.md new file mode 100644 index 000000000000..e185a148c917 --- /dev/null +++ b/.changeset/fuzzy-cherries-buy.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": major +--- + +Api login should not suggest which credential is wrong (password/username) + +Failed login attemps will always return `Unauthorized` instead of the internal fail reason diff --git a/apps/meteor/app/lib/server/lib/loginErrorMessageOverride.js b/apps/meteor/app/lib/server/lib/loginErrorMessageOverride.js deleted file mode 100644 index 4e054b81b2cf..000000000000 --- a/apps/meteor/app/lib/server/lib/loginErrorMessageOverride.js +++ /dev/null @@ -1,14 +0,0 @@ -// Do not disclose if user exists when password is invalid -import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; - -const { _runLoginHandlers } = Accounts; -Accounts._runLoginHandlers = function (methodInvocation, options) { - const result = _runLoginHandlers.call(Accounts, methodInvocation, options); - - if (result.error && result.error.reason === 'Incorrect password') { - result.error = new Meteor.Error(403, 'User not found'); - } - - return result; -}; diff --git a/apps/meteor/app/lib/server/lib/loginErrorMessageOverride.ts b/apps/meteor/app/lib/server/lib/loginErrorMessageOverride.ts new file mode 100644 index 000000000000..e2a6e0d10581 --- /dev/null +++ b/apps/meteor/app/lib/server/lib/loginErrorMessageOverride.ts @@ -0,0 +1,16 @@ +// Do not disclose if user exists when password is invalid +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; + +const { _runLoginHandlers } = Accounts; + +Accounts._options.ambiguousErrorMessages = true; +Accounts._runLoginHandlers = async function (methodInvocation, options) { + const result = await _runLoginHandlers.call(Accounts, methodInvocation, options); + + if (result.error instanceof Meteor.Error) { + result.error = new Meteor.Error(401, 'User not found'); + } + + return result; +}; diff --git a/apps/meteor/client/meteorOverrides/login/google.ts b/apps/meteor/client/meteorOverrides/login/google.ts index 2742cade15d2..4e99ac3a281b 100644 --- a/apps/meteor/client/meteorOverrides/login/google.ts +++ b/apps/meteor/client/meteorOverrides/login/google.ts @@ -8,16 +8,6 @@ import { overrideLoginMethod, type LoginCallback } from '../../lib/2fa/overrideL import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn'; import { createOAuthTotpLoginMethod } from './oauth'; -declare module 'meteor/accounts-base' { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace Accounts { - export const _options: { - restrictCreationByEmailDomain?: string | (() => string); - forbidClientAccountCreation?: boolean | undefined; - }; - } -} - declare module 'meteor/meteor' { // eslint-disable-next-line @typescript-eslint/no-namespace namespace Meteor { diff --git a/apps/meteor/definition/externals/meteor/accounts-base.d.ts b/apps/meteor/definition/externals/meteor/accounts-base.d.ts index 3f0b148120e7..f51c2f383987 100644 --- a/apps/meteor/definition/externals/meteor/accounts-base.d.ts +++ b/apps/meteor/definition/externals/meteor/accounts-base.d.ts @@ -22,7 +22,7 @@ declare module 'meteor/accounts-base' { function _insertLoginToken(userId: string, token: { token: string; when: Date }): void; - function _runLoginHandlers(methodInvocation: T, loginRequest: Record): LoginMethodResult | undefined; + function _runLoginHandlers(methodInvocation: T, loginRequest: Record): Promise; function registerLoginHandler(name: string, handler: (options: any) => undefined | object): void; @@ -54,6 +54,14 @@ declare module 'meteor/accounts-base' { const _accountData: Record; + interface AccountsServerOptions { + ambiguousErrorMessages?: boolean; + restrictCreationByEmailDomain?: string | (() => string); + forbidClientAccountCreation?: boolean | undefined; + } + + export const _options: AccountsServerOptions; + // eslint-disable-next-line @typescript-eslint/no-namespace namespace oauth { function credentialRequestCompleteHandler( diff --git a/apps/meteor/tests/end-to-end/api/31-failed-login-attempts.ts b/apps/meteor/tests/end-to-end/api/31-failed-login-attempts.ts index 92ea1ac56ed3..be124c440b97 100644 --- a/apps/meteor/tests/end-to-end/api/31-failed-login-attempts.ts +++ b/apps/meteor/tests/end-to-end/api/31-failed-login-attempts.ts @@ -48,7 +48,7 @@ describe('[Failed Login Attempts]', function () { .expect(401) .expect((res) => { expect(res.body).to.have.property('status', 'error'); - expect(res.body).to.have.property('message', 'Incorrect password'); + expect(res.body).to.have.property('message', 'Unauthorized'); }); } From 7f65cf0d3d70db9f6918db28cb19233fdb5e0b95 Mon Sep 17 00:00:00 2001 From: Marcos Spessatto Defendi Date: Thu, 28 Mar 2024 16:52:05 -0300 Subject: [PATCH 007/131] test: make login api tests fully independent (#31786) --- .../api/31-failed-login-attempts.ts | 50 ++++++++++--------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/31-failed-login-attempts.ts b/apps/meteor/tests/end-to-end/api/31-failed-login-attempts.ts index be124c440b97..906b19d0a931 100644 --- a/apps/meteor/tests/end-to-end/api/31-failed-login-attempts.ts +++ b/apps/meteor/tests/end-to-end/api/31-failed-login-attempts.ts @@ -17,25 +17,31 @@ describe('[Failed Login Attempts]', function () { before((done) => getCredentials(done)); - before(async () => { - await updateSetting('Block_Multiple_Failed_Logins_Enabled', true); - await updateSetting('Block_Multiple_Failed_Logins_By_Ip', true); - await updateSetting('Block_Multiple_Failed_Logins_By_User', true); - await updateSetting('Block_Multiple_Failed_Logins_Attempts_Until_Block_by_User', maxAttemptsByUser); - await updateSetting('Block_Multiple_Failed_Logins_Time_To_Unblock_By_User_In_Minutes', userBlockSeconds / 60); - await updateSetting('Block_Multiple_Failed_Logins_Attempts_Until_Block_By_Ip', maxAttemptsByIp); - await updateSetting('Block_Multiple_Failed_Logins_Time_To_Unblock_By_Ip_In_Minutes', ipBlockSeconds / 60); - - await updatePermission('logout-other-user', ['admin']); - }); - - after(async () => { - await updateSetting('Block_Multiple_Failed_Logins_Attempts_Until_Block_by_User', 10); - await updateSetting('Block_Multiple_Failed_Logins_Time_To_Unblock_By_User_In_Minutes', 5); - await updateSetting('Block_Multiple_Failed_Logins_Attempts_Until_Block_By_Ip', 50); - await updateSetting('Block_Multiple_Failed_Logins_Time_To_Unblock_By_Ip_In_Minutes', 5); - await updateSetting('Block_Multiple_Failed_Logins_Enabled', false); - }); + before(() => + Promise.all([ + updateSetting('Block_Multiple_Failed_Logins_Enabled', true), + updateSetting('Block_Multiple_Failed_Logins_By_Ip', true), + updateSetting('Block_Multiple_Failed_Logins_By_User', true), + updateSetting('Block_Multiple_Failed_Logins_Attempts_Until_Block_by_User', maxAttemptsByUser), + updateSetting('Block_Multiple_Failed_Logins_Time_To_Unblock_By_User_In_Minutes', userBlockSeconds / 60), + updateSetting('Block_Multiple_Failed_Logins_Attempts_Until_Block_By_Ip', maxAttemptsByIp), + updateSetting('Block_Multiple_Failed_Logins_Time_To_Unblock_By_Ip_In_Minutes', ipBlockSeconds / 60), + updatePermission('logout-other-user', ['admin']), + ]), + ); + + after(() => + Promise.all([ + updateSetting('Block_Multiple_Failed_Logins_Attempts_Until_Block_by_User', 10), + updateSetting('Block_Multiple_Failed_Logins_Time_To_Unblock_By_User_In_Minutes', 5), + updateSetting('Block_Multiple_Failed_Logins_Attempts_Until_Block_By_Ip', 50), + updateSetting('Block_Multiple_Failed_Logins_Time_To_Unblock_By_Ip_In_Minutes', 5), + updateSetting('Block_Multiple_Failed_Logins_Enabled', true), + updateSetting('Block_Multiple_Failed_Logins_By_Ip', true), + updateSetting('Block_Multiple_Failed_Logins_By_User', true), + updatePermission('logout-other-user', ['admin']), + ]), + ); async function shouldFailLoginWithUser(username: string, password: string) { await request @@ -163,11 +169,7 @@ describe('[Failed Login Attempts]', function () { userLogin = await createUser(); }); - afterEach(async () => { - await deleteUser(user); - await deleteUser(user2); - await deleteUser(userLogin); - }); + afterEach(() => Promise.all([deleteUser(user), deleteUser(user2), deleteUser(userLogin)])); afterEach(async () => { // reset counter From b8aefc2c73a09372bd1445f5d86452d511ba8312 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 28 Mar 2024 17:07:46 -0300 Subject: [PATCH 008/131] chore: bump version to 7.0.0-develop --- apps/meteor/app/utils/rocketchat.info | 2 +- apps/meteor/package.json | 2 +- package.json | 2 +- packages/core-typings/package.json | 2 +- packages/rest-typings/package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/meteor/app/utils/rocketchat.info b/apps/meteor/app/utils/rocketchat.info index f2031f21f05d..34642c087e2e 100644 --- a/apps/meteor/app/utils/rocketchat.info +++ b/apps/meteor/app/utils/rocketchat.info @@ -1,3 +1,3 @@ { - "version": "6.7.0-develop" + "version": "7.0.0-develop" } diff --git a/apps/meteor/package.json b/apps/meteor/package.json index b11d4a8b99e8..7c51c1a92a3a 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -1,7 +1,7 @@ { "name": "@rocket.chat/meteor", "description": "The Ultimate Open Source WebChat Platform", - "version": "6.7.0-develop", + "version": "7.0.0-develop", "private": true, "author": { "name": "Rocket.Chat", diff --git a/package.json b/package.json index 1498a54ec200..4e0b99fa9daa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rocket.chat", - "version": "6.7.0-develop", + "version": "7.0.0-develop", "description": "Rocket.Chat Monorepo", "main": "index.js", "private": true, diff --git a/packages/core-typings/package.json b/packages/core-typings/package.json index f5ca5e231fa3..71aa4da0906d 100644 --- a/packages/core-typings/package.json +++ b/packages/core-typings/package.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/package", "name": "@rocket.chat/core-typings", "private": true, - "version": "6.7.0-develop", + "version": "7.0.0-develop", "devDependencies": { "@rocket.chat/eslint-config": "workspace:^", "eslint": "~8.45.0", diff --git a/packages/rest-typings/package.json b/packages/rest-typings/package.json index 33c6f23cbc55..3f880c5b20d3 100644 --- a/packages/rest-typings/package.json +++ b/packages/rest-typings/package.json @@ -1,7 +1,7 @@ { "name": "@rocket.chat/rest-typings", "private": true, - "version": "6.7.0-develop", + "version": "7.0.0-develop", "devDependencies": { "@rocket.chat/eslint-config": "workspace:^", "@types/jest": "~29.5.7", From 466de64bd07871fd23331b0333ec43b724cca7ad Mon Sep 17 00:00:00 2001 From: Marcos Spessatto Defendi Date: Thu, 28 Mar 2024 17:56:25 -0300 Subject: [PATCH 009/131] test: make roles fully independent (#31783) --- apps/meteor/tests/end-to-end/api/28-roles.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/meteor/tests/end-to-end/api/28-roles.ts b/apps/meteor/tests/end-to-end/api/28-roles.ts index 1900dcc323a9..ad0e693bf2d8 100644 --- a/apps/meteor/tests/end-to-end/api/28-roles.ts +++ b/apps/meteor/tests/end-to-end/api/28-roles.ts @@ -1,5 +1,5 @@ import { expect } from 'chai'; -import { before, describe, it } from 'mocha'; +import { after, before, describe, it } from 'mocha'; import type { Response } from 'supertest'; import { getCredentials, api, request, credentials } from '../../data/api-data.js'; @@ -84,6 +84,15 @@ describe('[Roles]', function () { }); }); + after(async () => { + if (!isEnterprise) { + return; + } + await request.post(api('roles.delete')).set(credentials).send({ + roleId: testRoleId, + }); + }); + it('should throw an error when not running EE to update a role', async function () { // TODO this is not the right way to do it. We're doing this way for now just because we have separate CI jobs for EE and CE, // ideally we should have a single CI job that adds a license and runs both CE and EE tests. From 3158edf8c0768cc6873b82fdbe1e413e61f7425c Mon Sep 17 00:00:00 2001 From: Marcos Spessatto Defendi Date: Thu, 28 Mar 2024 18:32:58 -0300 Subject: [PATCH 010/131] test: make presence api tests fully independent (#31782) --- apps/meteor/tests/end-to-end/api/27-presence.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/27-presence.ts b/apps/meteor/tests/end-to-end/api/27-presence.ts index 25eceb4beeaa..80a95e18e5b3 100644 --- a/apps/meteor/tests/end-to-end/api/27-presence.ts +++ b/apps/meteor/tests/end-to-end/api/27-presence.ts @@ -1,23 +1,26 @@ import { expect } from 'chai'; -import { before, describe, it } from 'mocha'; +import { before, describe, it, after } from 'mocha'; import type { Response } from 'supertest'; import { getCredentials, api, request, credentials } from '../../data/api-data.js'; import { updatePermission } from '../../data/permissions.helper'; import { password } from '../../data/user'; -import { createUser, login } from '../../data/users.helper'; +import { createUser, deleteUser, login } from '../../data/users.helper'; describe('[Presence]', function () { + let createdUser: any; this.retries(0); before((done) => getCredentials(done)); let unauthorizedUserCredentials: any; before(async () => { - const createdUser = await createUser(); + createdUser = await createUser(); unauthorizedUserCredentials = await login(createdUser.username, password); }); + after(() => Promise.all([updatePermission('manage-user-status', ['admin']), deleteUser(createdUser)])); + describe('[/presence.getConnections]', () => { it('should throw an error if not authenticated', async () => { await request From 68f541cf0d64ae8ebf95344a83270b87413eb929 Mon Sep 17 00:00:00 2001 From: Martin Schoeler Date: Thu, 28 Mar 2024 20:51:13 -0300 Subject: [PATCH 011/131] test(Livechat): Clean up after registerGuest() test (#32092) --- .../omnichannel-livechat-api.spec.ts | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts index 2800709ef9ba..88a89940279a 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts @@ -220,6 +220,7 @@ test.describe('OC - Livechat API', () => { let agent: Awaited>; let agent2: Awaited>; let departments: Awaited>[]; + let pageContext: Page; test.beforeAll(async ({ api }) => { agent = await createAgent(api, 'user1'); @@ -262,6 +263,7 @@ test.describe('OC - Livechat API', () => { await poAuxContext.page.close(); await page.close(); await poAuxContext2?.page.close(); + await pageContext?.close(); }); test.afterAll(async ({ api }) => { @@ -388,20 +390,20 @@ test.describe('OC - Livechat API', () => { }); await test.step('Expect registerGuest to log in an existing guest and load chat history', async () => { - const { page: pageCtx } = await createAuxContext(browser, Users.user1); + ({ page: pageContext } = await createAuxContext(browser, Users.user1)); - await pageCtx.goto('/packages/rocketchat_livechat/assets/demo.html'); + await pageContext.goto('/packages/rocketchat_livechat/assets/demo.html'); - await pageCtx.evaluate(() => window.RocketChat.livechat.maximizeWidget()); - await expect(pageCtx.frameLocator('#rocketchat-iframe').getByText('Start Chat')).toBeVisible(); + await pageContext.evaluate(() => window.RocketChat.livechat.maximizeWidget()); + await expect(pageContext.frameLocator('#rocketchat-iframe').getByText('Start Chat')).toBeVisible(); - await pageCtx.evaluate( + await pageContext.evaluate( (registerGuestVisitor) => window.RocketChat.livechat.registerGuest(registerGuestVisitor), registerGuestVisitor, ); - await expect(pageCtx.frameLocator('#rocketchat-iframe').getByText('Start Chat')).not.toBeVisible(); - await expect(pageCtx.frameLocator('#rocketchat-iframe').getByText('this_a_test_message_from_visitor')).toBeVisible(); + await expect(pageContext.frameLocator('#rocketchat-iframe').getByText('Start Chat')).not.toBeVisible(); + await expect(pageContext.frameLocator('#rocketchat-iframe').getByText('this_a_test_message_from_visitor')).toBeVisible(); }); }); @@ -593,20 +595,20 @@ test.describe('OC - Livechat API', () => { await poLiveChat.btnSendMessageToOnlineAgent.click(); await test.step('Expect setGuestToken to log in an existing guest and load chat history', async () => { - const { page: pageCtx } = await createAuxContext(browser, Users.user1); + ({ page: pageContext } = await createAuxContext(browser, Users.user1)); - await pageCtx.goto('/packages/rocketchat_livechat/assets/demo.html'); + await pageContext.goto('/packages/rocketchat_livechat/assets/demo.html'); - await pageCtx.evaluate(() => window.RocketChat.livechat.maximizeWidget()); - await expect(pageCtx.frameLocator('#rocketchat-iframe').getByText('Start Chat')).toBeVisible(); + await pageContext.evaluate(() => window.RocketChat.livechat.maximizeWidget()); + await expect(pageContext.frameLocator('#rocketchat-iframe').getByText('Start Chat')).toBeVisible(); - await pageCtx.evaluate( + await pageContext.evaluate( (registerGuestVisitor) => window.RocketChat.livechat.setGuestToken(registerGuestVisitor.token), registerGuestVisitor, ); - await expect(pageCtx.frameLocator('#rocketchat-iframe').getByText('Start Chat')).not.toBeVisible(); - await expect(pageCtx.frameLocator('#rocketchat-iframe').getByText('this_a_test_message_from_visitor')).toBeVisible(); + await expect(pageContext.frameLocator('#rocketchat-iframe').getByText('Start Chat')).not.toBeVisible(); + await expect(pageContext.frameLocator('#rocketchat-iframe').getByText('this_a_test_message_from_visitor')).toBeVisible(); }); }); }); From 9b530240214b3534a7e19f3d3616518d0b178668 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Mon, 1 Apr 2024 12:43:19 -0300 Subject: [PATCH 012/131] chore: Remove references to EE code from the app events (#31926) Co-authored-by: Guilherme Gazzo --- .../authentication/server/startup/index.js | 8 +-- .../app/file-upload/server/lib/FileUpload.ts | 4 +- .../app/lib/server/functions/addUserToRoom.ts | 6 +- .../lib/server/functions/createDirectRoom.ts | 11 ++-- .../app/lib/server/functions/createRoom.ts | 11 ++-- .../app/lib/server/functions/deleteMessage.ts | 10 ++-- .../server/functions/removeUserFromRoom.ts | 6 +- .../app/lib/server/functions/saveUser.js | 4 +- .../app/lib/server/functions/sendMessage.ts | 2 +- .../app/lib/server/functions/updateMessage.ts | 35 +++++------ .../server/methods/deleteUserOwnAccount.ts | 4 +- apps/meteor/app/livechat/server/lib/Helper.ts | 8 +-- .../app/livechat/server/lib/LivechatTyped.ts | 10 ++-- .../app/livechat/server/lib/QueueManager.ts | 4 +- .../app/livechat/server/lib/RoutingManager.ts | 4 +- apps/meteor/app/mailer/server/api.ts | 4 +- .../app/message-pin/server/pinMessage.ts | 6 +- .../app/message-star/server/starMessage.ts | 4 +- .../app/reactions/server/setReaction.ts | 4 +- .../server/lib/getAppsStatistics.js | 15 ++--- .../threads/server/methods/followMessage.ts | 4 +- .../threads/server/methods/unfollowMessage.ts | 4 +- apps/meteor/ee/server/apps/index.ts | 2 +- apps/meteor/ee/server/apps/orchestrator.js | 4 +- .../server/lib/moderation/reportMessage.ts | 4 +- apps/meteor/server/methods/deleteUser.ts | 4 +- apps/meteor/server/methods/eraseRoom.ts | 6 +- apps/meteor/server/methods/logoutCleanUp.ts | 4 +- apps/meteor/server/methods/reportMessage.ts | 4 +- apps/meteor/server/methods/saveUserProfile.ts | 4 +- .../server/services/apps-engine/service.ts | 58 ++++++++++--------- .../services/video-conference/service.ts | 4 +- apps/meteor/server/startup/migrations/v291.ts | 11 ++-- apps/meteor/server/startup/migrations/v292.ts | 6 +- apps/meteor/server/startup/migrations/v294.ts | 6 +- packages/apps/src/AppsEngine.ts | 3 + packages/apps/src/IAppServerOrchestrator.ts | 8 +++ packages/apps/src/bridges/IListenerBridge.ts | 48 +++++++++++++++ packages/apps/src/index.ts | 3 + packages/apps/src/orchestrator.ts | 7 +++ 40 files changed, 222 insertions(+), 132 deletions(-) create mode 100644 packages/apps/src/bridges/IListenerBridge.ts create mode 100644 packages/apps/src/orchestrator.ts diff --git a/apps/meteor/app/authentication/server/startup/index.js b/apps/meteor/app/authentication/server/startup/index.js index e3b97c1aae88..ab622be95d53 100644 --- a/apps/meteor/app/authentication/server/startup/index.js +++ b/apps/meteor/app/authentication/server/startup/index.js @@ -1,3 +1,4 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; import { Roles, Settings, Users } from '@rocket.chat/models'; import { escapeRegExp, escapeHTML } from '@rocket.chat/string-helpers'; import { Accounts } from 'meteor/accounts-base'; @@ -5,7 +6,6 @@ import { Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import _ from 'underscore'; -import { AppEvents, Apps } from '../../../../ee/server/apps/orchestrator'; import { callbacks } from '../../../../lib/callbacks'; import { beforeCreateUserCallback } from '../../../../lib/callbacks/beforeCreateUserCallback'; import { parseCSV } from '../../../../lib/utils/parseCSV'; @@ -350,8 +350,8 @@ const insertUserDocAsync = async function (options, user) { if (!options.skipAppsEngineEvent) { // `post` triggered events don't need to wait for the promise to resolve - Apps.triggerEvent(AppEvents.IPostUserCreated, { user, performedBy: await safeGetMeteorUser() }).catch((e) => { - Apps.getRocketChatLogger().error('Error while executing post user created event:', e); + Apps?.triggerEvent(AppEvents.IPostUserCreated, { user, performedBy: await safeGetMeteorUser() }).catch((e) => { + Apps?.getRocketChatLogger().error('Error while executing post user created event:', e); }); } @@ -424,7 +424,7 @@ const validateLoginAttemptAsync = async function (login) { */ if (login.type !== 'resume') { // App IPostUserLoggedIn event hook - await Apps.triggerEvent(AppEvents.IPostUserLoggedIn, login.user); + await Apps?.triggerEvent(AppEvents.IPostUserLoggedIn, login.user); } return true; diff --git a/apps/meteor/app/file-upload/server/lib/FileUpload.ts b/apps/meteor/app/file-upload/server/lib/FileUpload.ts index 35bb62cebe94..3fd00f5e3e2a 100644 --- a/apps/meteor/app/file-upload/server/lib/FileUpload.ts +++ b/apps/meteor/app/file-upload/server/lib/FileUpload.ts @@ -8,6 +8,7 @@ import stream from 'stream'; import URL from 'url'; import { hashLoginToken } from '@rocket.chat/account-utils'; +import { Apps, AppEvents } from '@rocket.chat/apps'; import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; import type { IUpload } from '@rocket.chat/core-typings'; import { Users, Avatars, UserDataFiles, Uploads, Settings, Subscriptions, Messages, Rooms } from '@rocket.chat/models'; @@ -21,7 +22,6 @@ import sharp from 'sharp'; import type { WritableStreamBuffer } from 'stream-buffers'; import streamBuffers from 'stream-buffers'; -import { AppEvents, Apps } from '../../../../ee/server/apps'; import { i18n } from '../../../../server/lib/i18n'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; @@ -177,7 +177,7 @@ export const FileUpload = { // App IPreFileUpload event hook try { - await Apps.triggerEvent(AppEvents.IPreFileUpload, { file, content: content || Buffer.from([]) }); + await Apps?.triggerEvent(AppEvents.IPreFileUpload, { file, content: content || Buffer.from([]) }); } catch (error: any) { if (error.name === AppsEngineException.name) { throw new Meteor.Error('error-app-prevented', error.message); diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index 4e29576cf3bb..4a70943d28e2 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -1,3 +1,4 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; import { Message, Team } from '@rocket.chat/core-services'; import type { IUser } from '@rocket.chat/core-typings'; @@ -5,7 +6,6 @@ import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; -import { AppEvents, Apps } from '../../../../ee/server/apps'; import { callbacks } from '../../../../lib/callbacks'; import { getSubscriptionAutotranslateDefaultConfig } from '../../../../server/lib/getSubscriptionAutotranslateDefaultConfig'; import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; @@ -54,7 +54,7 @@ export const addUserToRoom = async function ( } try { - await Apps.triggerEvent(AppEvents.IPreRoomUserJoined, room, userToBeAdded, inviter); + await Apps?.triggerEvent(AppEvents.IPreRoomUserJoined, room, userToBeAdded, inviter); } catch (error: any) { if (error.name === AppsEngineException.name) { throw new Meteor.Error('error-app-prevented', error.message); @@ -118,7 +118,7 @@ export const addUserToRoom = async function ( // Keep the current event await callbacks.run('afterJoinRoom', userToBeAdded, room); - void Apps.triggerEvent(AppEvents.IPostRoomUserJoined, room, userToBeAdded, inviter); + void Apps?.triggerEvent(AppEvents.IPostRoomUserJoined, room, userToBeAdded, inviter); }); } diff --git a/apps/meteor/app/lib/server/functions/createDirectRoom.ts b/apps/meteor/app/lib/server/functions/createDirectRoom.ts index 28bb74d7abe9..dea6004eb4e5 100644 --- a/apps/meteor/app/lib/server/functions/createDirectRoom.ts +++ b/apps/meteor/app/lib/server/functions/createDirectRoom.ts @@ -1,3 +1,4 @@ +import { AppEvents, Apps } from '@rocket.chat/apps'; import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; import type { ISubscriptionExtraData } from '@rocket.chat/core-services'; import type { ICreatedRoom, IRoom, ISubscription, IUser } from '@rocket.chat/core-typings'; @@ -6,7 +7,6 @@ import { Random } from '@rocket.chat/random'; import { Meteor } from 'meteor/meteor'; import type { MatchKeysAndValues } from 'mongodb'; -import { Apps } from '../../../../ee/server/apps'; import { callbacks } from '../../../../lib/callbacks'; import { isTruthy } from '../../../../lib/isTruthy'; import { settings } from '../../../settings/server'; @@ -103,7 +103,7 @@ export async function createDirectRoom( _USERNAMES: usernames, }; - const prevent = await Apps.triggerEvent('IPreRoomCreatePrevent', tmpRoom).catch((error) => { + const prevent = await Apps?.triggerEvent(AppEvents.IPreRoomCreatePrevent, tmpRoom).catch((error) => { if (error.name === AppsEngineException.name) { throw new Meteor.Error('error-app-prevented', error.message); } @@ -115,7 +115,10 @@ export async function createDirectRoom( throw new Meteor.Error('error-app-prevented', 'A Rocket.Chat App prevented the room creation.'); } - const result = await Apps.triggerEvent('IPreRoomCreateModify', await Apps.triggerEvent('IPreRoomCreateExtend', tmpRoom)); + const result = await Apps?.triggerEvent( + AppEvents.IPreRoomCreateModify, + await Apps?.triggerEvent(AppEvents.IPreRoomCreateExtend, tmpRoom), + ); if (typeof result === 'object') { Object.assign(roomInfo, result); @@ -169,7 +172,7 @@ export async function createDirectRoom( await callbacks.run('afterCreateDirectRoom', insertedRoom, { members: roomMembers, creatorId: options?.creator }); - void Apps.triggerEvent('IPostRoomCreate', insertedRoom); + void Apps?.triggerEvent(AppEvents.IPostRoomCreate, insertedRoom); } return { diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index f3c6730b1dfe..11577a76c4fb 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -1,4 +1,5 @@ /* eslint-disable complexity */ +import { AppEvents, Apps } from '@rocket.chat/apps'; import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; import { Message, Team } from '@rocket.chat/core-services'; import type { ICreateRoomParams, ISubscriptionExtraData } from '@rocket.chat/core-services'; @@ -6,7 +7,6 @@ import type { ICreatedRoom, IUser, IRoom, RoomType } from '@rocket.chat/core-typ import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import { Apps } from '../../../../ee/server/apps/orchestrator'; import { callbacks } from '../../../../lib/callbacks'; import { beforeCreateRoomCallback } from '../../../../lib/callbacks/beforeCreateRoomCallback'; import { getSubscriptionAutotranslateDefaultConfig } from '../../../../server/lib/getSubscriptionAutotranslateDefaultConfig'; @@ -198,7 +198,7 @@ export const createRoom = async ( _USERNAMES: members, }; - const prevent = await Apps.triggerEvent('IPreRoomCreatePrevent', tmp).catch((error) => { + const prevent = await Apps?.triggerEvent(AppEvents.IPreRoomCreatePrevent, tmp).catch((error) => { if (error.name === AppsEngineException.name) { throw new Meteor.Error('error-app-prevented', error.message); } @@ -210,7 +210,10 @@ export const createRoom = async ( throw new Meteor.Error('error-app-prevented', 'A Rocket.Chat App prevented the room creation.'); } - const eventResult = await Apps.triggerEvent('IPreRoomCreateModify', await Apps.triggerEvent('IPreRoomCreateExtend', tmp)); + const eventResult = await Apps?.triggerEvent( + AppEvents.IPreRoomCreateModify, + await Apps.triggerEvent(AppEvents.IPreRoomCreateExtend, tmp), + ); if (eventResult && typeof eventResult === 'object' && delete eventResult._USERNAMES) { Object.assign(roomProps, eventResult); @@ -242,7 +245,7 @@ export const createRoom = async ( callbacks.runAsync('federation.afterCreateFederatedRoom', room, { owner, originalMemberList: members }); } - void Apps.triggerEvent('IPostRoomCreate', room); + void Apps?.triggerEvent(AppEvents.IPostRoomCreate, room); return { rid: room._id, // backwards compatible inserted: true, diff --git a/apps/meteor/app/lib/server/functions/deleteMessage.ts b/apps/meteor/app/lib/server/functions/deleteMessage.ts index cd4456b24514..26677bf37fff 100644 --- a/apps/meteor/app/lib/server/functions/deleteMessage.ts +++ b/apps/meteor/app/lib/server/functions/deleteMessage.ts @@ -1,9 +1,9 @@ +import { AppEvents, Apps } from '@rocket.chat/apps'; import { api } from '@rocket.chat/core-services'; import type { AtLeast, IMessage, IUser } from '@rocket.chat/core-typings'; import { Messages, Rooms, Uploads, Users, ReadReceipts } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import { Apps } from '../../../../ee/server/apps'; import { callbacks } from '../../../../lib/callbacks'; import { broadcastMessageFromData } from '../../../../server/modules/watchers/lib/messages'; import { canDeleteMessageAsync } from '../../../authorization/server/functions/canDeleteMessage'; @@ -29,14 +29,14 @@ export const deleteMessageValidatingPermission = async (message: AtLeast { - const deletedMsg = await Messages.findOneById(message._id); + const deletedMsg: IMessage | null = await Messages.findOneById(message._id); const isThread = (deletedMsg?.tcount || 0) > 0; const keepHistory = settings.get('Message_KeepHistory') || isThread; const showDeletedStatus = settings.get('Message_ShowDeletedStatus') || isThread; const bridges = Apps?.isLoaded() && Apps.getBridges(); if (deletedMsg && bridges) { - const prevent = await bridges.getListenerBridge().messageEvent('IPreMessageDeletePrevent', deletedMsg); + const prevent = await bridges.getListenerBridge().messageEvent(AppEvents.IPreMessageDeletePrevent, deletedMsg); if (prevent) { throw new Meteor.Error('error-app-prevented-deleting', 'A Rocket.Chat App prevented the message deleting.'); } @@ -95,7 +95,7 @@ export async function deleteMessage(message: IMessage, user: IUser): Promise { const originalMessage = originalMsg || (await Messages.findOneById(message._id)); + if (!originalMessage) { + throw new Error('Invalid message ID.'); + } + + let messageData: IMessage = Object.assign({}, originalMessage, message); // For the Rocket.Chat Apps :) if (message && Apps && Apps.isLoaded()) { - const appMessage = Object.assign({}, originalMessage, message); - - const prevent = await Apps.getBridges()?.getListenerBridge().messageEvent('IPreMessageUpdatedPrevent', appMessage); + const prevent = await Apps.getBridges().getListenerBridge().messageEvent(AppEvents.IPreMessageUpdatedPrevent, messageData); if (prevent) { throw new Meteor.Error('error-app-prevented-updating', 'A Rocket.Chat App prevented the message updating.'); } - let result; - result = await Apps.getBridges()?.getListenerBridge().messageEvent('IPreMessageUpdatedExtend', appMessage); - result = await Apps.getBridges()?.getListenerBridge().messageEvent('IPreMessageUpdatedModify', result); + let result = await Apps.getBridges().getListenerBridge().messageEvent(AppEvents.IPreMessageUpdatedExtend, messageData); + result = await Apps.getBridges().getListenerBridge().messageEvent(AppEvents.IPreMessageUpdatedModify, result); if (typeof result === 'object') { - message = Object.assign(appMessage, result); + Object.assign(messageData, result); } } // If we keep history of edits, insert a new message to store history information if (settings.get('Message_KeepHistory')) { - await Messages.cloneAndSaveAsHistoryById(message._id, user as Required>); + await Messages.cloneAndSaveAsHistoryById(messageData._id, user as Required>); } - Object.assign, Omit>(message, { + Object.assign(messageData, { editedAt: new Date(), editedBy: { _id: user._id, @@ -48,17 +50,16 @@ export const updateMessage = async function ( }, }); - parseUrlsInMessage(message, previewUrls); + parseUrlsInMessage(messageData, previewUrls); - const room = await Rooms.findOneById(message.rid); + const room = await Rooms.findOneById(messageData.rid); if (!room) { return; } - // TODO remove type cast - message = await Message.beforeSave({ message: message as IMessage, room, user }); + messageData = await Message.beforeSave({ message: messageData, room, user }); - const { _id, ...editedMessage } = message; + const { _id, ...editedMessage } = messageData; if (!editedMessage.msg) { delete editedMessage.md; @@ -78,7 +79,7 @@ export const updateMessage = async function ( if (Apps?.isLoaded()) { // This returns a promise, but it won't mutate anything about the message // so, we don't really care if it is successful or fails - void Apps.getBridges()?.getListenerBridge().messageEvent('IPostMessageUpdated', message); + void Apps.getBridges()?.getListenerBridge().messageEvent(AppEvents.IPostMessageUpdated, messageData); } setImmediate(async () => { diff --git a/apps/meteor/app/lib/server/methods/deleteUserOwnAccount.ts b/apps/meteor/app/lib/server/methods/deleteUserOwnAccount.ts index ed9929622c6d..f30182def68e 100644 --- a/apps/meteor/app/lib/server/methods/deleteUserOwnAccount.ts +++ b/apps/meteor/app/lib/server/methods/deleteUserOwnAccount.ts @@ -1,3 +1,4 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; import { Users } from '@rocket.chat/models'; import { SHA256 } from '@rocket.chat/sha256'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; @@ -5,7 +6,6 @@ import { Accounts } from 'meteor/accounts-base'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { AppEvents, Apps } from '../../../../ee/server/apps/orchestrator'; import { trim } from '../../../../lib/utils/stringUtils'; import { settings } from '../../../settings/server'; import { deleteUser } from '../functions/deleteUser'; @@ -66,7 +66,7 @@ Meteor.methods({ await deleteUser(uid, confirmRelinquish); // App IPostUserDeleted event hook - await Apps.triggerEvent(AppEvents.IPostUserDeleted, { user }); + await Apps?.triggerEvent(AppEvents.IPostUserDeleted, { user }); return true; }, diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index bf575d9e346d..3f9a555d6b86 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -1,3 +1,4 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; import { LivechatTransferEventType } from '@rocket.chat/apps-engine/definition/livechat'; import { api, Message, Omnichannel } from '@rocket.chat/core-services'; import type { @@ -30,7 +31,6 @@ import { import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { Apps, AppEvents } from '../../../../ee/server/apps'; import { callbacks } from '../../../../lib/callbacks'; import { validateEmail as validatorFunc } from '../../../../lib/emailValidator'; import { i18n } from '../../../../server/lib/i18n'; @@ -273,7 +273,7 @@ export const removeAgentFromSubscription = async (rid: string, { _id, username } await Message.saveSystemMessage('ul', rid, username || '', { _id: user._id, username: user.username, name: user.name }); setImmediate(() => { - void Apps.triggerEvent(AppEvents.IPostLivechatAgentUnassigned, { room, user }); + void Apps?.triggerEvent(AppEvents.IPostLivechatAgentUnassigned, { room, user }); }); }; @@ -452,7 +452,7 @@ export const forwardRoomToAgent = async (room: IOmnichannelRoom, transferData: T } setImmediate(() => { - void Apps.triggerEvent(AppEvents.IPostLivechatRoomTransferred, { + void Apps?.triggerEvent(AppEvents.IPostLivechatRoomTransferred, { type: LivechatTransferEventType.AGENT, room: rid, from: oldServedBy?._id, @@ -482,7 +482,7 @@ export const updateChatDepartment = async ({ ]); setImmediate(() => { - void Apps.triggerEvent(AppEvents.IPostLivechatRoomTransferred, { + void Apps?.triggerEvent(AppEvents.IPostLivechatRoomTransferred, { type: LivechatTransferEventType.DEPARTMENT, room: rid, from: oldDepartmentId, diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index 0a7b29880881..39d3467fbaf2 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -1,6 +1,7 @@ import dns from 'dns'; import * as util from 'util'; +import { Apps, AppEvents } from '@rocket.chat/apps'; import { Message, VideoConf, api, Omnichannel } from '@rocket.chat/core-services'; import type { IOmnichannelRoom, @@ -42,7 +43,6 @@ import moment from 'moment-timezone'; import type { Filter, FindCursor, UpdateFilter } from 'mongodb'; import UAParser from 'ua-parser-js'; -import { Apps, AppEvents } from '../../../../ee/server/apps'; import { callbacks } from '../../../../lib/callbacks'; import { trim } from '../../../../lib/utils/stringUtils'; import { i18n } from '../../../../server/lib/i18n'; @@ -329,8 +329,8 @@ class LivechatClass { * @deprecated the `AppEvents.ILivechatRoomClosedHandler` event will be removed * in the next major version of the Apps-Engine */ - void Apps.getBridges()?.getListenerBridge().livechatEvent(AppEvents.ILivechatRoomClosedHandler, newRoom); - void Apps.getBridges()?.getListenerBridge().livechatEvent(AppEvents.IPostLivechatRoomClosed, newRoom); + void Apps?.getBridges()?.getListenerBridge().livechatEvent(AppEvents.ILivechatRoomClosedHandler, newRoom); + void Apps?.getBridges()?.getListenerBridge().livechatEvent(AppEvents.IPostLivechatRoomClosed, newRoom); }); if (process.env.TEST_MODE) { await callbacks.run('livechat.closeRoom', { @@ -1426,7 +1426,7 @@ class LivechatClass { const ret = await LivechatVisitors.saveGuestById(_id, updateData); setImmediate(() => { - void Apps.triggerEvent(AppEvents.IPostLivechatGuestSaved, _id); + void Apps?.triggerEvent(AppEvents.IPostLivechatGuestSaved, _id); }); return ret; @@ -1792,7 +1792,7 @@ class LivechatClass { await LivechatRooms.saveRoomById(roomData); setImmediate(() => { - void Apps.triggerEvent(AppEvents.IPostLivechatRoomSaved, roomData._id); + void Apps?.triggerEvent(AppEvents.IPostLivechatRoomSaved, roomData._id); }); if (guestData?.name?.trim().length) { diff --git a/apps/meteor/app/livechat/server/lib/QueueManager.ts b/apps/meteor/app/livechat/server/lib/QueueManager.ts index 4569f3da42b8..8a11a36238fa 100644 --- a/apps/meteor/app/livechat/server/lib/QueueManager.ts +++ b/apps/meteor/app/livechat/server/lib/QueueManager.ts @@ -1,3 +1,4 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; import { Omnichannel } from '@rocket.chat/core-services'; import type { ILivechatInquiryRecord, ILivechatVisitor, IMessage, IOmnichannelRoom, SelectedAgent } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; @@ -5,7 +6,6 @@ import { LivechatInquiry, LivechatRooms, Users } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { Apps, AppEvents } from '../../../../ee/server/apps'; import { callbacks } from '../../../../lib/callbacks'; import { checkServiceStatus, createLivechatRoom, createLivechatInquiry } from './Helper'; import { RoutingManager } from './RoutingManager'; @@ -105,7 +105,7 @@ export const QueueManager: queueManager = { throw new Error('inquiry-not-found'); } - void Apps.triggerEvent(AppEvents.IPostLivechatRoomStarted, room); + void Apps?.triggerEvent(AppEvents.IPostLivechatRoomStarted, room); await LivechatRooms.updateRoomCount(); await queueInquiry(inquiry, agent); diff --git a/apps/meteor/app/livechat/server/lib/RoutingManager.ts b/apps/meteor/app/livechat/server/lib/RoutingManager.ts index f1fe1d506a8a..051053f761b1 100644 --- a/apps/meteor/app/livechat/server/lib/RoutingManager.ts +++ b/apps/meteor/app/livechat/server/lib/RoutingManager.ts @@ -1,3 +1,4 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; import { Message, Omnichannel } from '@rocket.chat/core-services'; import type { ILivechatInquiryRecord, @@ -16,7 +17,6 @@ import { LivechatInquiry, LivechatRooms, Subscriptions, Rooms, Users } from '@ro import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { Apps, AppEvents } from '../../../../ee/server/apps'; import { callbacks } from '../../../../lib/callbacks'; import { settings } from '../../../settings/server'; import { @@ -154,7 +154,7 @@ export const RoutingManager: Routing = { await dispatchAgentDelegated(rid, agent.agentId); logger.debug(`Agent ${agent.agentId} assigned to inquriy ${inquiry._id}. Instances notified`); - void Apps.getBridges()?.getListenerBridge().livechatEvent(AppEvents.IPostLivechatAgentAssigned, { room, user }); + void Apps?.getBridges()?.getListenerBridge().livechatEvent(AppEvents.IPostLivechatAgentAssigned, { room, user }); return inquiry; }, diff --git a/apps/meteor/app/mailer/server/api.ts b/apps/meteor/app/mailer/server/api.ts index b50fdfd26a2a..cc2caae74ba6 100644 --- a/apps/meteor/app/mailer/server/api.ts +++ b/apps/meteor/app/mailer/server/api.ts @@ -1,3 +1,4 @@ +import { AppEvents, Apps } from '@rocket.chat/apps'; import type { ISetting } from '@rocket.chat/core-typings'; import { Settings } from '@rocket.chat/models'; import { escapeHTML } from '@rocket.chat/string-helpers'; @@ -7,7 +8,6 @@ import { Meteor } from 'meteor/meteor'; import stripHtml from 'string-strip-html'; import _ from 'underscore'; -import { Apps } from '../../../ee/server/apps'; import { validateEmail } from '../../../lib/emailValidator'; import { strLeft, strRightBack } from '../../../lib/utils/stringUtils'; import { i18n } from '../../../server/lib/i18n'; @@ -170,7 +170,7 @@ export const sendNoWrap = async ({ const email = { to, from, replyTo, subject, html, text, headers }; - const eventResult = await Apps.triggerEvent('IPreEmailSent', { email }); + const eventResult = await Apps?.triggerEvent(AppEvents.IPreEmailSent, { email }); setImmediate(() => Email.sendAsync(eventResult || email).catch((e) => console.error(e))); }; diff --git a/apps/meteor/app/message-pin/server/pinMessage.ts b/apps/meteor/app/message-pin/server/pinMessage.ts index 1ed0a172028b..4887e3603122 100644 --- a/apps/meteor/app/message-pin/server/pinMessage.ts +++ b/apps/meteor/app/message-pin/server/pinMessage.ts @@ -1,3 +1,4 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; import { Message } from '@rocket.chat/core-services'; import { isQuoteAttachment, isRegisterUser } from '@rocket.chat/core-typings'; import type { IMessage, MessageAttachment, MessageQuoteAttachment } from '@rocket.chat/core-typings'; @@ -6,7 +7,6 @@ import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { Apps, AppEvents } from '../../../ee/server/apps/orchestrator'; import { isTruthy } from '../../../lib/isTruthy'; import { broadcastMessageFromData } from '../../../server/modules/watchers/lib/messages'; import { canAccessRoomAsync, roomAccessAttributes } from '../../authorization/server'; @@ -129,7 +129,7 @@ Meteor.methods({ } // App IPostMessagePinned event hook - await Apps.triggerEvent(AppEvents.IPostMessagePinned, originalMessage, await Meteor.userAsync(), originalMessage.pinned); + await Apps?.triggerEvent(AppEvents.IPostMessagePinned, originalMessage, await Meteor.userAsync(), originalMessage.pinned); const msgId = await Message.saveSystemMessage('message_pinned', originalMessage.rid, '', me, { attachments: [ @@ -216,7 +216,7 @@ Meteor.methods({ } // App IPostMessagePinned event hook - await Apps.triggerEvent(AppEvents.IPostMessagePinned, originalMessage, await Meteor.userAsync(), originalMessage.pinned); + await Apps?.triggerEvent(AppEvents.IPostMessagePinned, originalMessage, await Meteor.userAsync(), originalMessage.pinned); await Messages.setPinnedByIdAndUserId(originalMessage._id, originalMessage.pinnedBy, originalMessage.pinned); if (settings.get('Message_Read_Receipt_Store_Users')) { diff --git a/apps/meteor/app/message-star/server/starMessage.ts b/apps/meteor/app/message-star/server/starMessage.ts index 8f025d920057..9f8ba75c4536 100644 --- a/apps/meteor/app/message-star/server/starMessage.ts +++ b/apps/meteor/app/message-star/server/starMessage.ts @@ -1,9 +1,9 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; import type { IMessage } from '@rocket.chat/core-typings'; import { Messages, Subscriptions, Rooms } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; -import { Apps, AppEvents } from '../../../ee/server/apps/orchestrator'; import { broadcastMessageFromData } from '../../../server/modules/watchers/lib/messages'; import { canAccessRoomAsync, roomAccessAttributes } from '../../authorization/server'; import { isTheLastMessage } from '../../lib/server/functions/isTheLastMessage'; @@ -57,7 +57,7 @@ Meteor.methods({ await Rooms.updateLastMessageStar(room._id, uid, message.starred); } - await Apps.triggerEvent(AppEvents.IPostMessageStarred, message, await Meteor.userAsync(), message.starred); + await Apps?.triggerEvent(AppEvents.IPostMessageStarred, message, await Meteor.userAsync(), message.starred); await Messages.updateUserStarById(message._id, uid, message.starred); diff --git a/apps/meteor/app/reactions/server/setReaction.ts b/apps/meteor/app/reactions/server/setReaction.ts index 27fe4d36a053..ed2271a5d4d0 100644 --- a/apps/meteor/app/reactions/server/setReaction.ts +++ b/apps/meteor/app/reactions/server/setReaction.ts @@ -1,3 +1,4 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; import { api } from '@rocket.chat/core-services'; import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; import { Messages, EmojiCustom, Rooms, Users } from '@rocket.chat/models'; @@ -5,7 +6,6 @@ import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import _ from 'underscore'; -import { AppEvents, Apps } from '../../../ee/server/apps/orchestrator'; import { callbacks } from '../../../lib/callbacks'; import { i18n } from '../../../server/lib/i18n'; import { broadcastMessageFromData } from '../../../server/modules/watchers/lib/messages'; @@ -106,7 +106,7 @@ async function setReaction(room: IRoom, user: IUser, message: IMessage, reaction isReacted = true; } - await Apps.triggerEvent(AppEvents.IPostMessageReacted, message, user, reaction, isReacted); + await Apps?.triggerEvent(AppEvents.IPostMessageReacted, message, user, reaction, isReacted); void broadcastMessageFromData({ id: message._id, diff --git a/apps/meteor/app/statistics/server/lib/getAppsStatistics.js b/apps/meteor/app/statistics/server/lib/getAppsStatistics.js index 6337b287506a..652686e6715c 100644 --- a/apps/meteor/app/statistics/server/lib/getAppsStatistics.js +++ b/apps/meteor/app/statistics/server/lib/getAppsStatistics.js @@ -1,17 +1,18 @@ +import { Apps } from '@rocket.chat/apps'; import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; -import { Apps } from '../../../../ee/server/apps'; import { Info } from '../../../utils/rocketchat.info'; export function getAppsStatistics() { return { engineVersion: Info.marketplaceApiVersion, - totalInstalled: Apps.isInitialized() && Apps.getManager().get().length, - totalActive: Apps.isInitialized() && Apps.getManager().get({ enabled: true }).length, + totalInstalled: (Apps?.isInitialized() && Apps.getManager().get().length) ?? 0, + totalActive: (Apps?.isInitialized() && Apps.getManager().get({ enabled: true }).length) ?? 0, totalFailed: - Apps.isInitialized() && - Apps.getManager() - .get({ disabled: true }) - .filter(({ app: { status } }) => status !== AppStatus.MANUALLY_DISABLED).length, + (Apps?.isInitialized() && + Apps.getManager() + .get({ disabled: true }) + .filter(({ app: { status } }) => status !== AppStatus.MANUALLY_DISABLED).length) ?? + 0, }; } diff --git a/apps/meteor/app/threads/server/methods/followMessage.ts b/apps/meteor/app/threads/server/methods/followMessage.ts index cede3dda33a7..f6bae69b1aaa 100644 --- a/apps/meteor/app/threads/server/methods/followMessage.ts +++ b/apps/meteor/app/threads/server/methods/followMessage.ts @@ -1,10 +1,10 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; import type { IMessage } from '@rocket.chat/core-typings'; import { Messages } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { Apps, AppEvents } from '../../../../ee/server/apps/orchestrator'; import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; import { RateLimiter } from '../../../lib/server'; import { settings } from '../../../settings/server'; @@ -44,7 +44,7 @@ Meteor.methods({ const followResult = await follow({ tmid: message.tmid || message._id, uid }); const isFollowed = true; - await Apps.triggerEvent(AppEvents.IPostMessageFollowed, message, await Meteor.userAsync(), isFollowed); + await Apps?.triggerEvent(AppEvents.IPostMessageFollowed, message, await Meteor.userAsync(), isFollowed); return followResult; }, diff --git a/apps/meteor/app/threads/server/methods/unfollowMessage.ts b/apps/meteor/app/threads/server/methods/unfollowMessage.ts index c5dad1233173..b50c26508ebc 100644 --- a/apps/meteor/app/threads/server/methods/unfollowMessage.ts +++ b/apps/meteor/app/threads/server/methods/unfollowMessage.ts @@ -1,10 +1,10 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; import type { IMessage } from '@rocket.chat/core-typings'; import { Messages } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { Apps, AppEvents } from '../../../../ee/server/apps/orchestrator'; import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; import { RateLimiter } from '../../../lib/server'; import { settings } from '../../../settings/server'; @@ -44,7 +44,7 @@ Meteor.methods({ const unfollowResult = await unfollow({ rid: message.rid, tmid: message.tmid || message._id, uid }); const isFollowed = false; - await Apps.triggerEvent(AppEvents.IPostMessageFollowed, message, await Meteor.userAsync(), isFollowed); + await Apps?.triggerEvent(AppEvents.IPostMessageFollowed, message, await Meteor.userAsync(), isFollowed); return unfollowResult; }, diff --git a/apps/meteor/ee/server/apps/index.ts b/apps/meteor/ee/server/apps/index.ts index 35f7c2cc041f..0306575e00f1 100644 --- a/apps/meteor/ee/server/apps/index.ts +++ b/apps/meteor/ee/server/apps/index.ts @@ -1,4 +1,4 @@ import './cron'; import './appRequestsCron'; -export { Apps, AppEvents } from './orchestrator'; +export { Apps } from './orchestrator'; diff --git a/apps/meteor/ee/server/apps/orchestrator.js b/apps/meteor/ee/server/apps/orchestrator.js index 84f9cb1372f3..37c31e890e89 100644 --- a/apps/meteor/ee/server/apps/orchestrator.js +++ b/apps/meteor/ee/server/apps/orchestrator.js @@ -1,5 +1,5 @@ +import { registerOrchestrator } from '@rocket.chat/apps'; import { EssentialAppDisabledException } from '@rocket.chat/apps-engine/definition/exceptions'; -import { AppInterface } from '@rocket.chat/apps-engine/definition/metadata'; import { AppManager } from '@rocket.chat/apps-engine/server/AppManager'; import { Logger } from '@rocket.chat/logger'; import { AppLogs, Apps as AppsModel, AppsPersistence } from '@rocket.chat/models'; @@ -249,8 +249,8 @@ export class AppServerOrchestrator { } } -export const AppEvents = AppInterface; export const Apps = new AppServerOrchestrator(); +registerOrchestrator(Apps); settings.watch('Apps_Framework_Source_Package_Storage_Type', (value) => { if (!Apps.isInitialized()) { diff --git a/apps/meteor/server/lib/moderation/reportMessage.ts b/apps/meteor/server/lib/moderation/reportMessage.ts index be8b917fd6f5..710ea6e1b685 100644 --- a/apps/meteor/server/lib/moderation/reportMessage.ts +++ b/apps/meteor/server/lib/moderation/reportMessage.ts @@ -1,8 +1,8 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; import type { IMessage, IUser } from '@rocket.chat/core-typings'; import { Messages, ModerationReports, Rooms, Users } from '@rocket.chat/models'; import { canAccessRoomAsync } from '../../../app/authorization/server/functions/canAccessRoom'; -import { AppEvents, Apps } from '../../../ee/server/apps'; export const reportMessage = async (messageId: IMessage['_id'], description: string, uid: IUser['_id']) => { if (!uid) { @@ -49,7 +49,7 @@ export const reportMessage = async (messageId: IMessage['_id'], description: str await ModerationReports.createWithMessageDescriptionAndUserId(message, description, roomInfo, reportedBy); - await Apps.triggerEvent(AppEvents.IPostMessageReported, message, user, description); + await Apps?.triggerEvent(AppEvents.IPostMessageReported, message, user, description); return true; }; diff --git a/apps/meteor/server/methods/deleteUser.ts b/apps/meteor/server/methods/deleteUser.ts index 4dafad7a3a0c..8762cfab2437 100644 --- a/apps/meteor/server/methods/deleteUser.ts +++ b/apps/meteor/server/methods/deleteUser.ts @@ -1,3 +1,4 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; import type { IUser } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; @@ -6,7 +7,6 @@ import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; import { deleteUser } from '../../app/lib/server/functions/deleteUser'; -import { AppEvents, Apps } from '../../ee/server/apps/orchestrator'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -52,7 +52,7 @@ Meteor.methods({ await deleteUser(userId, confirmRelinquish, uid); // App IPostUserDeleted event hook - await Apps.triggerEvent(AppEvents.IPostUserDeleted, { user, performedBy: await Meteor.userAsync() }); + await Apps?.triggerEvent(AppEvents.IPostUserDeleted, { user, performedBy: await Meteor.userAsync() }); return true; }, diff --git a/apps/meteor/server/methods/eraseRoom.ts b/apps/meteor/server/methods/eraseRoom.ts index 177b3c23bb9c..687b9ad66992 100644 --- a/apps/meteor/server/methods/eraseRoom.ts +++ b/apps/meteor/server/methods/eraseRoom.ts @@ -1,3 +1,4 @@ +import { AppEvents, Apps } from '@rocket.chat/apps'; import { Message, Team } from '@rocket.chat/core-services'; import { Rooms } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; @@ -7,7 +8,6 @@ import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; import { deleteRoom } from '../../app/lib/server/functions/deleteRoom'; import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; -import { Apps } from '../../ee/server/apps'; import { roomCoordinator } from '../lib/rooms/roomCoordinator'; export async function eraseRoom(rid: string, uid: string): Promise { @@ -36,7 +36,7 @@ export async function eraseRoom(rid: string, uid: string): Promise { } if (Apps?.isLoaded()) { - const prevent = await Apps.getBridges()?.getListenerBridge().roomEvent('IPreRoomDeletePrevent', room); + const prevent = await Apps.getBridges()?.getListenerBridge().roomEvent(AppEvents.IPreRoomDeletePrevent, room); if (prevent) { throw new Meteor.Error('error-app-prevented-deleting', 'A Rocket.Chat App prevented the room erasing.'); } @@ -54,7 +54,7 @@ export async function eraseRoom(rid: string, uid: string): Promise { } if (Apps?.isLoaded()) { - void Apps.getBridges()?.getListenerBridge().roomEvent('IPostRoomDeleted', room); + void Apps.getBridges()?.getListenerBridge().roomEvent(AppEvents.IPostRoomDeleted, room); } } diff --git a/apps/meteor/server/methods/logoutCleanUp.ts b/apps/meteor/server/methods/logoutCleanUp.ts index 9b9af5356af5..502cad3c5fbf 100644 --- a/apps/meteor/server/methods/logoutCleanUp.ts +++ b/apps/meteor/server/methods/logoutCleanUp.ts @@ -1,9 +1,9 @@ +import { AppEvents, Apps } from '@rocket.chat/apps'; import type { IUser } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { AppEvents, Apps } from '../../ee/server/apps/orchestrator'; import { afterLogoutCleanUpCallback } from '../../lib/callbacks/afterLogoutCleanUpCallback'; declare module '@rocket.chat/ui-contexts' { @@ -22,6 +22,6 @@ Meteor.methods({ }); // App IPostUserLogout event hook - await Apps.triggerEvent(AppEvents.IPostUserLoggedOut, user); + await Apps?.triggerEvent(AppEvents.IPostUserLoggedOut, user); }, }); diff --git a/apps/meteor/server/methods/reportMessage.ts b/apps/meteor/server/methods/reportMessage.ts index 94d6fc1fd315..44087dad0424 100644 --- a/apps/meteor/server/methods/reportMessage.ts +++ b/apps/meteor/server/methods/reportMessage.ts @@ -1,3 +1,4 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; import type { IMessage } from '@rocket.chat/core-typings'; import { ModerationReports, Rooms, Users, Messages } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; @@ -6,7 +7,6 @@ import { Meteor } from 'meteor/meteor'; import { canAccessRoomAsync } from '../../app/authorization/server/functions/canAccessRoom'; import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; -import { AppEvents, Apps } from '../../ee/server/apps'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -77,7 +77,7 @@ Meteor.methods({ await ModerationReports.createWithMessageDescriptionAndUserId(message, description, roomInfo, reportedBy); - await Apps.triggerEvent(AppEvents.IPostMessageReported, message, await Meteor.userAsync(), description); + await Apps?.triggerEvent(AppEvents.IPostMessageReported, message, await Meteor.userAsync(), description); return true; }, diff --git a/apps/meteor/server/methods/saveUserProfile.ts b/apps/meteor/server/methods/saveUserProfile.ts index 5bfbba5b1b3f..695742977ad3 100644 --- a/apps/meteor/server/methods/saveUserProfile.ts +++ b/apps/meteor/server/methods/saveUserProfile.ts @@ -1,3 +1,4 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; import type { UserStatus } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; @@ -12,7 +13,6 @@ import { saveUserIdentity } from '../../app/lib/server/functions/saveUserIdentit import { passwordPolicy } from '../../app/lib/server/lib/passwordPolicy'; import { settings as rcSettings } from '../../app/settings/server'; import { setUserStatusMethod } from '../../app/user-status/server/methods/setUserStatus'; -import { AppEvents, Apps } from '../../ee/server/apps/orchestrator'; import { compareUserPassword } from '../lib/compareUserPassword'; import { compareUserPasswordHistory } from '../lib/compareUserPasswordHistory'; @@ -156,7 +156,7 @@ async function saveUserProfile( // App IPostUserUpdated event hook const updatedUser = await Users.findOneById(this.userId); - await Apps.triggerEvent(AppEvents.IPostUserUpdated, { user: updatedUser, previousUser: user }); + await Apps?.triggerEvent(AppEvents.IPostUserUpdated, { user: updatedUser, previousUser: user }); return true; } diff --git a/apps/meteor/server/services/apps-engine/service.ts b/apps/meteor/server/services/apps-engine/service.ts index e72ce3cbce0a..7e36a937e6a6 100644 --- a/apps/meteor/server/services/apps-engine/service.ts +++ b/apps/meteor/server/services/apps-engine/service.ts @@ -1,3 +1,4 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; import { AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus'; import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; @@ -6,7 +7,6 @@ import type { IAppStorageItem } from '@rocket.chat/apps-engine/server/storage'; import type { IAppsEngineService } from '@rocket.chat/core-services'; import { ServiceClassInternal } from '@rocket.chat/core-services'; -import { Apps, AppEvents } from '../../../ee/server/apps/orchestrator'; import { SystemLogger } from '../../lib/logger/system'; export class AppsEngineService extends ServiceClassInternal implements IAppsEngineService { @@ -16,7 +16,7 @@ export class AppsEngineService extends ServiceClassInternal implements IAppsEngi super(); this.onEvent('presence.status', async ({ user, previousStatus }): Promise => { - await Apps.triggerEvent(AppEvents.IPostUserStatusChanged, { + await Apps?.triggerEvent(AppEvents.IPostUserStatusChanged, { user, currentStatus: user.status, previousStatus, @@ -24,68 +24,70 @@ export class AppsEngineService extends ServiceClassInternal implements IAppsEngi }); this.onEvent('apps.added', async (appId: string): Promise => { - Apps.getRocketChatLogger().debug(`"apps.added" event received for app "${appId}"`); + Apps?.getRocketChatLogger().debug(`"apps.added" event received for app "${appId}"`); // if the app already exists in this instance, don't load it again - const app = Apps.getManager()?.getOneById(appId); + const app = Apps?.getManager()?.getOneById(appId); if (app) { - Apps.getRocketChatLogger().info(`"apps.added" event received for app "${appId}", but it already exists in this instance`); + Apps?.getRocketChatLogger().info(`"apps.added" event received for app "${appId}", but it already exists in this instance`); return; } - await Apps.getManager()?.addLocal(appId); + await Apps?.getManager()?.addLocal(appId); }); this.onEvent('apps.removed', async (appId: string): Promise => { - Apps.getRocketChatLogger().debug(`"apps.removed" event received for app "${appId}"`); - const app = Apps.getManager()?.getOneById(appId); + Apps?.getRocketChatLogger().debug(`"apps.removed" event received for app "${appId}"`); + const app = Apps?.getManager()?.getOneById(appId); if (!app) { - Apps.getRocketChatLogger().info(`"apps.removed" event received for app "${appId}", but it couldn't be found in this instance`); + Apps?.getRocketChatLogger().info(`"apps.removed" event received for app "${appId}", but it couldn't be found in this instance`); return; } - await Apps.getManager()?.removeLocal(appId); + await Apps?.getManager()?.removeLocal(appId); }); this.onEvent('apps.updated', async (appId: string): Promise => { - Apps.getRocketChatLogger().debug(`"apps.updated" event received for app "${appId}"`); - const storageItem = await Apps.getStorage()?.retrieveOne(appId); + Apps?.getRocketChatLogger().debug(`"apps.updated" event received for app "${appId}"`); + const storageItem = await Apps?.getStorage()?.retrieveOne(appId); if (!storageItem) { - Apps.getRocketChatLogger().info(`"apps.updated" event received for app "${appId}", but it couldn't be found in the storage`); + Apps?.getRocketChatLogger().info(`"apps.updated" event received for app "${appId}", but it couldn't be found in the storage`); return; } - const appPackage = await Apps.getAppSourceStorage()?.fetch(storageItem); + const appPackage = await Apps?.getAppSourceStorage()?.fetch(storageItem); if (!appPackage) { return; } - await Apps.getManager()?.updateLocal(storageItem, appPackage); + await Apps?.getManager()?.updateLocal(storageItem, appPackage); }); this.onEvent('apps.statusUpdate', async (appId: string, status: AppStatus): Promise => { - Apps.getRocketChatLogger().debug(`"apps.statusUpdate" event received for app "${appId}" with status "${status}"`); - const app = Apps.getManager()?.getOneById(appId); + Apps?.getRocketChatLogger().debug(`"apps.statusUpdate" event received for app "${appId}" with status "${status}"`); + const app = Apps?.getManager()?.getOneById(appId); if (!app) { - Apps.getRocketChatLogger().info(`"apps.statusUpdate" event received for app "${appId}", but it couldn't be found in this instance`); + Apps?.getRocketChatLogger().info( + `"apps.statusUpdate" event received for app "${appId}", but it couldn't be found in this instance`, + ); return; } if (app.getStatus() === status) { - Apps.getRocketChatLogger().info(`"apps.statusUpdate" event received for app "${appId}", but the status is the same`); + Apps?.getRocketChatLogger().info(`"apps.statusUpdate" event received for app "${appId}", but the status is the same`); return; } if (AppStatusUtils.isEnabled(status)) { - await Apps.getManager()?.enable(appId).catch(SystemLogger.error); + await Apps?.getManager()?.enable(appId).catch(SystemLogger.error); } else if (AppStatusUtils.isDisabled(status)) { - await Apps.getManager()?.disable(appId, status, true).catch(SystemLogger.error); + await Apps?.getManager()?.disable(appId, status, true).catch(SystemLogger.error); } }); this.onEvent('apps.settingUpdated', async (appId: string, setting): Promise => { - Apps.getRocketChatLogger().debug(`"apps.settingUpdated" event received for app "${appId}"`, { setting }); - const app = Apps.getManager()?.getOneById(appId); + Apps?.getRocketChatLogger().debug(`"apps.settingUpdated" event received for app "${appId}"`, { setting }); + const app = Apps?.getManager()?.getOneById(appId); const oldSetting = app?.getStorageItem().settings[setting.id].value; // avoid updating the setting if the value is the same, @@ -94,30 +96,30 @@ export class AppsEngineService extends ServiceClassInternal implements IAppsEngi // so we need to convert it to JSON stringified to compare it if (JSON.stringify(oldSetting) === JSON.stringify(setting.value)) { - Apps.getRocketChatLogger().info( + Apps?.getRocketChatLogger().info( `"apps.settingUpdated" event received for setting ${setting.id} of app "${appId}", but the setting value is the same`, ); return; } - await Apps.getManager() + await Apps?.getManager() ?.getSettingsManager() .updateAppSetting(appId, setting as any); }); } isInitialized(): boolean { - return Apps.isInitialized(); + return Boolean(Apps?.isInitialized()); } async getApps(query: IGetAppsFilter): Promise { - return Apps.getManager() + return Apps?.getManager() ?.get(query) .map((app) => app.getApp().getInfo()); } async getAppStorageItemById(appId: string): Promise { - const app = Apps.getManager()?.getOneById(appId); + const app = Apps?.getManager()?.getOneById(appId); if (!app) { return; diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index 90a7a3302427..87fe279d0d94 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -1,3 +1,4 @@ +import { Apps } from '@rocket.chat/apps'; import type { AppVideoConfProviderManager } from '@rocket.chat/apps-engine/server/managers'; import type { IVideoConfService, VideoConferenceJoinOptions } from '@rocket.chat/core-services'; import { api, ServiceClassInternal } from '@rocket.chat/core-services'; @@ -41,7 +42,6 @@ import { settings } from '../../../app/settings/server'; import { updateCounter } from '../../../app/statistics/server/functions/updateStatsCounter'; import { getUserAvatarURL } from '../../../app/utils/server/getUserAvatarURL'; import { getUserPreference } from '../../../app/utils/server/lib/getUserPreference'; -import { Apps } from '../../../ee/server/apps'; import { callbacks } from '../../../lib/callbacks'; import { availabilityErrors } from '../../../lib/videoConference/constants'; import { readSecondaryPreferred } from '../../database/readSecondaryPreferred'; @@ -832,7 +832,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf throw new Error('apps-engine-not-loaded'); } - const manager = Apps.getManager()?.getVideoConfProviderManager(); + const manager = Apps?.getManager()?.getVideoConfProviderManager(); if (!manager) { throw new Error(availabilityErrors.NO_APP); } diff --git a/apps/meteor/server/startup/migrations/v291.ts b/apps/meteor/server/startup/migrations/v291.ts index 8923f3b282c3..f4fdbb743447 100644 --- a/apps/meteor/server/startup/migrations/v291.ts +++ b/apps/meteor/server/startup/migrations/v291.ts @@ -1,8 +1,7 @@ +import { Apps, type AppMetadataStorage } from '@rocket.chat/apps'; import type { IAppStorageItem } from '@rocket.chat/apps-engine/server/storage'; import { Settings } from '@rocket.chat/models'; -import { Apps } from '../../../ee/server/apps'; -import type { AppRealStorage } from '../../../ee/server/apps/storage'; import { addMigration } from '../../lib/migrations'; addMigration({ @@ -13,13 +12,17 @@ addMigration({ await Settings.removeById('Apps_Framework_Development_Mode'); await Settings.removeById('Apps_Framework_enabled'); + if (!Apps) { + throw new Error('Apps Orchestrator not registered.'); + } + Apps.initialize(); - const appsStorage = Apps.getStorage() as AppRealStorage; + const appsStorage = Apps.getStorage(); const apps = await appsStorage.retrieveAll(); - const promises: Array> = []; + const promises: Array> = []; apps.forEach((app) => promises.push( diff --git a/apps/meteor/server/startup/migrations/v292.ts b/apps/meteor/server/startup/migrations/v292.ts index 7f590f4038e2..beec6967a904 100644 --- a/apps/meteor/server/startup/migrations/v292.ts +++ b/apps/meteor/server/startup/migrations/v292.ts @@ -1,7 +1,7 @@ +import { Apps } from '@rocket.chat/apps'; import type { AppSignatureManager } from '@rocket.chat/apps-engine/server/managers/AppSignatureManager'; import type { IAppStorageItem } from '@rocket.chat/apps-engine/server/storage'; -import { Apps } from '../../../ee/server/apps'; import type { AppRealStorage } from '../../../ee/server/apps/storage'; import { addMigration } from '../../lib/migrations'; @@ -9,6 +9,10 @@ addMigration({ version: 292, name: 'Add checksum signature to existing apps', async up() { + if (!Apps) { + throw new Error('Apps Orchestrator not registered.'); + } + Apps.initialize(); const sigMan = Apps.getManager()?.getSignatureManager() as AppSignatureManager; diff --git a/apps/meteor/server/startup/migrations/v294.ts b/apps/meteor/server/startup/migrations/v294.ts index abcb20d079ec..832043740f89 100644 --- a/apps/meteor/server/startup/migrations/v294.ts +++ b/apps/meteor/server/startup/migrations/v294.ts @@ -1,13 +1,17 @@ +import { Apps } from '@rocket.chat/apps'; import type { AppSignatureManager } from '@rocket.chat/apps-engine/server/managers/AppSignatureManager'; import type { IAppStorageItem } from '@rocket.chat/apps-engine/server/storage'; -import { Apps } from '../../../ee/server/apps'; import type { AppRealStorage } from '../../../ee/server/apps/storage'; import { addMigration } from '../../lib/migrations'; addMigration({ version: 294, async up() { + if (!Apps) { + throw new Error('Apps Orchestrator not registered.'); + } + Apps.initialize(); const sigMan = Apps.getManager()?.getSignatureManager() as AppSignatureManager; diff --git a/packages/apps/src/AppsEngine.ts b/packages/apps/src/AppsEngine.ts index 117e93c0ec2f..856bc1253790 100644 --- a/packages/apps/src/AppsEngine.ts +++ b/packages/apps/src/AppsEngine.ts @@ -8,6 +8,7 @@ export type { IVisitorPhone as IAppsVisitorPhone, } from '@rocket.chat/apps-engine/definition/livechat'; export type { IMessage as IAppsMessage } from '@rocket.chat/apps-engine/definition/messages'; +export { AppInterface as AppEvents } from '@rocket.chat/apps-engine/definition/metadata'; export type { IUser as IAppsUser } from '@rocket.chat/apps-engine/definition/users'; export type { IRole as IAppsRole } from '@rocket.chat/apps-engine/definition/roles'; export type { IRoom as IAppsRoom } from '@rocket.chat/apps-engine/definition/rooms'; @@ -18,3 +19,5 @@ export type { VideoConference as AppsVideoConference, } from '@rocket.chat/apps-engine/definition/videoConferences'; export { AppManager } from '@rocket.chat/apps-engine/server/AppManager'; +export { AppBridges } from '@rocket.chat/apps-engine/server/bridges'; +export { AppMetadataStorage } from '@rocket.chat/apps-engine/server/storage'; diff --git a/packages/apps/src/IAppServerOrchestrator.ts b/packages/apps/src/IAppServerOrchestrator.ts index dbfc5aee7a20..2f1f7db5d4b5 100644 --- a/packages/apps/src/IAppServerOrchestrator.ts +++ b/packages/apps/src/IAppServerOrchestrator.ts @@ -1,12 +1,16 @@ import type { AppManager } from '@rocket.chat/apps-engine/server/AppManager'; +import type { AppSourceStorage } from '@rocket.chat/apps-engine/server/storage'; import type { Logger } from '@rocket.chat/logger'; import type { IAppsPersistenceModel } from '@rocket.chat/model-typings'; +import type { AppBridges, AppEvents, AppMetadataStorage } from './AppsEngine'; import type { IAppServerNotifier } from './IAppServerNotifier'; import type { IAppConvertersMap } from './converters'; export interface IAppServerOrchestrator { initialize(): void; + isInitialized(): boolean; + isLoaded(): boolean; getNotifier(): IAppServerNotifier; isDebugging(): boolean; debugLog(...args: any[]): void; @@ -14,4 +18,8 @@ export interface IAppServerOrchestrator { getConverters(): IAppConvertersMap; getPersistenceModel(): IAppsPersistenceModel; getRocketChatLogger(): Logger; + triggerEvent(event: AppEvents, ...payload: any[]): Promise; + getBridges(): AppBridges; + getStorage(): AppMetadataStorage; + getAppSourceStorage(): AppSourceStorage; } diff --git a/packages/apps/src/bridges/IListenerBridge.ts b/packages/apps/src/bridges/IListenerBridge.ts new file mode 100644 index 000000000000..faf34118cd30 --- /dev/null +++ b/packages/apps/src/bridges/IListenerBridge.ts @@ -0,0 +1,48 @@ +import type { IMessage, IRoom, IUser, ILivechatDepartment, ILivechatVisitor, IOmnichannelRoom } from '@rocket.chat/core-typings'; + +import type { AppEvents } from '../AppsEngine'; + +declare module '@rocket.chat/apps-engine/server/bridges' { + interface IListenerBridge { + messageEvent(int: 'IPostMessageDeleted', message: IMessage, userDeleted: IUser): Promise; + messageEvent(int: 'IPostMessageReacted', message: IMessage, userReacted: IUser, reaction: string, isReacted: boolean): Promise; + messageEvent(int: 'IPostMessageFollowed', message: IMessage, userFollowed: IUser, isFollowed: boolean): Promise; + messageEvent(int: 'IPostMessagePinned', message: IMessage, userPinned: IUser, isPinned: boolean): Promise; + messageEvent(int: 'IPostMessageStarred', message: IMessage, userStarred: IUser, isStarred: boolean): Promise; + messageEvent(int: 'IPostMessageReported', message: IMessage, userReported: IUser, reason: boolean): Promise; + + messageEvent( + int: 'IPreMessageSentPrevent' | 'IPreMessageDeletePrevent' | 'IPreMessageUpdatedPrevent', + message: IMessage, + ): Promise; + messageEvent( + int: 'IPreMessageSentExtend' | 'IPreMessageSentModify' | 'IPreMessageUpdatedExtend' | 'IPreMessageUpdatedModify', + message: IMessage, + ): Promise; + messageEvent(int: 'IPostMessageSent' | 'IPostMessageUpdated', message: IMessage): Promise; + + roomEvent(int: 'IPreRoomUserJoined' | 'IPostRoomUserJoined', room: IRoom, joiningUser: IUser, invitingUser?: IUser): Promise; + roomEvent(int: 'IPreRoomUserLeave' | 'IPostRoomUserLeave', room: IRoom, leavingUser: IUser): Promise; + + roomEvent(int: 'IPreRoomCreatePrevent' | 'IPreRoomDeletePrevent', room: IRoom): Promise; + roomEvent(int: 'IPreRoomCreateExtend' | 'IPreRoomCreateModify', room: IRoom): Promise; + roomEvent(int: 'IPostRoomCreate' | 'IPostRoomDeleted', room: IRoom): Promise; + + livechatEvent( + int: 'IPostLivechatAgentAssigned' | 'IPostLivechatAgentUnassigned', + data: { user: IUser; room: IOmnichannelRoom }, + ): Promise; + livechatEvent( + int: 'IPostLivechatRoomTransferred', + data: { type: 'agent'; room: IRoom['_id']; from: IUser['_id']; to: IUser['_id'] }, + ): Promise; + livechatEvent( + int: 'IPostLivechatRoomTransferred', + data: { type: 'department'; room: IRoom['_id']; from: ILivechatDepartment['_id']; to: ILivechatDepartment['_id'] }, + ): Promise; + livechatEvent(int: 'IPostLivechatGuestSaved', data: ILivechatVisitor['_id']): Promise; + livechatEvent(int: 'IPostLivechatRoomSaved', data: IRoom['_id']): Promise; + livechatEvent(int: 'ILivechatRoomClosedHandler' | 'IPostLivechatRoomStarted' | 'IPostLivechatRoomClosed', data: IRoom): Promise; + livechatEvent(int: AppEvents | AppEvents[keyof AppEvents], data: any): Promise; + } +} diff --git a/packages/apps/src/index.ts b/packages/apps/src/index.ts index e137fa3cf007..837749af62c0 100644 --- a/packages/apps/src/index.ts +++ b/packages/apps/src/index.ts @@ -1,4 +1,7 @@ +import './bridges/IListenerBridge'; + export * from './converters'; export * from './AppsEngine'; export * from './IAppServerNotifier'; export * from './IAppServerOrchestrator'; +export * from './orchestrator'; diff --git a/packages/apps/src/orchestrator.ts b/packages/apps/src/orchestrator.ts new file mode 100644 index 000000000000..4e3a53d9d5f0 --- /dev/null +++ b/packages/apps/src/orchestrator.ts @@ -0,0 +1,7 @@ +import type { IAppServerOrchestrator } from './IAppServerOrchestrator'; + +export let Apps: IAppServerOrchestrator | undefined; + +export function registerOrchestrator(orch: IAppServerOrchestrator): void { + Apps = orch; +} From d1fd9da100739d531fe33fe39481a59c71749b05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Jaeger=20Foresti?= <60678893+juliajforesti@users.noreply.github.com> Date: Mon, 1 Apr 2024 14:57:45 -0300 Subject: [PATCH 013/131] chore: Create/Edit room consistency (#31960) --- apps/meteor/app/api/server/v1/rooms.ts | 3 +- .../server/methods/createDiscussion.ts | 10 +- .../CreateDiscussion/CreateDiscussion.tsx | 91 ++++---- .../InfoPanel/InfoPanelActionGroup.tsx | 2 +- .../CreateChannel/CreateChannelModal.tsx | 69 ++++--- .../sidebar/header/CreateDirectMessage.tsx | 16 +- .../header/CreateTeam/CreateTeamModal.tsx | 69 +++---- .../actions/hooks/useCreateRoomItems.tsx | 6 +- .../hooks/useEncryptedRoomDescription.tsx | 23 +++ .../views/hooks/roomActions/useDeleteRoom.tsx | 15 +- .../Info/EditRoomInfo/EditRoomInfo.tsx | 195 +++++++++++++----- .../contextualBar/Info/RoomInfo/RoomInfo.tsx | 15 +- .../DeleteTeam/DeleteTeamConfirmation.tsx | 6 +- .../teams/contextualBar/info/TeamsInfo.tsx | 12 +- apps/meteor/package.json | 2 +- .../tests/e2e/channel-management.spec.ts | 2 +- apps/meteor/tests/e2e/create-direct.spec.ts | 2 +- .../fragments/home-flextab-room.ts | 2 +- .../page-objects/fragments/home-sidenav.ts | 6 +- .../tests/e2e/page-objects/home-discussion.ts | 8 +- .../tests/e2e/page-objects/home-team.ts | 8 +- ee/packages/ui-theming/package.json | 2 +- packages/fuselage-ui-kit/package.json | 2 +- packages/gazzodown/package.json | 2 +- packages/i18n/src/locales/en.i18n.json | 157 ++++++++------ packages/rest-typings/src/v1/rooms.ts | 1 + packages/ui-avatar/package.json | 2 +- packages/ui-client/package.json | 2 +- packages/ui-composer/package.json | 2 +- packages/ui-video-conf/package.json | 2 +- packages/uikit-playground/package.json | 2 +- yarn.lock | 26 +-- 32 files changed, 473 insertions(+), 289 deletions(-) create mode 100644 apps/meteor/client/sidebar/header/hooks/useEncryptedRoomDescription.tsx diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index ae08dae04938..47e2c03e6064 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -322,7 +322,7 @@ API.v1.addRoute( { async post() { // eslint-disable-next-line @typescript-eslint/naming-convention - const { prid, pmid, reply, t_name, users, encrypted } = this.bodyParams; + const { prid, pmid, reply, t_name, users, encrypted, topic } = this.bodyParams; if (!prid) { return API.v1.failure('Body parameter "prid" is required.'); } @@ -344,6 +344,7 @@ API.v1.addRoute( reply, users: users?.filter(isTruthy) || [], encrypted, + topic, }); return API.v1.success({ discussion }); diff --git a/apps/meteor/app/discussion/server/methods/createDiscussion.ts b/apps/meteor/app/discussion/server/methods/createDiscussion.ts index 6d60e9af24bc..18b42ba1a31f 100644 --- a/apps/meteor/app/discussion/server/methods/createDiscussion.ts +++ b/apps/meteor/app/discussion/server/methods/createDiscussion.ts @@ -62,6 +62,7 @@ type CreateDiscussionProperties = { users: Array>; user: IUser; encrypted?: boolean; + topic?: string; }; const create = async ({ @@ -72,6 +73,7 @@ const create = async ({ users, user, encrypted, + topic, }: CreateDiscussionProperties): Promise => { // if you set both, prid and pmid, and the rooms dont match... should throw an error) let message: null | IMessage = null; @@ -145,7 +147,7 @@ const create = async ({ const type = await roomCoordinator.getRoomDirectives(parentRoom.t).getDiscussionType(parentRoom); const description = parentRoom.encrypted ? '' : message?.msg; - const topic = parentRoom.name; + const discussionTopic = topic || parentRoom.name; if (!type) { throw new Meteor.Error('error-invalid-type', 'Cannot define discussion room type', { @@ -163,7 +165,7 @@ const create = async ({ { fname: discussionName, description, // TODO discussions remove - topic, // TODO discussions remove + topic: discussionTopic, prid, encrypted, }, @@ -203,7 +205,7 @@ declare module '@rocket.chat/ui-contexts' { export const createDiscussion = async ( userId: string, - { prid, pmid, t_name: discussionName, reply, users, encrypted }: Omit, + { prid, pmid, t_name: discussionName, reply, users, encrypted, topic }: Omit, ): Promise< IRoom & { rid: string; @@ -229,7 +231,7 @@ export const createDiscussion = async ( }); } - return create({ prid, pmid, t_name: discussionName, reply, users, user, encrypted }); + return create({ prid, pmid, t_name: discussionName, reply, users, user, encrypted, topic }); }; Meteor.methods({ diff --git a/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx b/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx index c191fb150873..e6bce31b0b87 100644 --- a/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx +++ b/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx @@ -32,6 +32,7 @@ type CreateDiscussionFormValues = { encrypted: boolean; usernames: Array; firstMessage: string; + topic: string; }; type CreateDiscussionProps = { @@ -49,6 +50,7 @@ const CreateDiscussion = ({ onClose, defaultParentRoom, parentMessageId, nameSug handleSubmit, control, watch, + register, } = useForm({ mode: 'onBlur', defaultValues: { @@ -57,6 +59,7 @@ const CreateDiscussion = ({ onClose, defaultParentRoom, parentMessageId, nameSug encrypted: false, usernames: [], firstMessage: '', + topic: '', }, }); @@ -72,21 +75,23 @@ const CreateDiscussion = ({ onClose, defaultParentRoom, parentMessageId, nameSug }, }); - const handleCreate = async ({ name, parentRoom, encrypted, usernames, firstMessage }: CreateDiscussionFormValues) => { + const handleCreate = async ({ name, parentRoom, encrypted, usernames, firstMessage, topic }: CreateDiscussionFormValues) => { createDiscussionMutation.mutate({ prid: defaultParentRoom || parentRoom, t_name: name, users: usernames, reply: encrypted ? undefined : firstMessage, + topic, ...(parentMessageId && { pmid: parentMessageId }), }); }; - const targetChannelField = useUniqueId(); - const encryptedField = useUniqueId(); - const discussionField = useUniqueId(); - const usersField = useUniqueId(); - const firstMessageField = useUniqueId(); + const parentRoomId = useUniqueId(); + const encryptedId = useUniqueId(); + const discussionNameId = useUniqueId(); + const membersId = useUniqueId(); + const firstMessageId = useUniqueId(); + const topicId = useUniqueId(); return ( {t('Discussion_description')} - + {t('Discussion_target_channel')} @@ -123,36 +128,26 @@ const CreateDiscussion = ({ onClose, defaultParentRoom, parentMessageId, nameSug onBlur={onBlur} onChange={onChange} value={value} - id={targetChannelField} - placeholder={t('Discussion_target_channel_description')} + id={parentRoomId} + placeholder={t('Search_options')} disabled={Boolean(defaultParentRoom)} aria-invalid={Boolean(errors.parentRoom)} aria-required='true' - aria-describedby={`${targetChannelField}-error`} + aria-describedby={`${parentRoomId}-error`} /> )} /> )} {errors.parentRoom && ( - + {errors.parentRoom.message} )} - - {t('Encrypted')} - } - /> - - - - - {t('Discussion_name')} + + {t('Name')} ( } /> )} /> {errors.name && ( - + {errors.name.message} )} - {t('Invite_Users')} + {t('Topic')} + + + + + {t('Displayed_next_to_name')} + + + + {t('Members')} ( )} /> - {t('Discussion_first_message_title')} + {t('Discussion_first_message_title')} ( )} /> - {encrypted && {t('Discussion_first_message_disabled_due_to_e2e')}} + {encrypted ? ( + {t('Discussion_first_message_disabled_due_to_e2e')} + ) : ( + {t('First_message_hint')} + )} + + + + {t('Encrypted')} + } + /> + + {encrypted ? ( + {t('Encrypted_messages', { roomType: 'discussion' })} + ) : ( + {t('Encrypted_messages_false')} + )} diff --git a/apps/meteor/client/components/InfoPanel/InfoPanelActionGroup.tsx b/apps/meteor/client/components/InfoPanel/InfoPanelActionGroup.tsx index 3bf9f7e33c70..00af64b0fa61 100644 --- a/apps/meteor/client/components/InfoPanel/InfoPanelActionGroup.tsx +++ b/apps/meteor/client/components/InfoPanel/InfoPanelActionGroup.tsx @@ -6,7 +6,7 @@ import Section from './InfoPanelSection'; const InfoPanelActionGroup: FC> = (props) => (

- +
); diff --git a/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx b/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx index 2708e6e0a1e3..cd5e19947991 100644 --- a/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx +++ b/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx @@ -30,6 +30,7 @@ import { useForm, Controller } from 'react-hook-form'; import { useHasLicenseModule } from '../../../../ee/client/hooks/useHasLicenseModule'; import UserAutoCompleteMultipleFederated from '../../../components/UserAutoCompleteMultiple/UserAutoCompleteMultipleFederated'; import { goToRoomById } from '../../../lib/utils/goToRoomById'; +import { useEncryptedRoomDescription } from '../hooks/useEncryptedRoomDescription'; type CreateChannelModalProps = { teamId?: string; @@ -68,6 +69,7 @@ const CreateChannelModal = ({ teamId = '', onClose }: CreateChannelModalProps): const canCreateChannel = usePermission('create-c'); const canCreatePrivateChannel = usePermission('create-p'); + const getEncryptedHint = useEncryptedRoomDescription('channel'); const channelNameRegex = useMemo(() => new RegExp(`^${namesValidation}$`), [namesValidation]); const federatedModule = useHasLicenseModule('federation'); @@ -110,7 +112,7 @@ const CreateChannelModal = ({ teamId = '', onClose }: CreateChannelModalProps): }, }); - const { isPrivate, broadcast, readOnly, federated } = watch(); + const { isPrivate, broadcast, readOnly, federated, encrypted } = watch(); useEffect(() => { if (!isPrivate) { @@ -137,7 +139,7 @@ const CreateChannelModal = ({ teamId = '', onClose }: CreateChannelModalProps): } if (!allowSpecialNames && !channelNameRegex.test(name)) { - return t('error-invalid-name'); + return t('Name_cannot_have_special_characters'); } const { exists } = await channelNameExists({ roomName: name }); @@ -209,7 +211,7 @@ const CreateChannelModal = ({ teamId = '', onClose }: CreateChannelModalProps): - {t('Channel_name')} + {t('Name')} } aria-invalid={errors.name ? 'true' : 'false'} - aria-describedby={`${nameId}-error`} + aria-describedby={`${nameId}-error ${nameId}-hint`} aria-required='true' /> @@ -231,13 +233,24 @@ const CreateChannelModal = ({ teamId = '', onClose }: CreateChannelModalProps): {errors.name.message} )} + {!allowSpecialNames && {t('No_spaces')}} {t('Topic')} - {t('Channel_what_is_this_channel_about')} + {t('Displayed_next_to_name')} + + + {t('Members')} + ( + + )} + /> @@ -258,7 +271,7 @@ const CreateChannelModal = ({ teamId = '', onClose }: CreateChannelModalProps): /> - {isPrivate ? t('Only_invited_users_can_acess_this_channel') : t('Everyone_can_access_this_channel')} + {isPrivate ? t('People_can_only_join_by_being_invited') : t('Anyone_can_access')} @@ -283,48 +296,46 @@ const CreateChannelModal = ({ teamId = '', onClose }: CreateChannelModalProps): - {t('Read_only')} + {t('Encrypted')} ( )} /> - - {readOnly ? t('Only_authorized_users_can_write_new_messages') : t('All_users_in_the_channel_can_write_new_messages')} - + {getEncryptedHint({ isPrivate, broadcast, encrypted })} - {t('Encrypted')} + {t('Read_only')} ( )} /> - - {isPrivate ? t('Encrypted_channel_Description') : t('Encrypted_not_available')} - + + {readOnly ? t('Read_only_field_hint_enabled', { roomType: 'channel' }) : t('Anyone_can_send_new_messages')} + @@ -344,17 +355,7 @@ const CreateChannelModal = ({ teamId = '', onClose }: CreateChannelModalProps): )} /> - {t('Broadcast_channel_Description')} - - - {t('Add_members')} - ( - - )} - /> + {broadcast && {t('Broadcast_hint_enabled', { roomType: 'channel' })}} diff --git a/apps/meteor/client/sidebar/header/CreateDirectMessage.tsx b/apps/meteor/client/sidebar/header/CreateDirectMessage.tsx index 626d1202a8e0..7c34f7ac01a3 100644 --- a/apps/meteor/client/sidebar/header/CreateDirectMessage.tsx +++ b/apps/meteor/client/sidebar/header/CreateDirectMessage.tsx @@ -1,5 +1,5 @@ import type { IUser } from '@rocket.chat/core-typings'; -import { Box, Modal, Button, FieldGroup, Field, FieldRow, FieldLabel, FieldError } from '@rocket.chat/fuselage'; +import { Box, Modal, Button, FieldGroup, Field, FieldRow, FieldError, FieldHint } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useTranslation, useEndpoint, useToastMessageDispatch, useSetting } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; @@ -20,7 +20,7 @@ const CreateDirectMessage = ({ onClose }: { onClose: () => void }) => { const { control, handleSubmit, - formState: { isDirty, isSubmitting, isValidating, errors }, + formState: { isSubmitting, isValidating, errors }, } = useForm({ mode: 'onBlur', defaultValues: { users: [] } }); const mutateDirectMessage = useMutation({ @@ -47,17 +47,14 @@ const CreateDirectMessage = ({ onClose }: { onClose: () => void }) => { - {t('Direct_message_creation_description')} - - {t('Members')} - + {t('Direct_message_creation_description')} users.length + 1 > directMaxUsers ? t('error-direct-message-max-user-exceeded', { maxUsers: directMaxUsers }) @@ -71,7 +68,7 @@ const CreateDirectMessage = ({ onClose }: { onClose: () => void }) => { value={value} onBlur={onBlur} id={membersFieldId} - aria-describedby={`${membersFieldId}-error`} + aria-describedby={`${membersFieldId}-hint ${membersFieldId}-error`} aria-required='true' aria-invalid={Boolean(errors.users)} /> @@ -83,13 +80,14 @@ const CreateDirectMessage = ({ onClose }: { onClose: () => void }) => { {errors.users.message} )} + {t('Direct_message_creation_description_hint')} - diff --git a/apps/meteor/client/sidebar/header/CreateTeam/CreateTeamModal.tsx b/apps/meteor/client/sidebar/header/CreateTeam/CreateTeamModal.tsx index 115a8563f393..b56bb003aa9e 100644 --- a/apps/meteor/client/sidebar/header/CreateTeam/CreateTeamModal.tsx +++ b/apps/meteor/client/sidebar/header/CreateTeam/CreateTeamModal.tsx @@ -11,6 +11,7 @@ import { FieldRow, FieldError, FieldDescription, + FieldHint, } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { @@ -27,6 +28,7 @@ import { Controller, useForm } from 'react-hook-form'; import UserAutoCompleteMultiple from '../../../components/UserAutoCompleteMultiple'; import { goToRoomById } from '../../../lib/utils/goToRoomById'; +import { useEncryptedRoomDescription } from '../hooks/useEncryptedRoomDescription'; type CreateTeamModalInputs = { name: string; @@ -65,7 +67,7 @@ const CreateTeamModal = ({ onClose }: { onClose: () => void }): ReactElement => } if (teamNameRegex && !teamNameRegex?.test(name)) { - return t('Teams_Errors_team_name', { name }); + return t('Name_cannot_have_special_characters'); } const { exists } = await checkTeamNameExists({ roomName: name }); @@ -80,7 +82,7 @@ const CreateTeamModal = ({ onClose }: { onClose: () => void }): ReactElement => handleSubmit, setValue, watch, - formState: { isDirty, errors, isSubmitting }, + formState: { errors, isSubmitting }, } = useForm({ defaultValues: { isPrivate: true, @@ -91,7 +93,7 @@ const CreateTeamModal = ({ onClose }: { onClose: () => void }): ReactElement => }, }); - const { isPrivate, broadcast, readOnly } = watch(); + const { isPrivate, broadcast, readOnly, encrypted } = watch(); useEffect(() => { if (!isPrivate) { @@ -107,7 +109,7 @@ const CreateTeamModal = ({ onClose }: { onClose: () => void }): ReactElement => const canChangeReadOnly = !broadcast; const canChangeEncrypted = isPrivate && !broadcast && e2eEnabled && !e2eEnabledForPrivateByDefault; - const isButtonEnabled = isDirty && canCreateTeam; + const getEncryptedHint = useEncryptedRoomDescription('team'); const handleCreateTeam = async ({ name, @@ -164,6 +166,9 @@ const CreateTeamModal = ({ onClose }: { onClose: () => void }): ReactElement => + + {t('Teams_new_description')} + @@ -177,10 +182,9 @@ const CreateTeamModal = ({ onClose }: { onClose: () => void }): ReactElement => required: t('error-the-field-is-required', { field: t('Name') }), validate: (value) => validateTeamName(value), })} - placeholder={t('Team_Name')} addon={} error={errors.name?.message} - aria-describedby={`${nameId}-error`} + aria-describedby={`${nameId}-error ${nameId}-hint`} aria-required='true' /> @@ -189,23 +193,27 @@ const CreateTeamModal = ({ onClose }: { onClose: () => void }): ReactElement => {errors.name.message} )} + {!allowSpecialNames && {t('No_spaces')}} - - {t('Teams_New_Description_Label')}{' '} - - ({t('optional')}) - - + {t('Topic')} - + + + + {t('Displayed_next_to_name')} + + {t('Teams_New_Add_members_Label')} + ( + + )} + /> + {t('Teams_New_Private_Label')} @@ -218,7 +226,7 @@ const CreateTeamModal = ({ onClose }: { onClose: () => void }): ReactElement => /> - {isPrivate ? t('Teams_New_Private_Description_Enabled') : t('Teams_New_Private_Description_Disabled')} + {isPrivate ? t('People_can_only_join_by_being_invited') : t('Anyone_can_access')} @@ -240,7 +248,7 @@ const CreateTeamModal = ({ onClose }: { onClose: () => void }): ReactElement => /> - {readOnly ? t('Only_authorized_users_can_write_new_messages') : t('Teams_New_Read_only_Description')} + {readOnly ? t('Read_only_field_hint_enabled', { roomType: 'team' }) : t('Anyone_can_send_new_messages')} @@ -261,9 +269,7 @@ const CreateTeamModal = ({ onClose }: { onClose: () => void }): ReactElement => )} /> - - {isPrivate ? t('Teams_New_Encrypted_Description_Enabled') : t('Teams_New_Encrypted_Description_Disabled')} - + {getEncryptedHint({ isPrivate, broadcast, encrypted })} @@ -276,27 +282,14 @@ const CreateTeamModal = ({ onClose }: { onClose: () => void }): ReactElement => )} /> - {t('Teams_New_Broadcast_Description')} - - - - {t('Teams_New_Add_members_Label')}{' '} - - ({t('optional')}) - - - } - /> + {broadcast && {t('Teams_New_Broadcast_Description')}} - diff --git a/apps/meteor/client/sidebar/header/actions/hooks/useCreateRoomItems.tsx b/apps/meteor/client/sidebar/header/actions/hooks/useCreateRoomItems.tsx index c6847fbe7d1c..7b2770a60ab5 100644 --- a/apps/meteor/client/sidebar/header/actions/hooks/useCreateRoomItems.tsx +++ b/apps/meteor/client/sidebar/header/actions/hooks/useCreateRoomItems.tsx @@ -44,7 +44,7 @@ export const useCreateRoomItems = (): GenericMenuItemProps[] => { }; const createDirectMessageItem: GenericMenuItemProps = { id: 'direct', - content: t('Direct_Messages'), + content: t('Direct_message'), icon: 'balloon', onClick: () => { createDirectMessage(); @@ -60,9 +60,9 @@ export const useCreateRoomItems = (): GenericMenuItemProps[] => { }; return [ - ...(canCreateChannel ? [createChannelItem] : []), - ...(canCreateTeam ? [createTeamItem] : []), ...(canCreateDirectMessages ? [createDirectMessageItem] : []), ...(canCreateDiscussion && discussionEnabled ? [createDiscussionItem] : []), + ...(canCreateChannel ? [createChannelItem] : []), + ...(canCreateTeam ? [createTeamItem] : []), ]; }; diff --git a/apps/meteor/client/sidebar/header/hooks/useEncryptedRoomDescription.tsx b/apps/meteor/client/sidebar/header/hooks/useEncryptedRoomDescription.tsx new file mode 100644 index 000000000000..09796dd7a6b7 --- /dev/null +++ b/apps/meteor/client/sidebar/header/hooks/useEncryptedRoomDescription.tsx @@ -0,0 +1,23 @@ +import { useSetting, useTranslation } from '@rocket.chat/ui-contexts'; + +export const useEncryptedRoomDescription = (roomType: 'channel' | 'team') => { + const t = useTranslation(); + const e2eEnabled = useSetting('E2E_Enable'); + const e2eEnabledForPrivateByDefault = useSetting('E2E_Enabled_Default_PrivateRooms'); + + return ({ isPrivate, broadcast, encrypted }: { isPrivate: boolean; broadcast: boolean; encrypted: boolean }) => { + if (!e2eEnabled) { + return t('Not_available_for_this_workspace'); + } + if (!isPrivate) { + return t('Encrypted_not_available', { roomType }); + } + if (broadcast) { + return t('Not_available_for_broadcast', { roomType }); + } + if (e2eEnabledForPrivateByDefault || encrypted) { + return t('Encrypted_messages', { roomType }); + } + return t('Encrypted_messages_false'); + }; +}; diff --git a/apps/meteor/client/views/hooks/roomActions/useDeleteRoom.tsx b/apps/meteor/client/views/hooks/roomActions/useDeleteRoom.tsx index be4728732284..48b6507a331d 100644 --- a/apps/meteor/client/views/hooks/roomActions/useDeleteRoom.tsx +++ b/apps/meteor/client/views/hooks/roomActions/useDeleteRoom.tsx @@ -15,7 +15,8 @@ export const useDeleteRoom = (room: IRoom | Pick, { const dispatchToastMessage = useToastMessageDispatch(); const hasPermissionToDelete = usePermission(`delete-${room.t}`, room._id); const canDeleteRoom = isRoomFederated(room) ? false : hasPermissionToDelete; - + // eslint-disable-next-line no-nested-ternary + const roomType = 'prid' in room ? 'discussion' : room.teamId && room.teamMain ? 'team' : 'channel'; const isAdminRoute = router.getRouteName() === 'admin-rooms'; const deleteRoomEndpoint = useEndpoint('POST', '/v1/rooms.delete'); @@ -24,7 +25,7 @@ export const useDeleteRoom = (room: IRoom | Pick, { const deleteRoomMutation = useMutation({ mutationFn: deleteRoomEndpoint, onSuccess: () => { - dispatchToastMessage({ type: 'success', message: t('Room_has_been_deleted') }); + dispatchToastMessage({ type: 'success', message: t('Deleted_roomType', { roomName: room.name, roomType }) }); if (isAdminRoute) { return router.navigate('/admin/rooms'); } @@ -79,8 +80,14 @@ export const useDeleteRoom = (room: IRoom | Pick, { }; setModal( - setModal(null)} confirmText={t('Yes_delete_it')}> - {t('Delete_Room_Warning')} + setModal(null)} + confirmText={t('Yes_delete_it')} + > + {t('Delete_Room_Warning', { roomType })} , ); }); diff --git a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx index 2a9397364757..bc7714da06ae 100644 --- a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx +++ b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx @@ -39,7 +39,6 @@ import RawText from '../../../../../components/RawText'; import RoomAvatarEditor from '../../../../../components/avatar/RoomAvatarEditor'; import { getDirtyFields } from '../../../../../lib/getDirtyFields'; import { useArchiveRoom } from '../../../../hooks/roomActions/useArchiveRoom'; -import { useDeleteRoom } from '../../../../hooks/roomActions/useDeleteRoom'; import { useEditRoomInitialValues } from './useEditRoomInitialValues'; import { useEditRoomPermissions } from './useEditRoomPermissions'; @@ -49,20 +48,39 @@ type EditRoomInfoProps = { onClickBack: () => void; }; +const title = { + team: 'Edit_team' as TranslationKey, + channel: 'Edit_channel' as TranslationKey, + discussion: 'Edit_discussion' as TranslationKey, +}; + const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => { const t = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); const isFederated = useMemo(() => isRoomFederated(room), [room]); + // eslint-disable-next-line no-nested-ternary + const roomType = 'prid' in room ? 'discussion' : room.teamId ? 'team' : 'channel'; const retentionPolicy = useSetting('RetentionPolicy_Enabled'); - const { handleDelete, canDeleteRoom } = useDeleteRoom(room); const defaultValues = useEditRoomInitialValues(room); + const namesValidation = useSetting('UTF8_Channel_Names_Validation'); + const allowSpecialNames = useSetting('UI_Allow_room_names_with_special_chars'); + const checkTeamNameExists = useEndpoint('GET', '/v1/rooms.nameExists'); + + const teamNameRegex = useMemo(() => { + if (allowSpecialNames) { + return null; + } + + return new RegExp(`^${namesValidation}$`); + }, [allowSpecialNames, namesValidation]); const { watch, reset, control, handleSubmit, + getFieldState, formState: { isDirty, dirtyFields, errors, isSubmitting }, } = useForm({ mode: 'onBlur', defaultValues }); @@ -71,7 +89,19 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => [t], ); - const { readOnly, archived, joinCodeRequired, hideSysMes, retentionEnabled, retentionMaxAge, retentionOverrideGlobal } = watch(); + const { isDirty: isRoomNameDirty } = getFieldState('roomName'); + + const { + readOnly, + archived, + joinCodeRequired, + hideSysMes, + retentionEnabled, + retentionMaxAge, + retentionOverrideGlobal, + roomType: roomTypeP, + reactWhenReadOnly, + } = watch(); const { canChangeType, @@ -123,6 +153,20 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => Promise.all([isDirty && handleUpdateRoomData(data), changeArchiving && handleArchive()].filter(Boolean)), ); + const validateName = async (name: string): Promise => { + if (!name || !isRoomNameDirty) return; + if (roomType === 'discussion') return; + + if (teamNameRegex && !teamNameRegex?.test(name)) { + return t('Name_cannot_have_special_characters'); + } + + const { exists } = await checkTeamNameExists({ roomName: name }); + if (exists) { + return t('Teams_Errors_Already_exists', { name }); + } + }; + const formId = useUniqueId(); const roomNameField = useUniqueId(); const roomDescriptionField = useUniqueId(); @@ -145,7 +189,7 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => <> {onClickBack && } - {room.teamId ? t('edit-team') : t('edit-room')} + {t(`${title[roomType]}`)} {onClickClose && } @@ -166,22 +210,37 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => } + rules={{ + required: t('error-the-field-is-required', { field: t('Name') }), + validate: (value) => validateName(value), + }} + render={({ field }) => ( + + )} /> - {errors.roomName && {errors.roomName.message}} + {errors.roomName && {errors.roomName.message}} - {canViewDescription && ( + {canViewTopic && ( - {t('Description')} + {t('Topic')} } + render={({ field }) => } /> + + {t('Displayed_next_to_name')} + )} {canViewAnnouncement && ( @@ -191,23 +250,34 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => } + render={({ field }) => ( + + )} /> + + {t('Information_to_keep_top_of_mind')} + )} - {canViewTopic && ( + {canViewDescription && ( - {t('Topic')} + {t('Description')} } + render={({ field }) => } /> )} + {canViewType && ( @@ -229,7 +299,11 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => )} /> - {t('Teams_New_Private_Description_Enabled')} + + + {roomTypeP === 'p' ? t('Only_invited_people') : t('Anyone_can_access')} + + )} {canViewReadOnly && ( @@ -250,7 +324,9 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => )} /> - {t('Only_authorized_users_can_write_new_messages')} + + {readOnly ? t('Read_only_field_hint_enabled', { roomType }) : t('Read_only_field_hint_disabled')} + )} {readOnly && ( @@ -271,7 +347,11 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => )} /> - {t('Only_authorized_users_can_react_to_messages')} + + + {reactWhenReadOnly ? t('Anyone_can_react_to_messages') : t('Only_authorized_users_can_react_to_messages')} + + )} {canViewArchived && ( @@ -282,10 +362,21 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => control={control} name='archived' render={({ field: { value, ...field } }) => ( - + )} /> + {archived && ( + + {t('New_messages_cannot_be_sent')} + + )} )} {canViewJoinCode && ( @@ -300,13 +391,15 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => )} /> - - } - /> - + {joinCodeRequired && ( + + } + /> + + )} )} {canViewHideSysMes && ( @@ -330,27 +423,13 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => {...field} options={sysMesOptions} disabled={!hideSysMes || isFederated} - placeholder={t('Select_an_option')} + placeholder={t('Select_messages_to_hide')} /> )} /> )} - {canViewEncrypted && ( - - - {t('Encrypted')} - ( - - )} - /> - - - )} {retentionPolicy && ( @@ -428,6 +507,29 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => /> + {canViewEncrypted && ( + + + {t('Encrypted')} + ( + + )} + /> + + + {t('Encrypted_field_hint')} + + + )} )} @@ -441,17 +543,10 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => - - - - - - ); diff --git a/apps/meteor/client/views/room/contextualBar/Info/RoomInfo/RoomInfo.tsx b/apps/meteor/client/views/room/contextualBar/Info/RoomInfo/RoomInfo.tsx index 38a7593937ff..80abe104c88e 100644 --- a/apps/meteor/client/views/room/contextualBar/Info/RoomInfo/RoomInfo.tsx +++ b/apps/meteor/client/views/room/contextualBar/Info/RoomInfo/RoomInfo.tsx @@ -34,6 +34,7 @@ const RoomInfo = ({ room, icon, onClickBack, onClickClose, onClickEnterRoom, onC const t = useTranslation(); const { name, fname, description, topic, archived, broadcast, announcement } = room; const roomTitle = fname || name; + const isDiscussion = 'prid' in room; const retentionPolicy = useRetentionPolicy(room); const memoizedActions = useRoomActions(room, { onClickEnterRoom, onClickEdit }, resetState); @@ -48,7 +49,7 @@ const RoomInfo = ({ room, icon, onClickBack, onClickClose, onClickEnterRoom, onC {onClickBack ? : } - {t('Room_Info')} + {isDiscussion ? t('Discussion_info') : t('Channel_info')} {onClickClose && } - - - + + + + - {actions} + {actions} + {archived && ( diff --git a/apps/meteor/client/views/teams/contextualBar/info/DeleteTeam/DeleteTeamConfirmation.tsx b/apps/meteor/client/views/teams/contextualBar/info/DeleteTeam/DeleteTeamConfirmation.tsx index 06d47d7bec37..d7c711cf2d42 100644 --- a/apps/meteor/client/views/teams/contextualBar/info/DeleteTeam/DeleteTeamConfirmation.tsx +++ b/apps/meteor/client/views/teams/contextualBar/info/DeleteTeam/DeleteTeamConfirmation.tsx @@ -20,14 +20,14 @@ const DeleteTeamConfirmation = ({ deletedRooms, keptRooms, onConfirm, onReturn, return ( onConfirm(roomIds)} onCancel={onReturn} - confirmText={t('Remove')} + confirmText={t('Yes_delete_it')} cancelText={t('Back')} onClose={onCancel} > -

{t('Teams_delete_team')}

+

{t('Delete_roomType_description', { roomType: 'team' })}

{!!Object.values(deletedRooms).length && ( <>
diff --git a/apps/meteor/client/views/teams/contextualBar/info/TeamsInfo.tsx b/apps/meteor/client/views/teams/contextualBar/info/TeamsInfo.tsx index 8fd6623c8d9f..809e20100c9b 100644 --- a/apps/meteor/client/views/teams/contextualBar/info/TeamsInfo.tsx +++ b/apps/meteor/client/views/teams/contextualBar/info/TeamsInfo.tsx @@ -105,7 +105,7 @@ const TeamsInfo = ({ - - - + + + + - {actions} + {actions} + {room.archived && ( diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 7c51c1a92a3a..098c488b83c2 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -242,7 +242,7 @@ "@rocket.chat/favicon": "workspace:^", "@rocket.chat/forked-matrix-appservice-bridge": "^4.0.2", "@rocket.chat/forked-matrix-bot-sdk": "^0.6.0-beta.3", - "@rocket.chat/fuselage": "^0.51.1", + "@rocket.chat/fuselage": "^0.52.0", "@rocket.chat/fuselage-hooks": "^0.33.0", "@rocket.chat/fuselage-polyfills": "~0.31.25", "@rocket.chat/fuselage-toastbar": "^0.31.26", diff --git a/apps/meteor/tests/e2e/channel-management.spec.ts b/apps/meteor/tests/e2e/channel-management.spec.ts index 0586598a5dda..efa39e0773b3 100644 --- a/apps/meteor/tests/e2e/channel-management.spec.ts +++ b/apps/meteor/tests/e2e/channel-management.spec.ts @@ -162,7 +162,7 @@ test.describe.serial('channel-management', () => { await poHomeChannel.sidenav.openChat(targetChannel); await poHomeChannel.content.btnMenuMoreActions.click(); await page.getByRole('menuitem', { name: 'Discussion' }).click(); - await page.getByRole('textbox', { name: 'Discussion name' }).fill(discussionName); + await page.getByRole('textbox', { name: 'Name' }).fill(discussionName); await page.getByRole('button', { name: 'Create' }).click(); await expect(page.getByRole('heading', { name: discussionName })).toBeVisible(); diff --git a/apps/meteor/tests/e2e/create-direct.spec.ts b/apps/meteor/tests/e2e/create-direct.spec.ts index 4e74840619ba..1b4e2d7c752d 100644 --- a/apps/meteor/tests/e2e/create-direct.spec.ts +++ b/apps/meteor/tests/e2e/create-direct.spec.ts @@ -14,7 +14,7 @@ test.describe.serial('channel-direct-message', () => { }); test('expect create a direct room', async ({ page }) => { - await poHomeChannel.sidenav.openNewByLabel('Direct messages'); + await poHomeChannel.sidenav.openNewByLabel('Direct message'); await poHomeChannel.sidenav.inputDirectUsername.click(); await page.keyboard.type('rocket.cat'); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-flextab-room.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-flextab-room.ts index c711d6bcdc30..d7b0b3c2d9cd 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-flextab-room.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-flextab-room.ts @@ -28,7 +28,7 @@ export class HomeFlextabRoom { } get checkboxReadOnly(): Locator { - return this.page.locator('text=Read OnlyOnly authorized users can write new messages >> i'); + return this.page.locator('label', { has: this.page.getByRole('checkbox', { name: 'Read-only' }) }); } get btnSave(): Locator { diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts index 12a38f3b6185..ec085dce6bc7 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts @@ -10,15 +10,15 @@ export class HomeSidenav { } get checkboxPrivateChannel(): Locator { - return this.page.locator('role=dialog[name="Create Channel"] >> label >> text="Private"'); + return this.page.locator('label', { has: this.page.getByRole('checkbox', { name: 'Private' }) }); } get checkboxEncryption(): Locator { - return this.page.locator('role=dialog[name="Create Channel"] >> label >> text="Encrypted"'); + return this.page.locator('role=dialog[name="Create channel"] >> label >> text="Encrypted"'); } get checkboxReadOnly(): Locator { - return this.page.locator('role=dialog[name="Create Channel"] >> label >> text="Read Only"'); + return this.page.locator('label', { has: this.page.getByRole('checkbox', { name: 'Read-only' }) }); } get inputChannelName(): Locator { diff --git a/apps/meteor/tests/e2e/page-objects/home-discussion.ts b/apps/meteor/tests/e2e/page-objects/home-discussion.ts index dc055df73bce..58e129d745a1 100644 --- a/apps/meteor/tests/e2e/page-objects/home-discussion.ts +++ b/apps/meteor/tests/e2e/page-objects/home-discussion.ts @@ -19,18 +19,18 @@ export class HomeDiscussion { } get inputChannelName(): Locator { - return this.page.locator('.rcx-input-box--undecorated.rcx-input-box').first(); + return this.page.locator('role=textbox[name="Parent channel or team"]'); } get inputName(): Locator { - return this.page.locator('[placeholder="A meaningful name for the discussion room"]'); + return this.page.locator('role=textbox[name="Name"]'); } get inputMessage(): Locator { - return this.page.locator('textarea.rcx-input-box'); + return this.page.locator('role=textbox[name="Message"]'); } get btnCreate(): Locator { - return this.page.locator('button.rcx-button--primary.rcx-button >> text="Create"'); + return this.page.locator('role=dialog >> role=group >> role=button[name="Create"]'); } } diff --git a/apps/meteor/tests/e2e/page-objects/home-team.ts b/apps/meteor/tests/e2e/page-objects/home-team.ts index 9c890da05db8..a3b9130eb341 100644 --- a/apps/meteor/tests/e2e/page-objects/home-team.ts +++ b/apps/meteor/tests/e2e/page-objects/home-team.ts @@ -19,11 +19,11 @@ export class HomeTeam { } get inputTeamName(): Locator { - return this.page.locator('.rcx-field-group__item:nth-child(1) input'); + return this.page.locator('role=textbox[name="Name"]'); } async addMember(memberName: string): Promise { - await this.page.locator('.rcx-field-group__item:nth-child(7) input').type(memberName, { delay: 100 }); + await this.page.locator('role=textbox[name="Members"]').type(memberName, { delay: 100 }); await this.page.locator(`.rcx-option__content:has-text("${memberName}")`).click(); } @@ -32,10 +32,10 @@ export class HomeTeam { } get textPrivate(): Locator { - return this.page.locator('role=dialog[name="Create Team"] >> label >> text="Private"'); + return this.page.locator('label', {has: this.page.getByRole('checkbox', {name: 'Private'})}); } get textReadOnly(): Locator { - return this.page.locator('role=dialog[name="Create Team"] >> label >> text="Read Only"'); + return this.page.locator('label', {has: this.page.getByRole('checkbox', {name: 'Read-only'})}); } } diff --git a/ee/packages/ui-theming/package.json b/ee/packages/ui-theming/package.json index e4b2908ff0a7..32b628c6835a 100644 --- a/ee/packages/ui-theming/package.json +++ b/ee/packages/ui-theming/package.json @@ -4,7 +4,7 @@ "private": true, "devDependencies": { "@rocket.chat/css-in-js": "~0.31.25", - "@rocket.chat/fuselage": "^0.51.1", + "@rocket.chat/fuselage": "^0.52.0", "@rocket.chat/fuselage-hooks": "^0.33.0", "@rocket.chat/icons": "^0.34.0", "@rocket.chat/ui-contexts": "workspace:~", diff --git a/packages/fuselage-ui-kit/package.json b/packages/fuselage-ui-kit/package.json index 36b7cde2fb97..216efd02ef54 100644 --- a/packages/fuselage-ui-kit/package.json +++ b/packages/fuselage-ui-kit/package.json @@ -63,7 +63,7 @@ "@babel/preset-typescript": "~7.22.15", "@rocket.chat/apps-engine": "1.42.0-alpha.619", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/fuselage": "^0.51.1", + "@rocket.chat/fuselage": "^0.52.0", "@rocket.chat/fuselage-hooks": "^0.33.0", "@rocket.chat/fuselage-polyfills": "~0.31.25", "@rocket.chat/icons": "^0.34.0", diff --git a/packages/gazzodown/package.json b/packages/gazzodown/package.json index 791a0129f18d..368f8b18e5a9 100644 --- a/packages/gazzodown/package.json +++ b/packages/gazzodown/package.json @@ -6,7 +6,7 @@ "@babel/core": "~7.22.20", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/css-in-js": "~0.31.25", - "@rocket.chat/fuselage": "^0.51.1", + "@rocket.chat/fuselage": "^0.52.0", "@rocket.chat/fuselage-tokens": "^0.33.0", "@rocket.chat/message-parser": "workspace:^", "@rocket.chat/styled": "~0.31.25", diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 8b9ab62fe910..bd3222cdce93 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -912,7 +912,7 @@ "Canned_Responses": "Canned Responses", "Canned_Responses_Enable": "Enable Canned Responses", "Create_department": "Create department", - "Create_direct_message": "Create direct message", + "Create_direct_message": "New direct message", "Create_tag": "Create tag", "Create_trigger": "Create trigger", "Create_SLA_policy": "Create SLA policy", @@ -959,6 +959,7 @@ "Changing_email": "Changing email", "channel": "channel", "Channel": "Channel", + "Channel_info": "Channel info", "Channel_already_exist": "The channel `#%s` already exists.", "Channel_already_exist_static": "The channel already exists.", "Channel_already_Unarchived": "Channel with name `#%s` is already in Unarchived state", @@ -1436,7 +1437,7 @@ "Create": "Create", "Create_canned_response": "Create canned response", "Create_custom_field": "Create custom field", - "Create_channel": "Create Channel", + "Create_channel": "Create channel", "Create_channels": "Create channels", "Create_a_public_channel_that_new_workspace_members_can_join": "Create a public channel that new workspace members can join.", "Create_A_New_Channel": "Create a New Channel", @@ -1581,7 +1582,7 @@ "Delete_my_account": "Delete my account", "Delete_Role_Warning": "This cannot be undone", "Delete_Role_Warning_Not_Enterprise": "This cannot be undone. You won't be able to create a new custom role, since that feature is no longer available for your current plan.", - "Delete_Room_Warning": "Deleting a room will delete all messages posted within the room. This cannot be undone.", + "Delete_Room_Warning": "Deleting this {{roomType}} will also delete all contained message. This cannot be undone.", "Delete_User_Warning": "Deleting a user will delete all messages from that user as well. This cannot be undone.", "Delete_User_Warning_Delete": "Deleting a user will delete all messages from that user as well. This cannot be undone.", "Delete_User_Warning_Keep": "The user will be deleted, but their messages will remain visible. This cannot be undone.", @@ -1661,10 +1662,13 @@ "DirectMesssage_maxUsers": "Max users in direct messages", "Direct_Message": "Direct message", "Livechat_Facebook_Enabled": "Facebook integration enabled", - "Direct_message_creation_description": "You are about to create a chat with multiple users. Add the ones you would like to talk, everyone in the same place, using direct messages.", + "Direct_message_creation_description": "Select one or more people to message", + "Direct_message_creation_error": "Please select at least one person", + "Direct_message_creation_description_hint": "More people cannot be added once created", "Direct_message_someone": "Direct message someone", "Direct_message_you_have_joined": "You have joined a new direct message with", "Direct_Messages": "Direct messages", + "Direct_message": "Direct message", "Direct_Reply": "Direct Reply", "Direct_Reply_Advice": "You can directly reply to this email. Do not modify previous emails in the thread.", "Direct_Reply_Debug": "Debug Direct Reply", @@ -1697,13 +1701,14 @@ "Disconnect": "Disconnect", "Discover_public_channels_and_teams_in_the_workspace_directory": "Discover public channels and teams in the workspace directory.", "Discussion": "Discussion", + "Discussion_info": "Discussion info", "Discussion_Description": "Discussions are an additional way to organize conversations that allows inviting users from outside channels to participate in specific conversations.", - "Discussion_description": "Help keep an overview of what's going on! By creating a discussion, a sub-channel of the one you selected is created and both are linked.", + "Discussion_description": "Discussions allow separate conversations around a specific topic inside a channel or team where any workspace member can be added.", "Discussion_first_message_disabled_due_to_e2e": "You can start sending End-to-end encrypted messages in this discussion after its creation.", - "Discussion_first_message_title": "Your message", + "Discussion_first_message_title": "Message", "Discussion_name": "Discussion name", "Discussion_start": "Start a Discussion", - "Discussion_target_channel": "Parent channel or group", + "Discussion_target_channel": "Parent channel or team", "Discussion_target_channel_description": "Select a channel which is related to what you want to ask", "Discussion_target_channel_prefix": "You are creating a discussion in", "Discussion_title": "Create discussion", @@ -1719,6 +1724,7 @@ "Display_setting_permissions": "Display permissions to change settings", "Display_unread_counter": "Display room as unread when there are unread messages", "Displays_action_text": "Displays action text", + "Displayed_next_to_name": "Displayed next to name", "Do_It_Later": "Do It Later", "Do_not_display_unread_counter": "Do not display any counter of this channel", "Do_not_provide_this_code_to_anyone": "Do not provide this code to anyone.", @@ -1788,6 +1794,9 @@ "E2E_unavailable_for_federation": "E2E is unavailable for federated rooms", "ECDH_Enabled": "Enable second layer encryption for data transport", "Edit": "Edit", + "Edit_team": "Edit team", + "Edit_channel": "Edit channel", + "Edit_discussion": "Edit discussion", "Edit_Business_Hour": "Edit Business Hour", "Edit_Canned_Response": "Edit Canned Response", "Edit_Canned_Responses": "Edit Canned Responses", @@ -1888,6 +1897,7 @@ "Email_two-factor_authentication": "Email two-factor authentication", "Email_verified": "Email verified", "Enterprise_Only": "Enterprise only", + "Encrypted_field_hint": "Messages are end-to-end encrypted, search will not work and notifications may not show message content", "Email_sent": "Email sent", "Emoji": "Emoji", "Emoji_picker": "Emoji picker", @@ -1914,11 +1924,11 @@ "Enable_unlimited_apps": "Enable unlimited apps", "Enabled": "Enabled", "Encrypted": "Encrypted", - "Encrypted_channel_Description": "End-to-end encrypted channel. Search will not work with encrypted channels and notifications may not show the messages content.", + "Encrypted_channel_Description": "Messages are end-to-end encrypted, search will not work and notifications may not show message content", "Encrypted_key_title": "Click here to disable end-to-end encryption for this channel (requires e2ee-permission)", "Encrypted_message": "Encrypted message", "Encrypted_setting_changed_successfully": "Encrypted setting changed successfully", - "Encrypted_not_available": "Not available for Public Channels", + "Encrypted_not_available": "Not available for public {{roomType}}", "Encryption_key_saved_successfully": "Your encryption key was saved successfully.", "EncryptionKey_Change_Disabled": "You can't set a password for your encryption key because your private key is not present on this client. In order to set a new password you need load your private key using your existing password or use a client where the key is already loaded.", "End": "End", @@ -2125,7 +2135,7 @@ "error-tags-must-be-assigned-before-closing-chat": "Tag(s) must be assigned before closing the chat", "error-the-field-is-required": "The field {{field}} is required.", "error-this-is-not-a-livechat-room": "This is not a Omnichannel room", - "error-this-is-a-premium-feature": "This is from a premium feature", + "error-this-is-a-premium-feature": "Only available on premium plans", "error-token-already-exists": "A token with this name already exists", "error-token-does-not-exists": "Token does not exists", "error-too-many-requests": "Error, too many requests. Please slow down. You must wait {{seconds}} seconds before trying again.", @@ -2269,7 +2279,7 @@ "Federation_Matrix_Enabled_Alert": "More Information about Matrix Federation support can be found here (After any configuration, a restart is required to the changes take effect)", "Federation_Matrix_Federated": "Federated", "Federation_Matrix_Federated_Description": "By creating a federated room you'll not be able to enable encryption nor broadcast", - "Federation_Matrix_Federated_Description_disabled": "Federation is currently disabled in this workspace.", + "Federation_Matrix_Federated_Description_disabled": "Federation is currently disabled on this workspace", "Federation_Matrix_id": "AppService ID", "Federation_Matrix_hs_token": "Homeserver Token", "Federation_Matrix_as_token": "AppService Token", @@ -2526,7 +2536,7 @@ "Hide_roles": "Hide Roles", "Hide_room": "Hide", "Hide_Room_Warning": "Are you sure you want to hide the channel \"%s\"?", - "Hide_System_Messages": "Hide System Messages", + "Hide_System_Messages": "Hide system messages", "Hide_Unread_Room_Status": "Hide Unread Room Status", "Hide_usernames": "Hide Usernames", "Hide_video": "Hide video", @@ -2657,6 +2667,7 @@ "Incoming_WebHook": "Incoming WebHook", "Industry": "Industry", "Info": "Info", + "Information_to_keep_top_of_mind": "Information to keep top-of-mind", "initials_avatar": "Initials Avatar", "Inline_code": "Inline code", "Install": "Install", @@ -3506,40 +3517,40 @@ "Message_has_been_starred": "Message has been starred", "Message_has_been_unpinned": "Message has been unpinned", "Message_has_been_unstarred": "Message has been unstarred", - "Message_HideType_au": "Hide \"User Added\" messages", - "Message_HideType_added_user_to_team": "Hide \"User Added to Team\" messages", - "Message_HideType_mute_unmute": "Hide \"User Muted / Unmuted\" messages", - "Message_HideType_r": "Hide \"Room Name Changed\" messages", - "Message_HideType_rm": "Hide \"Message Removed\" messages", - "Message_HideType_room_allowed_reacting": "Hide \"Room allowed reacting\" messages", - "Message_HideType_room_archived": "Hide \"Room Archived\" messages", - "Message_HideType_room_changed_avatar": "Hide \"Room avatar changed\" messages", - "Message_HideType_room_changed_privacy": "Hide \"Room type changed\" messages", - "Message_HideType_room_changed_topic": "Hide \"Room topic changed\" messages", - "Message_HideType_room_disallowed_reacting": "Hide \"Room disallowed reacting\" messages", - "Message_HideType_room_enabled_encryption": "Hide \"Room encryption enabled\" messages", - "Message_HideType_room_disabled_encryption": "Hide \"Room encryption disabled\" messages", - "Message_HideType_room_set_read_only": "Hide \"Room set Read Only\" messages", - "Message_HideType_room_removed_read_only": "Hide \"Room added writing permission\" messages", - "Message_HideType_room_unarchived": "Hide \"Room Unarchived\" messages", - "Message_HideType_ru": "Hide \"User Removed\" messages", - "Message_HideType_removed_user_from_team": "Hide \"User Removed from Team\" messages", - "Message_HideType_subscription_role_added": "Hide \"Was Set Role\" messages", - "Message_HideType_subscription_role_removed": "Hide \"Role No Longer Defined\" messages", - "Message_HideType_uj": "Hide \"User Join\" messages", - "Message_HideType_ujt": "Hide \"User Joined Team\" messages", + "Message_HideType_au": "User added", + "Message_HideType_added_user_to_team": "User added to team", + "Message_HideType_mute_unmute": "User muted / unmuted", + "Message_HideType_r": "Room name changed", + "Message_HideType_rm": "Message removed", + "Message_HideType_room_allowed_reacting": "Room allowed reacting", + "Message_HideType_room_archived": "Room archived", + "Message_HideType_room_changed_avatar": "Room avatar changed", + "Message_HideType_room_changed_privacy": "Room type changed", + "Message_HideType_room_changed_topic": "Room topic changed", + "Message_HideType_room_disallowed_reacting": "Room disallowed reacting", + "Message_HideType_room_enabled_encryption": "Room encryption enabled", + "Message_HideType_room_disabled_encryption": "Room encryption disabled", + "Message_HideType_room_set_read_only": "Room set to Read Only", + "Message_HideType_room_removed_read_only": "Room added writing permission", + "Message_HideType_room_unarchived": "Room unarchived", + "Message_HideType_ru": "User removed", + "Message_HideType_removed_user_from_team": "User removed from team", + "Message_HideType_subscription_role_added": "Was set role", + "Message_HideType_subscription_role_removed": "Role no longer defined", + "Message_HideType_uj": "User joined", + "Message_HideType_ujt": "User joined team", "New_Call_Enterprise_Edition_Only": "New Call (Enterprise Edition Only)", - "Message_HideType_ul": "Hide \"User Leave\" messages", - "Message_HideType_ult": "Hide \"User Left Team\" messages", - "Message_HideType_user_added_room_to_team": "Hide \"User Added Room to Team\" messages", - "Message_HideType_user_converted_to_channel": "Hide \"User converted team to a Channel\" messages", - "Message_HideType_user_converted_to_team": "Hide \"User converted channel to a Team\" messages", - "Message_HideType_user_deleted_room_from_team": "Hide \"User deleted room from Team\" messages", - "Message_HideType_user_removed_room_from_team": "Hide \"User removed room from Team\" messages", - "Message_HideType_changed_description": "Hide \"Room description changed to\" messages", - "Message_HideType_changed_announcement": "Hide \"Room announcement changed to\" messages", - "Message_HideType_ut": "Hide \"User Joined Conversation\" messages", - "Message_HideType_wm": "Hide \"Welcome\" messages", + "Message_HideType_ul": "User left", + "Message_HideType_ult": "User left team", + "Message_HideType_user_added_room_to_team": "User added room to team", + "Message_HideType_user_converted_to_channel": "User converted team to a channel", + "Message_HideType_user_converted_to_team": "User converted channel to a team", + "Message_HideType_user_deleted_room_from_team": "User deleted room from team", + "Message_HideType_user_removed_room_from_team": "User removed room from team", + "Message_HideType_changed_description": "Room description changed", + "Message_HideType_changed_announcement": "Room announcement changed", + "Message_HideType_ut": "User joined conversation", + "Message_HideType_wm": "Welcome", "Message_HideType_livechat_closed": "Hide \"Conversation finished\" messages", "Message_HideType_livechat_started": "Hide \"Conversation started\" messages", "Message_HideType_livechat_transfer_history": "Hide \"Conversation transfered\" messages", @@ -3731,7 +3742,7 @@ "New_Custom_Field": "New Custom Field", "New_Department": "New Department", "New_discussion": "New discussion", - "New_discussion_first_message": "Usually, a discussion starts with a question, like \"How do I upload a picture?\"", + "New_discussion_first_message": "Usually, a discussion starts with a question, like “How do I upload a picture?”", "New_discussion_name": "A meaningful name for the discussion room", "New_Email_Inbox": "New Email Inbox", "New_encryption_password": "New encryption password", @@ -3759,6 +3770,7 @@ "New_workspace_confirmed": "New workspace confirmed", "New_workspace": "New workspace", "Newer_than": "Newer than", + "New_messages_cannot_be_sent": "New messages cannot be sent", "Newer_than_may_not_exceed_Older_than": "\"Newer than\" may not exceed \"Older than\"", "Nickname": "Nickname", "Nickname_Placeholder": "Enter your nickname...", @@ -3961,7 +3973,7 @@ "Only_On_Desktop": "Desktop mode (only sends with enter on desktop)", "Only_works_with_chrome_version_greater_50": "Only works with Chrome browser versions > 50", "Only_you_can_see_this_message": "Only you can see this message", - "Only_invited_users_can_acess_this_channel": "Only invited users can access this Channel", + "Only_invited_users_can_acess_this_channel": "Only invited users can access this channel", "Oops_page_not_found": "Oops, page not found", "Oops!": "Oops", "Person_Or_Channel": "Person or Channel", @@ -4052,6 +4064,7 @@ "Page_URL": "Page URL", "Pages": "Pages", "Parent_channel_doesnt_exist": "Channel does not exist.", + "Parent_channel_or_team": "Parent channel or team", "Participants": "Participants", "Password": "Password", "Password_Change_Disabled": "Your Rocket.Chat administrator has disabled the changing of passwords", @@ -4261,12 +4274,14 @@ "Rate Limiter_Description": "Control the rate of requests sent or received by your server to prevent cyber attacks and scraping.", "Rate_Limiter_Limit_RegisterUser": "Default number calls to the rate limiter for registering a user", "Rate_Limiter_Limit_RegisterUser_Description": "Number of default calls for user registering endpoints(REST and real-time API's), allowed within the time range defined in the API Rate Limiter section.", - "React_when_read_only": "Allow Reacting", + "React_when_read_only": "Allow reacting", "React_when_read_only_changed_successfully": "Allow reacting when read only changed successfully", "Reacted_with": "Reacted with", "Reactions": "Reactions", "Read_by": "Read by", - "Read_only": "Read Only", + "Read_only": "Read-only", + "Read_only_field_hint_enabled": "Only {{roomType}} owners can send new messages", + "Read_only_field_hint_disabled": "Anyone can send new messages", "Read_Receipts": "Read receipts", "Readability": "Readability", "This_room_is_read_only": "This room is read only", @@ -5079,7 +5094,7 @@ "Teams_channels_last_owner_delete_channel_warning": "You are the last owner of this Channel. Once you convert the Team into a channel, the Channel will be moved to the Workspace.", "Teams_channels_last_owner_leave_channel_warning": "You are the last owner of this Channel. Once you leave the Team, the Channel will be kept inside the Team but you will managing it from outside.", "Teams_leaving_team": "You are leaving this Team.", - "Teams_channels": "Team's Channels", + "Teams_channels": "Team channels", "Teams_convert_channel_to_team": "Convert to Team", "Teams_delete_team_choose_channels": "Select the Channels you would like to delete. The ones you decide to keep, will be available on your workspace.", "Teams_delete_team_public_notice": "Notice that public Channels will still be public and visible to everyone.", @@ -5094,25 +5109,26 @@ "Teams_move_channel_to_team_description_third": "Team’s members and even Team’s owners, if not a member of this Channel, can not have access to the Channel’s content.", "Teams_move_channel_to_team_description_fourth": "Please notice that the Team’s owner will be able to remove members from the Channel.", "Teams_move_channel_to_team_confirm_description": "After reading the previous instructions about this behavior, do you want to move forward with this action?", - "Teams_New_Title": "Create Team", + "Teams_New_Title": "Create team", + "Teams_new_description": "Teams allow a group of people to collaborate and can contain multiple channels.", "Teams_New_Name_Label": "Name", - "Teams_Info": "Team Information", + "Teams_Info": "Team info", "Teams_kept_channels": "You did not select the following Channels so they will be moved to the Workspace:", "Teams_kept__username__channels": "You did not select the following Channels so {{username}} will be kept on them:", "Teams_leave_channels": "Select the Team’s Channels you would like to leave.", "Teams_leave": "Leave Team", "Teams_left_team_successfully": "Left the Team successfully", "Teams_members": "Teams Members", - "Teams_New_Add_members_Label": "Add Members", - "Teams_New_Broadcast_Description": "Only authorized users can write new messages, but the other users will be able to reply", + "Teams_New_Add_members_Label": "Members", + "Teams_New_Broadcast_Description": "Only team owners can write new messages but anyone can reply in a thread", "Teams_New_Broadcast_Label": "Broadcast", "Teams_New_Description_Label": "Topic", "Teams_New_Description_Placeholder": "What is this team about", "Teams_New_Encrypted_Description_Disabled": "Only available for private team", "Teams_New_Encrypted_Description_Enabled": "End-to-end encrypted team. Search will not work with encrypted Teams and notifications may not show the messages content.", "Teams_New_Encrypted_Label": "Encrypted", - "Teams_New_Private_Description_Disabled": "When disabled, anyone can join the team", - "Teams_New_Private_Description_Enabled": "Only invited people can join", + "Teams_New_Private_Description_Disabled": "Anyone can access", + "Teams_New_Private_Description_Enabled": "People can only join by being invited", "Teams_New_Private_Label": "Private", "Teams_New_Read_only_Description": "All users in this team can write messages", "Teams_Public_Team": "Public Team", @@ -5122,7 +5138,7 @@ "Teams_removing__username__from_team_and_channels": "You are removing {{username}} from this Team and all its Channels.", "Teams_Select_a_team": "Select a team", "Teams_Search_teams": "Search Teams", - "Teams_New_Read_only_Label": "Read Only", + "Teams_New_Read_only_Label": "Read-only", "Technology_Services": "Technology Services", "Upgrade_tab_connection_error_description": "Looks like you have no internet connection. This may be because your workspace is installed on a fully-secured air-gapped server", "Terms": "Terms", @@ -5659,7 +5675,7 @@ "Videos": "Videos", "View_mode": "View Mode", "View_All": "View All Members", - "View_channels": "View Channels", + "View_channels": "View channels", "view-agent-canned-responses": "View Agent Canned Responses", "view-agent-canned-responses_description": "Permission to view agent canned responses", "view-agent-extension-association": "View Agent Extension Association", @@ -5887,7 +5903,7 @@ "Yes_clear_all": "Yes, clear all!", "Yes_continue": "Yes, continue!", "Yes_deactivate_it": "Yes, deactivate it!", - "Yes_delete_it": "Yes, delete it!", + "Yes_delete_it": "Yes, delete", "Yes_hide_it": "Yes, hide it!", "Yes_leave_it": "Yes, leave it!", "Yes_mute_user": "Yes, mute user!", @@ -6333,7 +6349,28 @@ "Seat_limit_reached": "Seat limit reached", "Seat_limit_reached_Description": "Your workspace reached its contractual seat limit. Buy more seats to add more users.", "Buy_more_seats": "Buy more seats", + "Anyone_can_react_to_messages": "Anyone can react to messages", + "Name_cannot_have_spaces": "Name cannot have spaces", + "No_spaces": "No spaces", + "Add_people": "Add people", + "Anyone_can_access": "Anyone can access", + "Only_invited_people": "People can only join by being invited", + "Broadcast_hint_enabled": "Only {{roomType}} owners can write new messages but anyone can reply in a thread", + "Federation_is_currently_disabled_on_this_workspace": "Federation is currently disabled on this workspace", + "Search_options": "Search options", + "First_message_hint": "A discussion can start with a question like \"How do I upload a picture?\"", + "Delete_roomType": "Delete {{roomType}}", + "Delete_roomType_description": "Deleting this {{roomType}} will also delete all contained message. This cannot be undone.", + "Deleted_roomType": "{{roomName}} {{roomType}} has been deleted", "unread_messages_one": "{{count}} unread message", "unread_messages_other": "{{count}} unread messages", - "Go_to_href": "Go to: {{href}}" + "Encrypted_messages": "End-to-end encrypted {{roomType}}. Search will not work with encrypted {{roomType}} and notifications may not show the messages content.", + "Encrypted_messages_false": "Messages are not encrypted", + "Not_available_for_broadcast": "Not available for broadcast {{roomType}}", + "Not_available_for_this_workspace": "Not available for this workspace", + "People_can_only_join_by_being_invited": "People can only join by being invited", + "Go_to_href": "Go to: {{href}}", + "Anyone_can_send_new_messages": "Anyone can send new messages", + "Select_messages_to_hide": "Select messages to hide", + "Name_cannot_have_special_characters": "Name cannot have spaces or special characters" } diff --git a/packages/rest-typings/src/v1/rooms.ts b/packages/rest-typings/src/v1/rooms.ts index 8d6a7476d939..ab0046bdc38b 100644 --- a/packages/rest-typings/src/v1/rooms.ts +++ b/packages/rest-typings/src/v1/rooms.ts @@ -130,6 +130,7 @@ type RoomsCreateDiscussionProps = { users?: IUser['username'][]; encrypted?: boolean; reply?: string; + topic?: string; }; const RoomsCreateDiscussionSchema = { diff --git a/packages/ui-avatar/package.json b/packages/ui-avatar/package.json index 30a7eda16e62..1d16fadf11f5 100644 --- a/packages/ui-avatar/package.json +++ b/packages/ui-avatar/package.json @@ -4,7 +4,7 @@ "private": true, "devDependencies": { "@babel/core": "~7.22.20", - "@rocket.chat/fuselage": "^0.51.1", + "@rocket.chat/fuselage": "^0.52.0", "@rocket.chat/ui-contexts": "workspace:^", "@types/babel__core": "~7.20.3", "@types/react": "~17.0.69", diff --git a/packages/ui-client/package.json b/packages/ui-client/package.json index 0d099721c9a9..936497ef732f 100644 --- a/packages/ui-client/package.json +++ b/packages/ui-client/package.json @@ -6,7 +6,7 @@ "@babel/core": "~7.22.20", "@react-aria/toolbar": "^3.0.0-beta.1", "@rocket.chat/css-in-js": "~0.31.25", - "@rocket.chat/fuselage": "^0.51.1", + "@rocket.chat/fuselage": "^0.52.0", "@rocket.chat/fuselage-hooks": "^0.33.0", "@rocket.chat/icons": "^0.34.0", "@rocket.chat/mock-providers": "workspace:^", diff --git a/packages/ui-composer/package.json b/packages/ui-composer/package.json index 37599b373ddd..fec0f7167937 100644 --- a/packages/ui-composer/package.json +++ b/packages/ui-composer/package.json @@ -6,7 +6,7 @@ "@babel/core": "~7.22.20", "@react-aria/toolbar": "^3.0.0-beta.1", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/fuselage": "^0.51.1", + "@rocket.chat/fuselage": "^0.52.0", "@rocket.chat/icons": "^0.34.0", "@storybook/addon-actions": "~6.5.16", "@storybook/addon-docs": "~6.5.16", diff --git a/packages/ui-video-conf/package.json b/packages/ui-video-conf/package.json index 05a5e390f25b..8839e6c1b5b5 100644 --- a/packages/ui-video-conf/package.json +++ b/packages/ui-video-conf/package.json @@ -6,7 +6,7 @@ "@babel/core": "~7.22.20", "@rocket.chat/css-in-js": "~0.31.25", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/fuselage": "^0.51.1", + "@rocket.chat/fuselage": "^0.52.0", "@rocket.chat/fuselage-hooks": "^0.33.0", "@rocket.chat/icons": "^0.34.0", "@rocket.chat/styled": "~0.31.25", diff --git a/packages/uikit-playground/package.json b/packages/uikit-playground/package.json index 6ed09b1265e2..b3d1683b7455 100644 --- a/packages/uikit-playground/package.json +++ b/packages/uikit-playground/package.json @@ -15,7 +15,7 @@ "@codemirror/tooltip": "^0.19.16", "@lezer/highlight": "^1.1.6", "@rocket.chat/css-in-js": "~0.31.25", - "@rocket.chat/fuselage": "^0.51.1", + "@rocket.chat/fuselage": "^0.52.0", "@rocket.chat/fuselage-hooks": "^0.33.0", "@rocket.chat/fuselage-polyfills": "~0.31.25", "@rocket.chat/fuselage-toastbar": "^0.31.26", diff --git a/yarn.lock b/yarn.lock index dba696d109dc..ba550482d119 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8732,7 +8732,7 @@ __metadata: "@babel/preset-typescript": ~7.22.15 "@rocket.chat/apps-engine": 1.42.0-alpha.619 "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/fuselage": ^0.51.1 + "@rocket.chat/fuselage": ^0.52.0 "@rocket.chat/fuselage-hooks": ^0.33.0 "@rocket.chat/fuselage-polyfills": ~0.31.25 "@rocket.chat/gazzodown": "workspace:^" @@ -8787,9 +8787,9 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/fuselage@npm:^0.51.1": - version: 0.51.1 - resolution: "@rocket.chat/fuselage@npm:0.51.1" +"@rocket.chat/fuselage@npm:^0.52.0": + version: 0.52.0 + resolution: "@rocket.chat/fuselage@npm:0.52.0" dependencies: "@rocket.chat/css-in-js": ^0.31.25 "@rocket.chat/css-supports": ^0.31.25 @@ -8807,7 +8807,7 @@ __metadata: react: ^17.0.2 react-dom: ^17.0.2 react-virtuoso: 1.2.4 - checksum: f7f49a59d67a485a4aff8aa2684fb0bb290c73b643763c867da23ed00c19994b24b994c554d2bd63d48657ca7fdfbff9bf34bbe2436376972809b56cb15ee456 + checksum: 518c96ad67dcb2395c6299842250c1e87959e53ee54fee2bb4d813a41a8e78913e599adc4c73258f7e99249bb2351cf8b94dd8a3bfb2ba5473aa80f051cfac40 languageName: node linkType: hard @@ -8818,7 +8818,7 @@ __metadata: "@babel/core": ~7.22.20 "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/css-in-js": ~0.31.25 - "@rocket.chat/fuselage": ^0.51.1 + "@rocket.chat/fuselage": ^0.52.0 "@rocket.chat/fuselage-tokens": ^0.33.0 "@rocket.chat/message-parser": "workspace:^" "@rocket.chat/styled": ~0.31.25 @@ -9178,7 +9178,7 @@ __metadata: "@rocket.chat/favicon": "workspace:^" "@rocket.chat/forked-matrix-appservice-bridge": ^4.0.2 "@rocket.chat/forked-matrix-bot-sdk": ^0.6.0-beta.3 - "@rocket.chat/fuselage": ^0.51.1 + "@rocket.chat/fuselage": ^0.52.0 "@rocket.chat/fuselage-hooks": ^0.33.0 "@rocket.chat/fuselage-polyfills": ~0.31.25 "@rocket.chat/fuselage-toastbar": ^0.31.26 @@ -10060,7 +10060,7 @@ __metadata: resolution: "@rocket.chat/ui-avatar@workspace:packages/ui-avatar" dependencies: "@babel/core": ~7.22.20 - "@rocket.chat/fuselage": ^0.51.1 + "@rocket.chat/fuselage": ^0.52.0 "@rocket.chat/ui-contexts": "workspace:^" "@types/babel__core": ~7.20.3 "@types/react": ~17.0.69 @@ -10086,7 +10086,7 @@ __metadata: "@babel/core": ~7.22.20 "@react-aria/toolbar": ^3.0.0-beta.1 "@rocket.chat/css-in-js": ~0.31.25 - "@rocket.chat/fuselage": ^0.51.1 + "@rocket.chat/fuselage": ^0.52.0 "@rocket.chat/fuselage-hooks": ^0.33.0 "@rocket.chat/icons": ^0.34.0 "@rocket.chat/mock-providers": "workspace:^" @@ -10139,7 +10139,7 @@ __metadata: "@babel/core": ~7.22.20 "@react-aria/toolbar": ^3.0.0-beta.1 "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/fuselage": ^0.51.1 + "@rocket.chat/fuselage": ^0.52.0 "@rocket.chat/icons": ^0.34.0 "@storybook/addon-actions": ~6.5.16 "@storybook/addon-docs": ~6.5.16 @@ -10231,7 +10231,7 @@ __metadata: resolution: "@rocket.chat/ui-theming@workspace:ee/packages/ui-theming" dependencies: "@rocket.chat/css-in-js": ~0.31.25 - "@rocket.chat/fuselage": ^0.51.1 + "@rocket.chat/fuselage": ^0.52.0 "@rocket.chat/fuselage-hooks": ^0.33.0 "@rocket.chat/icons": ^0.34.0 "@rocket.chat/ui-contexts": "workspace:~" @@ -10274,7 +10274,7 @@ __metadata: "@rocket.chat/css-in-js": ~0.31.25 "@rocket.chat/emitter": ~0.31.25 "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/fuselage": ^0.51.1 + "@rocket.chat/fuselage": ^0.52.0 "@rocket.chat/fuselage-hooks": ^0.33.0 "@rocket.chat/icons": ^0.34.0 "@rocket.chat/styled": ~0.31.25 @@ -10319,7 +10319,7 @@ __metadata: "@codemirror/tooltip": ^0.19.16 "@lezer/highlight": ^1.1.6 "@rocket.chat/css-in-js": ~0.31.25 - "@rocket.chat/fuselage": ^0.51.1 + "@rocket.chat/fuselage": ^0.52.0 "@rocket.chat/fuselage-hooks": ^0.33.0 "@rocket.chat/fuselage-polyfills": ~0.31.25 "@rocket.chat/fuselage-toastbar": ^0.31.26 From fc97aedc504e3ac69eae72891027cee165d37285 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 2 Apr 2024 14:25:32 -0300 Subject: [PATCH 014/131] fix: `CSP` error right after `setInlineScriptsAllowed` (#32108) --- apps/meteor/app/cors/server/cors.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/meteor/app/cors/server/cors.ts b/apps/meteor/app/cors/server/cors.ts index 2a862635e1cd..7857ec89bec2 100644 --- a/apps/meteor/app/cors/server/cors.ts +++ b/apps/meteor/app/cors/server/cors.ts @@ -16,14 +16,28 @@ type NextFunction = (err?: any) => void; const logger = new Logger('CORS'); +let templatePromise: Promise | void; + +declare module 'meteor/webapp' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace WebApp { + function setInlineScriptsAllowed(allowed: boolean): Promise; + } +} + settings.watch( 'Enable_CSP', - Meteor.bindEnvironment((enabled) => { - WebAppInternals.setInlineScriptsAllowed(!enabled); + Meteor.bindEnvironment(async (enabled) => { + templatePromise = WebAppInternals.setInlineScriptsAllowed(!enabled); }), ); -WebApp.rawConnectHandlers.use((_req: http.IncomingMessage, res: http.ServerResponse, next: NextFunction) => { +WebApp.rawConnectHandlers.use(async (_req: http.IncomingMessage, res: http.ServerResponse, next: NextFunction) => { + if (templatePromise) { + await templatePromise; + templatePromise = void 0; + } + // XSS Protection for old browsers (IE) res.setHeader('X-XSS-Protection', '1'); From fe93a2b315be768d4e39cc92bad1b50d50b2a566 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 2 Apr 2024 13:35:36 -0600 Subject: [PATCH 015/131] test: `InitialData.insertAdminUserFromEnv` (#32066) --- apps/meteor/server/startup/initialData.js | 146 +++++------ .../unit/server/startup/initialData.tests.ts | 235 ++++++++++++++++++ 2 files changed, 311 insertions(+), 70 deletions(-) create mode 100644 apps/meteor/tests/unit/server/startup/initialData.tests.ts diff --git a/apps/meteor/server/startup/initialData.js b/apps/meteor/server/startup/initialData.js index 01a808a3c103..8e008191ea44 100644 --- a/apps/meteor/server/startup/initialData.js +++ b/apps/meteor/server/startup/initialData.js @@ -12,6 +12,81 @@ import { settings } from '../../app/settings/server'; import { validateEmail } from '../../lib/emailValidator'; import { addUserRolesAsync } from '../lib/roles/addUserRoles'; +export async function insertAdminUserFromEnv() { + if (process.env.ADMIN_PASS) { + if ((await (await getUsersInRole('admin')).count()) === 0) { + const adminUser = { + name: 'Administrator', + username: 'admin', + status: 'offline', + statusDefault: 'online', + utcOffset: 0, + active: true, + }; + + if (process.env.ADMIN_NAME) { + adminUser.name = process.env.ADMIN_NAME; + } + + console.log(colors.green(`Name: ${adminUser.name}`)); + + if (process.env.ADMIN_EMAIL) { + if (validateEmail(process.env.ADMIN_EMAIL)) { + if (!(await Users.findOneByEmailAddress(process.env.ADMIN_EMAIL))) { + adminUser.emails = [ + { + address: process.env.ADMIN_EMAIL, + verified: process.env.ADMIN_EMAIL_VERIFIED === 'true', + }, + ]; + + console.log(colors.green(`Email: ${process.env.ADMIN_EMAIL}`)); + } else { + console.log(colors.red('Email provided already exists; Ignoring environment variables ADMIN_EMAIL')); + } + } else { + console.log(colors.red('Email provided is invalid; Ignoring environment variables ADMIN_EMAIL')); + } + } + + if (process.env.ADMIN_USERNAME) { + let nameValidation; + + try { + nameValidation = new RegExp(`^${settings.get('UTF8_User_Names_Validation')}$`); + } catch (error) { + nameValidation = new RegExp('^[0-9a-zA-Z-_.]+$'); + } + + if (nameValidation.test(process.env.ADMIN_USERNAME)) { + try { + await checkUsernameAvailability(process.env.ADMIN_USERNAME); + adminUser.username = process.env.ADMIN_USERNAME; + } catch (error) { + console.log( + colors.red('Username provided already exists or is blocked from usage; Ignoring environment variables ADMIN_USERNAME'), + ); + } + } else { + console.log(colors.red('Username provided is invalid; Ignoring environment variables ADMIN_USERNAME')); + } + } + + console.log(colors.green(`Username: ${adminUser.username}`)); + + adminUser.type = 'user'; + + const { insertedId: userId } = await Users.create(adminUser); + + await Accounts.setPasswordAsync(userId, process.env.ADMIN_PASS); + + await addUserRolesAsync(userId, ['admin']); + } else { + console.log(colors.red('Users with admin role already exist; Ignoring environment variables ADMIN_PASS')); + } + } +} + Meteor.startup(async () => { const dynamicImport = { 'dynamic-import': { @@ -91,76 +166,7 @@ Meteor.startup(async () => { throw error; } - if (process.env.ADMIN_PASS) { - if ((await (await getUsersInRole('admin')).count()) === 0) { - console.log(colors.green('Inserting admin user:')); - const adminUser = { - name: 'Administrator', - username: 'admin', - status: 'offline', - statusDefault: 'online', - utcOffset: 0, - active: true, - }; - - if (process.env.ADMIN_NAME) { - adminUser.name = process.env.ADMIN_NAME; - } - - console.log(colors.green(`Name: ${adminUser.name}`)); - - if (process.env.ADMIN_EMAIL) { - if (validateEmail(process.env.ADMIN_EMAIL)) { - if (!(await Users.findOneByEmailAddress(process.env.ADMIN_EMAIL))) { - adminUser.emails = [ - { - address: process.env.ADMIN_EMAIL, - verified: process.env.ADMIN_EMAIL_VERIFIED === 'true', - }, - ]; - - console.log(colors.green(`Email: ${process.env.ADMIN_EMAIL}`)); - } else { - console.log(colors.red('Email provided already exists; Ignoring environment variables ADMIN_EMAIL')); - } - } else { - console.log(colors.red('Email provided is invalid; Ignoring environment variables ADMIN_EMAIL')); - } - } - - if (process.env.ADMIN_USERNAME) { - let nameValidation; - - try { - nameValidation = new RegExp(`^${settings.get('UTF8_User_Names_Validation')}$`); - } catch (error) { - nameValidation = new RegExp('^[0-9a-zA-Z-_.]+$'); - } - - if (nameValidation.test(process.env.ADMIN_USERNAME)) { - if (await checkUsernameAvailability(process.env.ADMIN_USERNAME)) { - adminUser.username = process.env.ADMIN_USERNAME; - } else { - console.log(colors.red('Username provided already exists; Ignoring environment variables ADMIN_USERNAME')); - } - } else { - console.log(colors.red('Username provided is invalid; Ignoring environment variables ADMIN_USERNAME')); - } - } - - console.log(colors.green(`Username: ${adminUser.username}`)); - - adminUser.type = 'user'; - - const { insertedId: userId } = await Users.create(adminUser); - - await Accounts.setPasswordAsync(userId, process.env.ADMIN_PASS); - - await addUserRolesAsync(userId, ['admin']); - } else { - console.log(colors.red('Users with admin role already exist; Ignoring environment variables ADMIN_PASS')); - } - } + await insertAdminUserFromEnv(); if (typeof process.env.INITIAL_USER === 'string' && process.env.INITIAL_USER.length > 0) { try { diff --git a/apps/meteor/tests/unit/server/startup/initialData.tests.ts b/apps/meteor/tests/unit/server/startup/initialData.tests.ts new file mode 100644 index 000000000000..c9980db5ba3e --- /dev/null +++ b/apps/meteor/tests/unit/server/startup/initialData.tests.ts @@ -0,0 +1,235 @@ +import { expect } from 'chai'; +import { beforeEach, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const getUsersInRole = sinon.stub(); +const checkUsernameAvailability = sinon.stub(); +const validateEmail = sinon.stub(); +const addUserRolesAsync = sinon.stub(); +const models = { + Settings: {}, + Rooms: {}, + Users: { + create: sinon.stub(), + findOneByEmailAddress: sinon.stub(), + }, +}; +const setPasswordAsync = sinon.stub(); +const settingsGet = sinon.stub(); + +const { insertAdminUserFromEnv } = proxyquire.noCallThru().load('../../../../server/startup/initialData.js', { + 'meteor/accounts-base': { + Accounts: { + setPasswordAsync, + }, + }, + 'meteor/meteor': { + Meteor: { + startup: sinon.stub(), + }, + }, + '../../app/authorization/server': { + getUsersInRole, + }, + '../../app/file-upload/server': {}, + '../../app/file/server': {}, + '../../app/lib/server/functions/addUserToDefaultChannels': {}, + '../../app/lib/server/functions/checkUsernameAvailability': { + checkUsernameAvailability, + }, + '../../app/settings/server': { + settings: { get: settingsGet }, + }, + '../../lib/emailValidator': { + validateEmail, + }, + '../lib/roles/addUserRoles': { + addUserRolesAsync, + }, + '@rocket.chat/models': models, +}); + +describe('insertAdminUserFromEnv', () => { + beforeEach(() => { + getUsersInRole.reset(); + checkUsernameAvailability.reset(); + validateEmail.reset(); + addUserRolesAsync.reset(); + models.Users.create.reset(); + models.Users.findOneByEmailAddress.reset(); + setPasswordAsync.reset(); + settingsGet.reset(); + process.env.ADMIN_PASS = 'pass'; + }); + + it('should do nothing if process.env.ADMIN_PASS is empty', async () => { + process.env.ADMIN_PASS = ''; + const result = await insertAdminUserFromEnv(); + expect(getUsersInRole.called).to.be.false; + expect(result).to.be.undefined; + }); + it('should do nothing if theres already an admin user', async () => { + getUsersInRole.returns({ count: () => 1 }); + + const result = await insertAdminUserFromEnv(); + expect(getUsersInRole.called).to.be.true; + expect(validateEmail.called).to.be.false; + expect(result).to.be.undefined; + }); + it('should try to validate an email when process.env.ADMIN_EMAIL is set', async () => { + process.env.ADMIN_EMAIL = 'email'; + getUsersInRole.returns({ count: () => 0 }); + validateEmail.returns(false); + models.Users.create.returns({ insertedId: 'newuserid' }); + + const result = await insertAdminUserFromEnv(); + expect(getUsersInRole.called).to.be.true; + expect(validateEmail.called).to.be.true; + expect(validateEmail.calledWith('email')).to.be.true; + expect(models.Users.create.called).to.be.true; + expect(setPasswordAsync.called).to.be.true; + expect(result).to.be.undefined; + }); + it('should override the admins name when process.env.ADMIN_NAME is set', async () => { + process.env.ADMIN_EMAIL = 'email'; + process.env.ADMIN_NAME = 'name'; + getUsersInRole.returns({ count: () => 0 }); + validateEmail.returns(true); + validateEmail.returns(false); + models.Users.create.returns({ insertedId: 'newuserid' }); + + await insertAdminUserFromEnv(); + + expect( + models.Users.create.calledWith( + sinon.match({ + name: 'name', + username: 'admin', + status: 'offline', + statusDefault: 'online', + utcOffset: 0, + active: true, + type: 'user', + }), + ), + ).to.be.true; + }); + it('should ignore the admin email when another user already has it set', async () => { + process.env.ADMIN_EMAIL = 'email'; + getUsersInRole.returns({ count: () => 0 }); + validateEmail.returns(true); + models.Users.create.returns({ insertedId: 'newuserid' }); + models.Users.findOneByEmailAddress.returns({ _id: 'someuser' }); + + await insertAdminUserFromEnv(); + + expect(models.Users.create.getCall(0).firstArg).to.not.to.have.property('email'); + }); + it('should add the email from env when its valid and no users are using it', async () => { + process.env.ADMIN_EMAIL = 'email'; + getUsersInRole.returns({ count: () => 0 }); + validateEmail.returns(true); + models.Users.create.returns({ insertedId: 'newuserid' }); + models.Users.findOneByEmailAddress.returns(undefined); + + await insertAdminUserFromEnv(); + + expect(models.Users.create.getCall(0).firstArg) + .to.have.property('emails') + .to.deep.equal([{ address: 'email', verified: false }]); + }); + it('should mark the admin email as verified when process.env.ADMIN_EMAIL_VERIFIED is set to true', async () => { + process.env.ADMIN_EMAIL = 'email'; + process.env.ADMIN_EMAIL_VERIFIED = 'true'; + getUsersInRole.returns({ count: () => 0 }); + validateEmail.returns(true); + models.Users.create.returns({ insertedId: 'newuserid' }); + models.Users.findOneByEmailAddress.returns(undefined); + + await insertAdminUserFromEnv(); + + expect(models.Users.create.getCall(0).firstArg) + .to.have.property('emails') + .to.deep.equal([{ address: 'email', verified: true }]); + }); + it('should validate a username with setting UTF8_User_Names_Validation when process.env.ADMIN_USERNAME is set', async () => { + process.env.ADMIN_USERNAME = '1234'; + getUsersInRole.returns({ count: () => 0 }); + validateEmail.returns(true); + settingsGet.returns('[0-9]+'); + models.Users.create.returns({ insertedId: 'newuserid' }); + + await insertAdminUserFromEnv(); + + expect(checkUsernameAvailability.called).to.be.true; + }); + it('should override the username from admin if the env ADMIN_USERNAME is set, is valid and the username is available', async () => { + process.env.ADMIN_USERNAME = '1234'; + getUsersInRole.returns({ count: () => 0 }); + validateEmail.returns(true); + settingsGet.returns('[0-9]+'); + checkUsernameAvailability.returns(true); + models.Users.create.returns({ insertedId: 'newuserid' }); + + await insertAdminUserFromEnv(); + + expect(models.Users.create.calledWith(sinon.match({ username: '1234' }))).to.be.true; + }); + it('should ignore the username when it does not pass setting regexp validation', async () => { + process.env.ADMIN_USERNAME = '1234'; + getUsersInRole.returns({ count: () => 0 }); + validateEmail.returns(true); + settingsGet.returns('[A-Z]+'); + checkUsernameAvailability.returns(true); + models.Users.create.returns({ insertedId: 'newuserid' }); + + await insertAdminUserFromEnv(); + + expect(models.Users.create.calledWith(sinon.match({ username: 'admin' }))).to.be.true; + }); + it('should call addUserRolesAsync as the last step when all data is valid and all overrides are valid', async () => { + process.env.ADMIN_EMAIL = 'email'; + process.env.ADMIN_NAME = 'name'; + process.env.ADMIN_USERNAME = '1234'; + process.env.ADMIN_EMAIL_VERIFIED = 'true'; + + getUsersInRole.returns({ count: () => 0 }); + validateEmail.returns(true); + settingsGet.returns('[0-9]+'); + checkUsernameAvailability.returns(true); + models.Users.create.returns({ insertedId: 'newuserid' }); + models.Users.findOneByEmailAddress.returns(undefined); + + await insertAdminUserFromEnv(); + + expect(addUserRolesAsync.called).to.be.true; + expect(setPasswordAsync.called).to.be.true; + expect(models.Users.create.calledWith(sinon.match({ name: 'name', username: '1234', emails: [{ address: 'email', verified: true }] }))) + .to.be.true; + }); + it('should use the default nameValidation regex when the regex on the setting is invalid', async () => { + process.env.ADMIN_NAME = 'name'; + process.env.ADMIN_USERNAME = '$$$$$$'; + + getUsersInRole.returns({ count: () => 0 }); + settingsGet.returns('['); + checkUsernameAvailability.returns(true); + models.Users.create.returns({ insertedId: 'newuserid' }); + + await insertAdminUserFromEnv(); + + expect(models.Users.create.calledWith(sinon.match({ username: 'admin' }))); + }); + it('should ignore the username when is not available', async () => { + process.env.ADMIN_USERNAME = '1234'; + + getUsersInRole.returns({ count: () => 0 }); + checkUsernameAvailability.throws('some error'); + models.Users.create.returns({ insertedId: 'newuserid' }); + + await insertAdminUserFromEnv(); + + expect(models.Users.create.calledWith(sinon.match({ username: 'admin' }))).to.be.true; + }); +}); From 390c95a6e73bb81aacce305f50d90bfac9a6df25 Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Tue, 2 Apr 2024 18:39:46 -0300 Subject: [PATCH 016/131] chore: Move portals to the portals folder (#32090) --- .../meteor/client/components/{modal => }/ModalBackdrop.tsx | 0 .../client/{components/modal => portals}/ModalPortal.tsx | 7 ++----- .../client/{components => portals}/TooltipPortal.tsx | 2 +- apps/meteor/client/providers/TooltipProvider.tsx | 2 +- apps/meteor/client/views/modal/ModalRegion.tsx | 4 ++-- 5 files changed, 6 insertions(+), 9 deletions(-) rename apps/meteor/client/components/{modal => }/ModalBackdrop.tsx (100%) rename apps/meteor/client/{components/modal => portals}/ModalPortal.tsx (75%) rename apps/meteor/client/{components => portals}/TooltipPortal.tsx (87%) diff --git a/apps/meteor/client/components/modal/ModalBackdrop.tsx b/apps/meteor/client/components/ModalBackdrop.tsx similarity index 100% rename from apps/meteor/client/components/modal/ModalBackdrop.tsx rename to apps/meteor/client/components/ModalBackdrop.tsx diff --git a/apps/meteor/client/components/modal/ModalPortal.tsx b/apps/meteor/client/portals/ModalPortal.tsx similarity index 75% rename from apps/meteor/client/components/modal/ModalPortal.tsx rename to apps/meteor/client/portals/ModalPortal.tsx index 577f89e72103..d7c9ae9caa2d 100644 --- a/apps/meteor/client/components/modal/ModalPortal.tsx +++ b/apps/meteor/client/portals/ModalPortal.tsx @@ -2,16 +2,13 @@ import type { ReactElement, ReactNode } from 'react'; import React, { memo, useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; -import { createAnchor } from '../../lib/utils/createAnchor'; -import { deleteAnchor } from '../../lib/utils/deleteAnchor'; +import { createAnchor } from '../lib/utils/createAnchor'; +import { deleteAnchor } from '../lib/utils/deleteAnchor'; type ModalPortalProps = { children?: ReactNode; }; -/** - * @todo: move to portals folder - */ const ModalPortal = ({ children }: ModalPortalProps): ReactElement => { const [modalRoot] = useState(() => createAnchor('modal-root')); useEffect(() => (): void => deleteAnchor(modalRoot), [modalRoot]); diff --git a/apps/meteor/client/components/TooltipPortal.tsx b/apps/meteor/client/portals/TooltipPortal.tsx similarity index 87% rename from apps/meteor/client/components/TooltipPortal.tsx rename to apps/meteor/client/portals/TooltipPortal.tsx index 937f6ed879ca..2ee0830313c4 100644 --- a/apps/meteor/client/components/TooltipPortal.tsx +++ b/apps/meteor/client/portals/TooltipPortal.tsx @@ -6,7 +6,7 @@ import { createAnchor } from '../lib/utils/createAnchor'; import { deleteAnchor } from '../lib/utils/deleteAnchor'; const TooltipPortal: FC = ({ children }) => { - const [tooltipRoot] = useState(() => createAnchor('react-tooltip')); + const [tooltipRoot] = useState(() => createAnchor('tooltip-root')); useEffect(() => (): void => deleteAnchor(tooltipRoot), [tooltipRoot]); return <>{createPortal(children, tooltipRoot)}; }; diff --git a/apps/meteor/client/providers/TooltipProvider.tsx b/apps/meteor/client/providers/TooltipProvider.tsx index 0fc7c996b9f3..4cc9aa3a767c 100644 --- a/apps/meteor/client/providers/TooltipProvider.tsx +++ b/apps/meteor/client/providers/TooltipProvider.tsx @@ -4,7 +4,7 @@ import { TooltipContext } from '@rocket.chat/ui-contexts'; import type { FC, ReactNode } from 'react'; import React, { useEffect, useMemo, useRef, memo, useCallback, useState } from 'react'; -import TooltipPortal from '../components/TooltipPortal'; +import TooltipPortal from '../portals/TooltipPortal'; const TooltipProvider: FC = ({ children }) => { const lastAnchor = useRef(); diff --git a/apps/meteor/client/views/modal/ModalRegion.tsx b/apps/meteor/client/views/modal/ModalRegion.tsx index b014a5be4f7c..5cbad2b52bc1 100644 --- a/apps/meteor/client/views/modal/ModalRegion.tsx +++ b/apps/meteor/client/views/modal/ModalRegion.tsx @@ -2,8 +2,8 @@ import { useModal, useCurrentModal } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { lazy, useCallback } from 'react'; -import ModalBackdrop from '../../components/modal/ModalBackdrop'; -import ModalPortal from '../../components/modal/ModalPortal'; +import ModalBackdrop from '../../components/ModalBackdrop'; +import ModalPortal from '../../portals/ModalPortal'; const FocusScope = lazy(() => import('react-aria').then((module) => ({ default: module.FocusScope }))); From a899d410e283a18d03efcad526b7cb4e1d46962f Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 2 Apr 2024 19:10:15 -0300 Subject: [PATCH 017/131] test: allow csp for livechat tests (#32116) --- apps/meteor/app/cors/server/cors.ts | 2 + .../rocketchat-livechat/assets/demo.html | 80 +++++++++---------- .../omnichannel-livechat-api.spec.ts | 11 +-- .../omnichannel-livechat-widget.spec.ts | 6 +- 4 files changed, 43 insertions(+), 56 deletions(-) diff --git a/apps/meteor/app/cors/server/cors.ts b/apps/meteor/app/cors/server/cors.ts index 7857ec89bec2..b4936d1456bc 100644 --- a/apps/meteor/app/cors/server/cors.ts +++ b/apps/meteor/app/cors/server/cors.ts @@ -60,6 +60,8 @@ WebApp.rawConnectHandlers.use(async (_req: http.IncomingMessage, res: http.Serve const inlineHashes = [ // Hash for `window.close()`, required by the CAS login popup. "'sha256-jqxtvDkBbRAl9Hpqv68WdNOieepg8tJSYu1xIy7zT34='", + // Hash for /apps/meteor/packages/rocketchat-livechat/assets/demo.html:25 + "'sha256-aui5xYk3Lu1dQcnsPlNZI+qDTdfzdUv3fzsw80VLJgw='", ] .filter(Boolean) .join(' '); diff --git a/apps/meteor/packages/rocketchat-livechat/assets/demo.html b/apps/meteor/packages/rocketchat-livechat/assets/demo.html index ee5fd6944d4f..f1b0ddedeeb8 100644 --- a/apps/meteor/packages/rocketchat-livechat/assets/demo.html +++ b/apps/meteor/packages/rocketchat-livechat/assets/demo.html @@ -1,45 +1,39 @@ - - - - - - - - -

test

-

Talk to us.

- - - - -

changing page title

-
page 0
- page 1
- page 2
- page 3
- -

without changing page title

- page 4
- page 5
- page 6
- page 7
- - - - + + + + + + + +

test

+

Talk to us.

+ +

changing page title

+ page 0
+ page 1
+ page 2
+ page 3
+ +

without changing page title

+ page 4
+ page 5
+ page 6
+ page 7
+ + + diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts index 88a89940279a..b34910ddf3d6 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts @@ -70,7 +70,6 @@ test.describe('OC - Livechat API', () => { agent = await createAgent(api, 'user1'); page = await browser.newPage(); - await expect((await api.post('/settings/Enable_CSP', { value: false })).status()).toBe(200); poLiveChat = new OmnichannelLiveChatEmbedded(page); @@ -80,8 +79,7 @@ test.describe('OC - Livechat API', () => { await page.goto('/packages/rocketchat_livechat/assets/demo.html'); }); - test.afterAll(async ({ api }) => { - await expect((await api.post('/settings/Enable_CSP', { value: true })).status()).toBe(200); + test.afterAll(async () => { await agent.delete(); await poAuxContext.page.close(); await page.close(); @@ -231,8 +229,6 @@ test.describe('OC - Livechat API', () => { await addAgentToDepartment(api, { department: departmentA, agentId: agent.data._id }); await addAgentToDepartment(api, { department: departmentB, agentId: agent2.data._id }); - - await expect((await api.post('/settings/Enable_CSP', { value: false })).status()).toBe(200); await expect((await api.post('/settings/Livechat_offline_email', { value: 'test@testing.com' })).status()).toBe(200); }); @@ -267,7 +263,6 @@ test.describe('OC - Livechat API', () => { }); test.afterAll(async ({ api }) => { - await expect((await api.post('/settings/Enable_CSP', { value: true })).status()).toBe(200); await agent.delete(); await agent2.delete(); @@ -623,7 +618,6 @@ test.describe('OC - Livechat API', () => { test.beforeAll(async ({ api }) => { agent = await createAgent(api, 'user1'); - await expect((await api.post('/settings/Enable_CSP', { value: false })).status()).toBe(200); await expect((await api.post('/settings/Livechat_offline_email', { value: 'test@testing.com' })).status()).toBe(200); }); @@ -650,8 +644,7 @@ test.describe('OC - Livechat API', () => { await page.close(); }); - test.afterAll(async ({ api }) => { - await expect((await api.post('/settings/Enable_CSP', { value: true })).status()).toBe(200); + test.afterAll(async () => { await agent.delete(); }); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-widget.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-widget.spec.ts index 37279923dbda..5b57920bb7a4 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-widget.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-widget.spec.ts @@ -8,16 +8,14 @@ test.describe('Omnichannel - Livechat Widget Embedded', () => { let page: Page; let poLiveChat: OmnichannelLiveChatEmbedded; - test.beforeAll(async ({ browser, api }) => { + test.beforeAll(async ({ browser }) => { page = await browser.newPage(); poLiveChat = new OmnichannelLiveChatEmbedded(page); - await expect((await api.post('/settings/Enable_CSP', { value: false })).status()).toBe(200); await page.goto('/packages/rocketchat_livechat/assets/demo.html'); }); - test.afterAll(async ({ api }) => { - await expect((await api.post('/settings/Enable_CSP', { value: true })).status()).toBe(200); + test.afterAll(async () => { await page.close(); }); From e5d94d53c4123be8cda3ad9c112442d98e2e43d4 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 2 Apr 2024 20:01:13 -0300 Subject: [PATCH 018/131] fix(livechat): registering guest multiple times cause message loss (#32069) --- .changeset/nine-houses-reply.md | 6 ++ .../omnichannel-livechat-api.spec.ts | 4 - packages/livechat/src/lib/hooks.ts | 74 ++++++++++++++----- 3 files changed, 63 insertions(+), 21 deletions(-) create mode 100644 .changeset/nine-houses-reply.md diff --git a/.changeset/nine-houses-reply.md b/.changeset/nine-houses-reply.md new file mode 100644 index 000000000000..29bbe0882a76 --- /dev/null +++ b/.changeset/nine-houses-reply.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/livechat": patch +--- + +Livechat: A registered user loses their messages if 'registerGuest' is called using the same token. diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts index b34910ddf3d6..807e00e61615 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts @@ -430,7 +430,6 @@ test.describe('OC - Livechat API', () => { await poLiveChat.btnSendMessageToOnlineAgent.click(); await expect(poLiveChat.txtChatMessage('this_a_test_message_from_visitor_1')).toBeVisible(); - }); await test.step('Expect registerGuest to create guest 2', async () => { @@ -448,7 +447,6 @@ test.describe('OC - Livechat API', () => { await poLiveChat.txtChatMessage('this_a_test_message_from_visitor_2').waitFor({ state: 'visible' }); await expect(poLiveChat.txtChatMessage('this_a_test_message_from_visitor_2')).toBeVisible(); - }); }); @@ -460,8 +458,6 @@ test.describe('OC - Livechat API', () => { }; await test.step('Expect registerGuest work with the same token, multiple times', async () => { - test.fail(); - await poLiveChat.page.evaluate(() => window.RocketChat.livechat.maximizeWidget()); await expect(page.frameLocator('#rocketchat-iframe').getByText('Start Chat')).toBeVisible(); diff --git a/packages/livechat/src/lib/hooks.ts b/packages/livechat/src/lib/hooks.ts index 393645fed586..9ccc597a37e8 100644 --- a/packages/livechat/src/lib/hooks.ts +++ b/packages/livechat/src/lib/hooks.ts @@ -11,10 +11,49 @@ import { createToken } from './random'; import { loadMessages } from './room'; import Triggers from './triggers'; +const evaluateChangesAndLoadConfigByFields = async (fn: () => Promise) => { + const oldStore = JSON.parse( + JSON.stringify({ + user: store.state.user || {}, + department: store.state.department, + token: store.state.token, + }), + ); + await fn(); + + /** + * it solves the issues where the registerGuest is called every time the widget is opened + * and the guest is already registered. If there is nothing different in the data, + * it will not call the loadConfig again. + * + * if user changes, it will call loadConfig + * if department changes, it will call loadConfig + * if token changes, it will call loadConfig + */ + + if (oldStore.user._id !== store.state.user?._id) { + await loadConfig(); + await loadMessages(); + return; + } + + if (oldStore.department !== store.state.department) { + await loadConfig(); + await loadMessages(); + return; + } + + if (oldStore.token !== store.state.token) { + await loadConfig(); + await loadMessages(); + } +}; + const createOrUpdateGuest = async (guest: StoreState['guest']) => { if (!guest) { return; } + const { token } = guest; token && (await store.setState({ token })); const { visitor: user } = await Livechat.grantVisitor({ visitor: { ...guest } }); @@ -23,7 +62,6 @@ const createOrUpdateGuest = async (guest: StoreState['guest']) => { return; } store.setState({ user } as Omit); - await loadConfig(); Triggers.callbacks?.emit('chat-visitor-registered'); }; @@ -97,6 +135,10 @@ const api = { }, setDepartment: async (value: string) => { + await evaluateChangesAndLoadConfigByFields(async () => api._setDepartment(value)); + }, + + _setDepartment: async (value: string) => { const { user, config: { departments = [] }, @@ -108,8 +150,6 @@ const api = { return; } - const { department: existingDepartment } = user || {}; - const department = departments.find((dep) => dep._id === value || dep.name === value)?._id || ''; if (!department) { @@ -124,11 +164,6 @@ const api = { if (defaultAgent && defaultAgent.department !== department) { store.setState({ defaultAgent: undefined }); } - - if (department !== existingDepartment) { - await loadConfig(); - await loadMessages(); - } }, setBusinessUnit: async (newBusinessUnit: string) => { @@ -185,7 +220,10 @@ const api = { if (token === localToken) { return; } - await createOrUpdateGuest({ token }); + + await evaluateChangesAndLoadConfigByFields(async () => { + await createOrUpdateGuest({ token }); + }); }, setGuestName: (name: string) => { @@ -201,17 +239,19 @@ const api = { return; } - if (!data.token) { - data.token = createToken(); - } + await evaluateChangesAndLoadConfigByFields(async () => { + if (!data.token) { + data.token = createToken(); + } - if (data.department) { - api.setDepartment(data.department); - } + if (data.department) { + await api._setDepartment(data.department); + } - Livechat.unsubscribeAll(); + Livechat.unsubscribeAll(); - await createOrUpdateGuest(data); + await createOrUpdateGuest(data); + }); }, setLanguage: async (language: StoreState['iframe']['language']) => { From fa9c9057c0d6de2a18d0d647b6eaa649c379d0f3 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 2 Apr 2024 21:23:58 -0300 Subject: [PATCH 019/131] test: contact center after hook calling wrong endpoint (#32094) --- .../tests/e2e/omnichannel/omnichannel-contact-center.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center.spec.ts index b66ac538d5c2..d0e7b7133423 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center.spec.ts @@ -66,8 +66,8 @@ test.describe('Omnichannel Contact Center', () => { test.afterAll(async ({ api }) => { // Remove added contacts - await api.delete('/livechat/visitor', { token: EXISTING_CONTACT.token }); - await api.delete('/livechat/visitor', { token: NEW_CONTACT.token }); + await api.delete(`/livechat/visitor/${EXISTING_CONTACT.token}`); + await api.delete(`/livechat/visitor/${NEW_CONTACT.token}`); if (IS_EE) { await api.post('method.call/livechat:removeCustomField', { message: NEW_CUSTOM_FIELD.field }); } From 6fc2e6935d98efec1b714a298fdafc40f981f6b7 Mon Sep 17 00:00:00 2001 From: Martin Schoeler Date: Wed, 3 Apr 2024 00:34:59 -0300 Subject: [PATCH 020/131] test(livechat): File upload settings (#32060) --- .../omnichannel-livechat-fileupload.spec.ts | 124 ++++++++++++++++++ .../e2e/page-objects/omnichannel-livechat.ts | 46 +++++++ .../src/components/FilesDropTarget/index.tsx | 1 + 3 files changed, 171 insertions(+) create mode 100644 apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-fileupload.spec.ts diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-fileupload.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-fileupload.spec.ts new file mode 100644 index 000000000000..1da41ba238f9 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-fileupload.spec.ts @@ -0,0 +1,124 @@ +import { faker } from '@faker-js/faker'; + +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel, OmnichannelLiveChat } from '../page-objects'; +import { createAgent } from '../utils/omnichannel/agents'; +import { test, expect } from '../utils/test'; + +const visitor = { + name: `${faker.person.firstName()} ${faker.string.uuid()}}`, + email: faker.internet.email(), +} + +// Endpoint defaults are reset after each test, so if not in matrix assume is true +const endpointMatrix = [ + [{ url: '/settings/FileUpload_Enabled', value: false}], + [{ url: '/settings/Livechat_fileupload_enabled', value: false}], + [{ url: '/settings/FileUpload_Enabled', value: false}, { url: '/settings/Livechat_fileupload_enabled', value: false}], +] + +const beforeTest = async (poLiveChat: OmnichannelLiveChat) => { + await poLiveChat.page.goto('/livechat'); + + await poLiveChat.openAnyLiveChat(); + await poLiveChat.sendMessage(visitor, false); + await poLiveChat.onlineAgentMessage.fill('this_a_test_message_from_user'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await poLiveChat.txtChatMessage('this_a_test_message_from_user').waitFor({state: 'visible'}); +} + +test.describe('OC - Livechat - OC - File Upload', () => { + let poLiveChat: OmnichannelLiveChat; + let poHomeOmnichannel: HomeOmnichannel; + let agent: Awaited>; + + test.beforeAll(async ({ browser, api }) => { + agent = await createAgent(api, 'user1'); + + const { page } = await createAuxContext(browser, Users.user1, '/'); + poHomeOmnichannel = new HomeOmnichannel(page); + }); + + test.beforeEach(async ({ page, api }) => { + poLiveChat = new OmnichannelLiveChat(page, api); + }); + + test.afterAll(async ({api}) => { + await api.post('/settings/FileUpload_Enabled', { value: true }); + await api.post('/settings/Livechat_fileupload_enabled', { value: true }); + + await poHomeOmnichannel.page?.close(); + await agent.delete(); + }); + + // Default settings are FileUpload_Enabled true and Livechat_fileupload_enabled true + test('OC - Livechat - txt Drag & Drop', async () => { + await beforeTest(poLiveChat); + + await test.step('expect to upload a txt file', async () => { + await poLiveChat.dragAndDropTxtFile(); + await expect(poLiveChat.findUploadedFileLink('any_file.txt')).toBeVisible(); + }); + }); + + test('OC - Livechat - lst Drag & Drop', async () => { + await beforeTest(poLiveChat); + + await test.step('expect to upload a lst file', async () => { + await poLiveChat.dragAndDropLstFile(); + await expect(poLiveChat.findUploadedFileLink('lst-test.lst')).toBeVisible(); + }); + }); +}); + +test.describe('OC - Livechat - OC - File Upload - Disabled', () => { + let poLiveChat: OmnichannelLiveChat; + let poHomeOmnichannel: HomeOmnichannel; + let agent: Awaited>; + + test.beforeAll(async ({ browser, api }) => { + agent = await createAgent(api, 'user1'); + + const { page } = await createAuxContext(browser, Users.user1, '/'); + poHomeOmnichannel = new HomeOmnichannel(page); + }); + + test.afterAll(async ({api}) => { + await api.post('/settings/FileUpload_Enabled', { value: true }); + await api.post('/settings/Livechat_fileupload_enabled', { value: true }); + + await poHomeOmnichannel.page?.close(); + await agent.delete(); + }); + + endpointMatrix.forEach((endpoints) => { + const testName = endpoints.map((endpoint) => endpoint.url.split('/').pop()?.concat(`=${endpoint.value}`)).join(' '); + + test(`OC - Livechat - txt Drag & Drop - ${testName}`, async ({ page, api }) => { + test.fail(); + + poLiveChat = new OmnichannelLiveChat(page, api); + + await Promise.all(endpoints.map(async (endpoint: { url: string, value: boolean }) => { + await api.post(endpoint.url, { value: endpoint.value }); + })); + + await poLiveChat.page.goto('/livechat'); + + await poLiveChat.openAnyLiveChat(); + await poLiveChat.sendMessage(visitor, false); + await poLiveChat.onlineAgentMessage.fill('this_a_test_message_from_user'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await poLiveChat.txtChatMessage('this_a_test_message_from_user').waitFor({state: 'visible'}); + + await test.step('expect to upload a txt file', async () => { + await poLiveChat.dragAndDropTxtFile(); + + await expect(poLiveChat.alertMessage('file_upload_disabled')).toBeVisible(); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel-livechat.ts b/apps/meteor/tests/e2e/page-objects/omnichannel-livechat.ts index c372dd6d8572..7099af127ee2 100644 --- a/apps/meteor/tests/e2e/page-objects/omnichannel-livechat.ts +++ b/apps/meteor/tests/e2e/page-objects/omnichannel-livechat.ts @@ -1,3 +1,5 @@ +import fs from 'fs/promises'; + import type { Page, Locator, APIResponse } from '@playwright/test'; import { expect } from '../utils/test'; @@ -49,6 +51,10 @@ export class OmnichannelLiveChat { return this.page.locator('[data-qa="header-title"]'); } + alertMessage(message: string): Locator { + return this.page.getByRole('alert').locator(`text="${message}"`); + } + txtChatMessage(message: string): Locator { return this.page.locator(`text="${message}"`); } @@ -130,6 +136,14 @@ export class OmnichannelLiveChat { return this.page.locator('div.message-text__WwYco p'); } + get fileUploadTarget(): Locator { + return this.page.locator('#files-drop-target'); + } + + findUploadedFileLink (fileName: string): Locator { + return this.page.getByRole('link', { name: fileName }); + } + public async sendMessage(liveChatUser: { name: string; email: string }, isOffline = true, department?: string): Promise { const buttonLabel = isOffline ? 'Send' : 'Start chat'; await this.inputName.fill(liveChatUser.name); @@ -158,4 +172,36 @@ export class OmnichannelLiveChat { await expect(this.txtChatMessage(message)).toBeVisible(); await this.closeChat(); } + + async dragAndDropTxtFile(): Promise { + const contract = await fs.readFile('./tests/e2e/fixtures/files/any_file.txt', 'utf-8'); + const dataTransfer = await this.page.evaluateHandle((contract) => { + const data = new DataTransfer(); + const file = new File([`${contract}`], 'any_file.txt', { + type: 'text/plain', + }); + data.items.add(file); + return data; + }, contract); + + await this.fileUploadTarget.dispatchEvent('dragenter', { dataTransfer }); + + await this.fileUploadTarget.dispatchEvent('drop', { dataTransfer }); + } + + async dragAndDropLstFile(): Promise { + const contract = await fs.readFile('./tests/e2e/fixtures/files/lst-test.lst', 'utf-8'); + const dataTransfer = await this.page.evaluateHandle((contract) => { + const data = new DataTransfer(); + const file = new File([`${contract}`], 'lst-test.lst', { + type: 'text/plain', + }); + data.items.add(file); + return data; + }, contract); + + await this.fileUploadTarget.dispatchEvent('dragenter', { dataTransfer }); + + await this.fileUploadTarget.dispatchEvent('drop', { dataTransfer }); + } } diff --git a/packages/livechat/src/components/FilesDropTarget/index.tsx b/packages/livechat/src/components/FilesDropTarget/index.tsx index 10d5dcbb9ac8..7a4d0959d54c 100644 --- a/packages/livechat/src/components/FilesDropTarget/index.tsx +++ b/packages/livechat/src/components/FilesDropTarget/index.tsx @@ -105,6 +105,7 @@ export const FilesDropTarget = ({ onDrop={handleDrop} className={createClassName(styles, 'drop', { overlayed, dragover: dragLevel > 0 }, [className])} style={style} + id='files-drop-target' > Date: Wed, 3 Apr 2024 11:19:19 -0300 Subject: [PATCH 021/131] test(livechat): fix Department flaky test (#32102) --- .../omnichannel-livechat-department.spec.ts | 70 +++++++++++++++---- .../e2e/page-objects/omnichannel-livechat.ts | 18 ++--- .../src/components/Modal/component.js | 7 +- 3 files changed, 68 insertions(+), 27 deletions(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-department.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-department.spec.ts index fe820a71f62c..c0b2bf8ae852 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-department.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-department.spec.ts @@ -8,10 +8,7 @@ import { createAgent } from '../utils/omnichannel/agents'; import { addAgentToDepartment, createDepartment } from '../utils/omnichannel/departments'; import { test, expect } from '../utils/test'; -const firstUser = { - name: `${faker.person.firstName()} ${faker.string.uuid()}}`, - email: faker.internet.email(), -}; + test.use({ storageState: Users.user1.state }); @@ -54,8 +51,8 @@ test.describe('OC - Livechat - Department Flow', () => { }); test.afterEach(async ({ page }) => { - await poHomeOmnichannelAgent1.page?.close(); - await poHomeOmnichannelAgent2.page?.close(); + await poHomeOmnichannelAgent1?.page?.close(); + await poHomeOmnichannelAgent2?.page?.close(); await page.close(); }); @@ -67,9 +64,15 @@ test.describe('OC - Livechat - Department Flow', () => { }); test('OC - Livechat - Chat with Department', async () => { + + const guest = { + name: `${faker.person.firstName()} ${faker.string.nanoid(10)}}`, + email: faker.internet.email(), + }; + await test.step('expect start Chat with department', async () => { await poLiveChat.openAnyLiveChat(); - await poLiveChat.sendMessage(firstUser, false, departmentA.name); + await poLiveChat.sendMessage(guest, false, departmentA.name); await expect(poLiveChat.onlineAgentMessage).toBeVisible(); await poLiveChat.onlineAgentMessage.fill('this_a_test_message_from_user'); await poLiveChat.btnSendMessageToOnlineAgent.click(); @@ -77,7 +80,7 @@ test.describe('OC - Livechat - Department Flow', () => { }); await test.step('expect message to be received by department', async () => { - await poHomeOmnichannelAgent1.sidenav.openChat(firstUser.name); + await poHomeOmnichannelAgent1.sidenav.openChat(guest.name); await expect(poHomeOmnichannelAgent1.content.lastUserMessage).toBeVisible(); await expect(poHomeOmnichannelAgent1.content.lastUserMessage).toContainText('this_a_test_message_from_user'); }); @@ -89,34 +92,73 @@ test.describe('OC - Livechat - Department Flow', () => { }); test('OC - Livechat - Change Department', async () => { + + const guest = { + name: `${faker.person.firstName()} ${faker.string.nanoid(10)}}`, + email: faker.internet.email(), + + }; await test.step('expect start Chat with department', async () => { await poLiveChat.openAnyLiveChat(); - await poLiveChat.sendMessage(firstUser, false, departmentA.name); + await poLiveChat.sendMessage(guest, false, departmentA.name); await expect(poLiveChat.onlineAgentMessage).toBeVisible(); await poLiveChat.onlineAgentMessage.fill('this_a_test_message_from_user'); await poLiveChat.btnSendMessageToOnlineAgent.click(); await expect(poLiveChat.page.locator('div >> text="this_a_test_message_from_user"')).toBeVisible(); }); + await test.step('expect message to be received by department 1', async () => { + await poHomeOmnichannelAgent1.sidenav.openChat(guest.name); + await expect(poHomeOmnichannelAgent1.content.lastUserMessage).toBeVisible(); + await expect(poHomeOmnichannelAgent1.content.lastUserMessage).toContainText('this_a_test_message_from_user'); + }); + + await test.step('expect message to be sent by department 1', async () => { + await poHomeOmnichannelAgent1.content.sendMessage('this_a_test_message_from_agent_department_1'); + await expect(poLiveChat.page.locator('div >> text="this_a_test_message_from_agent_department_1"')).toBeVisible(); + await poHomeOmnichannelAgent1.page.close(); + }); + await test.step('expect to change department', async () => { - await poLiveChat.changeDepartment(departmentB.name); + await poLiveChat.btnOptions.click(); + await poLiveChat.btnChangeDepartment.click(); + + await expect(poLiveChat.selectDepartment).toBeVisible(); + await poLiveChat.selectDepartment.selectOption({ label: departmentB.name }); + + await expect(poLiveChat.btnSendMessage('Start chat')).toBeEnabled(); + await poLiveChat.btnSendMessage('Start chat').click(); + + await expect(poLiveChat.livechatModal).toBeVisible(); + + await expect(poLiveChat.livechatModalText('Are you sure you want to switch the department?')).toBeVisible(); + await poLiveChat.btnYes.click(); + + await expect(poLiveChat.livechatModal).toBeVisible(); + + await expect(poLiveChat.livechatModalText('Department switched')).toBeVisible(); + await poLiveChat.btnOk.click(); // Expect keep chat history await expect(poLiveChat.page.locator('div >> text="this_a_test_message_from_user"')).toBeVisible(); // Expect user to have changed await expect(await poLiveChat.headerTitle.textContent()).toEqual(agent2.username); + + await poLiveChat.onlineAgentMessage.fill('this_a_test_message_from_user_to_department_2'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + await expect(poLiveChat.page.locator('div >> text="this_a_test_message_from_user_to_department_2"')).toBeVisible(); }); await test.step('expect message to be received by department', async () => { - await poHomeOmnichannelAgent2.sidenav.openChat(firstUser.name); + await poHomeOmnichannelAgent2.sidenav.openChat(guest.name); await expect(poHomeOmnichannelAgent2.content.lastUserMessage).toBeVisible(); - await expect(poHomeOmnichannelAgent2.content.lastUserMessage).toContainText('this_a_test_message_from_user'); + await expect(poHomeOmnichannelAgent2.content.lastUserMessage).toContainText('this_a_test_message_from_user_to_department_2'); }); await test.step('expect message to be sent by department', async () => { - await poHomeOmnichannelAgent2.content.sendMessage('this_a_test_message_from_agent'); - await expect(poLiveChat.page.locator('div >> text="this_a_test_message_from_agent"')).toBeVisible(); + await poHomeOmnichannelAgent2.content.sendMessage('this_a_test_message_from_agent_department_2'); + await expect(poLiveChat.page.locator('div >> text="this_a_test_message_from_agent_department_2"')).toBeVisible(); }); }); }); diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel-livechat.ts b/apps/meteor/tests/e2e/page-objects/omnichannel-livechat.ts index 7099af127ee2..1029c8ba2819 100644 --- a/apps/meteor/tests/e2e/page-objects/omnichannel-livechat.ts +++ b/apps/meteor/tests/e2e/page-objects/omnichannel-livechat.ts @@ -59,16 +59,6 @@ export class OmnichannelLiveChat { return this.page.locator(`text="${message}"`); } - async changeDepartment (department: string): Promise { - await this.btnOptions.click(); - await this.btnChangeDepartment.click(); - await this.selectDepartment.waitFor({ state: 'visible' }); - await this.selectDepartment.selectOption({ label: department }); - await this.btnSendMessage('Start chat').click(); - await this.btnYes.click(); - await this.btnOk.click(); - } - async closeChat(): Promise { await this.btnOptions.click(); await this.btnCloseChat.click(); @@ -132,8 +122,12 @@ export class OmnichannelLiveChat { return this.page.locator('footer div div div:nth-child(3) button'); } - get firstAutoMessage(): Locator { - return this.page.locator('div.message-text__WwYco p'); + get livechatModal(): Locator { + return this.page.locator('[data-qa-type="modal-overlay"]'); + } + + livechatModalText(text: string): Locator { + return this.page.locator(`[data-qa-type="modal-overlay"] >> text=${text}`); } get fileUploadTarget(): Locator { diff --git a/packages/livechat/src/components/Modal/component.js b/packages/livechat/src/components/Modal/component.js index 195d4598f217..16f0df40d8ea 100644 --- a/packages/livechat/src/components/Modal/component.js +++ b/packages/livechat/src/components/Modal/component.js @@ -48,7 +48,12 @@ export class Modal extends Component { render = ({ children, animated, open, ...props }) => open ? ( -
+
{children}
From 0fcf1f0f1b3500bdcbb2b5bee7076629deb10a2d Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Wed, 3 Apr 2024 13:01:51 -0300 Subject: [PATCH 022/131] chore: Remove duplicated `ChannelDeletionTable` (#32114) --- .../info/Delete/ChannelDeletionTable.tsx | 78 ------------------- .../info/Delete/ChannelDeletionTableRow.tsx | 35 --------- .../ChannelDeletionTable.tsx | 4 +- 3 files changed, 2 insertions(+), 115 deletions(-) delete mode 100644 apps/meteor/client/views/teams/contextualBar/info/Delete/ChannelDeletionTable.tsx delete mode 100644 apps/meteor/client/views/teams/contextualBar/info/Delete/ChannelDeletionTableRow.tsx diff --git a/apps/meteor/client/views/teams/contextualBar/info/Delete/ChannelDeletionTable.tsx b/apps/meteor/client/views/teams/contextualBar/info/Delete/ChannelDeletionTable.tsx deleted file mode 100644 index b04c0dbc7bf7..000000000000 --- a/apps/meteor/client/views/teams/contextualBar/info/Delete/ChannelDeletionTable.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import type { IRoom, Serialized } from '@rocket.chat/core-typings'; -import { Box, CheckBox } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; -import React from 'react'; - -import { GenericTable, GenericTableHeaderCell, GenericTableBody, GenericTableHeader } from '../../../../../components/GenericTable'; -import { useSort } from '../../../../../components/GenericTable/hooks/useSort'; -import ChannelDeletionTableRow from './ChannelDeletionTableRow'; - -type ChannelDeletationTable = { - rooms: Serialized[]; - onToggleAllRooms: () => void; - onChangeRoomSelection: (room: Serialized) => void; - selectedRooms: { [key: string]: Serialized }; -}; - -const ChannelDeletionTable = ({ rooms, onChangeRoomSelection, selectedRooms, onToggleAllRooms }: ChannelDeletationTable) => { - const t = useTranslation(); - const { sortBy, sortDirection, setSort } = useSort<'name' | 'usersCount'>('name'); - - const selectedRoomsLength = Object.values(selectedRooms).filter(Boolean).length; - - const getSortedChannels = () => { - if (rooms) { - const sortedRooms = [...rooms]; - if (sortBy === 'name') { - sortedRooms.sort((a, b) => (a.name && b.name ? a.name.localeCompare(b.name) : 0)); - } - if (sortBy === 'usersCount') { - sortedRooms.sort((a, b) => a.usersCount - b.usersCount); - } - if (sortDirection === 'desc') { - return sortedRooms?.reverse(); - } - return sortedRooms; - } - }; - - const sortedRooms = getSortedChannels(); - - const checked = rooms.length === selectedRoomsLength; - const indeterminate = rooms.length > selectedRoomsLength && selectedRoomsLength > 0; - - const headers = ( - <> - - - {t('Channel_name')} - - - - {t('Members')} - - - - ); - - return ( - - - {headers} - - {sortedRooms?.map((room) => ( - - ))} - - - - ); -}; - -export default ChannelDeletionTable; diff --git a/apps/meteor/client/views/teams/contextualBar/info/Delete/ChannelDeletionTableRow.tsx b/apps/meteor/client/views/teams/contextualBar/info/Delete/ChannelDeletionTableRow.tsx deleted file mode 100644 index 7531d4c2b3f5..000000000000 --- a/apps/meteor/client/views/teams/contextualBar/info/Delete/ChannelDeletionTableRow.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type { IRoom, Serialized } from '@rocket.chat/core-typings'; -import { CheckBox, Margins } from '@rocket.chat/fuselage'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import React from 'react'; - -import { GenericTableRow, GenericTableCell } from '../../../../../components/GenericTable'; -import { RoomIcon } from '../../../../../components/RoomIcon'; - -type ChannelDeletionTableRowProps = { - room: Serialized; - onChange: (room: Serialized) => void; - selected: boolean; -}; - -const ChannelDeletionTableRow = ({ room, onChange, selected }: ChannelDeletionTableRowProps) => { - const { name, fname, usersCount } = room; - const handleChange = useMutableCallback(() => onChange(room)); - - return ( - - - - - - {fname ?? name} - - - - {usersCount} - - - ); -}; - -export default ChannelDeletionTableRow; diff --git a/apps/meteor/client/views/teams/contextualBar/info/DeleteTeam/ChannelDeletionTable/ChannelDeletionTable.tsx b/apps/meteor/client/views/teams/contextualBar/info/DeleteTeam/ChannelDeletionTable/ChannelDeletionTable.tsx index fb5bf144372b..4bd85aed663c 100644 --- a/apps/meteor/client/views/teams/contextualBar/info/DeleteTeam/ChannelDeletionTable/ChannelDeletionTable.tsx +++ b/apps/meteor/client/views/teams/contextualBar/info/DeleteTeam/ChannelDeletionTable/ChannelDeletionTable.tsx @@ -7,14 +7,14 @@ import { GenericTable, GenericTableHeaderCell, GenericTableBody, GenericTableHea import { useSort } from '../../../../../../components/GenericTable/hooks/useSort'; import ChannelDeletionTableRow from './ChannelDeletionTableRow'; -type ChannelDeletationTable = { +type ChannelDeletionTableProps = { rooms: Serialized[]; onToggleAllRooms: () => void; onChangeRoomSelection: (room: Serialized) => void; selectedRooms: { [key: string]: Serialized }; }; -const ChannelDeletionTable = ({ rooms, onChangeRoomSelection, selectedRooms, onToggleAllRooms }: ChannelDeletationTable) => { +const ChannelDeletionTable = ({ rooms, onChangeRoomSelection, selectedRooms, onToggleAllRooms }: ChannelDeletionTableProps) => { const t = useTranslation(); const { sortBy, sortDirection, setSort } = useSort<'name' | 'usersCount'>('name'); From 1626945fa3c4a9ade57fe09a479f442a5a51bc62 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 3 Apr 2024 14:59:33 -0600 Subject: [PATCH 023/131] fix: UI allowing to mark room as favorite despite room was not a `default` room (#32063) --- .changeset/pink-ants-sing.md | 6 ++ .../client/views/admin/rooms/EditRoom.tsx | 4 +- apps/meteor/tests/e2e/administration.spec.ts | 15 +++++ apps/meteor/tests/end-to-end/api/09-rooms.js | 61 ++++++++++++++++++- 4 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 .changeset/pink-ants-sing.md diff --git a/.changeset/pink-ants-sing.md b/.changeset/pink-ants-sing.md new file mode 100644 index 000000000000..7b4841a11561 --- /dev/null +++ b/.changeset/pink-ants-sing.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed a UI issue that allowed a user to "mark" a room as favorite even when a room was not default. The Back-End was correctly ignoring the `favorite` property from being updated when the room was not default, but the UI still allowed users to try. +As UI allowed but changes were not saved, this gave the impression that the function was not working. diff --git a/apps/meteor/client/views/admin/rooms/EditRoom.tsx b/apps/meteor/client/views/admin/rooms/EditRoom.tsx index 1522f4694160..cc165bca215b 100644 --- a/apps/meteor/client/views/admin/rooms/EditRoom.tsx +++ b/apps/meteor/client/views/admin/rooms/EditRoom.tsx @@ -86,7 +86,7 @@ const EditRoom = ({ room, onChange, onDelete }: EditRoomProps) => { canViewReactWhenReadOnly, } = useEditAdminRoomPermissions(room); - const { roomType, readOnly, archived } = watch(); + const { roomType, readOnly, archived, isDefault } = watch(); const changeArchiving = archived !== !!room.archived; @@ -324,7 +324,7 @@ const EditRoom = ({ room, onChange, onDelete }: EditRoomProps) => { name='favorite' control={control} render={({ field: { value, ...field } }) => ( - + )} /> diff --git a/apps/meteor/tests/e2e/administration.spec.ts b/apps/meteor/tests/e2e/administration.spec.ts index dcfb85373186..23d9d5214aaf 100644 --- a/apps/meteor/tests/e2e/administration.spec.ts +++ b/apps/meteor/tests/e2e/administration.spec.ts @@ -123,6 +123,21 @@ test.describe.parallel('administration', () => { await poAdmin.getRoomRow(targetChannel).click(); await expect(poAdmin.favoriteInput).toBeChecked(); }); + + test('should see favorite switch disabled when default is not true', async () => { + await poAdmin.inputSearchRooms.type(targetChannel); + await poAdmin.getRoomRow(targetChannel).click(); + await poAdmin.defaultLabel.click(); + + await expect(poAdmin.favoriteInput).toBeDisabled(); + }); + + test('should see favorite switch enabled when default is true', async () => { + await poAdmin.inputSearchRooms.type(targetChannel); + await poAdmin.getRoomRow(targetChannel).click(); + + await expect(poAdmin.favoriteInput).toBeEnabled(); + }); }); }); diff --git a/apps/meteor/tests/end-to-end/api/09-rooms.js b/apps/meteor/tests/end-to-end/api/09-rooms.js index e60cf8c1f6b4..92b73f34557e 100644 --- a/apps/meteor/tests/end-to-end/api/09-rooms.js +++ b/apps/meteor/tests/end-to-end/api/09-rooms.js @@ -1742,7 +1742,7 @@ describe('[Rooms]', function () { }); }); - describe('/rooms.saveRoomSettings', () => { + describe('rooms.saveRoomSettings', () => { let testChannel; const randomString = `randomString${Date.now()}`; let discussion; @@ -1847,5 +1847,64 @@ describe('[Rooms]', function () { expect(res.body.room).to.have.property('fname', newDiscussionName); }); }); + + it('should mark a room as favorite', async () => { + await request + .post(api('rooms.saveRoomSettings')) + .set(credentials) + .send({ + rid: testChannel._id, + favorite: { + favorite: true, + defaultValue: true, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + await request + .get(api('rooms.info')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room').and.to.be.an('object'); + + expect(res.body.room).to.have.property('_id', testChannel._id); + expect(res.body.room).to.have.property('favorite', true); + }); + }); + it('should not mark a room as favorite when room is not a default room', async () => { + await request + .post(api('rooms.saveRoomSettings')) + .set(credentials) + .send({ + rid: testChannel._id, + favorite: { + favorite: true, + defaultValue: false, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + await request + .get(api('rooms.info')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room').and.to.be.an('object'); + + expect(res.body.room).to.have.property('_id', testChannel._id); + expect(res.body.room).to.not.have.property('favorite'); + }); + }); }); }); From c7fb2eba5db6bd3dd71c5785478600e52f33420e Mon Sep 17 00:00:00 2001 From: Martin Schoeler Date: Wed, 3 Apr 2024 19:04:02 -0300 Subject: [PATCH 024/131] test: fix `should edit name of targetChannel` flaky test (#32121) Co-authored-by: Guilherme Gazzo --- apps/meteor/tests/e2e/channel-management.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/meteor/tests/e2e/channel-management.spec.ts b/apps/meteor/tests/e2e/channel-management.spec.ts index efa39e0773b3..86bab6981346 100644 --- a/apps/meteor/tests/e2e/channel-management.spec.ts +++ b/apps/meteor/tests/e2e/channel-management.spec.ts @@ -130,6 +130,7 @@ test.describe.serial('channel-management', () => { await poHomeChannel.tabs.room.btnSave.click(); targetChannel = `NAME-EDITED-${targetChannel}`; + await expect(page.locator(`role=main >> role=heading[name="${targetChannel}"]`)).toBeVisible(); await poHomeChannel.sidenav.openChat(targetChannel); await expect(page).toHaveURL(`/channel/${targetChannel}`); From 3c5d4ffac539fbb59e31e98036e179123cb73594 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 3 Apr 2024 19:47:42 -0300 Subject: [PATCH 025/131] fix: search room not reactive after room name changes (#32123) --- .changeset/sweet-books-trade.md | 5 +++++ apps/meteor/client/sidebar/search/SearchList.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/sweet-books-trade.md diff --git a/.changeset/sweet-books-trade.md b/.changeset/sweet-books-trade.md new file mode 100644 index 000000000000..be828d662f32 --- /dev/null +++ b/.changeset/sweet-books-trade.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fixed search room not showing the new name room name changes diff --git a/apps/meteor/client/sidebar/search/SearchList.tsx b/apps/meteor/client/sidebar/search/SearchList.tsx index ceb89d3d7092..d215a77ce4bd 100644 --- a/apps/meteor/client/sidebar/search/SearchList.tsx +++ b/apps/meteor/client/sidebar/search/SearchList.tsx @@ -101,7 +101,7 @@ const useSearchItems = (filterText: string): UseQueryResult<(ISubscription & IRo const getSpotlight = useMethod('spotlight'); return useQuery( - ['sidebar/search/spotlight', name, usernamesFromClient, type, localRooms.map(({ _id }) => _id)], + ['sidebar/search/spotlight', name, usernamesFromClient, type, localRooms.map(({ _id, name }) => _id + name)], async () => { if (localRooms.length === LIMIT) { return localRooms; From aee039b2a26d119f95f28b2c8ab20debf68cced2 Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Thu, 4 Apr 2024 14:13:15 -0300 Subject: [PATCH 026/131] refactor: `TeamsChannels` to typescript (#32109) --- .../roomActions/useTeamChannelsRoomAction.ts | 2 +- .../CreateChannel/CreateChannelModal.tsx | 4 +- .../AddExistingModal/AddExistingModal.tsx | 6 +- .../channels/BaseTeamsChannels.tsx | 156 ------------- .../ConfirmationModal/ConfirmationModal.tsx | 41 ---- .../channels/ConfirmationModal/index.ts | 1 - .../contextualBar/channels/RoomActions.js | 154 ------------- .../views/teams/contextualBar/channels/Row.js | 13 -- ...amsChannelItem.js => TeamsChannelItem.tsx} | 23 +- .../channels/TeamsChannelItemMenu.tsx | 59 +++++ .../contextualBar/channels/TeamsChannels.tsx | 218 +++++++++++------- .../channels/TeamsChannelsWithData.tsx | 73 ++++++ .../channels/hooks/useRemoveRoomFromTeam.tsx | 43 ++++ .../channels/hooks/useToggleAutoJoin.tsx | 23 ++ .../teams/contextualBar/channels/index.ts | 2 +- packages/i18n/src/locales/af.i18n.json | 1 - packages/i18n/src/locales/ar.i18n.json | 1 - packages/i18n/src/locales/az.i18n.json | 1 - packages/i18n/src/locales/be-BY.i18n.json | 1 - packages/i18n/src/locales/bg.i18n.json | 1 - packages/i18n/src/locales/bs.i18n.json | 1 - packages/i18n/src/locales/ca.i18n.json | 1 - packages/i18n/src/locales/cs.i18n.json | 1 - packages/i18n/src/locales/cy.i18n.json | 1 - packages/i18n/src/locales/da.i18n.json | 1 - packages/i18n/src/locales/de-AT.i18n.json | 1 - packages/i18n/src/locales/de-IN.i18n.json | 1 - packages/i18n/src/locales/de.i18n.json | 1 - packages/i18n/src/locales/el.i18n.json | 1 - packages/i18n/src/locales/en.i18n.json | 1 - packages/i18n/src/locales/eo.i18n.json | 1 - packages/i18n/src/locales/es.i18n.json | 1 - packages/i18n/src/locales/fa.i18n.json | 1 - packages/i18n/src/locales/fi.i18n.json | 1 - packages/i18n/src/locales/fr.i18n.json | 1 - packages/i18n/src/locales/he.i18n.json | 1 - packages/i18n/src/locales/hr.i18n.json | 1 - packages/i18n/src/locales/hu.i18n.json | 1 - packages/i18n/src/locales/id.i18n.json | 1 - packages/i18n/src/locales/it.i18n.json | 1 - packages/i18n/src/locales/ja.i18n.json | 1 - packages/i18n/src/locales/ka-GE.i18n.json | 1 - packages/i18n/src/locales/km.i18n.json | 1 - packages/i18n/src/locales/ko.i18n.json | 1 - packages/i18n/src/locales/ku.i18n.json | 1 - packages/i18n/src/locales/lo.i18n.json | 1 - packages/i18n/src/locales/lt.i18n.json | 1 - packages/i18n/src/locales/lv.i18n.json | 1 - packages/i18n/src/locales/mn.i18n.json | 1 - packages/i18n/src/locales/ms-MY.i18n.json | 1 - packages/i18n/src/locales/nl.i18n.json | 1 - packages/i18n/src/locales/no.i18n.json | 1 - packages/i18n/src/locales/pl.i18n.json | 1 - packages/i18n/src/locales/pt-BR.i18n.json | 1 - packages/i18n/src/locales/pt.i18n.json | 1 - packages/i18n/src/locales/ro.i18n.json | 1 - packages/i18n/src/locales/ru.i18n.json | 1 - packages/i18n/src/locales/sk-SK.i18n.json | 1 - packages/i18n/src/locales/sl-SI.i18n.json | 1 - packages/i18n/src/locales/sq.i18n.json | 1 - packages/i18n/src/locales/sr.i18n.json | 1 - packages/i18n/src/locales/sv.i18n.json | 1 - packages/i18n/src/locales/ta-IN.i18n.json | 1 - packages/i18n/src/locales/th-TH.i18n.json | 1 - packages/i18n/src/locales/tr.i18n.json | 1 - packages/i18n/src/locales/ug.i18n.json | 1 - packages/i18n/src/locales/uk.i18n.json | 1 - packages/i18n/src/locales/vi-VN.i18n.json | 1 - packages/i18n/src/locales/zh-HK.i18n.json | 1 - packages/i18n/src/locales/zh-TW.i18n.json | 1 - packages/i18n/src/locales/zh.i18n.json | 1 - 71 files changed, 362 insertions(+), 512 deletions(-) delete mode 100644 apps/meteor/client/views/teams/contextualBar/channels/BaseTeamsChannels.tsx delete mode 100644 apps/meteor/client/views/teams/contextualBar/channels/ConfirmationModal/ConfirmationModal.tsx delete mode 100644 apps/meteor/client/views/teams/contextualBar/channels/ConfirmationModal/index.ts delete mode 100644 apps/meteor/client/views/teams/contextualBar/channels/RoomActions.js delete mode 100644 apps/meteor/client/views/teams/contextualBar/channels/Row.js rename apps/meteor/client/views/teams/contextualBar/channels/{TeamsChannelItem.js => TeamsChannelItem.tsx} (77%) create mode 100644 apps/meteor/client/views/teams/contextualBar/channels/TeamsChannelItemMenu.tsx create mode 100644 apps/meteor/client/views/teams/contextualBar/channels/TeamsChannelsWithData.tsx create mode 100644 apps/meteor/client/views/teams/contextualBar/channels/hooks/useRemoveRoomFromTeam.tsx create mode 100644 apps/meteor/client/views/teams/contextualBar/channels/hooks/useToggleAutoJoin.tsx diff --git a/apps/meteor/client/hooks/roomActions/useTeamChannelsRoomAction.ts b/apps/meteor/client/hooks/roomActions/useTeamChannelsRoomAction.ts index 5876a88f2985..6fddd89f2dce 100644 --- a/apps/meteor/client/hooks/roomActions/useTeamChannelsRoomAction.ts +++ b/apps/meteor/client/hooks/roomActions/useTeamChannelsRoomAction.ts @@ -2,7 +2,7 @@ import { lazy, useMemo } from 'react'; import type { RoomToolboxActionConfig } from '../../views/room/contexts/RoomToolboxContext'; -const TeamsChannels = lazy(() => import('../../views/teams/contextualBar/channels/TeamsChannels')); +const TeamsChannels = lazy(() => import('../../views/teams/contextualBar/channels')); export const useTeamChannelsRoomAction = () => { return useMemo( diff --git a/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx b/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx index cd5e19947991..5738798f194e 100644 --- a/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx +++ b/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx @@ -35,6 +35,7 @@ import { useEncryptedRoomDescription } from '../hooks/useEncryptedRoomDescriptio type CreateChannelModalProps = { teamId?: string; onClose: () => void; + reload?: () => void; }; type CreateChannelModalPayload = { @@ -58,7 +59,7 @@ const getFederationHintKey = (licenseModule: ReturnType { +const CreateChannelModal = ({ teamId = '', onClose, reload }: CreateChannelModalProps): ReactElement => { const t = useTranslation(); const canSetReadOnly = usePermissionWithScopedRoles('set-readonly', ['owner']); const e2eEnabled = useSetting('E2E_Enable'); @@ -173,6 +174,7 @@ const CreateChannelModal = ({ teamId = '', onClose }: CreateChannelModalProps): } dispatchToastMessage({ type: 'success', message: t('Room_has_been_created') }); + reload?.(); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } finally { diff --git a/apps/meteor/client/views/teams/contextualBar/channels/AddExistingModal/AddExistingModal.tsx b/apps/meteor/client/views/teams/contextualBar/channels/AddExistingModal/AddExistingModal.tsx index 4fa5828d462f..4917304f33fe 100644 --- a/apps/meteor/client/views/teams/contextualBar/channels/AddExistingModal/AddExistingModal.tsx +++ b/apps/meteor/client/views/teams/contextualBar/channels/AddExistingModal/AddExistingModal.tsx @@ -8,9 +8,10 @@ import RoomsAvailableForTeamsAutoComplete from './RoomsAvailableForTeamsAutoComp type AddExistingModalProps = { teamId: string; onClose: () => void; + reload?: () => void; }; -const AddExistingModal = ({ onClose, teamId }: AddExistingModalProps) => { +const AddExistingModal = ({ teamId, onClose, reload }: AddExistingModalProps) => { const t = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); @@ -31,13 +32,14 @@ const AddExistingModal = ({ onClose, teamId }: AddExistingModalProps) => { }); dispatchToastMessage({ type: 'success', message: t('Channels_added') }); + reload?.(); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } finally { onClose(); } }, - [addRoomEndpoint, teamId, onClose, dispatchToastMessage, t], + [addRoomEndpoint, teamId, onClose, dispatchToastMessage, reload, t], ); return ( diff --git a/apps/meteor/client/views/teams/contextualBar/channels/BaseTeamsChannels.tsx b/apps/meteor/client/views/teams/contextualBar/channels/BaseTeamsChannels.tsx deleted file mode 100644 index ed0a83d39fce..000000000000 --- a/apps/meteor/client/views/teams/contextualBar/channels/BaseTeamsChannels.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import type { IRoom } from '@rocket.chat/core-typings'; -import type { SelectOption } from '@rocket.chat/fuselage'; -import { Box, Icon, TextInput, Margins, Select, Throbber, ButtonGroup, Button } from '@rocket.chat/fuselage'; -import { useMutableCallback, useAutoFocus, useDebouncedCallback } from '@rocket.chat/fuselage-hooks'; -import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { ChangeEvent, Dispatch, SetStateAction, SyntheticEvent } from 'react'; -import React, { useMemo } from 'react'; -import { Virtuoso } from 'react-virtuoso'; - -import { - ContextualbarHeader, - ContextualbarIcon, - ContextualbarTitle, - ContextualbarClose, - ContextualbarContent, - ContextualbarFooter, - ContextualbarEmptyContent, -} from '../../../../components/Contextualbar'; -import { VirtuosoScrollbars } from '../../../../components/CustomScrollbars'; -import InfiniteListAnchor from '../../../../components/InfiniteListAnchor'; -import Row from './Row'; - -type BaseTeamsChannelsProps = { - loading: boolean; - channels: IRoom[]; - text: string; - type: 'all' | 'autoJoin'; - setType: Dispatch>; - setText: (e: ChangeEvent) => void; - onClickClose: () => void; - onClickAddExisting: false | ((e: SyntheticEvent) => void); - onClickCreateNew: false | ((e: SyntheticEvent) => void); - total: number; - loadMoreItems: (start: number, end: number) => void; - onClickView: (room: IRoom) => void; - reload: () => void; -}; - -const BaseTeamsChannels = ({ - loading, - channels = [], - text, - type, - setText, - setType, - onClickClose, - onClickAddExisting, - onClickCreateNew, - total, - loadMoreItems, - onClickView, - reload, -}: BaseTeamsChannelsProps) => { - const t = useTranslation(); - const inputRef = useAutoFocus(true); - - const options: SelectOption[] = useMemo( - () => [ - ['all', t('All')], - ['autoJoin', t('Team_Auto-join')], - ], - [t], - ); - - const lm = useMutableCallback((start) => !loading && loadMoreItems(start, Math.min(50, total - start))); - - const loadMoreChannels = useDebouncedCallback( - () => { - if (channels.length >= total) { - return; - } - - lm(channels.length); - }, - 300, - [lm, channels], - ); - - return ( - <> - - - {t('Team_Channels')} - {onClickClose && } - - - - - - - } - /> - - setType(val as 'all' | 'autoJoin')} value={type} options={options} /> + + + + + {loading && ( + + + + )} + {!loading && channels.length === 0 && } + {!loading && channels.length > 0 && ( + <> + + + {t('Showing')}: {channels.length} + + + + {t('Total')}: {total} + + + + }} + itemContent={(index, data) => } + /> + + + )} + + {(onClickAddExisting || onClickCreateNew) && ( + + + {onClickAddExisting && ( + + )} + {onClickCreateNew && ( + + )} + + + )} + ); }; diff --git a/apps/meteor/client/views/teams/contextualBar/channels/TeamsChannelsWithData.tsx b/apps/meteor/client/views/teams/contextualBar/channels/TeamsChannelsWithData.tsx new file mode 100644 index 000000000000..965414400ee5 --- /dev/null +++ b/apps/meteor/client/views/teams/contextualBar/channels/TeamsChannelsWithData.tsx @@ -0,0 +1,73 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { useLocalStorage, useDebouncedValue, useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useSetModal, usePermission } from '@rocket.chat/ui-contexts'; +import React, { useCallback, useMemo, useState } from 'react'; + +import { useRecordList } from '../../../../hooks/lists/useRecordList'; +import { AsyncStatePhase } from '../../../../lib/asyncState'; +import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator'; +import CreateChannelWithData from '../../../../sidebar/header/CreateChannel'; +import { useRoom } from '../../../room/contexts/RoomContext'; +import { useRoomToolbox } from '../../../room/contexts/RoomToolboxContext'; +import AddExistingModal from './AddExistingModal'; +import TeamsChannels from './TeamsChannels'; +import { useTeamsChannelList } from './hooks/useTeamsChannelList'; + +const TeamsChannelsWithData = () => { + const room = useRoom(); + const setModal = useSetModal(); + const { closeTab } = useRoomToolbox(); + const canAddExistingTeam = usePermission('add-team-channel', room._id); + + const { teamId } = room; + + if (!teamId) { + throw new Error('Invalid teamId'); + } + + const [type, setType] = useLocalStorage<'all' | 'autoJoin'>('channels-list-type', 'all'); + const [text, setText] = useState(''); + const debouncedText = useDebouncedValue(text, 800); + + const { teamsChannelList, loadMoreItems, reload } = useTeamsChannelList( + useMemo(() => ({ teamId, text: debouncedText, type }), [teamId, debouncedText, type]), + ); + + const { phase, items, itemCount: total } = useRecordList(teamsChannelList); + + const handleTextChange = useCallback((event) => { + setText(event.currentTarget.value); + }, []); + + const handleAddExisting = useEffectEvent(() => { + setModal( setModal(null)} reload={reload} />); + }); + + const handleCreateNew = useEffectEvent(() => { + setModal( setModal(null)} reload={reload} />); + }); + + const goToRoom = useEffectEvent((room: IRoom) => { + roomCoordinator.openRouteLink(room.t, room); + }); + + return ( + + ); +}; + +export default TeamsChannelsWithData; diff --git a/apps/meteor/client/views/teams/contextualBar/channels/hooks/useRemoveRoomFromTeam.tsx b/apps/meteor/client/views/teams/contextualBar/channels/hooks/useRemoveRoomFromTeam.tsx new file mode 100644 index 000000000000..de68070d2061 --- /dev/null +++ b/apps/meteor/client/views/teams/contextualBar/channels/hooks/useRemoveRoomFromTeam.tsx @@ -0,0 +1,43 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { useEndpoint, usePermission, useSetModal, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import GenericModal from '../../../../../components/GenericModal'; +import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator'; + +export const useRemoveRoomFromTeam = (room: IRoom, { reload }: { reload?: () => void }) => { + const t = useTranslation(); + const setModal = useSetModal(); + const dispatchToastMessage = useToastMessageDispatch(); + const canRemoveTeamChannel = usePermission('remove-team-channel', room._id); + + const removeRoomEndpoint = useEndpoint('POST', '/v1/teams.removeRoom'); + + const handleRemoveRoom = () => { + const onConfirmAction = async () => { + if (!room.teamId) { + return; + } + + try { + await removeRoomEndpoint({ teamId: room.teamId, roomId: room._id }); + dispatchToastMessage({ type: 'error', message: t('Room_has_been_removed') }); + reload?.(); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } finally { + setModal(null); + } + }; + + return setModal( + setModal(null)} onConfirm={onConfirmAction} confirmText={t('Remove')}> + {t('Team_Remove_from_team_modal_content', { + teamName: roomCoordinator.getRoomName(room.t, room), + })} + , + ); + }; + + return { handleRemoveRoom, canRemoveTeamChannel }; +}; diff --git a/apps/meteor/client/views/teams/contextualBar/channels/hooks/useToggleAutoJoin.tsx b/apps/meteor/client/views/teams/contextualBar/channels/hooks/useToggleAutoJoin.tsx new file mode 100644 index 000000000000..3a437cd8e5c7 --- /dev/null +++ b/apps/meteor/client/views/teams/contextualBar/channels/hooks/useToggleAutoJoin.tsx @@ -0,0 +1,23 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { useEndpoint, usePermission, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; + +export const useToggleAutoJoin = (room: IRoom, { reload }: { reload?: () => void }) => { + const dispatchToastMessage = useToastMessageDispatch(); + const updateRoomEndpoint = useEndpoint('POST', '/v1/teams.updateRoom'); + const canEditTeamChannel = usePermission('edit-team-channel', room._id); + + const handleToggleAutoJoin = async () => { + try { + await updateRoomEndpoint({ + roomId: room._id, + isDefault: !room.teamDefault, + }); + dispatchToastMessage({ type: 'success', message: room.teamDefault ? 'channel set as non autojoin' : 'channel set as autojoin' }); + reload?.(); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }; + + return { handleToggleAutoJoin, canEditTeamChannel }; +}; diff --git a/apps/meteor/client/views/teams/contextualBar/channels/index.ts b/apps/meteor/client/views/teams/contextualBar/channels/index.ts index 12523fd3815d..cd6564dfd512 100644 --- a/apps/meteor/client/views/teams/contextualBar/channels/index.ts +++ b/apps/meteor/client/views/teams/contextualBar/channels/index.ts @@ -1 +1 @@ -export { default } from './TeamsChannels'; +export { default } from './TeamsChannelsWithData'; diff --git a/packages/i18n/src/locales/af.i18n.json b/packages/i18n/src/locales/af.i18n.json index 05926a54689a..992c74fa1174 100644 --- a/packages/i18n/src/locales/af.i18n.json +++ b/packages/i18n/src/locales/af.i18n.json @@ -2102,7 +2102,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Dit is 'n verstekkanaal en verander dit na 'n privaat groep. Dit sal veroorsaak dat dit nie meer 'n verstekkanaal is nie. Wil jy voortgaan?", "Room_description_changed_successfully": "Kamerbeskrywing suksesvol verander", "Room_has_been_archived": "Kamer is geargiveer", - "Room_has_been_deleted": "Kamer is verwyder", "Room_has_been_unarchived": "Kamer is gearchiveer", "Room_Info": "Kamerinligting", "room_is_blocked": "Hierdie kamer is geblokkeer", diff --git a/packages/i18n/src/locales/ar.i18n.json b/packages/i18n/src/locales/ar.i18n.json index 214a1940a388..64772c8f495d 100644 --- a/packages/i18n/src/locales/ar.i18n.json +++ b/packages/i18n/src/locales/ar.i18n.json @@ -3618,7 +3618,6 @@ "room_disallowed_reacting": "غير مسموح لـ Room بالتفاعل من قِبل {{user_by}}", "Room_Edit": "تحرير Room", "Room_has_been_archived": "تمت أرشفة Room", - "Room_has_been_deleted": "تم حذف Room", "Room_has_been_removed": "تمت إزالة Room", "Room_has_been_unarchived": "تم إلغاء أرشفة Room", "Room_Info": "معلومات Room", diff --git a/packages/i18n/src/locales/az.i18n.json b/packages/i18n/src/locales/az.i18n.json index 513bd976f2f6..a61bb8bbff00 100644 --- a/packages/i18n/src/locales/az.i18n.json +++ b/packages/i18n/src/locales/az.i18n.json @@ -2102,7 +2102,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Bu standart bir kanaldır və onu xüsusi bir qrupa dəyişir, artıq bir default kanal olmayacaq. Davam etmək istəyirsinizmi?", "Room_description_changed_successfully": "Otaq təsviri uğurla dəyişdi", "Room_has_been_archived": "Otaq arxivləşdirildi", - "Room_has_been_deleted": "Otaq silindi", "Room_has_been_unarchived": "Otaq arxivləşdirilmişdir", "Room_Info": "Otaq məlumatı", "room_is_blocked": "Bu otaq blokdur", diff --git a/packages/i18n/src/locales/be-BY.i18n.json b/packages/i18n/src/locales/be-BY.i18n.json index 5c9fb33cc0d8..865efa0f9c5a 100644 --- a/packages/i18n/src/locales/be-BY.i18n.json +++ b/packages/i18n/src/locales/be-BY.i18n.json @@ -2119,7 +2119,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Гэта канал па змаўчанні і змяніць яго да прыватнай групе прымусіць яго больш не будзе каналам па змаўчанні. Вы хочаце працягнуць?", "Room_description_changed_successfully": "Апісанне нумароў паспяхова зменены", "Room_has_been_archived": "Нумар знаходзіцца ў архіве", - "Room_has_been_deleted": "Нумар быў выдалены", "Room_has_been_unarchived": "Нумар быў разархіваваць", "Room_Info": "пакой інфармацыя", "room_is_blocked": "Гэты нумар заблякаваны", diff --git a/packages/i18n/src/locales/bg.i18n.json b/packages/i18n/src/locales/bg.i18n.json index 171f8c32ce4f..318a310eed56 100644 --- a/packages/i18n/src/locales/bg.i18n.json +++ b/packages/i18n/src/locales/bg.i18n.json @@ -2099,7 +2099,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Това е канал по подразбиране и ако го промените на частна група, той вече няма да бъде канал по подразбиране. Искаш ли да продължиш?", "Room_description_changed_successfully": "Описание на стаята се промени успешно", "Room_has_been_archived": "Стаята е архивирана", - "Room_has_been_deleted": "Стаята е изтрита", "Room_has_been_unarchived": "Стаята е деархивирана", "Room_Info": "Информация за стаята", "room_is_blocked": "Тази стая е блокирана", diff --git a/packages/i18n/src/locales/bs.i18n.json b/packages/i18n/src/locales/bs.i18n.json index 4fe15d1e24ae..86fd7083b2ce 100644 --- a/packages/i18n/src/locales/bs.i18n.json +++ b/packages/i18n/src/locales/bs.i18n.json @@ -2095,7 +2095,6 @@ "Room_default_change_to_private_will_be_default_no_more": "To je zadani kanal i mijenja se u privatnu grupu, jer više neće biti zadani kanal. Želiš li nastaviti?", "Room_description_changed_successfully": "Opis sobe je uspješno promjenjen", "Room_has_been_archived": "Soba je arhivirana", - "Room_has_been_deleted": "Soba je obrisana", "Room_has_been_unarchived": "Soba je vraćena iz arhive", "Room_Info": "Info Sobe", "room_is_blocked": "Ova soba je blokirana", diff --git a/packages/i18n/src/locales/ca.i18n.json b/packages/i18n/src/locales/ca.i18n.json index 56b5713d155d..834ad853ff0f 100644 --- a/packages/i18n/src/locales/ca.i18n.json +++ b/packages/i18n/src/locales/ca.i18n.json @@ -3553,7 +3553,6 @@ "room_disallowed_reacting": "Room no permesa reaccionant per {{user_by}}", "Room_Edit": "Editar Room ", "Room_has_been_archived": "La sala s'ha arxivat", - "Room_has_been_deleted": "La sala s'ha eliminat", "Room_has_been_removed": "Room ha estat eliminat", "Room_has_been_unarchived": "La Room s'ha desarxivat", "Room_Info": "Informació de la Room", diff --git a/packages/i18n/src/locales/cs.i18n.json b/packages/i18n/src/locales/cs.i18n.json index 32a127396509..4f1eb948651b 100644 --- a/packages/i18n/src/locales/cs.i18n.json +++ b/packages/i18n/src/locales/cs.i18n.json @@ -3024,7 +3024,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Tato místnost je výchozí a pokud bude změněna na privátní, již nebude moci být mezi výchozími. Přesto změnit?", "Room_description_changed_successfully": "Popis místnosti změněn", "Room_has_been_archived": "Místnost byla archivována", - "Room_has_been_deleted": "Místnost smazána", "Room_has_been_unarchived": "Místnost již není archivována", "Room_Info": "Informace o místnosti", "room_is_blocked": "Místnost je blokována", diff --git a/packages/i18n/src/locales/cy.i18n.json b/packages/i18n/src/locales/cy.i18n.json index 38cb42a46eb3..f9f99d0e6423 100644 --- a/packages/i18n/src/locales/cy.i18n.json +++ b/packages/i18n/src/locales/cy.i18n.json @@ -2097,7 +2097,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Sianel ddiofyn yw hon a'i newid i grŵp preifat fydd yn achosi iddo beidio â bod yn sianel ddiofyn mwyach. Ydych chi am fynd ymlaen?", "Room_description_changed_successfully": "Newidiodd disgrifiad ystafell yn llwyddiannus", "Room_has_been_archived": "Mae'r ystafell wedi'i archifo", - "Room_has_been_deleted": "Mae'r ystafell wedi'i ddileu", "Room_has_been_unarchived": "Mae'r ystafell wedi ei anwybyddu", "Room_Info": "Gwybodaeth Ystafell", "room_is_blocked": "Mae'r ystafell hon wedi'i rhwystro", diff --git a/packages/i18n/src/locales/da.i18n.json b/packages/i18n/src/locales/da.i18n.json index 748e6e3f1fc1..789fa5347410 100644 --- a/packages/i18n/src/locales/da.i18n.json +++ b/packages/i18n/src/locales/da.i18n.json @@ -3125,7 +3125,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Dette er en standardkanal og ændrer den til en privat gruppe, fordi den ikke længere er en standardkanal. Vil du fortsætte?", "Room_description_changed_successfully": "Værelsesbeskrivelsen er ændret", "Room_has_been_archived": "Værelset er blevet arkiveret", - "Room_has_been_deleted": "Værelset er blevet slettet", "Room_has_been_unarchived": "Værelset er blevet arkiveret", "Room_Info": "Info om rummet", "room_is_blocked": "Dette rum er blokeret", diff --git a/packages/i18n/src/locales/de-AT.i18n.json b/packages/i18n/src/locales/de-AT.i18n.json index b4073551357c..b41505392150 100644 --- a/packages/i18n/src/locales/de-AT.i18n.json +++ b/packages/i18n/src/locales/de-AT.i18n.json @@ -2105,7 +2105,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Dies ist ein Standardkanal und wenn Sie ihn in eine private Gruppe ändern, wird er nicht mehr als Standardkanal angezeigt. Willst du fortfahren?", "Room_description_changed_successfully": "Raumbeschreibung erfolgreich geändert", "Room_has_been_archived": "Das Zimmer wurde archiviert", - "Room_has_been_deleted": "Der Raum wurde gelöscht.", "Room_has_been_unarchived": "Der Raum wurde entfernt", "Room_Info": "Raum", "room_is_blocked": "Dieser Raum ist blockiert", diff --git a/packages/i18n/src/locales/de-IN.i18n.json b/packages/i18n/src/locales/de-IN.i18n.json index 109df203897a..719cfe9da4fd 100644 --- a/packages/i18n/src/locales/de-IN.i18n.json +++ b/packages/i18n/src/locales/de-IN.i18n.json @@ -2390,7 +2390,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Das ist ein Standardkanal. Die Änderung zu einem privaten Kanal führt dazu, dass er dies nicht mehr ist. Willst Du das?", "Room_description_changed_successfully": "Raumbeschreibung erfolgreich geändert", "Room_has_been_archived": "Der Raum wurde archiviert", - "Room_has_been_deleted": "Der Raum wurde gelöscht", "Room_has_been_unarchived": "Der Raum wurde aus dem Archiv geholt", "Room_Info": "Rauminformation", "room_is_blocked": "Der Raum ist blockiert", diff --git a/packages/i18n/src/locales/de.i18n.json b/packages/i18n/src/locales/de.i18n.json index 2ce24e2aa0e4..64e30aeec0fc 100644 --- a/packages/i18n/src/locales/de.i18n.json +++ b/packages/i18n/src/locales/de.i18n.json @@ -4062,7 +4062,6 @@ "Room_has_been_archived": "Der Room wurde archiviert", "Room_has_been_converted": "Room wurde konvertiert", "Room_has_been_created": "Room wurde erstellt", - "Room_has_been_deleted": "Der Room wurde gelöscht", "Room_has_been_removed": "Raum wurde entfernt", "Room_has_been_unarchived": "Der Room wurde aus dem Archiv geholt", "Room_Info": "Room-Information", diff --git a/packages/i18n/src/locales/el.i18n.json b/packages/i18n/src/locales/el.i18n.json index 0c52b2df3259..4cf3b7764dd0 100644 --- a/packages/i18n/src/locales/el.i18n.json +++ b/packages/i18n/src/locales/el.i18n.json @@ -2110,7 +2110,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Αυτό είναι ένα προεπιλεγμένο κανάλι και η αλλαγή του σε μια ιδιωτική ομάδα θα το κάνει να μην είναι πλέον προεπιλεγμένο κανάλι. Θέλετε να συνεχίσετε?", "Room_description_changed_successfully": "Η περιγραφή δωματίου άλλαξε με επιτυχία", "Room_has_been_archived": "Η αίθουσα έχει αρχειοθετηθεί", - "Room_has_been_deleted": "Δωμάτιο έχει διαγραφεί", "Room_has_been_unarchived": "Η αίθουσα έχει αφαιρεθεί", "Room_Info": "Πληροφορίες δωματίου", "room_is_blocked": "Αυτό το δωμάτιο έχει αποκλειστεί", diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index bd3222cdce93..bfdb3aa65236 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -4519,7 +4519,6 @@ "Room_has_been_archived": "Room has been archived", "Room_has_been_converted": "Room has been converted", "Room_has_been_created": "Room has been created", - "Room_has_been_deleted": "Room has been deleted", "Room_has_been_removed": "Room has been removed", "Room_has_been_unarchived": "Room has been unarchived", "Room_Info": "Room Information", diff --git a/packages/i18n/src/locales/eo.i18n.json b/packages/i18n/src/locales/eo.i18n.json index f20d82eb9682..5631b9240ffa 100644 --- a/packages/i18n/src/locales/eo.i18n.json +++ b/packages/i18n/src/locales/eo.i18n.json @@ -2102,7 +2102,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Ĉi tiu estas defaŭlta kanalo kaj ŝanĝanta ĝin al privata grupo kaŭzos ke ĝi ne plu estos normala kanalo. Ĉu vi volas daŭrigi?", "Room_description_changed_successfully": "Salono priskribo ŝanĝiĝis sukcese", "Room_has_been_archived": "Salono estis arkivita", - "Room_has_been_deleted": "Salono estis forigita", "Room_has_been_unarchived": "Salono estis neatendita", "Room_Info": "Salono Informoj", "room_is_blocked": "Ĉi tiu ĉambro estas blokita", diff --git a/packages/i18n/src/locales/es.i18n.json b/packages/i18n/src/locales/es.i18n.json index 8eca2a2aec15..9bb24537f555 100644 --- a/packages/i18n/src/locales/es.i18n.json +++ b/packages/i18n/src/locales/es.i18n.json @@ -3605,7 +3605,6 @@ "room_disallowed_reacting": "Room no permite reacciones por {{user_by}}", "Room_Edit": "Editar Room ", "Room_has_been_archived": "La Room se ha archivado", - "Room_has_been_deleted": "La Room se ha eliminado", "Room_has_been_removed": "La Room se ha eliminado", "Room_has_been_unarchived": "La Room se ha desarchivado", "Room_Info": "Información de Room", diff --git a/packages/i18n/src/locales/fa.i18n.json b/packages/i18n/src/locales/fa.i18n.json index cfeb59e37ddd..c812d39a543a 100644 --- a/packages/i18n/src/locales/fa.i18n.json +++ b/packages/i18n/src/locales/fa.i18n.json @@ -2413,7 +2413,6 @@ "Room_default_change_to_private_will_be_default_no_more": "این یک کانال پیشفرض است و تغییر آن به یک گروه خصوصی سبب میشود دیگر کانال پیشفرض نباشد. آیا شما می خواهید ادامه دهید؟", "Room_description_changed_successfully": "توضیحات اتاق با موفقیت تغییر کرد", "Room_has_been_archived": "اتاق بایگانی شده است", - "Room_has_been_deleted": "اتاق حذف شده است", "Room_has_been_unarchived": "اتاق آرام شده است", "Room_Info": "اطلاعات اتاق", "room_is_blocked": "این اتاق مسدود شده است", diff --git a/packages/i18n/src/locales/fi.i18n.json b/packages/i18n/src/locales/fi.i18n.json index 7cc44651e568..53f1c0cb4dc1 100644 --- a/packages/i18n/src/locales/fi.i18n.json +++ b/packages/i18n/src/locales/fi.i18n.json @@ -4143,7 +4143,6 @@ "Room_has_been_archived": "Huone on arkistoitu", "Room_has_been_converted": "Huone on muunnettu", "Room_has_been_created": "Huone on luotu", - "Room_has_been_deleted": "Huone on poistettu", "Room_has_been_removed": "Huone on poistettu", "Room_has_been_unarchived": "Huone on palautettu arkistosta", "Room_Info": "Huoneen tiedot", diff --git a/packages/i18n/src/locales/fr.i18n.json b/packages/i18n/src/locales/fr.i18n.json index b8c6ee5b6414..2bcbb3e78a62 100644 --- a/packages/i18n/src/locales/fr.i18n.json +++ b/packages/i18n/src/locales/fr.i18n.json @@ -3607,7 +3607,6 @@ "room_disallowed_reacting": "Salon non autorisé à réagir par {{user_by}}", "Room_Edit": "Modifier le salon", "Room_has_been_archived": "Le salon a été archivé", - "Room_has_been_deleted": "Le salon a été supprimé", "Room_has_been_removed": "Le salon a été supprimé", "Room_has_been_unarchived": "Le salon a été désarchivé", "Room_Info": "Information sur le salon", diff --git a/packages/i18n/src/locales/he.i18n.json b/packages/i18n/src/locales/he.i18n.json index 84da76d59421..1675e10b1d8f 100644 --- a/packages/i18n/src/locales/he.i18n.json +++ b/packages/i18n/src/locales/he.i18n.json @@ -1146,7 +1146,6 @@ "Room_archived": "חדר בארכיון", "room_changed_privacy": "סוג החדר השנה ל:{{room_type}} ע\"י {{user_by}}", "room_changed_topic": "נושא החדר שונה ל:{{room_topic}} ע\"י {{user_by}}", - "Room_has_been_deleted": "חדר נמחק", "Room_Info": "פרטי החדר", "Room_name_changed": "שם החדר שונה ל: {{room_name}} על ידי המשתמש {{user_by}}", "Room_name_changed_successfully": "שם החדר שונה בהצלחה", diff --git a/packages/i18n/src/locales/hr.i18n.json b/packages/i18n/src/locales/hr.i18n.json index 5f3f16e6ed67..e88c450fc6ce 100644 --- a/packages/i18n/src/locales/hr.i18n.json +++ b/packages/i18n/src/locales/hr.i18n.json @@ -2235,7 +2235,6 @@ "Room_default_change_to_private_will_be_default_no_more": "To je zadani kanal i mijenja se u privatnu grupu, jer više neće biti zadani kanal. Želiš li nastaviti?", "Room_description_changed_successfully": "Opis sobe je uspješno promjenjen", "Room_has_been_archived": "Soba je arhivirana", - "Room_has_been_deleted": "Soba je obrisana", "Room_has_been_unarchived": "Soba je vraćena iz arhive", "Room_Info": "Info Sobe", "room_is_blocked": "Ova soba je blokirana", diff --git a/packages/i18n/src/locales/hu.i18n.json b/packages/i18n/src/locales/hu.i18n.json index 69afdb75f233..d352ad6492a3 100644 --- a/packages/i18n/src/locales/hu.i18n.json +++ b/packages/i18n/src/locales/hu.i18n.json @@ -3983,7 +3983,6 @@ "Room_Edit": "Szoba szerkesztése", "Room_has_been_archived": "A szoba archiválva lett", "Room_has_been_created": "A szoba létre lett hozva", - "Room_has_been_deleted": "A szoba törölve lett", "Room_has_been_removed": "A szoba el lett távolítva", "Room_has_been_unarchived": "A szoba archiválása meg lett szüntetve", "Room_Info": "Szobainformációk", diff --git a/packages/i18n/src/locales/id.i18n.json b/packages/i18n/src/locales/id.i18n.json index db105e17538e..e0656e017425 100644 --- a/packages/i18n/src/locales/id.i18n.json +++ b/packages/i18n/src/locales/id.i18n.json @@ -2110,7 +2110,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Ini adalah saluran default dan mengubahnya menjadi grup pribadi akan menyebabkannya menjadi saluran default. Apakah kamu ingin melanjutkan?", "Room_description_changed_successfully": "Deskripsi kamar berubah dengan sukses", "Room_has_been_archived": "Kamar telah diarsipkan", - "Room_has_been_deleted": "Kamar telah dihapus", "Room_has_been_unarchived": "Kamar telah diarsipkan", "Room_Info": "Info kamar", "room_is_blocked": "Ruangan ini diblokir", diff --git a/packages/i18n/src/locales/it.i18n.json b/packages/i18n/src/locales/it.i18n.json index 79dcaded9d78..315780095cd9 100644 --- a/packages/i18n/src/locales/it.i18n.json +++ b/packages/i18n/src/locales/it.i18n.json @@ -2622,7 +2622,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Questo è un canale predefinito e cambiandolo a un gruppo privato non causerà più un canale predefinito. Vuoi procedere?", "Room_description_changed_successfully": "Descrizione del canale cambiata con successo", "Room_has_been_archived": "La stanza è stata archiviata", - "Room_has_been_deleted": "Il canale è stato eliminato", "Room_has_been_unarchived": "La stanza è stata tolta dall'archivio", "Room_Info": "Informazioni canale", "room_is_blocked": "La stanza è sbloccata", diff --git a/packages/i18n/src/locales/ja.i18n.json b/packages/i18n/src/locales/ja.i18n.json index 2e50c926f7bb..27a99598d88d 100644 --- a/packages/i18n/src/locales/ja.i18n.json +++ b/packages/i18n/src/locales/ja.i18n.json @@ -3568,7 +3568,6 @@ "room_disallowed_reacting": "Roomで{{user_by}}による応答が禁止されました", "Room_Edit": "Roomの編集", "Room_has_been_archived": "Roomはアーカイブされました", - "Room_has_been_deleted": "Roomが削除されました", "Room_has_been_removed": "Roomが削除されました", "Room_has_been_unarchived": "Roomのアーカイブが解除されました", "Room_Info": "Room情報", diff --git a/packages/i18n/src/locales/ka-GE.i18n.json b/packages/i18n/src/locales/ka-GE.i18n.json index 7fab42bd2f14..7e28a9792559 100644 --- a/packages/i18n/src/locales/ka-GE.i18n.json +++ b/packages/i18n/src/locales/ka-GE.i18n.json @@ -2816,7 +2816,6 @@ "Room_default_change_to_private_will_be_default_no_more": "ეს არის დეფაულტ არხი და პირად ჯგუფად გადაკეთების შემთხვევაში აღარ იქნება დეფაულტ არხი.გსურთ გაგრძელება?", "Room_description_changed_successfully": "ოთახის აღწერა წარმატებით შეიცვალა", "Room_has_been_archived": "ოთახი დაარქივებულია", - "Room_has_been_deleted": "ოთახი წაიშალა", "Room_has_been_unarchived": "ოთახი ამოარქივდა", "Room_Info": "ოთახის ინფორმაცია", "room_is_blocked": "ოთახი დაბლოკილია", diff --git a/packages/i18n/src/locales/km.i18n.json b/packages/i18n/src/locales/km.i18n.json index 116fbfc12d6a..f995ba297bd0 100644 --- a/packages/i18n/src/locales/km.i18n.json +++ b/packages/i18n/src/locales/km.i18n.json @@ -2420,7 +2420,6 @@ "Room_default_change_to_private_will_be_default_no_more": "នេះជាឆានែលលំនាំដើមហើយការប្តូរវាទៅជាក្រុមឯកជននឹងធ្វើឱ្យវាលែងក្លាយជាឆានែលលំនាំដើម។ តើអ្នកចង់បន្តទេ?", "Room_description_changed_successfully": "ការពិពណ៌នាបន្ទប់បានផ្លាស់ប្ដូរដោយជោគជ័យ", "Room_has_been_archived": "បន្ទប់ត្រូវបានទុកក្នុងប័ណ្ណសារ", - "Room_has_been_deleted": "បន្ទប់ត្រូវបានលុបចោល", "Room_has_been_unarchived": "បន្ទប់មិនត្រូវបានទុកក្នុងប័ណ្ណសារ", "Room_Info": "ព័តមានបន្ទប់", "room_is_blocked": "បន្ទប់នេះត្រូវបានទប់ស្កាត់", diff --git a/packages/i18n/src/locales/ko.i18n.json b/packages/i18n/src/locales/ko.i18n.json index 468da203f883..7f1fae38eda4 100644 --- a/packages/i18n/src/locales/ko.i18n.json +++ b/packages/i18n/src/locales/ko.i18n.json @@ -3077,7 +3077,6 @@ "Room_default_change_to_private_will_be_default_no_more": "이 채널은 기본 채널이며 비공개 그룹으로 변경하면 더 이상 기본 채널이 될 수 없습니다. 진행하시겠습니까?", "Room_description_changed_successfully": "대화방 설명을 변경했습니다.", "Room_has_been_archived": "대화방이 보관되었습니다.", - "Room_has_been_deleted": "대화방이 삭제되었습니다.", "Room_has_been_unarchived": "대화방 보관이 해제되었습니다.", "Room_Info": "대화방 정보", "room_is_blocked": "이 대화방은 차단되었습니다.", diff --git a/packages/i18n/src/locales/ku.i18n.json b/packages/i18n/src/locales/ku.i18n.json index b5b69caa0143..ccf008f20b78 100644 --- a/packages/i18n/src/locales/ku.i18n.json +++ b/packages/i18n/src/locales/ku.i18n.json @@ -2096,7 +2096,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Ev kanalek navekî ye û guhartin bi komê taybet re dê bibe sedema kanalek navekî. Ma hûn dixwazin pêşniyar bikin?", "Room_description_changed_successfully": "Pirtûka jor bi serkeftî guhertin", "Room_has_been_archived": "Room tête arşîvkirin", - "Room_has_been_deleted": "Room hatiye jêbirin", "Room_has_been_unarchived": "Room unarchived", "Room_Info": "Info room", "room_is_blocked": "Ev odeyê hate asteng kirin", diff --git a/packages/i18n/src/locales/lo.i18n.json b/packages/i18n/src/locales/lo.i18n.json index 7bcd89dc402a..ce86290fe61e 100644 --- a/packages/i18n/src/locales/lo.i18n.json +++ b/packages/i18n/src/locales/lo.i18n.json @@ -2140,7 +2140,6 @@ "Room_default_change_to_private_will_be_default_no_more": "ນີ້ແມ່ນຊ່ອງທາງເລີ່ມຕົ້ນແລະການປ່ຽນແປງມັນກັບກຸ່ມເອກະຊົນຈະເຮັດໃຫ້ມັນບໍ່ກາຍເປັນຊ່ອງທາງເລີ່ມຕົ້ນ. ທ່ານຕ້ອງການດໍາເນີນການ?", "Room_description_changed_successfully": "ລາຍະການຫ້ອງປ່ຽນແປງຢ່າງລວດໄວ", "Room_has_been_archived": "ຫ້ອງພັກໄດ້ຖືກເກັບໄວ້", - "Room_has_been_deleted": "ຫ້ອງໄດ້ຖືກລຶບອອກແລ້ວ", "Room_has_been_unarchived": "ຫ້ອງໄດ້ຖືກເປີດເຜີຍ", "Room_Info": "ຂໍ້ມູນຫ້ອງ", "room_is_blocked": "ຫ້ອງນີ້ຖືກປິດ", diff --git a/packages/i18n/src/locales/lt.i18n.json b/packages/i18n/src/locales/lt.i18n.json index 3a866a790e77..350049212f85 100644 --- a/packages/i18n/src/locales/lt.i18n.json +++ b/packages/i18n/src/locales/lt.i18n.json @@ -2157,7 +2157,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Tai numatytasis kanalas ir pakeičia jį į privačią grupę, todėl jis nebebus numatytasis kanalas. Ar norite testi?", "Room_description_changed_successfully": "Kambario aprašymas sėkmingai pakeistas", "Room_has_been_archived": "Kambarys buvo archyvuojamas", - "Room_has_been_deleted": "Kambarys buvo ištrintas", "Room_has_been_unarchived": "Kambarys buvo unarchyvuotas", "Room_Info": "Kambarių informacija", "room_is_blocked": "Šis kambarys yra užblokuotas", diff --git a/packages/i18n/src/locales/lv.i18n.json b/packages/i18n/src/locales/lv.i18n.json index 697c5b577600..f74ff4a62af2 100644 --- a/packages/i18n/src/locales/lv.i18n.json +++ b/packages/i18n/src/locales/lv.i18n.json @@ -2113,7 +2113,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Šis ir noklusējuma kanāls, mainot to uz privātu grupu, tas vairs nebūs noklusējuma kanāls. Vai vēlaties turpināt?", "Room_description_changed_successfully": "Istabas apraksts ir veiksmīgi mainīts", "Room_has_been_archived": "Istaba ir arhivēta", - "Room_has_been_deleted": "Istaba ir dzēsta", "Room_has_been_unarchived": "Istaba ir izņemta no arhīva", "Room_Info": "Istabas informācija", "room_is_blocked": "Šī istaba ir bloķēta", diff --git a/packages/i18n/src/locales/mn.i18n.json b/packages/i18n/src/locales/mn.i18n.json index b8dd78554925..df3463de92a9 100644 --- a/packages/i18n/src/locales/mn.i18n.json +++ b/packages/i18n/src/locales/mn.i18n.json @@ -2097,7 +2097,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Энэ нь анхдагч суваг бөгөөд үүнийг хувийн бүлэгт өөрчилснөөр энэ нь анхдагч суваг байхаа больсон. Та үргэлжлүүлэхийг хүсч байна уу?", "Room_description_changed_successfully": "Өрөөний тодорхойлолт амжилттай болсон", "Room_has_been_archived": "Өрөө архивлагдсан байна", - "Room_has_been_deleted": "Өрөө устгагдсан байна", "Room_has_been_unarchived": "Өрөө нь албан ёсоор бүртгэгдээгүй байна", "Room_Info": "Өрөөний мэдээлэл", "room_is_blocked": "Энэ өрөө хаагдсан", diff --git a/packages/i18n/src/locales/ms-MY.i18n.json b/packages/i18n/src/locales/ms-MY.i18n.json index 4089904315cb..065170e2ee3b 100644 --- a/packages/i18n/src/locales/ms-MY.i18n.json +++ b/packages/i18n/src/locales/ms-MY.i18n.json @@ -2109,7 +2109,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Ini adalah saluran lalai dan mengubahnya ke kumpulan persendirian akan menyebabkan ia tidak lagi menjadi saluran lalai. Adakah anda mahu meneruskan?", "Room_description_changed_successfully": "Penerangan bilik berubah dengan jayanya", "Room_has_been_archived": "Bilik telah diarkibkan", - "Room_has_been_deleted": "Bilik telah dipadam", "Room_has_been_unarchived": "Bilik telah dirahsiakan", "Room_Info": "Maklumat bilik", "room_is_blocked": "Bilik ini disekat", diff --git a/packages/i18n/src/locales/nl.i18n.json b/packages/i18n/src/locales/nl.i18n.json index a5626058a8fe..93433dff0bee 100644 --- a/packages/i18n/src/locales/nl.i18n.json +++ b/packages/i18n/src/locales/nl.i18n.json @@ -3597,7 +3597,6 @@ "room_disallowed_reacting": "Reageren in kamer mag niet meer door {{user_by}}", "Room_Edit": "Kamer bewerken", "Room_has_been_archived": "Kamer is gearchiveerd", - "Room_has_been_deleted": "Kamer is verwijderd", "Room_has_been_removed": "Kamer werd verwijderd", "Room_has_been_unarchived": "Kamer is uit archief gehaald", "Room_Info": "Kamerinformatie", diff --git a/packages/i18n/src/locales/no.i18n.json b/packages/i18n/src/locales/no.i18n.json index 0397bf518f7c..769e58c1e6a8 100644 --- a/packages/i18n/src/locales/no.i18n.json +++ b/packages/i18n/src/locales/no.i18n.json @@ -3513,7 +3513,6 @@ "Room_has_been_archived": "Rom har blitt arkivert", "Room_has_been_converted": "Room er konvertert", "Room_has_been_created": "Room er opprettet", - "Room_has_been_deleted": "Rommet har blitt slettet", "Room_has_been_removed": "Room er fjernet", "Room_has_been_unarchived": "Rom har blitt arkivert", "Room_Info": "Rominformasjon", diff --git a/packages/i18n/src/locales/pl.i18n.json b/packages/i18n/src/locales/pl.i18n.json index 0d2325e2e47e..e6d04c3ae9c7 100644 --- a/packages/i18n/src/locales/pl.i18n.json +++ b/packages/i18n/src/locales/pl.i18n.json @@ -3926,7 +3926,6 @@ "Room_Edit": "Edycja Room", "Room_has_been_archived": "Pokój został zarchiwizowany", "Room_has_been_created": "Room został utworzony", - "Room_has_been_deleted": "Pokój został usunięty", "Room_has_been_removed": "Room został usunięty", "Room_has_been_unarchived": "Pokój został przywrócony", "Room_Info": "Ustawienia pokoju", diff --git a/packages/i18n/src/locales/pt-BR.i18n.json b/packages/i18n/src/locales/pt-BR.i18n.json index 4ae11215c126..5849d51cc5bb 100644 --- a/packages/i18n/src/locales/pt-BR.i18n.json +++ b/packages/i18n/src/locales/pt-BR.i18n.json @@ -3697,7 +3697,6 @@ "room_disallowed_reacting": "Permissão de reagir removida da sala por {{user_by}}", "Room_Edit": "Editar Sala", "Room_has_been_archived": "A sala foi arquivada", - "Room_has_been_deleted": "A sala foi excluída", "Room_has_been_removed": "Sala foi removida", "Room_has_been_unarchived": "A sala foi desarquivada", "Room_Info": "Informações da Sala", diff --git a/packages/i18n/src/locales/pt.i18n.json b/packages/i18n/src/locales/pt.i18n.json index d60379ef7ed9..c3bf9d739b21 100644 --- a/packages/i18n/src/locales/pt.i18n.json +++ b/packages/i18n/src/locales/pt.i18n.json @@ -2442,7 +2442,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Este é um canal padrão e mudá-lo para um grupo privado fará com que ele não seja mais um canal padrão. Você quer prosseguir?", "Room_description_changed_successfully": "A descrição da sala mudou com sucesso", "Room_has_been_archived": "A sala foi arquivada", - "Room_has_been_deleted": "A sala foi removida", "Room_has_been_unarchived": "A sala foi desarquivada", "Room_Info": "Informações da sala", "room_is_blocked": "Esta sala está bloqueada", diff --git a/packages/i18n/src/locales/ro.i18n.json b/packages/i18n/src/locales/ro.i18n.json index 21e950c87678..8f6974fde123 100644 --- a/packages/i18n/src/locales/ro.i18n.json +++ b/packages/i18n/src/locales/ro.i18n.json @@ -2101,7 +2101,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Acesta este un canal implicit și schimbarea acestuia într-un grup privat va determina ca acesta să nu mai fie un canal implicit. Doriți să continuați?", "Room_description_changed_successfully": "Descrierea camerei sa schimbat cu succes", "Room_has_been_archived": "Camera a fost arhivată", - "Room_has_been_deleted": "Camera a fost ștearsă", "Room_has_been_unarchived": "Camera a fost dezarhivată", "Room_Info": "Info cameră", "room_is_blocked": "Această cameră este blocată", diff --git a/packages/i18n/src/locales/ru.i18n.json b/packages/i18n/src/locales/ru.i18n.json index b014b6da9d56..affb21c147a0 100644 --- a/packages/i18n/src/locales/ru.i18n.json +++ b/packages/i18n/src/locales/ru.i18n.json @@ -3772,7 +3772,6 @@ "room_disallowed_reacting": "Пользователь {{user_by}} запретил реакции в комнате", "Room_Edit": "Редактировать чат", "Room_has_been_archived": "Комната была архивирована", - "Room_has_been_deleted": "Комната была удалена", "Room_has_been_removed": "Комната удалена", "Room_has_been_unarchived": "Канал был восстановлен", "Room_Info": "Информация о чате", diff --git a/packages/i18n/src/locales/sk-SK.i18n.json b/packages/i18n/src/locales/sk-SK.i18n.json index 8e80bd7a0aff..27100c086c67 100644 --- a/packages/i18n/src/locales/sk-SK.i18n.json +++ b/packages/i18n/src/locales/sk-SK.i18n.json @@ -2111,7 +2111,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Toto je predvolený kanál a jeho zmena na súkromnú skupinu spôsobí, že už nebude predvoleným kanálom. Chcete pokračovať?", "Room_description_changed_successfully": "Popis izby sa úspešne zmenil", "Room_has_been_archived": "Izba bola archivovaná", - "Room_has_been_deleted": "Miestnosť bola vymazaná", "Room_has_been_unarchived": "Izba bola zrušená", "Room_Info": "Informácie o izbe", "room_is_blocked": "Táto miestnosť je zablokovaná", diff --git a/packages/i18n/src/locales/sl-SI.i18n.json b/packages/i18n/src/locales/sl-SI.i18n.json index d08c083ee9ce..a3630f6ce6c6 100644 --- a/packages/i18n/src/locales/sl-SI.i18n.json +++ b/packages/i18n/src/locales/sl-SI.i18n.json @@ -2091,7 +2091,6 @@ "Room_default_change_to_private_will_be_default_no_more": "To je privzeti kanal. Če ga spremenite v zasebno skupino, ne bo več privzeti kanal. Želite nadaljevati?", "Room_description_changed_successfully": "Opis sobe je uspešno spremenjen", "Room_has_been_archived": "Soba je bila arhivirana", - "Room_has_been_deleted": "Soba je bila izbrisana", "Room_has_been_unarchived": "Soba je bila odarhivirana", "Room_Info": "Informacije o sobi", "room_is_blocked": "Ta soba je blokirana", diff --git a/packages/i18n/src/locales/sq.i18n.json b/packages/i18n/src/locales/sq.i18n.json index 80ff384d4cc4..73e0ca0a56ed 100644 --- a/packages/i18n/src/locales/sq.i18n.json +++ b/packages/i18n/src/locales/sq.i18n.json @@ -2101,7 +2101,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Ky është një kanal i parazgjedhur dhe ndryshimi i tij në një grup privat do të bëjë që ajo të mos jetë më një kanal i paracaktuar. A doni të vazhdoni?", "Room_description_changed_successfully": "Përshkrimi i dhomës u ndryshua me sukses", "Room_has_been_archived": "Dhoma është arkivuar", - "Room_has_been_deleted": "Dhoma është fshirë", "Room_has_been_unarchived": "Dhoma nuk është arkivuar", "Room_Info": "Room Info", "room_is_blocked": "Kjo dhomë është e bllokuar", diff --git a/packages/i18n/src/locales/sr.i18n.json b/packages/i18n/src/locales/sr.i18n.json index adec89c9568b..4a741a750d4f 100644 --- a/packages/i18n/src/locales/sr.i18n.json +++ b/packages/i18n/src/locales/sr.i18n.json @@ -1930,7 +1930,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Ово је подразумевани канал, а промена у приватну групу ће више не бити подразумевани канал. Да ли желите да наставите?", "Room_description_changed_successfully": "Опис собе се успешно промјенио", "Room_has_been_archived": "Соба је архивирана", - "Room_has_been_deleted": "Соба је избрисана", "Room_has_been_unarchived": "Соба је деархивирана", "Room_Info": "Информације о соби", "room_is_blocked": "Ова просторија је блокирана", diff --git a/packages/i18n/src/locales/sv.i18n.json b/packages/i18n/src/locales/sv.i18n.json index f8eb0c610896..56cc49003941 100644 --- a/packages/i18n/src/locales/sv.i18n.json +++ b/packages/i18n/src/locales/sv.i18n.json @@ -4149,7 +4149,6 @@ "Room_has_been_archived": "Rummet har arkiverats", "Room_has_been_converted": "Rum har konverterats", "Room_has_been_created": "Rum har skapats", - "Room_has_been_deleted": "Rummet har raderats", "Room_has_been_removed": "Rummet har tagits bort", "Room_has_been_unarchived": "Rummet har tagits bort från arkivet", "Room_Info": "Rumsinformation", diff --git a/packages/i18n/src/locales/ta-IN.i18n.json b/packages/i18n/src/locales/ta-IN.i18n.json index 4ee378f67319..7ae4d9a96653 100644 --- a/packages/i18n/src/locales/ta-IN.i18n.json +++ b/packages/i18n/src/locales/ta-IN.i18n.json @@ -2102,7 +2102,6 @@ "Room_default_change_to_private_will_be_default_no_more": "இது ஒரு இயல்புநிலை சேனலாகும், மேலும் அது ஒரு தனியார் குழுவாக மாற்றப்படுவதால் இனி இயல்புநிலை சேனலாக இருக்காது. நீங்கள் தொடர விரும்புகிறீர்களா?", "Room_description_changed_successfully": "அறை விளக்கம் வெற்றிகரமாக மாற்றப்பட்டது", "Room_has_been_archived": "அறை காப்பகப்படுத்தப்பட்டுள்ளது", - "Room_has_been_deleted": "அறை நீக்கப்பட்டுள்ளது", "Room_has_been_unarchived": "அறை அகற்றப்பட்டது", "Room_Info": "அறை தகவல்", "room_is_blocked": "இந்த அறை தடுக்கப்பட்டுள்ளது", diff --git a/packages/i18n/src/locales/th-TH.i18n.json b/packages/i18n/src/locales/th-TH.i18n.json index 996333840758..e9f3705b559f 100644 --- a/packages/i18n/src/locales/th-TH.i18n.json +++ b/packages/i18n/src/locales/th-TH.i18n.json @@ -2095,7 +2095,6 @@ "Room_default_change_to_private_will_be_default_no_more": "นี่เป็นช่องทางเริ่มต้นและเปลี่ยนเป็นกลุ่มส่วนตัวจะทำให้ช่องนี้ไม่ได้เป็นช่องเริ่มต้นอีกต่อไป คุณต้องการดำเนินการต่อหรือไม่?", "Room_description_changed_successfully": "เปลี่ยนรายละเอียดห้องเรียบร้อยแล้ว", "Room_has_been_archived": "ห้องพักได้รับการเก็บถาวรแล้ว", - "Room_has_been_deleted": "ห้องถูกลบแล้ว", "Room_has_been_unarchived": "ห้องพักได้รับการยกเลิกการเก็บถาวรแล้ว", "Room_Info": "ข้อมูลห้องพัก", "room_is_blocked": "ห้องนี้ถูกบล็อก", diff --git a/packages/i18n/src/locales/tr.i18n.json b/packages/i18n/src/locales/tr.i18n.json index c4899dab439e..fbfe4c1a1d8c 100644 --- a/packages/i18n/src/locales/tr.i18n.json +++ b/packages/i18n/src/locales/tr.i18n.json @@ -2506,7 +2506,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Bu varsayılan bir kanaldır ve özel bir grup olarak değiştirmek, artık varsayılan kanal olmamasına neden olacaktır. Devam etmek istiyor musunuz?", "Room_description_changed_successfully": "Oda açıklaması başarıyla değiştirildi", "Room_has_been_archived": "Oda arşivlendi", - "Room_has_been_deleted": "Oda silindi", "Room_has_been_unarchived": "Oda arşivden çıkarıldı", "Room_Info": "Oda Bilgileri", "room_is_blocked": "Bu oda engellendi", diff --git a/packages/i18n/src/locales/ug.i18n.json b/packages/i18n/src/locales/ug.i18n.json index ba8077844663..78c3650e0038 100644 --- a/packages/i18n/src/locales/ug.i18n.json +++ b/packages/i18n/src/locales/ug.i18n.json @@ -903,7 +903,6 @@ "Room_archived": "ئۆي ئاللىبۇرۇن تۈرگە ئايرىپ ساقلاندى", "room_changed_privacy": "{{room_type}}ئۆينىڭ تىپىنى بۇنداق ئۆزگەرتتى :{{user_by}}", "room_changed_topic": "{{room_topic}} ئۆينىڭ تېمىسىنى بۇنداق قىلىپ ئۆزگەرتتى:{{user_by}}", - "Room_has_been_deleted": "ئۆي ئاللىبۇرۇن يۇيۇلدى", "Room_Info": "ئۆينىڭ ئۇچۇرى", "Room_name_changed": "تىن ئۆزگەرتىلدى. {{user_by}} ،{{room_name}} پاراڭلىشىش ئۆينىڭ ئىسمى بۇنداق ئۆزگەرتىلدى", "Room_name_changed_successfully": "پاراڭلىشىش ئۆيىنىڭ ئىسمى ئۇتۇقلۇق ئۆزگەرتىلدى", diff --git a/packages/i18n/src/locales/uk.i18n.json b/packages/i18n/src/locales/uk.i18n.json index 94d470171761..0ed157a736c3 100644 --- a/packages/i18n/src/locales/uk.i18n.json +++ b/packages/i18n/src/locales/uk.i18n.json @@ -2639,7 +2639,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Це типовий канал і зміна його на приватну групу призведе до того, що він більше не буде каналом за умовчанням. Ви хочете продовжити?", "Room_description_changed_successfully": "Опис номера змінено успішно", "Room_has_been_archived": "Кімната була заархівована", - "Room_has_been_deleted": "Номер був видалений", "Room_has_been_unarchived": "Номер було неархівовано", "Room_Info": "опис кімнати", "room_is_blocked": "Цей номер заблоковано", diff --git a/packages/i18n/src/locales/vi-VN.i18n.json b/packages/i18n/src/locales/vi-VN.i18n.json index a2fc50733b1e..21fc37f11f01 100644 --- a/packages/i18n/src/locales/vi-VN.i18n.json +++ b/packages/i18n/src/locales/vi-VN.i18n.json @@ -2201,7 +2201,6 @@ "Room_default_change_to_private_will_be_default_no_more": "Đây là kênh mặc định và thay đổi kênh đó thành một nhóm riêng sẽ khiến kênh đó không còn là kênh mặc định. Bạn có muốn tiếp tục?", "Room_description_changed_successfully": "Mô tả phòng đã được thay đổi thành công", "Room_has_been_archived": "Phòng đã được lưu trữ", - "Room_has_been_deleted": "Phòng đã bị xóa", "Room_has_been_unarchived": "Phòng chưa được lưu trữ", "Room_Info": "Thông tin phòng", "room_is_blocked": "Phòng này bị chặn", diff --git a/packages/i18n/src/locales/zh-HK.i18n.json b/packages/i18n/src/locales/zh-HK.i18n.json index 2f416acc4f2f..fa44a7fed369 100644 --- a/packages/i18n/src/locales/zh-HK.i18n.json +++ b/packages/i18n/src/locales/zh-HK.i18n.json @@ -2123,7 +2123,6 @@ "Room_default_change_to_private_will_be_default_no_more": "这是一个默认频道,并将其更改为专用群组将使其不再是默认频道。你想继续吗?", "Room_description_changed_successfully": "房间描述已成功更改", "Room_has_been_archived": "房间已存档", - "Room_has_been_deleted": "房间已被删除", "Room_has_been_unarchived": "房间已被取消存档", "Room_Info": "客房信息", "room_is_blocked": "这个房间被封锁了", diff --git a/packages/i18n/src/locales/zh-TW.i18n.json b/packages/i18n/src/locales/zh-TW.i18n.json index 1b9cbed4a5ee..d15e7739f226 100644 --- a/packages/i18n/src/locales/zh-TW.i18n.json +++ b/packages/i18n/src/locales/zh-TW.i18n.json @@ -3478,7 +3478,6 @@ "Room_default_change_to_private_will_be_default_no_more": "這是一個預設頻道,並將其更改為專用群組將使其不再是預設頻道。你想繼續嗎?", "Room_description_changed_successfully": "Room 描述已成功更改", "Room_has_been_archived": "Room 已封存", - "Room_has_been_deleted": "Room 已經被刪除", "Room_has_been_unarchived": "Room 已被取消封存", "Room_Info": "Room 資訊", "room_is_blocked": "這個房間被封鎖了", diff --git a/packages/i18n/src/locales/zh.i18n.json b/packages/i18n/src/locales/zh.i18n.json index 6929e3eeab8d..b5a972637d81 100644 --- a/packages/i18n/src/locales/zh.i18n.json +++ b/packages/i18n/src/locales/zh.i18n.json @@ -3150,7 +3150,6 @@ "Room_default_change_to_private_will_be_default_no_more": "将默认频道更改为私人组将使其不再是默认频道。您想继续吗?", "Room_description_changed_successfully": "聊天室描述修改成功", "Room_has_been_archived": "聊天室已归档", - "Room_has_been_deleted": "聊天室已被删除", "Room_has_been_unarchived": "已撤销Room归档", "Room_Info": "聊天室信息", "room_is_blocked": "这个聊天室已被屏蔽", From b615b24ace95dc10d011bbde13b249220fba687d Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Thu, 4 Apr 2024 16:00:58 -0300 Subject: [PATCH 027/131] chore: changes to SAML login in anticipation of meteor 3 (#32134) --- .../client/views/root/SAMLLoginRoute.tsx | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/meteor/client/views/root/SAMLLoginRoute.tsx b/apps/meteor/client/views/root/SAMLLoginRoute.tsx index 9b98db663469..5bf205c1fa2a 100644 --- a/apps/meteor/client/views/root/SAMLLoginRoute.tsx +++ b/apps/meteor/client/views/root/SAMLLoginRoute.tsx @@ -1,4 +1,4 @@ -import { useRouter, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useRouter, useToastMessageDispatch, useUserId } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import { useEffect } from 'react'; @@ -12,16 +12,23 @@ const SAMLLoginRoute = () => { if (error) { dispatchToastMessage({ type: 'error', message: error }); } - - router.navigate( - { - pathname: '/home', - }, - { replace: true }, - ); }); }, [dispatchToastMessage, router]); + const userId = useUserId(); + useEffect(() => { + if (!userId) { + return; + } + + router.navigate( + { + pathname: '/home', + }, + { replace: true }, + ); + }, [userId, router]); + return null; }; From ec544d6803c328ff316de323eefdc23776c0a9e9 Mon Sep 17 00:00:00 2001 From: Martin Schoeler Date: Thu, 4 Apr 2024 19:35:19 -0300 Subject: [PATCH 028/131] fix: livechat room desync on different windows (#32135) --- .changeset/wild-keys-obey.md | 5 +++++ packages/livechat/src/lib/room.js | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/wild-keys-obey.md diff --git a/.changeset/wild-keys-obey.md b/.changeset/wild-keys-obey.md new file mode 100644 index 000000000000..9de92ee5671b --- /dev/null +++ b/.changeset/wild-keys-obey.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/livechat": patch +--- + +Fixes issue causing a desync in different browser windows when a chat is closed and started again diff --git a/packages/livechat/src/lib/room.js b/packages/livechat/src/lib/room.js index 20d86d22a5cc..2dc6a93074b2 100644 --- a/packages/livechat/src/lib/room.js +++ b/packages/livechat/src/lib/room.js @@ -348,7 +348,7 @@ export const defaultRoomParams = () => { store.on('change', ([state, prevState]) => { // Cross-tab communication // Detects when a room is created and then route to the correct container - if (!prevState.room && state.room) { + if (prevState.room?._id !== state.room?._id) { route('/'); } }); From e5bbf83adf12bec7d7962294cfe980955a77f92b Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Fri, 5 Apr 2024 10:51:44 -0300 Subject: [PATCH 029/131] fix: Missing space between name and user name on system messages (#32136) --- .changeset/smart-squids-begin.md | 5 +++++ .../client/components/message/variants/SystemMessage.tsx | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 .changeset/smart-squids-begin.md diff --git a/.changeset/smart-squids-begin.md b/.changeset/smart-squids-begin.md new file mode 100644 index 000000000000..48f3f460ea7e --- /dev/null +++ b/.changeset/smart-squids-begin.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes the missing space between name and user name on system messages diff --git a/apps/meteor/client/components/message/variants/SystemMessage.tsx b/apps/meteor/client/components/message/variants/SystemMessage.tsx index d69b1ada10dc..9b1a82a156eb 100644 --- a/apps/meteor/client/components/message/variants/SystemMessage.tsx +++ b/apps/meteor/client/components/message/variants/SystemMessage.tsx @@ -85,7 +85,12 @@ const SystemMessage = ({ message, showUserAvatar, ...props }: SystemMessageProps {...triggerProps} > {getUserDisplayName(user.name, user.username, showRealName)} - {showUsername && @{user.username}} + {showUsername && ( + <> + {' '} + @{user.username} + + )} {messageType && ( Date: Fri, 5 Apr 2024 10:58:12 -0300 Subject: [PATCH 030/131] feat: `Contextualbar` resizable (#29461) Co-authored-by: Guilherme Gazzo --- .changeset/good-ghosts-doubt.md | 5 +++ .../Contextualbar/ContextualbarDialog.tsx | 13 +++++- .../Contextualbar/ContextualbarResizable.tsx | 40 +++++++++++++++++++ .../directory/CallsContextualBarDirectory.tsx | 6 +-- .../VideoConfList/VideoConfListItem.tsx | 2 +- apps/meteor/package.json | 1 + packages/i18n/src/locales/en.i18n.json | 2 + .../src/hooks/useFeaturePreviewList.ts | 10 ++++- yarn.lock | 11 +++++ 9 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 .changeset/good-ghosts-doubt.md create mode 100644 apps/meteor/client/components/Contextualbar/ContextualbarResizable.tsx diff --git a/.changeset/good-ghosts-doubt.md b/.changeset/good-ghosts-doubt.md new file mode 100644 index 000000000000..5f4ed8f5a36d --- /dev/null +++ b/.changeset/good-ghosts-doubt.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Introduces a resizable Contextualbar allowing users to change the width just by dragging it diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarDialog.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarDialog.tsx index 087149bd4a4e..c3421f3fc9d3 100644 --- a/apps/meteor/client/components/Contextualbar/ContextualbarDialog.tsx +++ b/apps/meteor/client/components/Contextualbar/ContextualbarDialog.tsx @@ -1,4 +1,5 @@ import { Contextualbar } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; import { useLayoutSizes, useLayoutContextualBarPosition } from '@rocket.chat/ui-contexts'; import type { ComponentProps, KeyboardEvent } from 'react'; import React, { useCallback, useRef } from 'react'; @@ -6,6 +7,7 @@ import type { AriaDialogProps } from 'react-aria'; import { FocusScope, useDialog } from 'react-aria'; import { useRoomToolbox } from '../../views/room/contexts/RoomToolboxContext'; +import ContextualbarResizable from './ContextualbarResizable'; type ContextualbarDialogProps = AriaDialogProps & ComponentProps; @@ -38,7 +40,16 @@ const ContextualbarDialog = (props: ContextualbarDialogProps) => { return ( - + + + + + + + + + + ); }; diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarResizable.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarResizable.tsx new file mode 100644 index 000000000000..05dd9bf0cf2e --- /dev/null +++ b/apps/meteor/client/components/Contextualbar/ContextualbarResizable.tsx @@ -0,0 +1,40 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Palette, Box } from '@rocket.chat/fuselage'; +import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; +import { Resizable } from 're-resizable'; +import type { ComponentProps } from 'react'; +import React from 'react'; + +type ContextualbarResizableProps = { defaultWidth: string } & ComponentProps; + +const ContextualbarResizable = ({ defaultWidth, children, ...props }: ContextualbarResizableProps) => { + const [contextualbarWidth, setContextualbarWidth] = useLocalStorage('contextualbarWidth', defaultWidth); + const handleStyle = css` + height: 100%; + &:hover { + background-color: ${Palette.stroke['stroke-highlight']}; + } + `; + + return ( + { + setContextualbarWidth(elRef.style.width); + }} + defaultSize={{ + width: contextualbarWidth, + height: '100%', + }} + minWidth={defaultWidth} + maxWidth='50%' + minHeight='100%' + handleStyles={{ left: { width: '3px', zIndex: '5', left: 0 } }} + handleComponent={{ left: }} + > + {children} + + ); +}; + +export default ContextualbarResizable; diff --git a/apps/meteor/client/views/omnichannel/directory/CallsContextualBarDirectory.tsx b/apps/meteor/client/views/omnichannel/directory/CallsContextualBarDirectory.tsx index 010bb5f65517..bf35c50a6bf3 100644 --- a/apps/meteor/client/views/omnichannel/directory/CallsContextualBarDirectory.tsx +++ b/apps/meteor/client/views/omnichannel/directory/CallsContextualBarDirectory.tsx @@ -20,7 +20,7 @@ const CallsContextualBarDirectory: FC = () => { const t = useTranslation(); - const handleCallsContextualbarCloseButtonClick = (): void => { + const handleClose = (): void => { directoryRoute.push({ page: 'calls' }); }; @@ -52,9 +52,7 @@ const CallsContextualBarDirectory: FC = () => { const room = data.room as unknown as IVoipRoom; // TODO Check why types are incompatible even though the endpoint returns an IVoipRooms - return ( - {bar === 'info' && } - ); + return {bar === 'info' && }; }; export default CallsContextualBarDirectory; diff --git a/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfListItem.tsx b/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfListItem.tsx index 0e155bc2b0b6..194380f2955e 100644 --- a/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfListItem.tsx +++ b/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfListItem.tsx @@ -54,7 +54,7 @@ const VideoConfListItem = ({ return ( feature.enabled); diff --git a/yarn.lock b/yarn.lock index ba550482d119..8993ef8f84cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9465,6 +9465,7 @@ __metadata: query-string: ^7.1.3 queue-fifo: ^0.2.6 rc-scrollbars: ^1.1.6 + re-resizable: ^6.9.9 react: ~17.0.2 react-aria: ~3.23.1 react-docgen-typescript-plugin: ^1.0.5 @@ -34700,6 +34701,16 @@ __metadata: languageName: node linkType: hard +"re-resizable@npm:^6.9.9": + version: 6.9.9 + resolution: "re-resizable@npm:6.9.9" + peerDependencies: + react: ^16.13.1 || ^17.0.0 || ^18.0.0 + react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0 + checksum: a2c8bfe86646fb02d5c9c624b1da26f9e6a5e2f552cd96ce4db690588bee6b21177065ce8e98646c6ca0b1a9c4ce233824b75eb346800d8248ac8a87b40f1b28 + languageName: node + linkType: hard + "react-aria@npm:~3.23.1": version: 3.23.1 resolution: "react-aria@npm:3.23.1" From 11d65a546cfb8207874674ec6a8d9193cf8d7e64 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Fri, 5 Apr 2024 12:21:04 -0300 Subject: [PATCH 031/131] ci: fix services container not building in build step (#32075) --- .github/actions/build-docker/action.yml | 16 ++++++---------- .github/workflows/ci-test-e2e.yml | 8 ++++++++ .github/workflows/ci.yml | 4 ++-- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/.github/actions/build-docker/action.yml b/.github/actions/build-docker/action.yml index 284a0985b78e..364957ecdf01 100644 --- a/.github/actions/build-docker/action.yml +++ b/.github/actions/build-docker/action.yml @@ -13,6 +13,10 @@ inputs: required: false description: 'Platform' type: string + build-containers: + required: false + description: 'Containers to build along with Rocket.Chat' + type: string runs: using: composite @@ -54,11 +58,7 @@ runs: - name: Build Docker images shell: bash run: | - args=(rocketchat) - - if [[ '${{ inputs.platform }}' = 'alpine' ]]; then - args+=($SERVICES_PUBLISH) - fi; + args=(rocketchat ${{ inputs.build-containers }}) docker compose -f docker-compose-ci.yml build "${args[@]}" @@ -66,10 +66,6 @@ runs: if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') shell: bash run: | - args=(rocketchat) - - if [[ '${{ inputs.platform }}' = 'alpine' ]]; then - args+=($SERVICES_PUBLISH) - fi; + args=(rocketchat ${{ inputs.build-containers }}) docker compose -f docker-compose-ci.yml push "${args[@]}" diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml index 1d953b9eb01f..12bb361d76f2 100644 --- a/.github/workflows/ci-test-e2e.yml +++ b/.github/workflows/ci-test-e2e.yml @@ -86,6 +86,14 @@ jobs: name: MongoDB ${{ matrix.mongodb-version }} (${{ matrix.shard }}/${{ inputs.total-shard }}) steps: + - name: Login to GitHub Container Registry + if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} + - name: Launch MongoDB uses: supercharge/mongodb-github-action@v1.10.0 with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a78ca940d03..51e034505a85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -219,7 +219,6 @@ jobs: RC_DOCKER_TAG: ${{ matrix.platform == 'alpine' && needs.release-versions.outputs.rc-docker-tag-alpine || needs.release-versions.outputs.rc-docker-tag }} DOCKER_TAG: ${{ needs.release-versions.outputs.gh-docker-tag }} LOWERCASE_REPOSITORY: ${{ needs.release-versions.outputs.lowercase-repo }} - SERVICES_PUBLISH: 'authorization-service account-service ddp-streamer-service presence-service stream-hub-service' strategy: fail-fast: false @@ -237,6 +236,7 @@ jobs: CR_PAT: ${{ secrets.CR_PAT }} node-version: ${{ needs.release-versions.outputs.node-version }} platform: ${{ matrix.platform }} + build-containers: ${{ matrix.platform == 'alpine' && 'authorization-service account-service ddp-streamer-service presence-service stream-hub-service queue-worker-service omnichannel-transcript-service' || '' }} build-gh-docker: name: 🚢 Build Docker Images for Production @@ -248,7 +248,6 @@ jobs: RC_DOCKER_TAG: ${{ matrix.platform == 'alpine' && needs.release-versions.outputs.rc-docker-tag-alpine || needs.release-versions.outputs.rc-docker-tag }} DOCKER_TAG: ${{ needs.release-versions.outputs.gh-docker-tag }} LOWERCASE_REPOSITORY: ${{ needs.release-versions.outputs.lowercase-repo }} - SERVICES_PUBLISH: 'authorization-service account-service ddp-streamer-service presence-service stream-hub-service' strategy: fail-fast: false @@ -264,6 +263,7 @@ jobs: CR_PAT: ${{ secrets.CR_PAT }} node-version: ${{ needs.release-versions.outputs.node-version }} platform: ${{ matrix.platform }} + build-containers: ${{ matrix.platform == 'alpine' && 'authorization-service account-service ddp-streamer-service presence-service stream-hub-service queue-worker-service omnichannel-transcript-service' || '' }} - name: Rename official Docker tag to GitHub Container Registry if: matrix.platform == 'official' From 4aba7c8a263e11ba04f8c82322fe493850b3912e Mon Sep 17 00:00:00 2001 From: Allan RIbeiro <35040806+AllanPazRibeiro@users.noreply.github.com> Date: Fri, 5 Apr 2024 13:20:07 -0300 Subject: [PATCH 032/131] feat: allowing forward to offline dep (#31976) Co-authored-by: Guilherme Gazzo --- .changeset/strange-rivers-live.md | 8 + apps/meteor/app/livechat/server/lib/Helper.ts | 23 ++- .../app/livechat/server/lib/RoutingManager.ts | 8 +- .../livechat/server/methods/saveDepartment.ts | 1 + .../departments/EditDepartment.tsx | 14 ++ .../server/lib/LivechatEnterprise.ts | 1 + apps/meteor/tests/data/livechat/department.ts | 52 +++++-- apps/meteor/tests/data/livechat/users.ts | 20 ++- .../tests/end-to-end/api/livechat/00-rooms.ts | 138 +++++++++++++++++- .../end-to-end/api/livechat/10-departments.ts | 13 +- .../core-typings/src/ILivechatDepartment.ts | 2 + packages/i18n/src/locales/en.i18n.json | 2 + 12 files changed, 260 insertions(+), 22 deletions(-) create mode 100644 .changeset/strange-rivers-live.md diff --git a/.changeset/strange-rivers-live.md b/.changeset/strange-rivers-live.md new file mode 100644 index 000000000000..b1ebd05c284d --- /dev/null +++ b/.changeset/strange-rivers-live.md @@ -0,0 +1,8 @@ +--- +'@rocket.chat/core-typings': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Added support for allowing agents to forward inquiries to departments that may not have any online agents given that `Allow department to receive forwarded inquiries even when there's no available agents` is set to `true` in the department configuration. +This configuration empowers agents to seamlessly direct incoming requests to the designated department, ensuring efficient handling of queries even when departmental resources are not actively online. When an agent becomes available, any pending inquiries will be automatically routed to them if the routing algorithm supports it. diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index 3f9a555d6b86..3c1d601250c1 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -539,10 +539,24 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi agent = { agentId, username }; } - if (!RoutingManager.getConfig()?.autoAssignAgent || !(await Omnichannel.isWithinMACLimit(room))) { + const department = await LivechatDepartment.findOneById< + Pick + >(departmentId, { + projection: { + allowReceiveForwardOffline: 1, + fallbackForwardDepartment: 1, + name: 1, + }, + }); + + if ( + !RoutingManager.getConfig()?.autoAssignAgent || + !(await Omnichannel.isWithinMACLimit(room)) || + (department?.allowReceiveForwardOffline && !(await LivechatTyped.checkOnlineAgents(departmentId))) + ) { logger.debug(`Room ${room._id} will be on department queue`); await LivechatTyped.saveTransferHistory(room, transferData); - return RoutingManager.unassignAgent(inquiry, departmentId); + return RoutingManager.unassignAgent(inquiry, departmentId, true); } // Fake the department to forward the inquiry - Case the forward process does not success @@ -559,11 +573,6 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi const { servedBy, chatQueued } = roomTaken; if (!chatQueued && oldServedBy && servedBy && oldServedBy._id === servedBy._id) { - const department = departmentId - ? await LivechatDepartment.findOneById>(departmentId, { - projection: { fallbackForwardDepartment: 1, name: 1 }, - }) - : null; if (!department?.fallbackForwardDepartment?.length) { logger.debug(`Cannot forward room ${room._id}. Chat assigned to agent ${servedBy._id} (Previous was ${oldServedBy._id})`); throw new Error('error-no-agents-online-in-department'); diff --git a/apps/meteor/app/livechat/server/lib/RoutingManager.ts b/apps/meteor/app/livechat/server/lib/RoutingManager.ts index 051053f761b1..5f0a458dc314 100644 --- a/apps/meteor/app/livechat/server/lib/RoutingManager.ts +++ b/apps/meteor/app/livechat/server/lib/RoutingManager.ts @@ -46,7 +46,7 @@ type Routing = { options?: { clientAction?: boolean; forwardingToDepartment?: { oldDepartmentId?: string; transferData?: any } }, ): Promise<(IOmnichannelRoom & { chatQueued?: boolean }) | null | void>; assignAgent(inquiry: InquiryWithAgentInfo, agent: SelectedAgent): Promise; - unassignAgent(inquiry: ILivechatInquiryRecord, departmentId?: string): Promise; + unassignAgent(inquiry: ILivechatInquiryRecord, departmentId?: string, shouldQueue?: boolean): Promise; takeInquiry( inquiry: Omit< ILivechatInquiryRecord, @@ -158,7 +158,7 @@ export const RoutingManager: Routing = { return inquiry; }, - async unassignAgent(inquiry, departmentId) { + async unassignAgent(inquiry, departmentId, shouldQueue = false) { const { rid, department } = inquiry; const room = await LivechatRooms.findOneById(rid); @@ -181,6 +181,10 @@ export const RoutingManager: Routing = { const { servedBy } = room; + if (shouldQueue) { + await LivechatInquiry.queueInquiry(inquiry._id); + } + if (servedBy) { await LivechatRooms.removeAgentByRoomId(rid); await this.removeAllRoomSubscriptions(room); diff --git a/apps/meteor/app/livechat/server/methods/saveDepartment.ts b/apps/meteor/app/livechat/server/methods/saveDepartment.ts index dd83a294cb0e..45b3b2ec2168 100644 --- a/apps/meteor/app/livechat/server/methods/saveDepartment.ts +++ b/apps/meteor/app/livechat/server/methods/saveDepartment.ts @@ -21,6 +21,7 @@ declare module '@rocket.chat/ui-contexts' { chatClosingTags?: string[]; fallbackForwardDepartment?: string; departmentsAllowedToForward?: string[]; + allowReceiveForwardOffline?: boolean; }, departmentAgents?: | { diff --git a/apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx b/apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx index 0f64e41d242f..30cad4142ff1 100644 --- a/apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx +++ b/apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx @@ -73,6 +73,7 @@ export type FormValues = { fallbackForwardDepartment: string; agentList: IDepartmentAgent[]; chatClosingTags: string[]; + allowReceiveForwardOffline: boolean; }; function withDefault(key: T | undefined | null, defaultValue: T) { @@ -96,6 +97,7 @@ const getInitialValues = ({ department, agents, allowedToForwardData }: InitialV fallbackForwardDepartment: withDefault(department?.fallbackForwardDepartment, ''), chatClosingTags: department?.chatClosingTags ?? [], agentList: agents || [], + allowReceiveForwardOffline: withDefault(department?.allowReceiveForwardOffline, false), }); function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmentProps) { @@ -151,6 +153,7 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen waitingQueueMessage, departmentsAllowedToForward, fallbackForwardDepartment, + allowReceiveForwardOffline, } = data; const payload = { @@ -169,6 +172,7 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen waitingQueueMessage, departmentsAllowedToForward: departmentsAllowedToForward?.map((dep) => dep.value), fallbackForwardDepartment, + allowReceiveForwardOffline, }; try { @@ -214,6 +218,7 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen const fallbackForwardDepartmentField = useUniqueId(); const requestTagBeforeClosingChatField = useUniqueId(); const chatClosingTagsField = useUniqueId(); + const allowReceiveForwardOffline = useUniqueId(); return ( @@ -424,6 +429,15 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen + + + {t('Accept_receive_inquiry_no_online_agents')} + + + + {t('Accept_receive_inquiry_no_online_agents_Hint')} + + {requestTagBeforeClosingChat && ( diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.ts index 6d0408dffc91..0e6a51cd0ff6 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.ts @@ -232,6 +232,7 @@ export const LivechatEnterprise = { chatClosingTags: Match.Optional([String]), fallbackForwardDepartment: Match.Optional(String), departmentsAllowedToForward: Match.Optional([String]), + allowReceiveForwardOffline: Match.Optional(Boolean), }; // The Livechat Form department support addition/custom fields, so those fields need to be added before validating diff --git a/apps/meteor/tests/data/livechat/department.ts b/apps/meteor/tests/data/livechat/department.ts index 3d18f9c394b9..ba0df137b567 100644 --- a/apps/meteor/tests/data/livechat/department.ts +++ b/apps/meteor/tests/data/livechat/department.ts @@ -3,7 +3,7 @@ import { expect } from 'chai'; import type { ILivechatDepartment, IUser, LivechatDepartmentDTO } from '@rocket.chat/core-typings'; import { api, credentials, methodCall, request } from '../api-data'; import { IUserCredentialsHeader } from '../user'; -import { createAnOnlineAgent } from './users'; +import { createAnOnlineAgent, createAnOfflineAgent } from './users'; import { WithRequiredProperty } from './utils'; export const NewDepartmentData = ((): Partial => ({ @@ -29,7 +29,9 @@ export const updateDepartment = async (departmentId: string, departmentData: Par return response.body.department; }; -export const createDepartmentWithMethod = (initialAgents: { agentId: string, username: string }[] = []) => +export const createDepartmentWithMethod = ( + initialAgents: { agentId: string, username: string }[] = [], + allowReceiveForwardOffline = false) => new Promise((resolve, reject) => { request .post(methodCall('livechat:saveDepartment')) @@ -37,14 +39,19 @@ new Promise((resolve, reject) => { .send({ message: JSON.stringify({ method: 'livechat:saveDepartment', - params: ['', { - enabled: true, - email: faker.internet.email(), - showOnRegistration: true, - showOnOfflineForm: true, - name: `new department ${Date.now()}`, - description: 'created from api', - }, initialAgents], + params: [ + '', + { + enabled: true, + email: faker.internet.email(), + showOnRegistration: true, + showOnOfflineForm: true, + name: `new department ${Date.now()}`, + description: 'created from api', + allowReceiveForwardOffline, + }, + initialAgents, + ], id: 'id', msg: 'method', }), @@ -102,6 +109,31 @@ export const addOrRemoveAgentFromDepartment = async (departmentId: string, agent throw new Error('Failed to add or remove agent from department. Status code: ' + response.status + '\n' + response.body); } } +export const createDepartmentWithAnOfflineAgent = async ({ + allowReceiveForwardOffline = false, +}: { + allowReceiveForwardOffline: boolean; +}): Promise<{ + department: ILivechatDepartment; + agent: { + credentials: IUserCredentialsHeader; + user: WithRequiredProperty; + }; +}> => { + const { user, credentials } = await createAnOfflineAgent(); + + const department = (await createDepartmentWithMethod(undefined, allowReceiveForwardOffline)) as ILivechatDepartment; + + await addOrRemoveAgentFromDepartment(department._id, { agentId: user._id, username: user.username }, true); + + return { + department, + agent: { + credentials, + user, + }, + }; +}; export const archiveDepartment = async (departmentId: string): Promise => { await request.post(api(`livechat/department/${ departmentId }/archive`)).set(credentials).expect(200); diff --git a/apps/meteor/tests/data/livechat/users.ts b/apps/meteor/tests/data/livechat/users.ts index 3a21cbee923c..161c20749b6c 100644 --- a/apps/meteor/tests/data/livechat/users.ts +++ b/apps/meteor/tests/data/livechat/users.ts @@ -2,7 +2,7 @@ import { faker } from "@faker-js/faker"; import type { ILivechatAgent, IUser } from "@rocket.chat/core-typings"; import { IUserCredentialsHeader, password } from "../user"; import { createUser, login } from "../users.helper"; -import { createAgent, makeAgentAvailable } from "./rooms"; +import { createAgent, makeAgentAvailable, makeAgentUnavailable } from "./rooms"; import { api, credentials, request } from "../api-data"; export const createBotAgent = async (): Promise<{ @@ -57,3 +57,21 @@ export const createAnOnlineAgent = async (): Promise<{ user: agent, }; } + +export const createAnOfflineAgent = async (): Promise<{ + credentials: IUserCredentialsHeader; + user: IUser & { username: string }; +}> => { + const username = `user.test.${Date.now()}.offline`; + const email = `${username}.offline@rocket.chat`; + const { body } = await request.post(api('users.create')).set(credentials).send({ email, name: username, username, password }); + const agent = body.user; + const createdUserCredentials = await login(agent.username, password); + await createAgent(agent.username); + await makeAgentUnavailable(createdUserCredentials); + + return { + credentials: createdUserCredentials, + user: agent, + }; +}; \ No newline at end of file diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index 0d9e5fff0a65..e99c893abf9a 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -18,7 +18,7 @@ import type { Response } from 'supertest'; import type { SuccessResult } from '../../../../app/api/server/definition'; import { getCredentials, api, request, credentials, methodCall } from '../../../data/api-data'; import { createCustomField } from '../../../data/livechat/custom-fields'; -import { createDepartmentWithAnOnlineAgent } from '../../../data/livechat/department'; +import { createDepartmentWithAnOfflineAgent, createDepartmentWithAnOnlineAgent, deleteDepartment } from '../../../data/livechat/department'; import { createSLA, getRandomPriority } from '../../../data/livechat/priorities'; import { createVisitor, @@ -32,6 +32,7 @@ import { closeOmnichannelRoom, createDepartment, fetchMessages, + makeAgentUnavailable, } from '../../../data/livechat/rooms'; import { saveTags } from '../../../data/livechat/tags'; import type { DummyResponse } from '../../../data/livechat/utils'; @@ -700,6 +701,35 @@ describe('LIVECHAT - rooms', function () { await deleteUser(initialAgentAssignedToChat); await deleteUser(forwardChatToUser); }); + + (IS_EE ? it : it.skip)('should return error message when transferred to a offline agent', async () => { + await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); + const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); + const { department: forwardToOfflineDepartment } = await createDepartmentWithAnOfflineAgent({ allowReceiveForwardOffline: false }); + + const newVisitor = await createVisitor(initialDepartment._id); + const newRoom = await createLivechatRoom(newVisitor.token); + + await request + .post(api('livechat/room.forward')) + .set(credentials) + .send({ + roomId: newRoom._id, + departmentId: forwardToOfflineDepartment._id, + clientAction: true, + comment: 'test comment', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-no-agents-online-in-department'); + }); + + await deleteDepartment(initialDepartment._id); + await deleteDepartment(forwardToOfflineDepartment._id); + }); + (IS_EE ? it : it.skip)('should return a success message when transferred successfully to a department', async () => { const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); const { department: forwardToDepartment } = await createDepartmentWithAnOnlineAgent(); @@ -734,6 +764,112 @@ describe('LIVECHAT - rooms', function () { expect((latestRoom.lastMessage as any)?.transferData?.scope).to.be.equal('department'); expect((latestRoom.lastMessage as any)?.transferData?.nextDepartment?._id).to.be.equal(forwardToDepartment._id); }); + (IS_EE ? it : it.skip)( + 'should return a success message when transferred successfully to an offline department when the department accepts it', + async () => { + const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); + const { department: forwardToOfflineDepartment } = await createDepartmentWithAnOfflineAgent({ allowReceiveForwardOffline: true }); + + const newVisitor = await createVisitor(initialDepartment._id); + const newRoom = await createLivechatRoom(newVisitor.token); + + await request + .post(api('livechat/room.forward')) + .set(credentials) + .send({ + roomId: newRoom._id, + departmentId: forwardToOfflineDepartment._id, + clientAction: true, + comment: 'test comment', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + await deleteDepartment(initialDepartment._id); + await deleteDepartment(forwardToOfflineDepartment._id); + }, + ); + (IS_EE ? it : it.skip)('inquiry should be taken automatically when agent on department is online again', async () => { + await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); + const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); + const { department: forwardToOfflineDepartment } = await createDepartmentWithAnOfflineAgent({ allowReceiveForwardOffline: true }); + + const newVisitor = await createVisitor(initialDepartment._id); + const newRoom = await createLivechatRoom(newVisitor.token); + + await request.post(api('livechat/room.forward')).set(credentials).send({ + roomId: newRoom._id, + departmentId: forwardToOfflineDepartment._id, + clientAction: true, + comment: 'test comment', + }); + + await makeAgentAvailable(); + + const latestRoom = await getLivechatRoomInfo(newRoom._id); + + expect(latestRoom).to.have.property('departmentId'); + expect(latestRoom.departmentId).to.be.equal(forwardToOfflineDepartment._id); + + expect(latestRoom).to.have.property('lastMessage'); + expect(latestRoom.lastMessage?.t).to.be.equal('livechat_transfer_history'); + expect(latestRoom.lastMessage?.u?.username).to.be.equal(adminUsername); + expect((latestRoom.lastMessage as any)?.transferData?.comment).to.be.equal('test comment'); + expect((latestRoom.lastMessage as any)?.transferData?.scope).to.be.equal('department'); + expect((latestRoom.lastMessage as any)?.transferData?.nextDepartment?._id).to.be.equal(forwardToOfflineDepartment._id); + + await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + await deleteDepartment(initialDepartment._id); + await deleteDepartment(forwardToOfflineDepartment._id); + }); + + (IS_EE ? it : it.skip)('when manager forward to offline department the inquiry should be set to the queue', async () => { + await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); + const { department: forwardToOfflineDepartment, agent: offlineAgent } = await createDepartmentWithAnOfflineAgent({ + allowReceiveForwardOffline: true, + }); + + const newVisitor = await createVisitor(initialDepartment._id); + const newRoom = await createLivechatRoom(newVisitor.token); + + await makeAgentUnavailable(offlineAgent.credentials); + + const manager: IUser = await createUser(); + const managerCredentials = await login(manager.username, password); + await createManager(manager.username); + + await request.post(api('livechat/room.forward')).set(managerCredentials).send({ + roomId: newRoom._id, + departmentId: forwardToOfflineDepartment._id, + clientAction: true, + comment: 'test comment', + }); + + await request + .get(api(`livechat/queue`)) + .set(credentials) + .query({ + count: 1, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.queue).to.be.an('array'); + expect(res.body.queue[0].chats).not.to.undefined; + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + }); + + await deleteDepartment(initialDepartment._id); + await deleteDepartment(forwardToOfflineDepartment._id); + }); + let roomId: string; let visitorToken: string; (IS_EE ? it : it.skip)('should return a success message when transferring to a fallback department', async () => { diff --git a/apps/meteor/tests/end-to-end/api/livechat/10-departments.ts b/apps/meteor/tests/end-to-end/api/livechat/10-departments.ts index 54f8739efee3..fc9af8d4580e 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/10-departments.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/10-departments.ts @@ -49,7 +49,16 @@ import { IS_EE } from '../../../e2e/config/constants'; const { body } = await request .post(api('livechat/department')) .set(credentials) - .send({ department: { name: 'Test', enabled: true, showOnOfflineForm: true, showOnRegistration: true, email: 'bla@bla' } }) + .send({ + department: { + name: 'Test', + enabled: true, + showOnOfflineForm: true, + showOnRegistration: true, + email: 'bla@bla', + allowReceiveForwardOffline: true, + }, + }) .expect('Content-Type', 'application/json') .expect(200); expect(body).to.have.property('success', true); @@ -59,6 +68,8 @@ import { IS_EE } from '../../../e2e/config/constants'; expect(body.department).to.have.property('enabled', true); expect(body.department).to.have.property('showOnOfflineForm', true); expect(body.department).to.have.property('showOnRegistration', true); + expect(body.department).to.have.property('allowReceiveForwardOffline', true); + departmentId = body.department._id; }); diff --git a/packages/core-typings/src/ILivechatDepartment.ts b/packages/core-typings/src/ILivechatDepartment.ts index 8c75913dc729..a73cf55cb235 100644 --- a/packages/core-typings/src/ILivechatDepartment.ts +++ b/packages/core-typings/src/ILivechatDepartment.ts @@ -17,6 +17,7 @@ export interface ILivechatDepartment { departmentsAllowedToForward?: string[]; maxNumberSimultaneousChat?: number; ancestors?: string[]; + allowReceiveForwardOffline?: boolean; // extra optional fields [k: string]: any; } @@ -32,4 +33,5 @@ export type LivechatDepartmentDTO = { chatClosingTags?: string[] | undefined; fallbackForwardDepartment?: string | undefined; departmentsAllowedToForward?: string[] | undefined; + allowReceiveForwardOffline?: boolean; }; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 84cc8a1965f2..6a13985f8a27 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -4841,6 +4841,8 @@ "Show_more": "Show more", "Show_name_field": "Show name field", "show_offline_users": "show offline users", + "Accept_receive_inquiry_no_online_agents": "Allow department to receive forwarded inquiries even when there's no available agents", + "Accept_receive_inquiry_no_online_agents_Hint": "This method is effective only with automatic assignment routing methods, and does not apply to Manual Selection.", "Show_on_offline_page": "Show on offline page", "Show_on_registration_page": "Show on registration page", "Show_only_online": "Show Online Only", From a7823c1b0901c510af5bfa994e7b3f96ee10dd91 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 5 Apr 2024 16:13:16 -0300 Subject: [PATCH 033/131] chore: move imported `Apps` to proxy (#32142) --- .../authentication/server/startup/index.js | 6 +- .../app/file-upload/server/lib/FileUpload.ts | 12 +--- .../app/lib/server/functions/addUserToRoom.ts | 4 +- .../lib/server/functions/createDirectRoom.ts | 8 +-- .../app/lib/server/functions/createRoom.ts | 6 +- .../app/lib/server/functions/deleteMessage.ts | 2 +- .../server/functions/removeUserFromRoom.ts | 4 +- .../app/lib/server/functions/saveUser.js | 2 +- .../app/lib/server/functions/sendMessage.ts | 4 +- .../app/lib/server/functions/updateMessage.ts | 4 +- .../server/methods/deleteUserOwnAccount.ts | 2 +- apps/meteor/app/livechat/server/lib/Helper.ts | 6 +- .../app/livechat/server/lib/LivechatTyped.ts | 8 +-- .../app/livechat/server/lib/QueueManager.ts | 2 +- .../app/livechat/server/lib/RoutingManager.ts | 2 +- apps/meteor/app/mailer/server/api.ts | 2 +- .../app/message-pin/server/pinMessage.ts | 4 +- .../app/message-star/server/starMessage.ts | 2 +- .../app/reactions/server/setReaction.ts | 2 +- .../server/lib/getAppsStatistics.js | 6 +- .../threads/server/methods/followMessage.ts | 2 +- .../threads/server/methods/unfollowMessage.ts | 2 +- .../ee/server/apps/communication/rest.ts | 5 +- apps/meteor/ee/server/index.ts | 1 + apps/meteor/ee/server/startup/index.ts | 1 - .../server/lib/moderation/reportMessage.ts | 2 +- apps/meteor/server/methods/deleteUser.ts | 2 +- apps/meteor/server/methods/eraseRoom.ts | 4 +- apps/meteor/server/methods/logoutCleanUp.ts | 2 +- apps/meteor/server/methods/reportMessage.ts | 2 +- apps/meteor/server/methods/saveUserProfile.ts | 2 +- .../server/services/apps-engine/service.ts | 66 ++++++++++--------- .../services/video-conference/service.ts | 4 +- apps/meteor/server/startup/migrations/v291.ts | 2 +- apps/meteor/server/startup/migrations/v292.ts | 2 +- apps/meteor/server/startup/migrations/v294.ts | 2 +- packages/apps/src/orchestrator.ts | 26 +++++++- 37 files changed, 115 insertions(+), 100 deletions(-) diff --git a/apps/meteor/app/authentication/server/startup/index.js b/apps/meteor/app/authentication/server/startup/index.js index ab622be95d53..c4f568f3c4f8 100644 --- a/apps/meteor/app/authentication/server/startup/index.js +++ b/apps/meteor/app/authentication/server/startup/index.js @@ -350,8 +350,8 @@ const insertUserDocAsync = async function (options, user) { if (!options.skipAppsEngineEvent) { // `post` triggered events don't need to wait for the promise to resolve - Apps?.triggerEvent(AppEvents.IPostUserCreated, { user, performedBy: await safeGetMeteorUser() }).catch((e) => { - Apps?.getRocketChatLogger().error('Error while executing post user created event:', e); + Apps.self?.triggerEvent(AppEvents.IPostUserCreated, { user, performedBy: await safeGetMeteorUser() }).catch((e) => { + Apps.self?.getRocketChatLogger().error('Error while executing post user created event:', e); }); } @@ -424,7 +424,7 @@ const validateLoginAttemptAsync = async function (login) { */ if (login.type !== 'resume') { // App IPostUserLoggedIn event hook - await Apps?.triggerEvent(AppEvents.IPostUserLoggedIn, login.user); + await Apps.self?.triggerEvent(AppEvents.IPostUserLoggedIn, login.user); } return true; diff --git a/apps/meteor/app/file-upload/server/lib/FileUpload.ts b/apps/meteor/app/file-upload/server/lib/FileUpload.ts index 3fd00f5e3e2a..4458f9d61881 100644 --- a/apps/meteor/app/file-upload/server/lib/FileUpload.ts +++ b/apps/meteor/app/file-upload/server/lib/FileUpload.ts @@ -177,7 +177,7 @@ export const FileUpload = { // App IPreFileUpload event hook try { - await Apps?.triggerEvent(AppEvents.IPreFileUpload, { file, content: content || Buffer.from([]) }); + await Apps.self?.triggerEvent(AppEvents.IPreFileUpload, { file, content: content || Buffer.from([]) }); } catch (error: any) { if (error.name === AppsEngineException.name) { throw new Meteor.Error('error-app-prevented', error.message); @@ -587,15 +587,7 @@ export const FileUpload = { } // eslint-disable-next-line prettier/prettier - const headersToProxy = [ - 'age', - 'cache-control', - 'content-length', - 'content-type', - 'date', - 'expired', - 'last-modified', - ]; + const headersToProxy = ['age', 'cache-control', 'content-length', 'content-type', 'date', 'expired', 'last-modified']; headersToProxy.forEach((header) => { fileRes.headers[header] && res.setHeader(header, String(fileRes.headers[header])); diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index 4a70943d28e2..4506b659bf4d 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -54,7 +54,7 @@ export const addUserToRoom = async function ( } try { - await Apps?.triggerEvent(AppEvents.IPreRoomUserJoined, room, userToBeAdded, inviter); + await Apps.self?.triggerEvent(AppEvents.IPreRoomUserJoined, room, userToBeAdded, inviter); } catch (error: any) { if (error.name === AppsEngineException.name) { throw new Meteor.Error('error-app-prevented', error.message); @@ -118,7 +118,7 @@ export const addUserToRoom = async function ( // Keep the current event await callbacks.run('afterJoinRoom', userToBeAdded, room); - void Apps?.triggerEvent(AppEvents.IPostRoomUserJoined, room, userToBeAdded, inviter); + void Apps.self?.triggerEvent(AppEvents.IPostRoomUserJoined, room, userToBeAdded, inviter); }); } diff --git a/apps/meteor/app/lib/server/functions/createDirectRoom.ts b/apps/meteor/app/lib/server/functions/createDirectRoom.ts index dea6004eb4e5..c1de81332543 100644 --- a/apps/meteor/app/lib/server/functions/createDirectRoom.ts +++ b/apps/meteor/app/lib/server/functions/createDirectRoom.ts @@ -103,7 +103,7 @@ export async function createDirectRoom( _USERNAMES: usernames, }; - const prevent = await Apps?.triggerEvent(AppEvents.IPreRoomCreatePrevent, tmpRoom).catch((error) => { + const prevent = await Apps.self?.triggerEvent(AppEvents.IPreRoomCreatePrevent, tmpRoom).catch((error) => { if (error.name === AppsEngineException.name) { throw new Meteor.Error('error-app-prevented', error.message); } @@ -115,9 +115,9 @@ export async function createDirectRoom( throw new Meteor.Error('error-app-prevented', 'A Rocket.Chat App prevented the room creation.'); } - const result = await Apps?.triggerEvent( + const result = await Apps.self?.triggerEvent( AppEvents.IPreRoomCreateModify, - await Apps?.triggerEvent(AppEvents.IPreRoomCreateExtend, tmpRoom), + await Apps.self?.triggerEvent(AppEvents.IPreRoomCreateExtend, tmpRoom), ); if (typeof result === 'object') { @@ -172,7 +172,7 @@ export async function createDirectRoom( await callbacks.run('afterCreateDirectRoom', insertedRoom, { members: roomMembers, creatorId: options?.creator }); - void Apps?.triggerEvent(AppEvents.IPostRoomCreate, insertedRoom); + void Apps.self?.triggerEvent(AppEvents.IPostRoomCreate, insertedRoom); } return { diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index 11577a76c4fb..615a0faf8bb3 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -198,7 +198,7 @@ export const createRoom = async ( _USERNAMES: members, }; - const prevent = await Apps?.triggerEvent(AppEvents.IPreRoomCreatePrevent, tmp).catch((error) => { + const prevent = await Apps.self?.triggerEvent(AppEvents.IPreRoomCreatePrevent, tmp).catch((error) => { if (error.name === AppsEngineException.name) { throw new Meteor.Error('error-app-prevented', error.message); } @@ -210,7 +210,7 @@ export const createRoom = async ( throw new Meteor.Error('error-app-prevented', 'A Rocket.Chat App prevented the room creation.'); } - const eventResult = await Apps?.triggerEvent( + const eventResult = await Apps.self?.triggerEvent( AppEvents.IPreRoomCreateModify, await Apps.triggerEvent(AppEvents.IPreRoomCreateExtend, tmp), ); @@ -245,7 +245,7 @@ export const createRoom = async ( callbacks.runAsync('federation.afterCreateFederatedRoom', room, { owner, originalMemberList: members }); } - void Apps?.triggerEvent(AppEvents.IPostRoomCreate, room); + void Apps.self?.triggerEvent(AppEvents.IPostRoomCreate, room); return { rid: room._id, // backwards compatible inserted: true, diff --git a/apps/meteor/app/lib/server/functions/deleteMessage.ts b/apps/meteor/app/lib/server/functions/deleteMessage.ts index 26677bf37fff..9368787bf7ea 100644 --- a/apps/meteor/app/lib/server/functions/deleteMessage.ts +++ b/apps/meteor/app/lib/server/functions/deleteMessage.ts @@ -33,7 +33,7 @@ export async function deleteMessage(message: IMessage, user: IUser): Promise 0; const keepHistory = settings.get('Message_KeepHistory') || isThread; const showDeletedStatus = settings.get('Message_ShowDeletedStatus') || isThread; - const bridges = Apps?.isLoaded() && Apps.getBridges(); + const bridges = Apps.self?.isLoaded() && Apps.getBridges(); if (deletedMsg && bridges) { const prevent = await bridges.getListenerBridge().messageEvent(AppEvents.IPreMessageDeletePrevent, deletedMsg); diff --git a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts index aaff8257f987..1cc8c4ad5432 100644 --- a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts +++ b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts @@ -20,7 +20,7 @@ export const removeUserFromRoom = async function ( } try { - await Apps?.triggerEvent(AppEvents.IPreRoomUserLeave, room, user); + await Apps.self?.triggerEvent(AppEvents.IPreRoomUserLeave, room, user); } catch (error: any) { if (error.name === AppsEngineException.name) { throw new Meteor.Error('error-app-prevented', error.message); @@ -67,5 +67,5 @@ export const removeUserFromRoom = async function ( // TODO: CACHE: maybe a queue? await afterLeaveRoomCallback.run(user, room); - await Apps?.triggerEvent(AppEvents.IPostRoomUserLeave, room, user); + await Apps.self?.triggerEvent(AppEvents.IPostRoomUserLeave, room, user); }; diff --git a/apps/meteor/app/lib/server/functions/saveUser.js b/apps/meteor/app/lib/server/functions/saveUser.js index 667b5179ceea..f34a54432c4f 100644 --- a/apps/meteor/app/lib/server/functions/saveUser.js +++ b/apps/meteor/app/lib/server/functions/saveUser.js @@ -434,7 +434,7 @@ export const saveUser = async function (userId, userData) { oldUser: oldUserData, }); - await Apps?.triggerEvent(AppEvents.IPostUserUpdated, { + await Apps.self?.triggerEvent(AppEvents.IPostUserUpdated, { user: userUpdated, previousUser: oldUserData, performedBy: await safeGetMeteorUser(), diff --git a/apps/meteor/app/lib/server/functions/sendMessage.ts b/apps/meteor/app/lib/server/functions/sendMessage.ts index 14da152c87f9..089d33f98034 100644 --- a/apps/meteor/app/lib/server/functions/sendMessage.ts +++ b/apps/meteor/app/lib/server/functions/sendMessage.ts @@ -225,7 +225,7 @@ export const sendMessage = async function (user: any, message: any, room: any, u } // For the Rocket.Chat Apps :) - if (Apps?.isLoaded()) { + if (Apps.self?.isLoaded()) { const listenerBridge = Apps.getBridges()?.getListenerBridge(); const prevent = await listenerBridge?.messageEvent('IPreMessageSentPrevent', message); @@ -275,7 +275,7 @@ export const sendMessage = async function (user: any, message: any, room: any, u message._id = insertedId; } - if (Apps?.isLoaded()) { + if (Apps.self?.isLoaded()) { // This returns a promise, but it won't mutate anything about the message // so, we don't really care if it is successful or fails void Apps.getBridges()?.getListenerBridge().messageEvent('IPostMessageSent', message); diff --git a/apps/meteor/app/lib/server/functions/updateMessage.ts b/apps/meteor/app/lib/server/functions/updateMessage.ts index 14d93d2874e9..6b3cb9ef64f5 100644 --- a/apps/meteor/app/lib/server/functions/updateMessage.ts +++ b/apps/meteor/app/lib/server/functions/updateMessage.ts @@ -23,7 +23,7 @@ export const updateMessage = async function ( let messageData: IMessage = Object.assign({}, originalMessage, message); // For the Rocket.Chat Apps :) - if (message && Apps && Apps.isLoaded()) { + if (message && Apps.self && Apps.isLoaded()) { const prevent = await Apps.getBridges().getListenerBridge().messageEvent(AppEvents.IPreMessageUpdatedPrevent, messageData); if (prevent) { throw new Meteor.Error('error-app-prevented-updating', 'A Rocket.Chat App prevented the message updating.'); @@ -76,7 +76,7 @@ export const updateMessage = async function ( }, ); - if (Apps?.isLoaded()) { + if (Apps.self?.isLoaded()) { // This returns a promise, but it won't mutate anything about the message // so, we don't really care if it is successful or fails void Apps.getBridges()?.getListenerBridge().messageEvent(AppEvents.IPostMessageUpdated, messageData); diff --git a/apps/meteor/app/lib/server/methods/deleteUserOwnAccount.ts b/apps/meteor/app/lib/server/methods/deleteUserOwnAccount.ts index f30182def68e..2d651950da19 100644 --- a/apps/meteor/app/lib/server/methods/deleteUserOwnAccount.ts +++ b/apps/meteor/app/lib/server/methods/deleteUserOwnAccount.ts @@ -66,7 +66,7 @@ Meteor.methods({ await deleteUser(uid, confirmRelinquish); // App IPostUserDeleted event hook - await Apps?.triggerEvent(AppEvents.IPostUserDeleted, { user }); + await Apps.self?.triggerEvent(AppEvents.IPostUserDeleted, { user }); return true; }, diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index 3c1d601250c1..453869d4425a 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -273,7 +273,7 @@ export const removeAgentFromSubscription = async (rid: string, { _id, username } await Message.saveSystemMessage('ul', rid, username || '', { _id: user._id, username: user.username, name: user.name }); setImmediate(() => { - void Apps?.triggerEvent(AppEvents.IPostLivechatAgentUnassigned, { room, user }); + void Apps.self?.triggerEvent(AppEvents.IPostLivechatAgentUnassigned, { room, user }); }); }; @@ -452,7 +452,7 @@ export const forwardRoomToAgent = async (room: IOmnichannelRoom, transferData: T } setImmediate(() => { - void Apps?.triggerEvent(AppEvents.IPostLivechatRoomTransferred, { + void Apps.self?.triggerEvent(AppEvents.IPostLivechatRoomTransferred, { type: LivechatTransferEventType.AGENT, room: rid, from: oldServedBy?._id, @@ -482,7 +482,7 @@ export const updateChatDepartment = async ({ ]); setImmediate(() => { - void Apps?.triggerEvent(AppEvents.IPostLivechatRoomTransferred, { + void Apps.self?.triggerEvent(AppEvents.IPostLivechatRoomTransferred, { type: LivechatTransferEventType.DEPARTMENT, room: rid, from: oldDepartmentId, diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index 39d3467fbaf2..dc5aa506f405 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -329,8 +329,8 @@ class LivechatClass { * @deprecated the `AppEvents.ILivechatRoomClosedHandler` event will be removed * in the next major version of the Apps-Engine */ - void Apps?.getBridges()?.getListenerBridge().livechatEvent(AppEvents.ILivechatRoomClosedHandler, newRoom); - void Apps?.getBridges()?.getListenerBridge().livechatEvent(AppEvents.IPostLivechatRoomClosed, newRoom); + void Apps.self?.getBridges()?.getListenerBridge().livechatEvent(AppEvents.ILivechatRoomClosedHandler, newRoom); + void Apps.self?.getBridges()?.getListenerBridge().livechatEvent(AppEvents.IPostLivechatRoomClosed, newRoom); }); if (process.env.TEST_MODE) { await callbacks.run('livechat.closeRoom', { @@ -1426,7 +1426,7 @@ class LivechatClass { const ret = await LivechatVisitors.saveGuestById(_id, updateData); setImmediate(() => { - void Apps?.triggerEvent(AppEvents.IPostLivechatGuestSaved, _id); + void Apps.self?.triggerEvent(AppEvents.IPostLivechatGuestSaved, _id); }); return ret; @@ -1792,7 +1792,7 @@ class LivechatClass { await LivechatRooms.saveRoomById(roomData); setImmediate(() => { - void Apps?.triggerEvent(AppEvents.IPostLivechatRoomSaved, roomData._id); + void Apps.self?.triggerEvent(AppEvents.IPostLivechatRoomSaved, roomData._id); }); if (guestData?.name?.trim().length) { diff --git a/apps/meteor/app/livechat/server/lib/QueueManager.ts b/apps/meteor/app/livechat/server/lib/QueueManager.ts index 8a11a36238fa..8be71aa4c991 100644 --- a/apps/meteor/app/livechat/server/lib/QueueManager.ts +++ b/apps/meteor/app/livechat/server/lib/QueueManager.ts @@ -105,7 +105,7 @@ export const QueueManager: queueManager = { throw new Error('inquiry-not-found'); } - void Apps?.triggerEvent(AppEvents.IPostLivechatRoomStarted, room); + void Apps.self?.triggerEvent(AppEvents.IPostLivechatRoomStarted, room); await LivechatRooms.updateRoomCount(); await queueInquiry(inquiry, agent); diff --git a/apps/meteor/app/livechat/server/lib/RoutingManager.ts b/apps/meteor/app/livechat/server/lib/RoutingManager.ts index 5f0a458dc314..19437d800ee2 100644 --- a/apps/meteor/app/livechat/server/lib/RoutingManager.ts +++ b/apps/meteor/app/livechat/server/lib/RoutingManager.ts @@ -154,7 +154,7 @@ export const RoutingManager: Routing = { await dispatchAgentDelegated(rid, agent.agentId); logger.debug(`Agent ${agent.agentId} assigned to inquriy ${inquiry._id}. Instances notified`); - void Apps?.getBridges()?.getListenerBridge().livechatEvent(AppEvents.IPostLivechatAgentAssigned, { room, user }); + void Apps.self?.getBridges()?.getListenerBridge().livechatEvent(AppEvents.IPostLivechatAgentAssigned, { room, user }); return inquiry; }, diff --git a/apps/meteor/app/mailer/server/api.ts b/apps/meteor/app/mailer/server/api.ts index cc2caae74ba6..e562fc8e7b39 100644 --- a/apps/meteor/app/mailer/server/api.ts +++ b/apps/meteor/app/mailer/server/api.ts @@ -170,7 +170,7 @@ export const sendNoWrap = async ({ const email = { to, from, replyTo, subject, html, text, headers }; - const eventResult = await Apps?.triggerEvent(AppEvents.IPreEmailSent, { email }); + const eventResult = await Apps.self?.triggerEvent(AppEvents.IPreEmailSent, { email }); setImmediate(() => Email.sendAsync(eventResult || email).catch((e) => console.error(e))); }; diff --git a/apps/meteor/app/message-pin/server/pinMessage.ts b/apps/meteor/app/message-pin/server/pinMessage.ts index 4887e3603122..dc17a75a0192 100644 --- a/apps/meteor/app/message-pin/server/pinMessage.ts +++ b/apps/meteor/app/message-pin/server/pinMessage.ts @@ -129,7 +129,7 @@ Meteor.methods({ } // App IPostMessagePinned event hook - await Apps?.triggerEvent(AppEvents.IPostMessagePinned, originalMessage, await Meteor.userAsync(), originalMessage.pinned); + await Apps.self?.triggerEvent(AppEvents.IPostMessagePinned, originalMessage, await Meteor.userAsync(), originalMessage.pinned); const msgId = await Message.saveSystemMessage('message_pinned', originalMessage.rid, '', me, { attachments: [ @@ -216,7 +216,7 @@ Meteor.methods({ } // App IPostMessagePinned event hook - await Apps?.triggerEvent(AppEvents.IPostMessagePinned, originalMessage, await Meteor.userAsync(), originalMessage.pinned); + await Apps.self?.triggerEvent(AppEvents.IPostMessagePinned, originalMessage, await Meteor.userAsync(), originalMessage.pinned); await Messages.setPinnedByIdAndUserId(originalMessage._id, originalMessage.pinnedBy, originalMessage.pinned); if (settings.get('Message_Read_Receipt_Store_Users')) { diff --git a/apps/meteor/app/message-star/server/starMessage.ts b/apps/meteor/app/message-star/server/starMessage.ts index 9f8ba75c4536..7ac8fd619d31 100644 --- a/apps/meteor/app/message-star/server/starMessage.ts +++ b/apps/meteor/app/message-star/server/starMessage.ts @@ -57,7 +57,7 @@ Meteor.methods({ await Rooms.updateLastMessageStar(room._id, uid, message.starred); } - await Apps?.triggerEvent(AppEvents.IPostMessageStarred, message, await Meteor.userAsync(), message.starred); + await Apps.self?.triggerEvent(AppEvents.IPostMessageStarred, message, await Meteor.userAsync(), message.starred); await Messages.updateUserStarById(message._id, uid, message.starred); diff --git a/apps/meteor/app/reactions/server/setReaction.ts b/apps/meteor/app/reactions/server/setReaction.ts index ed2271a5d4d0..36eaab695512 100644 --- a/apps/meteor/app/reactions/server/setReaction.ts +++ b/apps/meteor/app/reactions/server/setReaction.ts @@ -106,7 +106,7 @@ async function setReaction(room: IRoom, user: IUser, message: IMessage, reaction isReacted = true; } - await Apps?.triggerEvent(AppEvents.IPostMessageReacted, message, user, reaction, isReacted); + await Apps.self?.triggerEvent(AppEvents.IPostMessageReacted, message, user, reaction, isReacted); void broadcastMessageFromData({ id: message._id, diff --git a/apps/meteor/app/statistics/server/lib/getAppsStatistics.js b/apps/meteor/app/statistics/server/lib/getAppsStatistics.js index 652686e6715c..1d84bead3e85 100644 --- a/apps/meteor/app/statistics/server/lib/getAppsStatistics.js +++ b/apps/meteor/app/statistics/server/lib/getAppsStatistics.js @@ -6,10 +6,10 @@ import { Info } from '../../../utils/rocketchat.info'; export function getAppsStatistics() { return { engineVersion: Info.marketplaceApiVersion, - totalInstalled: (Apps?.isInitialized() && Apps.getManager().get().length) ?? 0, - totalActive: (Apps?.isInitialized() && Apps.getManager().get({ enabled: true }).length) ?? 0, + totalInstalled: (Apps.self?.isInitialized() && Apps.getManager().get().length) ?? 0, + totalActive: (Apps.self?.isInitialized() && Apps.getManager().get({ enabled: true }).length) ?? 0, totalFailed: - (Apps?.isInitialized() && + (Apps.self?.isInitialized() && Apps.getManager() .get({ disabled: true }) .filter(({ app: { status } }) => status !== AppStatus.MANUALLY_DISABLED).length) ?? diff --git a/apps/meteor/app/threads/server/methods/followMessage.ts b/apps/meteor/app/threads/server/methods/followMessage.ts index f6bae69b1aaa..05650d0ad2ef 100644 --- a/apps/meteor/app/threads/server/methods/followMessage.ts +++ b/apps/meteor/app/threads/server/methods/followMessage.ts @@ -44,7 +44,7 @@ Meteor.methods({ const followResult = await follow({ tmid: message.tmid || message._id, uid }); const isFollowed = true; - await Apps?.triggerEvent(AppEvents.IPostMessageFollowed, message, await Meteor.userAsync(), isFollowed); + await Apps.self?.triggerEvent(AppEvents.IPostMessageFollowed, message, await Meteor.userAsync(), isFollowed); return followResult; }, diff --git a/apps/meteor/app/threads/server/methods/unfollowMessage.ts b/apps/meteor/app/threads/server/methods/unfollowMessage.ts index b50c26508ebc..afc9206b038f 100644 --- a/apps/meteor/app/threads/server/methods/unfollowMessage.ts +++ b/apps/meteor/app/threads/server/methods/unfollowMessage.ts @@ -44,7 +44,7 @@ Meteor.methods({ const unfollowResult = await unfollow({ rid: message.rid, tmid: message.tmid || message._id, uid }); const isFollowed = false; - await Apps?.triggerEvent(AppEvents.IPostMessageFollowed, message, await Meteor.userAsync(), isFollowed); + await Apps.self?.triggerEvent(AppEvents.IPostMessageFollowed, message, await Meteor.userAsync(), isFollowed); return unfollowResult; }, diff --git a/apps/meteor/ee/server/apps/communication/rest.ts b/apps/meteor/ee/server/apps/communication/rest.ts index ea259fef5f0c..df30cccc8e73 100644 --- a/apps/meteor/ee/server/apps/communication/rest.ts +++ b/apps/meteor/ee/server/apps/communication/rest.ts @@ -534,10 +534,7 @@ export class AppsRestApi { try { const { event, externalComponent } = this.bodyParams; - const result = (Apps?.getBridges()?.getListenerBridge() as Record).externalComponentEvent( - event, - externalComponent, - ); + const result = (Apps.getBridges()?.getListenerBridge() as Record).externalComponentEvent(event, externalComponent); return API.v1.success({ result }); } catch (e: any) { diff --git a/apps/meteor/ee/server/index.ts b/apps/meteor/ee/server/index.ts index f00caa896e43..0a9ad57cb00f 100644 --- a/apps/meteor/ee/server/index.ts +++ b/apps/meteor/ee/server/index.ts @@ -12,5 +12,6 @@ import './requestSeatsRoute'; import './configuration/index'; import './local-services/ldap/service'; import './methods/getReadReceipts'; +import './apps/startup'; export { registerEEBroker } from './startup'; diff --git a/apps/meteor/ee/server/startup/index.ts b/apps/meteor/ee/server/startup/index.ts index ade83ea57227..a8091f0e9a37 100644 --- a/apps/meteor/ee/server/startup/index.ts +++ b/apps/meteor/ee/server/startup/index.ts @@ -1,4 +1,3 @@ -import '../apps/startup'; import '../../app/authorization/server'; import './apps'; import './audit'; diff --git a/apps/meteor/server/lib/moderation/reportMessage.ts b/apps/meteor/server/lib/moderation/reportMessage.ts index 710ea6e1b685..a546896b8332 100644 --- a/apps/meteor/server/lib/moderation/reportMessage.ts +++ b/apps/meteor/server/lib/moderation/reportMessage.ts @@ -49,7 +49,7 @@ export const reportMessage = async (messageId: IMessage['_id'], description: str await ModerationReports.createWithMessageDescriptionAndUserId(message, description, roomInfo, reportedBy); - await Apps?.triggerEvent(AppEvents.IPostMessageReported, message, user, description); + await Apps.self?.triggerEvent(AppEvents.IPostMessageReported, message, user, description); return true; }; diff --git a/apps/meteor/server/methods/deleteUser.ts b/apps/meteor/server/methods/deleteUser.ts index 8762cfab2437..e8b1f6eeed65 100644 --- a/apps/meteor/server/methods/deleteUser.ts +++ b/apps/meteor/server/methods/deleteUser.ts @@ -52,7 +52,7 @@ Meteor.methods({ await deleteUser(userId, confirmRelinquish, uid); // App IPostUserDeleted event hook - await Apps?.triggerEvent(AppEvents.IPostUserDeleted, { user, performedBy: await Meteor.userAsync() }); + await Apps.self?.triggerEvent(AppEvents.IPostUserDeleted, { user, performedBy: await Meteor.userAsync() }); return true; }, diff --git a/apps/meteor/server/methods/eraseRoom.ts b/apps/meteor/server/methods/eraseRoom.ts index 687b9ad66992..b9b4833ad67f 100644 --- a/apps/meteor/server/methods/eraseRoom.ts +++ b/apps/meteor/server/methods/eraseRoom.ts @@ -35,7 +35,7 @@ export async function eraseRoom(rid: string, uid: string): Promise { }); } - if (Apps?.isLoaded()) { + if (Apps.self?.isLoaded()) { const prevent = await Apps.getBridges()?.getListenerBridge().roomEvent(AppEvents.IPreRoomDeletePrevent, room); if (prevent) { throw new Meteor.Error('error-app-prevented-deleting', 'A Rocket.Chat App prevented the room erasing.'); @@ -53,7 +53,7 @@ export async function eraseRoom(rid: string, uid: string): Promise { } } - if (Apps?.isLoaded()) { + if (Apps.self?.isLoaded()) { void Apps.getBridges()?.getListenerBridge().roomEvent(AppEvents.IPostRoomDeleted, room); } } diff --git a/apps/meteor/server/methods/logoutCleanUp.ts b/apps/meteor/server/methods/logoutCleanUp.ts index 502cad3c5fbf..359933faccbd 100644 --- a/apps/meteor/server/methods/logoutCleanUp.ts +++ b/apps/meteor/server/methods/logoutCleanUp.ts @@ -22,6 +22,6 @@ Meteor.methods({ }); // App IPostUserLogout event hook - await Apps?.triggerEvent(AppEvents.IPostUserLoggedOut, user); + await Apps.self?.triggerEvent(AppEvents.IPostUserLoggedOut, user); }, }); diff --git a/apps/meteor/server/methods/reportMessage.ts b/apps/meteor/server/methods/reportMessage.ts index 44087dad0424..05ac5aaf7e7b 100644 --- a/apps/meteor/server/methods/reportMessage.ts +++ b/apps/meteor/server/methods/reportMessage.ts @@ -77,7 +77,7 @@ Meteor.methods({ await ModerationReports.createWithMessageDescriptionAndUserId(message, description, roomInfo, reportedBy); - await Apps?.triggerEvent(AppEvents.IPostMessageReported, message, await Meteor.userAsync(), description); + await Apps.self?.triggerEvent(AppEvents.IPostMessageReported, message, await Meteor.userAsync(), description); return true; }, diff --git a/apps/meteor/server/methods/saveUserProfile.ts b/apps/meteor/server/methods/saveUserProfile.ts index 695742977ad3..c2ed41adaab9 100644 --- a/apps/meteor/server/methods/saveUserProfile.ts +++ b/apps/meteor/server/methods/saveUserProfile.ts @@ -156,7 +156,7 @@ async function saveUserProfile( // App IPostUserUpdated event hook const updatedUser = await Users.findOneById(this.userId); - await Apps?.triggerEvent(AppEvents.IPostUserUpdated, { user: updatedUser, previousUser: user }); + await Apps.self?.triggerEvent(AppEvents.IPostUserUpdated, { user: updatedUser, previousUser: user }); return true; } diff --git a/apps/meteor/server/services/apps-engine/service.ts b/apps/meteor/server/services/apps-engine/service.ts index 7e36a937e6a6..41a53cf5bbb6 100644 --- a/apps/meteor/server/services/apps-engine/service.ts +++ b/apps/meteor/server/services/apps-engine/service.ts @@ -16,7 +16,7 @@ export class AppsEngineService extends ServiceClassInternal implements IAppsEngi super(); this.onEvent('presence.status', async ({ user, previousStatus }): Promise => { - await Apps?.triggerEvent(AppEvents.IPostUserStatusChanged, { + await Apps.self?.triggerEvent(AppEvents.IPostUserStatusChanged, { user, currentStatus: user.status, previousStatus, @@ -24,70 +24,72 @@ export class AppsEngineService extends ServiceClassInternal implements IAppsEngi }); this.onEvent('apps.added', async (appId: string): Promise => { - Apps?.getRocketChatLogger().debug(`"apps.added" event received for app "${appId}"`); + Apps.self?.getRocketChatLogger().debug(`"apps.added" event received for app "${appId}"`); // if the app already exists in this instance, don't load it again - const app = Apps?.getManager()?.getOneById(appId); + const app = Apps.self?.getManager()?.getOneById(appId); if (app) { - Apps?.getRocketChatLogger().info(`"apps.added" event received for app "${appId}", but it already exists in this instance`); + Apps.self?.getRocketChatLogger().info(`"apps.added" event received for app "${appId}", but it already exists in this instance`); return; } - await Apps?.getManager()?.addLocal(appId); + await Apps.self?.getManager()?.addLocal(appId); }); this.onEvent('apps.removed', async (appId: string): Promise => { - Apps?.getRocketChatLogger().debug(`"apps.removed" event received for app "${appId}"`); - const app = Apps?.getManager()?.getOneById(appId); + Apps.self?.getRocketChatLogger().debug(`"apps.removed" event received for app "${appId}"`); + const app = Apps.self?.getManager()?.getOneById(appId); if (!app) { - Apps?.getRocketChatLogger().info(`"apps.removed" event received for app "${appId}", but it couldn't be found in this instance`); + Apps.self + ?.getRocketChatLogger() + .info(`"apps.removed" event received for app "${appId}", but it couldn't be found in this instance`); return; } - await Apps?.getManager()?.removeLocal(appId); + await Apps.self?.getManager()?.removeLocal(appId); }); this.onEvent('apps.updated', async (appId: string): Promise => { - Apps?.getRocketChatLogger().debug(`"apps.updated" event received for app "${appId}"`); - const storageItem = await Apps?.getStorage()?.retrieveOne(appId); + Apps.self?.getRocketChatLogger().debug(`"apps.updated" event received for app "${appId}"`); + const storageItem = await Apps.self?.getStorage()?.retrieveOne(appId); if (!storageItem) { - Apps?.getRocketChatLogger().info(`"apps.updated" event received for app "${appId}", but it couldn't be found in the storage`); + Apps.self?.getRocketChatLogger().info(`"apps.updated" event received for app "${appId}", but it couldn't be found in the storage`); return; } - const appPackage = await Apps?.getAppSourceStorage()?.fetch(storageItem); + const appPackage = await Apps.self?.getAppSourceStorage()?.fetch(storageItem); if (!appPackage) { return; } - await Apps?.getManager()?.updateLocal(storageItem, appPackage); + await Apps.self?.getManager()?.updateLocal(storageItem, appPackage); }); this.onEvent('apps.statusUpdate', async (appId: string, status: AppStatus): Promise => { - Apps?.getRocketChatLogger().debug(`"apps.statusUpdate" event received for app "${appId}" with status "${status}"`); - const app = Apps?.getManager()?.getOneById(appId); + Apps.self?.getRocketChatLogger().debug(`"apps.statusUpdate" event received for app "${appId}" with status "${status}"`); + const app = Apps.self?.getManager()?.getOneById(appId); if (!app) { - Apps?.getRocketChatLogger().info( - `"apps.statusUpdate" event received for app "${appId}", but it couldn't be found in this instance`, - ); + Apps.self + ?.getRocketChatLogger() + .info(`"apps.statusUpdate" event received for app "${appId}", but it couldn't be found in this instance`); return; } if (app.getStatus() === status) { - Apps?.getRocketChatLogger().info(`"apps.statusUpdate" event received for app "${appId}", but the status is the same`); + Apps.self?.getRocketChatLogger().info(`"apps.statusUpdate" event received for app "${appId}", but the status is the same`); return; } if (AppStatusUtils.isEnabled(status)) { - await Apps?.getManager()?.enable(appId).catch(SystemLogger.error); + await Apps.self?.getManager()?.enable(appId).catch(SystemLogger.error); } else if (AppStatusUtils.isDisabled(status)) { - await Apps?.getManager()?.disable(appId, status, true).catch(SystemLogger.error); + await Apps.self?.getManager()?.disable(appId, status, true).catch(SystemLogger.error); } }); this.onEvent('apps.settingUpdated', async (appId: string, setting): Promise => { - Apps?.getRocketChatLogger().debug(`"apps.settingUpdated" event received for app "${appId}"`, { setting }); - const app = Apps?.getManager()?.getOneById(appId); + Apps.self?.getRocketChatLogger().debug(`"apps.settingUpdated" event received for app "${appId}"`, { setting }); + const app = Apps.self?.getManager()?.getOneById(appId); const oldSetting = app?.getStorageItem().settings[setting.id].value; // avoid updating the setting if the value is the same, @@ -96,30 +98,32 @@ export class AppsEngineService extends ServiceClassInternal implements IAppsEngi // so we need to convert it to JSON stringified to compare it if (JSON.stringify(oldSetting) === JSON.stringify(setting.value)) { - Apps?.getRocketChatLogger().info( - `"apps.settingUpdated" event received for setting ${setting.id} of app "${appId}", but the setting value is the same`, - ); + Apps.self + ?.getRocketChatLogger() + .info(`"apps.settingUpdated" event received for setting ${setting.id} of app "${appId}", but the setting value is the same`); return; } - await Apps?.getManager() + await Apps.self + ?.getManager() ?.getSettingsManager() .updateAppSetting(appId, setting as any); }); } isInitialized(): boolean { - return Boolean(Apps?.isInitialized()); + return Boolean(Apps.self?.isInitialized()); } async getApps(query: IGetAppsFilter): Promise { - return Apps?.getManager() + return Apps.self + ?.getManager() ?.get(query) .map((app) => app.getApp().getInfo()); } async getAppStorageItemById(appId: string): Promise { - const app = Apps?.getManager()?.getOneById(appId); + const app = Apps.self?.getManager()?.getOneById(appId); if (!app) { return; diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index 87fe279d0d94..7c7d5950cf5f 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -828,11 +828,11 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf } private async getProviderManager(): Promise { - if (!Apps?.isLoaded()) { + if (!Apps.self?.isLoaded()) { throw new Error('apps-engine-not-loaded'); } - const manager = Apps?.getManager()?.getVideoConfProviderManager(); + const manager = Apps.self?.getManager()?.getVideoConfProviderManager(); if (!manager) { throw new Error(availabilityErrors.NO_APP); } diff --git a/apps/meteor/server/startup/migrations/v291.ts b/apps/meteor/server/startup/migrations/v291.ts index f4fdbb743447..3f94c26dc6e0 100644 --- a/apps/meteor/server/startup/migrations/v291.ts +++ b/apps/meteor/server/startup/migrations/v291.ts @@ -12,7 +12,7 @@ addMigration({ await Settings.removeById('Apps_Framework_Development_Mode'); await Settings.removeById('Apps_Framework_enabled'); - if (!Apps) { + if (!Apps.self) { throw new Error('Apps Orchestrator not registered.'); } diff --git a/apps/meteor/server/startup/migrations/v292.ts b/apps/meteor/server/startup/migrations/v292.ts index beec6967a904..ac523f1b197e 100644 --- a/apps/meteor/server/startup/migrations/v292.ts +++ b/apps/meteor/server/startup/migrations/v292.ts @@ -9,7 +9,7 @@ addMigration({ version: 292, name: 'Add checksum signature to existing apps', async up() { - if (!Apps) { + if (!Apps.self) { throw new Error('Apps Orchestrator not registered.'); } diff --git a/apps/meteor/server/startup/migrations/v294.ts b/apps/meteor/server/startup/migrations/v294.ts index 832043740f89..8523db89e4b9 100644 --- a/apps/meteor/server/startup/migrations/v294.ts +++ b/apps/meteor/server/startup/migrations/v294.ts @@ -8,7 +8,7 @@ import { addMigration } from '../../lib/migrations'; addMigration({ version: 294, async up() { - if (!Apps) { + if (!Apps.self) { throw new Error('Apps Orchestrator not registered.'); } diff --git a/packages/apps/src/orchestrator.ts b/packages/apps/src/orchestrator.ts index 4e3a53d9d5f0..c0dee1609113 100644 --- a/packages/apps/src/orchestrator.ts +++ b/packages/apps/src/orchestrator.ts @@ -1,7 +1,29 @@ import type { IAppServerOrchestrator } from './IAppServerOrchestrator'; -export let Apps: IAppServerOrchestrator | undefined; +let app: IAppServerOrchestrator | undefined; + +type Orchestrator = { self: undefined } | (IAppServerOrchestrator & { self: IAppServerOrchestrator }); + +export const Apps = new Proxy({} as Orchestrator, { + get: (_target, nameProp: keyof IAppServerOrchestrator | 'self'): any => { + if (nameProp === 'self') { + return app; + } + + if (!app) { + throw new Error(`Orchestrator not found`); + } + + const prop = app[nameProp]; + + if (typeof prop === 'function') { + return prop.bind(app); + } + + return prop; + }, +}); export function registerOrchestrator(orch: IAppServerOrchestrator): void { - Apps = orch; + app = orch; } From 86bc4ca08cdd5aceac25bf2acba447a04400ec23 Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Mon, 8 Apr 2024 10:16:10 -0300 Subject: [PATCH 034/131] regression(fuselage-ui-kit): Use default translation namespace for `-core` apps (#32105) --- packages/fuselage-ui-kit/jest.config.ts | 27 ++++ packages/fuselage-ui-kit/package.json | 8 +- .../src/hooks/useAppTranslation.spec.tsx | 139 ++++++++++++++++++ .../src/hooks/useAppTranslation.ts | 2 +- yarn.lock | 78 +++++++++- 5 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 packages/fuselage-ui-kit/jest.config.ts create mode 100644 packages/fuselage-ui-kit/src/hooks/useAppTranslation.spec.tsx diff --git a/packages/fuselage-ui-kit/jest.config.ts b/packages/fuselage-ui-kit/jest.config.ts new file mode 100644 index 000000000000..23a14f54fde9 --- /dev/null +++ b/packages/fuselage-ui-kit/jest.config.ts @@ -0,0 +1,27 @@ +export default { + preset: 'ts-jest', + errorOnDeprecated: true, + testEnvironment: 'jsdom', + modulePathIgnorePatterns: ['/dist/'], + transform: { + '^.+\\.(t|j)sx?$': [ + '@swc/jest', + { + sourceMaps: true, + jsc: { + parser: { + syntax: 'typescript', + tsx: true, + decorators: false, + dynamicImport: true, + }, + transform: { + react: { + runtime: 'automatic', + }, + }, + }, + }, + ], + }, +}; diff --git a/packages/fuselage-ui-kit/package.json b/packages/fuselage-ui-kit/package.json index 216efd02ef54..927948921659 100644 --- a/packages/fuselage-ui-kit/package.json +++ b/packages/fuselage-ui-kit/package.json @@ -32,6 +32,7 @@ ".:build:clean": "rimraf dist", ".:build:esm": "tsc -p tsconfig-esm.json", ".:build:cjs": "tsc -p tsconfig-cjs.json", + "test": "jest", "lint": "eslint --ext .js,.jsx,.ts,.tsx .", "typecheck": "tsc --noEmit", "docs": "cross-env NODE_ENV=production build-storybook -o ../../static/fuselage-ui-kit", @@ -81,6 +82,8 @@ "@storybook/source-loader": "~6.5.16", "@storybook/theming": "~6.5.16", "@tanstack/react-query": "^4.16.1", + "@testing-library/react": "^14.2.2", + "@testing-library/react-hooks": "^8.0.1", "@types/babel__core": "^7.20.3", "@types/babel__preset-env": "^7.9.4", "@types/react": "~17.0.69", @@ -88,14 +91,17 @@ "babel-loader": "~8.2.5", "cross-env": "^7.0.3", "eslint": "~8.45.0", + "i18next": "^23.10.1", + "jest": "^29.7.0", "normalize.css": "^8.0.1", "npm-run-all": "^4.1.5", "prettier": "~2.8.8", "react-docgen-typescript-plugin": "~1.0.5", "react-dom": "^17.0.2", - "react-i18next": "~13.2.2", + "react-i18next": "^14.1.0", "rimraf": "^3.0.2", "storybook-dark-mode": "~3.0.1", + "ts-jest": "^29.1.2", "tslib": "^2.5.3", "typescript": "~5.3.3" }, diff --git a/packages/fuselage-ui-kit/src/hooks/useAppTranslation.spec.tsx b/packages/fuselage-ui-kit/src/hooks/useAppTranslation.spec.tsx new file mode 100644 index 000000000000..fe84b1ffb771 --- /dev/null +++ b/packages/fuselage-ui-kit/src/hooks/useAppTranslation.spec.tsx @@ -0,0 +1,139 @@ +import { renderHook } from '@testing-library/react-hooks'; +import * as i18next from 'i18next'; +import type { FunctionComponent } from 'react'; +import { Suspense } from 'react'; +import { I18nextProvider, initReactI18next } from 'react-i18next'; + +import { AppIdProvider } from '../contexts/AppIdContext'; +import { useAppTranslation } from './useAppTranslation'; + +let i18n: i18next.i18n; + +beforeEach(async () => { + i18n = i18next.createInstance().use(initReactI18next); + + await i18n.init({ + lng: 'en', + resources: { + en: { + 'translation': { + test: 'a quick brown fox', + }, + 'app-test': { + test: 'jumped over the lazy dog', + }, + 'app-test-core': { + test: 'this should not be used', + }, + }, + }, + }); +}); + +it('should work with normal app ID (`test`)', async () => { + const { result } = renderHook(() => useAppTranslation().t('test'), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toBe('jumped over the lazy dog'); +}); + +it('should work with core app ID (`test-core`)', async () => { + const { result } = renderHook(() => useAppTranslation().t('test'), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toBe('a quick brown fox'); +}); + +describe('with suspense', () => { + let i18n: i18next.i18n; + + beforeEach(async () => { + i18n = i18next + .createInstance() + .use({ + type: 'backend', + init: () => undefined, + read: async (language, namespace) => { + await new Promise((resolve) => queueMicrotask(resolve)); + + if (language === 'en' && namespace === 'core') { + return { + test: 'a quick brown fox', + }; + } + + if (language === 'en' && namespace === 'app-test') { + return { + test: 'jumped over the lazy dog', + }; + } + + throw new Error(`Unexpected read request: ${language} ${namespace}`); + }, + } satisfies i18next.BackendModule) + .use(initReactI18next); + + await i18n.init({ + lng: 'en', + defaultNS: 'core', + partialBundledLanguages: true, + react: { + useSuspense: true, + }, + }); + }); + + it('should work with normal app ID (`test`)', async () => { + const FakeFallback: FunctionComponent = jest.fn(() => null); + + const { result, waitForNextUpdate } = renderHook( + () => useAppTranslation().t('test'), + { + wrapper: ({ children }) => ( + + }> + {children} + + + ), + } + ); + + await waitForNextUpdate(); + + expect(result.current).toBe('jumped over the lazy dog'); + expect(FakeFallback).toHaveBeenCalled(); + }); + + it('should work with core app ID (`test-core`)', async () => { + const FakeFallback: FunctionComponent = jest.fn(() => null); + + const { result, waitForNextUpdate } = renderHook( + () => useAppTranslation().t('test'), + { + wrapper: ({ children }) => ( + + }> + {children} + + + ), + } + ); + + await waitForNextUpdate(); + + expect(result.current).toBe('a quick brown fox'); + expect(FakeFallback).toHaveBeenCalled(); + }); +}); diff --git a/packages/fuselage-ui-kit/src/hooks/useAppTranslation.ts b/packages/fuselage-ui-kit/src/hooks/useAppTranslation.ts index c29cf0953386..5925f2fada10 100644 --- a/packages/fuselage-ui-kit/src/hooks/useAppTranslation.ts +++ b/packages/fuselage-ui-kit/src/hooks/useAppTranslation.ts @@ -5,7 +5,7 @@ import { useAppId } from '../contexts/AppIdContext'; export const useAppTranslation = () => { const appId = useAppId(); - const appNs = `app-${appId}`; + const appNs = appId.endsWith(`-core`) ? undefined : `app-${appId}`; useDebugValue(appNs); diff --git a/yarn.lock b/yarn.lock index 8993ef8f84cb..d2a910b75b9b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2946,6 +2946,15 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.9": + version: 7.24.4 + resolution: "@babel/runtime@npm:7.24.4" + dependencies: + regenerator-runtime: ^0.14.0 + checksum: 2f27d4c0ffac7ae7999ac0385e1106f2a06992a8bdcbf3da06adcac7413863cd08c198c2e4e970041bbea849e17f02e1df18875539b6afba76c781b6b59a07c3 + languageName: node + linkType: hard + "@babel/runtime@npm:~7.22.15": version: 7.22.15 resolution: "@babel/runtime@npm:7.22.15" @@ -8751,6 +8760,8 @@ __metadata: "@storybook/source-loader": ~6.5.16 "@storybook/theming": ~6.5.16 "@tanstack/react-query": ^4.16.1 + "@testing-library/react": ^14.2.2 + "@testing-library/react-hooks": ^8.0.1 "@types/babel__core": ^7.20.3 "@types/babel__preset-env": ^7.9.4 "@types/react": ~17.0.69 @@ -8758,14 +8769,17 @@ __metadata: babel-loader: ~8.2.5 cross-env: ^7.0.3 eslint: ~8.45.0 + i18next: ^23.10.1 + jest: ^29.7.0 normalize.css: ^8.0.1 npm-run-all: ^4.1.5 prettier: ~2.8.8 react-docgen-typescript-plugin: ~1.0.5 react-dom: ^17.0.2 - react-i18next: ~13.2.2 + react-i18next: ^14.1.0 rimraf: ^3.0.2 storybook-dark-mode: ~3.0.1 + ts-jest: ^29.1.2 tslib: ^2.5.3 typescript: ~5.3.3 peerDependencies: @@ -12546,6 +12560,20 @@ __metadata: languageName: node linkType: hard +"@testing-library/react@npm:^14.2.2": + version: 14.2.2 + resolution: "@testing-library/react@npm:14.2.2" + dependencies: + "@babel/runtime": ^7.12.5 + "@testing-library/dom": ^9.0.0 + "@types/react-dom": ^18.0.0 + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + checksum: cb73df588592d9101429f057eaa6f320fc12524d5eb2acc8a16002c1ee2d9422a49e44841003bba42974c9ae1ced6b134f0d647826eca42ab8f19e4592971b16 + languageName: node + linkType: hard + "@testing-library/user-event@npm:^13.2.1, @testing-library/user-event@npm:~13.5.0": version: 13.5.0 resolution: "@testing-library/user-event@npm:13.5.0" @@ -25592,6 +25620,15 @@ __metadata: languageName: node linkType: hard +"i18next@npm:^23.10.1": + version: 23.10.1 + resolution: "i18next@npm:23.10.1" + dependencies: + "@babel/runtime": ^7.23.2 + checksum: 4aec10ddb0bb841f15b9b023daa59977052bc706ca4e94643b12b17640731862bde596c9797491638f6d9e7f125722ea9b1e87394c7aebbb72f45c20396f79d9 + languageName: node + linkType: hard + "i18next@npm:~21.6.16": version: 21.6.16 resolution: "i18next@npm:21.6.16" @@ -27313,7 +27350,7 @@ __metadata: languageName: node linkType: hard -"jest-cli@npm:^29.5.0, jest-cli@npm:^29.6.4": +"jest-cli@npm:^29.5.0, jest-cli@npm:^29.6.4, jest-cli@npm:^29.7.0": version: 29.7.0 resolution: "jest-cli@npm:29.7.0" dependencies: @@ -27842,6 +27879,25 @@ __metadata: languageName: node linkType: hard +"jest@npm:^29.7.0": + version: 29.7.0 + resolution: "jest@npm:29.7.0" + dependencies: + "@jest/core": ^29.7.0 + "@jest/types": ^29.6.3 + import-local: ^3.0.2 + jest-cli: ^29.7.0 + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + bin: + jest: bin/jest.js + checksum: 17ca8d67504a7dbb1998cf3c3077ec9031ba3eb512da8d71cb91bcabb2b8995c4e4b292b740cb9bf1cbff5ce3e110b3f7c777b0cefb6f41ab05445f248d0ee0b + languageName: node + linkType: hard + "jest@npm:~29.5.0": version: 29.5.0 resolution: "jest@npm:29.5.0" @@ -34903,6 +34959,24 @@ __metadata: languageName: node linkType: hard +"react-i18next@npm:^14.1.0": + version: 14.1.0 + resolution: "react-i18next@npm:14.1.0" + dependencies: + "@babel/runtime": ^7.23.9 + html-parse-stringify: ^3.0.1 + peerDependencies: + i18next: ">= 23.2.3" + react: ">= 16.8.0" + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + checksum: 96fbc4b0919b9f0c639f9f3eb35eecac528174aa97e3b1af469cfdbff4b34e40ae8969c26c0b737691a5fa9b56bb13093524cfca79b93cbd58f1319530da31b2 + languageName: node + linkType: hard + "react-i18next@npm:~13.2.2": version: 13.2.2 resolution: "react-i18next@npm:13.2.2" From cc56f16de865b1956462bb9c8a49d2537b12b124 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Mon, 8 Apr 2024 15:37:02 -0300 Subject: [PATCH 035/131] chore: remove unused onStartup function (#32149) --- apps/meteor/server/lib/onStartup.ts | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 apps/meteor/server/lib/onStartup.ts diff --git a/apps/meteor/server/lib/onStartup.ts b/apps/meteor/server/lib/onStartup.ts deleted file mode 100644 index 2f449c449f4a..000000000000 --- a/apps/meteor/server/lib/onStartup.ts +++ /dev/null @@ -1,25 +0,0 @@ -type StartupCallback = () => Promise; - -const callbackList: StartupCallback[] = []; -let hasStarted = false; - -export const onStartup = (cb: StartupCallback): void => { - if (hasStarted) { - return Promise.await(cb()); - } - - callbackList.push(cb); -}; - -const runCallbacks = async (): Promise => { - for await (const cb of callbackList) { - await cb(); - } - - callbackList.splice(0, callbackList.length); -}; - -Meteor.startup(() => { - hasStarted = true; - Promise.await(runCallbacks()); -}); From a316c2b420cf471e3db43d8b738f65aea5d60b9b Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Mon, 8 Apr 2024 16:42:29 -0300 Subject: [PATCH 036/131] chore: bump to 6.8.0 (#32153) --- apps/meteor/app/utils/rocketchat.info | 2 +- apps/meteor/package.json | 2 +- package.json | 2 +- packages/core-typings/package.json | 2 +- packages/rest-typings/package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/meteor/app/utils/rocketchat.info b/apps/meteor/app/utils/rocketchat.info index 34642c087e2e..4eb357fe9ee6 100644 --- a/apps/meteor/app/utils/rocketchat.info +++ b/apps/meteor/app/utils/rocketchat.info @@ -1,3 +1,3 @@ { - "version": "7.0.0-develop" + "version": "6.8.0-develop" } diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 55a4bb113659..77dca03343d9 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -1,7 +1,7 @@ { "name": "@rocket.chat/meteor", "description": "The Ultimate Open Source WebChat Platform", - "version": "7.0.0-develop", + "version": "6.8.0-develop", "private": true, "author": { "name": "Rocket.Chat", diff --git a/package.json b/package.json index 4e0b99fa9daa..240e1a9a1e02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rocket.chat", - "version": "7.0.0-develop", + "version": "6.8.0-develop", "description": "Rocket.Chat Monorepo", "main": "index.js", "private": true, diff --git a/packages/core-typings/package.json b/packages/core-typings/package.json index 71aa4da0906d..00db0ed2d621 100644 --- a/packages/core-typings/package.json +++ b/packages/core-typings/package.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/package", "name": "@rocket.chat/core-typings", "private": true, - "version": "7.0.0-develop", + "version": "6.8.0-develop", "devDependencies": { "@rocket.chat/eslint-config": "workspace:^", "eslint": "~8.45.0", diff --git a/packages/rest-typings/package.json b/packages/rest-typings/package.json index 3f880c5b20d3..aed48c665870 100644 --- a/packages/rest-typings/package.json +++ b/packages/rest-typings/package.json @@ -1,7 +1,7 @@ { "name": "@rocket.chat/rest-typings", "private": true, - "version": "7.0.0-develop", + "version": "6.8.0-develop", "devDependencies": { "@rocket.chat/eslint-config": "workspace:^", "@types/jest": "~29.5.7", From 67be331ac4ab59c2adb7c89183b2aae801aac985 Mon Sep 17 00:00:00 2001 From: Matheus Barbosa Silva <36537004+matheusbsilva137@users.noreply.github.com> Date: Mon, 8 Apr 2024 18:46:00 -0300 Subject: [PATCH 037/131] fix: New messages export overwrites previous one from the same day when using Amazon S3 (#32062) --- .changeset/twelve-seas-battle.md | 5 + .../server/lib/dataExport/uploadZipFile.ts | 5 +- .../lib/dataExport/uploadZipFile.spec.ts | 153 ++++++++++++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 .changeset/twelve-seas-battle.md create mode 100644 apps/meteor/tests/unit/server/lib/dataExport/uploadZipFile.spec.ts diff --git a/.changeset/twelve-seas-battle.md b/.changeset/twelve-seas-battle.md new file mode 100644 index 000000000000..a527a93f6212 --- /dev/null +++ b/.changeset/twelve-seas-battle.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed an issue where old exports would get overwritten by new ones if generated on the same day, when using external storage services (such as Amazon S3) diff --git a/apps/meteor/server/lib/dataExport/uploadZipFile.ts b/apps/meteor/server/lib/dataExport/uploadZipFile.ts index 5fe9ea2d57dd..77a16004bf64 100644 --- a/apps/meteor/server/lib/dataExport/uploadZipFile.ts +++ b/apps/meteor/server/lib/dataExport/uploadZipFile.ts @@ -3,6 +3,7 @@ import { stat } from 'fs/promises'; import type { IUser } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; import { FileUpload } from '../../../app/file-upload/server'; @@ -18,10 +19,12 @@ export const uploadZipFile = async (filePath: string, userId: IUser['_id'], expo const utcDate = new Date().toISOString().split('T')[0]; const fileSuffix = exportType === 'json' ? '-data' : ''; + const fileId = Random.id(); - const newFileName = encodeURIComponent(`${utcDate}-${userDisplayName}${fileSuffix}.zip`); + const newFileName = encodeURIComponent(`${utcDate}-${userDisplayName}${fileSuffix}-${fileId}.zip`); const details = { + _id: fileId, userId, type: contentType, size, diff --git a/apps/meteor/tests/unit/server/lib/dataExport/uploadZipFile.spec.ts b/apps/meteor/tests/unit/server/lib/dataExport/uploadZipFile.spec.ts new file mode 100644 index 000000000000..61a477b40df5 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/dataExport/uploadZipFile.spec.ts @@ -0,0 +1,153 @@ +import { expect } from 'chai'; +import { before, describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +// Create stubs for dependencies +const stubs = { + findOneUserById: sinon.stub(), + randomId: sinon.stub(), + stat: sinon.stub(), + getStore: sinon.stub(), + insertFileStub: sinon.stub(), + createReadStream: sinon.stub(), +}; + +const { uploadZipFile } = proxyquire.noCallThru().load('../../../../../server/lib/dataExport/uploadZipFile.ts', { + '@rocket.chat/models': { + Users: { + findOneById: stubs.findOneUserById, + }, + }, + '@rocket.chat/random': { + Random: { + id: stubs.randomId, + }, + }, + 'fs/promises': { + stat: stubs.stat, + }, + 'fs': { + createReadStream: stubs.createReadStream, + }, + '../../../app/file-upload/server': { + FileUpload: { + getStore: stubs.getStore, + }, + }, +}); + +describe('Export - uploadZipFile', () => { + const randomId = 'random-id'; + const fileStat = 100; + const userName = 'John Doe'; + const userUsername = 'john.doe'; + const userId = 'user-id'; + const filePath = 'random-path'; + + before(() => { + stubs.findOneUserById.returns({ name: userName }); + stubs.stat.returns({ size: fileStat }); + stubs.randomId.returns(randomId); + stubs.getStore.returns({ insert: stubs.insertFileStub }); + stubs.insertFileStub.callsFake((details) => ({ _id: details._id, name: details.name })); + }); + + it('should correctly build file name for json exports', async () => { + const result = await uploadZipFile(filePath, userId, 'json'); + + expect(stubs.findOneUserById.calledWith(userId)).to.be.true; + expect(stubs.stat.calledWith(filePath)).to.be.true; + expect(stubs.createReadStream.calledWith(filePath)).to.be.true; + expect(stubs.getStore.calledWith('UserDataFiles')).to.be.true; + expect( + stubs.insertFileStub.calledWith( + sinon.match({ + _id: randomId, + userId, + type: 'application/zip', + size: fileStat, + }), + ), + ).to.be.true; + + expect(result).to.have.property('_id', randomId); + expect(result).to.have.property('name').that.is.a.string; + const fileName: string = result.name; + expect(fileName.endsWith(encodeURIComponent(`${userName}-data-${randomId}.zip`))).to.be.true; + }); + + it('should correctly build file name for html exports', async () => { + const result = await uploadZipFile(filePath, userId, 'html'); + + expect(stubs.findOneUserById.calledWith(userId)).to.be.true; + expect(stubs.stat.calledWith(filePath)).to.be.true; + expect(stubs.createReadStream.calledWith(filePath)).to.be.true; + expect(stubs.getStore.calledWith('UserDataFiles')).to.be.true; + expect( + stubs.insertFileStub.calledWith( + sinon.match({ + _id: randomId, + userId, + type: 'application/zip', + size: fileStat, + }), + ), + ).to.be.true; + + expect(result).to.have.property('_id', randomId); + expect(result).to.have.property('name').that.is.a.string; + const fileName: string = result.name; + expect(fileName.endsWith(encodeURIComponent(`${userName}-${randomId}.zip`))).to.be.true; + }); + + it("should use username as a fallback in the zip file name when user's name is not defined", async () => { + stubs.findOneUserById.returns({ username: userUsername }); + const result = await uploadZipFile(filePath, userId, 'html'); + + expect(stubs.findOneUserById.calledWith(userId)).to.be.true; + expect(stubs.stat.calledWith(filePath)).to.be.true; + expect(stubs.createReadStream.calledWith(filePath)).to.be.true; + expect(stubs.getStore.calledWith('UserDataFiles')).to.be.true; + expect( + stubs.insertFileStub.calledWith( + sinon.match({ + _id: randomId, + userId, + type: 'application/zip', + size: fileStat, + }), + ), + ).to.be.true; + + expect(result).to.have.property('_id', randomId); + expect(result).to.have.property('name').that.is.a.string; + const fileName: string = result.name; + expect(fileName.endsWith(`${userUsername}-${randomId}.zip`)).to.be.true; + }); + + it("should use userId as a fallback in the zip file name when user's name and username are not defined", async () => { + stubs.findOneUserById.returns(undefined); + const result = await uploadZipFile(filePath, userId, 'html'); + + expect(stubs.findOneUserById.calledWith(userId)).to.be.true; + expect(stubs.stat.calledWith(filePath)).to.be.true; + expect(stubs.createReadStream.calledWith(filePath)).to.be.true; + expect(stubs.getStore.calledWith('UserDataFiles')).to.be.true; + expect( + stubs.insertFileStub.calledWith( + sinon.match({ + _id: randomId, + userId, + type: 'application/zip', + size: fileStat, + }), + ), + ).to.be.true; + + expect(result).to.have.property('_id', randomId); + expect(result).to.have.property('name').that.is.a.string; + const fileName: string = result.name; + expect(fileName.endsWith(`${userId}-${randomId}.zip`)).to.be.true; + }); +}); From 474589ffa73b557572012ec51541b1f0e91e84c9 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Mon, 8 Apr 2024 19:00:52 -0300 Subject: [PATCH 038/131] chore(meteor/packages): apply code style (#32145) --- .../packages/autoupdate/autoupdate_client.js | 89 +- .../packages/autoupdate/autoupdate_server.js | 60 +- .../packages/autoupdate/client_versions.js | 23 +- apps/meteor/packages/autoupdate/package.js | 2 +- .../packages/flow-router/client/_init.js | 6 +- .../packages/flow-router/client/group.js | 88 +- .../packages/flow-router/client/modules.js | 2 +- .../packages/flow-router/client/route.js | 178 ++- .../packages/flow-router/client/router.js | 1030 ++++++++--------- .../packages/flow-router/client/triggers.js | 176 +-- .../meteor/packages/flow-router/lib/router.js | 16 +- .../packages/flow-router/server/group.js | 24 +- .../packages/flow-router/server/route.js | 39 +- .../packages/flow-router/server/router.js | 182 ++- .../linkedin-oauth/linkedin-server.js | 10 +- .../meteor/packages/meteor-cookies/cookies.js | 908 +++++++-------- .../meteor/packages/meteor-cookies/package.js | 1 - .../lib/collection.overwrites.js | 80 +- .../packages/meteor-run-as-user/lib/common.js | 88 +- .../meteor-run-as-user/lib/pre.1.0.3.js | 154 ++- 20 files changed, 1552 insertions(+), 1604 deletions(-) diff --git a/apps/meteor/packages/autoupdate/autoupdate_client.js b/apps/meteor/packages/autoupdate/autoupdate_client.js index 36202d32bc5f..f9613935dbba 100644 --- a/apps/meteor/packages/autoupdate/autoupdate_client.js +++ b/apps/meteor/packages/autoupdate/autoupdate_client.js @@ -25,37 +25,27 @@ // The client version of the client code currently running in the // browser. -import { ClientVersions } from "./client_versions.js"; +import { ClientVersions } from './client_versions.js'; -const clientArch = Meteor.isCordova ? "web.cordova" : - Meteor.isModern ? "web.browser" : "web.browser.legacy"; +const clientArch = Meteor.isCordova ? 'web.cordova' : Meteor.isModern ? 'web.browser' : 'web.browser.legacy'; -const autoupdateVersions = - ((__meteor_runtime_config__.autoupdate || {}).versions || {})[clientArch] || { - version: "unknown", - versionRefreshable: "unknown", - versionNonRefreshable: "unknown", - assets: [], - }; +const autoupdateVersions = ((__meteor_runtime_config__.autoupdate || {}).versions || {})[clientArch] || { + version: 'unknown', + versionRefreshable: 'unknown', + versionNonRefreshable: 'unknown', + assets: [], +}; export const Autoupdate = {}; // Stores acceptable client versions. -const clientVersions = - Autoupdate._clientVersions = // Used by a self-test and hot-module-replacement - new ClientVersions(); +const clientVersions = (Autoupdate._clientVersions = // Used by a self-test and hot-module-replacement + new ClientVersions()); -Meteor.connection.registerStore( - "meteor_autoupdate_clientVersions", - clientVersions.createStore() -); +Meteor.connection.registerStore('meteor_autoupdate_clientVersions', clientVersions.createStore()); Autoupdate.newClientAvailable = function () { - return clientVersions.newClientAvailable( - clientArch, - ["versionRefreshable", "versionNonRefreshable"], - autoupdateVersions - ); + return clientVersions.newClientAvailable(clientArch, ['versionRefreshable', 'versionNonRefreshable'], autoupdateVersions); }; // Set to true if the link.onload callback ever fires for any node. @@ -71,15 +61,15 @@ const retry = new Retry({ // server fixing code will result in a restart and reconnect, but // potentially the subscription could have a transient error. minCount: 0, // don't do any immediate retries - baseTimeout: 30*1000 // start with 30s + baseTimeout: 30 * 1000, // start with 30s }); let failures = 0; Autoupdate._retrySubscription = () => { - Meteor.subscribe("meteor_autoupdate_clientVersions", { + Meteor.subscribe('meteor_autoupdate_clientVersions', { onError(error) { - Meteor._debug("autoupdate subscription failed", error); + Meteor._debug('autoupdate subscription failed', error); failures++; retry.retryLater(failures, function () { // Just retry making the subscription, don't reload the whole @@ -111,8 +101,7 @@ Autoupdate._retrySubscription = () => { return; } - if (doc.versionNonRefreshable !== - autoupdateVersions.versionNonRefreshable) { + if (doc.versionNonRefreshable !== autoupdateVersions.versionNonRefreshable) { // Non-refreshable assets have changed, so we have to reload the // whole page rather than just replacing tags. if (stop) stop(); @@ -121,7 +110,13 @@ Autoupdate._retrySubscription = () => { // is provided by the ddp package that autoupdate depends on. // Delay reload in 60 seconds - console.warn('Client version changed from', autoupdateVersions.versionNonRefreshable, 'to', doc.versionNonRefreshable, `Page will reload in ${reloadDelayInSeconds} seconds`); + console.warn( + 'Client version changed from', + autoupdateVersions.versionNonRefreshable, + 'to', + doc.versionNonRefreshable, + `Page will reload in ${reloadDelayInSeconds} seconds`, + ); setTimeout(() => { Package.reload.Reload._reload(); }, reloadDelayInSeconds * 1000); @@ -137,30 +132,27 @@ Autoupdate._retrySubscription = () => { var newCss = doc.assets || []; var oldLinks = []; - Array.prototype.forEach.call( - document.getElementsByTagName('link'), - function (link) { - if (link.className === '__meteor-css__') { - oldLinks.push(link); - } + Array.prototype.forEach.call(document.getElementsByTagName('link'), function (link) { + if (link.className === '__meteor-css__') { + oldLinks.push(link); } - ); + }); function waitUntilCssLoads(link, callback) { var called; link.onload = function () { knownToSupportCssOnLoad = true; - if (! called) { + if (!called) { called = true; callback(); } }; - if (! knownToSupportCssOnLoad) { + if (!knownToSupportCssOnLoad) { var id = Meteor.setInterval(function () { if (link.sheet) { - if (! called) { + if (!called) { called = true; callback(); } @@ -172,27 +164,26 @@ Autoupdate._retrySubscription = () => { let newLinksLeftToLoad = newCss.length; function removeOldLinks() { - if (oldLinks.length > 0 && - --newLinksLeftToLoad < 1) { - oldLinks.splice(0).forEach(link => { + if (oldLinks.length > 0 && --newLinksLeftToLoad < 1) { + oldLinks.splice(0).forEach((link) => { link.parentNode.removeChild(link); }); } } if (newCss.length > 0) { - newCss.forEach(css => { - const newLink = document.createElement("link"); - newLink.setAttribute("rel", "stylesheet"); - newLink.setAttribute("type", "text/css"); - newLink.setAttribute("class", "__meteor-css__"); - newLink.setAttribute("href", css.url); + newCss.forEach((css) => { + const newLink = document.createElement('link'); + newLink.setAttribute('rel', 'stylesheet'); + newLink.setAttribute('type', 'text/css'); + newLink.setAttribute('class', '__meteor-css__'); + newLink.setAttribute('href', css.url); waitUntilCssLoads(newLink, function () { Meteor.setTimeout(removeOldLinks, 200); }); - const head = document.getElementsByTagName("head").item(0); + const head = document.getElementsByTagName('head').item(0); head.appendChild(newLink); }); } else { @@ -200,7 +191,7 @@ Autoupdate._retrySubscription = () => { } } } - } + }, }); }; diff --git a/apps/meteor/packages/autoupdate/autoupdate_server.js b/apps/meteor/packages/autoupdate/autoupdate_server.js index a1d468490bfa..5ffbf92e82a6 100644 --- a/apps/meteor/packages/autoupdate/autoupdate_server.js +++ b/apps/meteor/packages/autoupdate/autoupdate_server.js @@ -25,18 +25,18 @@ // The ID of each document is the client architecture, and the fields of // the document are the versions described above. -import { ClientVersions } from "./client_versions.js"; -var Future = Npm.require("fibers/future"); +import { ClientVersions } from './client_versions.js'; +var Future = Npm.require('fibers/future'); -export const Autoupdate = __meteor_runtime_config__.autoupdate = { +export const Autoupdate = (__meteor_runtime_config__.autoupdate = { // Map from client architectures (web.browser, web.browser.legacy, // web.cordova) to version fields { version, versionRefreshable, // versionNonRefreshable, refreshable } that will be stored in // ClientVersions documents (whose IDs are client architectures). This // data gets serialized into the boilerplate because it's stored in // __meteor_runtime_config__.autoupdate.versions. - versions: {} -}; + versions: {}, +}); // Stores acceptable client versions. const clientVersions = new ClientVersions(); @@ -65,22 +65,18 @@ function updateVersions(shouldReloadClientProgram) { // If the AUTOUPDATE_VERSION environment variable is defined, it takes // precedence, but Autoupdate.autoupdateVersion is still supported as // a fallback. In most cases neither of these values will be defined. - AUTOUPDATE_VERSION = Autoupdate.autoupdateVersion + AUTOUPDATE_VERSION = Autoupdate.autoupdateVersion, } = process.env; // Step 2: update __meteor_runtime_config__.autoupdate.versions. const clientArchs = Object.keys(WebApp.clientPrograms); - clientArchs.forEach(arch => { + clientArchs.forEach((arch) => { Autoupdate.versions[arch] = { - version: AUTOUPDATE_VERSION || - WebApp.calculateClientHash(arch), - versionRefreshable: AUTOUPDATE_VERSION || - WebApp.calculateClientHashRefreshable(arch), - versionNonRefreshable: AUTOUPDATE_VERSION || - WebApp.calculateClientHashNonRefreshable(arch), - versionReplaceable: AUTOUPDATE_VERSION || - WebApp.calculateClientHashReplaceable(arch), - versionHmr: WebApp.clientPrograms[arch].hmrVersion + version: AUTOUPDATE_VERSION || WebApp.calculateClientHash(arch), + versionRefreshable: AUTOUPDATE_VERSION || WebApp.calculateClientHashRefreshable(arch), + versionNonRefreshable: AUTOUPDATE_VERSION || WebApp.calculateClientHashNonRefreshable(arch), + versionReplaceable: AUTOUPDATE_VERSION || WebApp.calculateClientHashReplaceable(arch), + versionHmr: WebApp.clientPrograms[arch].hmrVersion, }; }); @@ -95,7 +91,7 @@ function updateVersions(shouldReloadClientProgram) { // `WebApp.getRefreshableAssets`, which is only set after // `WebApp.generateBoilerplate` is called by `main` in webapp. WebApp.onListening(() => { - clientArchs.forEach(arch => { + clientArchs.forEach((arch) => { const payload = { ...Autoupdate.versions[arch], assets: WebApp.getRefreshableAssets(arch), @@ -107,7 +103,7 @@ function updateVersions(shouldReloadClientProgram) { } Meteor.publish( - "meteor_autoupdate_clientVersions", + 'meteor_autoupdate_clientVersions', function (appId) { // `null` happens when a client doesn't have an appId and passes // `undefined` to `Meteor.subscribe`. `undefined` is translated to @@ -116,23 +112,21 @@ Meteor.publish( // Don't notify clients using wrong appId such as mobile apps built with a // different server but pointing at the same local url - if (Autoupdate.appId && appId && Autoupdate.appId !== appId) - return []; + if (Autoupdate.appId && appId && Autoupdate.appId !== appId) return []; // Random value to delay the updates for 2-10 minutes const randomInterval = Meteor.isProduction ? (Math.floor(Math.random() * 8) + 2) * 1000 * 60 : 0; const stop = clientVersions.watch((version, isNew) => { setTimeout(() => { - (isNew ? this.added : this.changed) - .call(this, "meteor_autoupdate_clientVersions", version._id, version) + (isNew ? this.added : this.changed).call(this, 'meteor_autoupdate_clientVersions', version._id, version); }, randomInterval); }); this.onStop(() => stop()); this.ready(); }, - {is_auto: true} + { is_auto: true }, ); Meteor.startup(function () { @@ -140,12 +134,9 @@ Meteor.startup(function () { // Force any connected clients that are still looking for these older // document IDs to reload. - ["version", - "version-refreshable", - "version-cordova", - ].forEach(_id => { + ['version', 'version-refreshable', 'version-cordova'].forEach((_id) => { clientVersions.set(_id, { - version: "outdated" + version: 'outdated', }); }); }); @@ -173,10 +164,13 @@ function enqueueVersionsRefresh() { } // Listen for messages pertaining to the client-refresh topic. -import { onMessage } from "meteor/inter-process-messaging"; -onMessage("client-refresh", enqueueVersionsRefresh); +import { onMessage } from 'meteor/inter-process-messaging'; +onMessage('client-refresh', enqueueVersionsRefresh); // Another way to tell the process to refresh: send SIGHUP signal -process.on('SIGHUP', Meteor.bindEnvironment(function () { - enqueueVersionsRefresh(); -}, "handling SIGHUP signal for refresh")); +process.on( + 'SIGHUP', + Meteor.bindEnvironment(function () { + enqueueVersionsRefresh(); + }, 'handling SIGHUP signal for refresh'), +); diff --git a/apps/meteor/packages/autoupdate/client_versions.js b/apps/meteor/packages/autoupdate/client_versions.js index 1f7b6069fc2d..f0ffc0bedbeb 100644 --- a/apps/meteor/packages/autoupdate/client_versions.js +++ b/apps/meteor/packages/autoupdate/client_versions.js @@ -1,4 +1,4 @@ -import { Tracker } from "meteor/tracker"; +import { Tracker } from 'meteor/tracker'; export class ClientVersions { constructor() { @@ -12,10 +12,10 @@ export class ClientVersions { createStore() { return { update: ({ id, msg, fields }) => { - if (msg === "added" || msg === "changed") { + if (msg === 'added' || msg === 'changed') { this.set(id, fields); } - } + }, }; } @@ -39,7 +39,7 @@ export class ClientVersions { } else { version = { _id: id, - ...fields + ...fields, }; isNew = true; @@ -47,7 +47,7 @@ export class ClientVersions { } this._watchCallbacks.forEach(({ fn, filter }) => { - if (! filter || filter === version._id) { + if (!filter || filter === version._id) { fn(version, isNew); } }); @@ -59,11 +59,11 @@ export class ClientVersions { // documents. If `filter` is set, the callback is only invoked for documents // with ID `filter`. watch(fn, { skipInitial, filter } = {}) { - if (! skipInitial) { + if (!skipInitial) { const resolved = Promise.resolve(); this._versions.forEach((version) => { - if (! filter || filter === version._id) { + if (!filter || filter === version._id) { resolved.then(() => fn(version, true)); } }); @@ -78,10 +78,7 @@ export class ClientVersions { // A reactive data source for `Autoupdate.newClientAvailable`. newClientAvailable(id, fields, currentVersion) { function isNewVersion(version) { - return ( - version._id === id && - fields.some((field) => version[field] !== currentVersion[field]) - ); + return version._id === id && fields.some((field) => version[field] !== currentVersion[field]); } const dependency = new Tracker.Dependency(); @@ -96,9 +93,9 @@ export class ClientVersions { stop(); } }, - { skipInitial: true } + { skipInitial: true }, ); - return !! version && isNewVersion(version); + return !!version && isNewVersion(version); } } diff --git a/apps/meteor/packages/autoupdate/package.js b/apps/meteor/packages/autoupdate/package.js index a7ad595e0692..52941121a9b6 100644 --- a/apps/meteor/packages/autoupdate/package.js +++ b/apps/meteor/packages/autoupdate/package.js @@ -3,7 +3,7 @@ Package.describe({ version: '1.8.0', }); -Package.onUse(function(api) { +Package.onUse(function (api) { api.use(['webapp', 'check', 'inter-process-messaging'], 'server'); api.use(['tracker', 'retry'], 'client'); diff --git a/apps/meteor/packages/flow-router/client/_init.js b/apps/meteor/packages/flow-router/client/_init.js index a18fdc897bac..9bd2fe28c20e 100644 --- a/apps/meteor/packages/flow-router/client/_init.js +++ b/apps/meteor/packages/flow-router/client/_init.js @@ -5,7 +5,7 @@ FlowRouter.Route = Route; // Initialize FlowRouter Meteor.startup(function () { - if(!FlowRouter._askedToWait) { - FlowRouter.initialize(); - } + if (!FlowRouter._askedToWait) { + FlowRouter.initialize(); + } }); diff --git a/apps/meteor/packages/flow-router/client/group.js b/apps/meteor/packages/flow-router/client/group.js index b93296bc2ada..d8c9869e1378 100644 --- a/apps/meteor/packages/flow-router/client/group.js +++ b/apps/meteor/packages/flow-router/client/group.js @@ -1,57 +1,57 @@ -Group = function(router, options, parent) { - options = options || {}; - - if (options.prefix && !/^\/.*/.test(options.prefix)) { - var message = "group's prefix must start with '/'"; - throw new Error(message); - } - - this._router = router; - this.prefix = options.prefix || ''; - this.name = options.name; - this.options = options; - - this._triggersEnter = options.triggersEnter || []; - this._triggersExit = options.triggersExit || []; - this._subscriptions = options.subscriptions || Function.prototype; - - this.parent = parent; - if (this.parent) { - this.prefix = parent.prefix + this.prefix; - - this._triggersEnter = parent._triggersEnter.concat(this._triggersEnter); - this._triggersExit = this._triggersExit.concat(parent._triggersExit); - } +Group = function (router, options, parent) { + options = options || {}; + + if (options.prefix && !/^\/.*/.test(options.prefix)) { + var message = "group's prefix must start with '/'"; + throw new Error(message); + } + + this._router = router; + this.prefix = options.prefix || ''; + this.name = options.name; + this.options = options; + + this._triggersEnter = options.triggersEnter || []; + this._triggersExit = options.triggersExit || []; + this._subscriptions = options.subscriptions || Function.prototype; + + this.parent = parent; + if (this.parent) { + this.prefix = parent.prefix + this.prefix; + + this._triggersEnter = parent._triggersEnter.concat(this._triggersEnter); + this._triggersExit = this._triggersExit.concat(parent._triggersExit); + } }; -Group.prototype.route = function(pathDef, options, group) { - options = options || {}; +Group.prototype.route = function (pathDef, options, group) { + options = options || {}; - if (!/^\/.*/.test(pathDef)) { - var message = "route's path must start with '/'"; - throw new Error(message); - } + if (!/^\/.*/.test(pathDef)) { + var message = "route's path must start with '/'"; + throw new Error(message); + } - group = group || this; - pathDef = this.prefix + pathDef; + group = group || this; + pathDef = this.prefix + pathDef; - var triggersEnter = options.triggersEnter || []; - options.triggersEnter = this._triggersEnter.concat(triggersEnter); + var triggersEnter = options.triggersEnter || []; + options.triggersEnter = this._triggersEnter.concat(triggersEnter); - var triggersExit = options.triggersExit || []; - options.triggersExit = triggersExit.concat(this._triggersExit); + var triggersExit = options.triggersExit || []; + options.triggersExit = triggersExit.concat(this._triggersExit); - return this._router.route(pathDef, options, group); + return this._router.route(pathDef, options, group); }; -Group.prototype.group = function(options) { - return new Group(this._router, options, this); +Group.prototype.group = function (options) { + return new Group(this._router, options, this); }; -Group.prototype.callSubscriptions = function(current) { - if (this.parent) { - this.parent.callSubscriptions(current); - } +Group.prototype.callSubscriptions = function (current) { + if (this.parent) { + this.parent.callSubscriptions(current); + } - this._subscriptions.call(current.route, current.params, current.queryParams); + this._subscriptions.call(current.route, current.params, current.queryParams); }; diff --git a/apps/meteor/packages/flow-router/client/modules.js b/apps/meteor/packages/flow-router/client/modules.js index 7b734f449b30..c9e6e03a73f7 100644 --- a/apps/meteor/packages/flow-router/client/modules.js +++ b/apps/meteor/packages/flow-router/client/modules.js @@ -1,2 +1,2 @@ page = require('page'); -qs = require('qs'); +qs = require('qs'); diff --git a/apps/meteor/packages/flow-router/client/route.js b/apps/meteor/packages/flow-router/client/route.js index b82e9721380f..de8cfb8d3544 100644 --- a/apps/meteor/packages/flow-router/client/route.js +++ b/apps/meteor/packages/flow-router/client/route.js @@ -1,125 +1,123 @@ -Route = function(router, pathDef, options, group) { - options = options || {}; +Route = function (router, pathDef, options, group) { + options = options || {}; - this.options = options; - this.pathDef = pathDef + this.options = options; + this.pathDef = pathDef; - // Route.path is deprecated and will be removed in 3.0 - this.path = pathDef; + // Route.path is deprecated and will be removed in 3.0 + this.path = pathDef; - if (options.name) { - this.name = options.name; - } + if (options.name) { + this.name = options.name; + } - this._action = options.action || Function.prototype; - this._subscriptions = options.subscriptions || Function.prototype; - this._triggersEnter = options.triggersEnter || []; - this._triggersExit = options.triggersExit || []; - this._subsMap = {}; - this._router = router; + this._action = options.action || Function.prototype; + this._subscriptions = options.subscriptions || Function.prototype; + this._triggersEnter = options.triggersEnter || []; + this._triggersExit = options.triggersExit || []; + this._subsMap = {}; + this._router = router; - this._params = new ReactiveDict(); - this._queryParams = new ReactiveDict(); - this._routeCloseDep = new Tracker.Dependency(); + this._params = new ReactiveDict(); + this._queryParams = new ReactiveDict(); + this._routeCloseDep = new Tracker.Dependency(); - // tracks the changes in the URL - this._pathChangeDep = new Tracker.Dependency(); + // tracks the changes in the URL + this._pathChangeDep = new Tracker.Dependency(); - this.group = group; + this.group = group; }; -Route.prototype.clearSubscriptions = function() { - this._subsMap = {}; +Route.prototype.clearSubscriptions = function () { + this._subsMap = {}; }; -Route.prototype.register = function(name, sub, options) { - this._subsMap[name] = sub; +Route.prototype.register = function (name, sub, options) { + this._subsMap[name] = sub; }; - -Route.prototype.getSubscription = function(name) { - return this._subsMap[name]; +Route.prototype.getSubscription = function (name) { + return this._subsMap[name]; }; - -Route.prototype.getAllSubscriptions = function() { - return this._subsMap; +Route.prototype.getAllSubscriptions = function () { + return this._subsMap; }; -Route.prototype.callAction = function(current) { - var self = this; - self._action(current.params, current.queryParams); +Route.prototype.callAction = function (current) { + var self = this; + self._action(current.params, current.queryParams); }; -Route.prototype.callSubscriptions = function(current) { - this.clearSubscriptions(); - if (this.group) { - this.group.callSubscriptions(current); - } +Route.prototype.callSubscriptions = function (current) { + this.clearSubscriptions(); + if (this.group) { + this.group.callSubscriptions(current); + } - this._subscriptions(current.params, current.queryParams); + this._subscriptions(current.params, current.queryParams); }; -Route.prototype.getRouteName = function() { - this._routeCloseDep.depend(); - return this.name; +Route.prototype.getRouteName = function () { + this._routeCloseDep.depend(); + return this.name; }; -Route.prototype.getParam = function(key) { - this._routeCloseDep.depend(); - return this._params.get(key); +Route.prototype.getParam = function (key) { + this._routeCloseDep.depend(); + return this._params.get(key); }; -Route.prototype.getQueryParam = function(key) { - this._routeCloseDep.depend(); - return this._queryParams.get(key); +Route.prototype.getQueryParam = function (key) { + this._routeCloseDep.depend(); + return this._queryParams.get(key); }; -Route.prototype.watchPathChange = function() { - this._pathChangeDep.depend(); +Route.prototype.watchPathChange = function () { + this._pathChangeDep.depend(); }; -Route.prototype.registerRouteClose = function() { - this._params = new ReactiveDict(); - this._queryParams = new ReactiveDict(); - this._routeCloseDep.changed(); - this._pathChangeDep.changed(); +Route.prototype.registerRouteClose = function () { + this._params = new ReactiveDict(); + this._queryParams = new ReactiveDict(); + this._routeCloseDep.changed(); + this._pathChangeDep.changed(); }; -Route.prototype.registerRouteChange = function(currentContext, routeChanging) { - // register params - var params = currentContext.params; - this._updateReactiveDict(this._params, params); - - // register query params - var queryParams = currentContext.queryParams; - this._updateReactiveDict(this._queryParams, queryParams); - - // if the route is changing, we need to defer triggering path changing - // if we did this, old route's path watchers will detect this - // Real issue is, above watcher will get removed with the new route - // So, we don't need to trigger it now - // We are doing it on the route close event. So, if they exists they'll - // get notify that - if(!routeChanging) { - this._pathChangeDep.changed(); - } +Route.prototype.registerRouteChange = function (currentContext, routeChanging) { + // register params + var params = currentContext.params; + this._updateReactiveDict(this._params, params); + + // register query params + var queryParams = currentContext.queryParams; + this._updateReactiveDict(this._queryParams, queryParams); + + // if the route is changing, we need to defer triggering path changing + // if we did this, old route's path watchers will detect this + // Real issue is, above watcher will get removed with the new route + // So, we don't need to trigger it now + // We are doing it on the route close event. So, if they exists they'll + // get notify that + if (!routeChanging) { + this._pathChangeDep.changed(); + } }; -Route.prototype._updateReactiveDict = function(dict, newValues) { - var currentKeys = _.keys(newValues); - var oldKeys = _.keys(dict.keyDeps); - - // set new values - // params is an array. So, _.each(params) does not works - // to iterate params - _.each(currentKeys, function(key) { - dict.set(key, newValues[key]); - }); - - // remove keys which does not exisits here - var removedKeys = _.difference(oldKeys, currentKeys); - _.each(removedKeys, function(key) { - dict.set(key, undefined); - }); +Route.prototype._updateReactiveDict = function (dict, newValues) { + var currentKeys = _.keys(newValues); + var oldKeys = _.keys(dict.keyDeps); + + // set new values + // params is an array. So, _.each(params) does not works + // to iterate params + _.each(currentKeys, function (key) { + dict.set(key, newValues[key]); + }); + + // remove keys which does not exisits here + var removedKeys = _.difference(oldKeys, currentKeys); + _.each(removedKeys, function (key) { + dict.set(key, undefined); + }); }; diff --git a/apps/meteor/packages/flow-router/client/router.js b/apps/meteor/packages/flow-router/client/router.js index ae91751f2a72..3706b7b9fd53 100644 --- a/apps/meteor/packages/flow-router/client/router.js +++ b/apps/meteor/packages/flow-router/client/router.js @@ -1,586 +1,574 @@ Router = function () { - var self = this; - this.globals = []; - this.subscriptions = Function.prototype; - - this._tracker = this._buildTracker(); - this._current = {}; - - // tracks the current path change - this._onEveryPath = new Tracker.Dependency(); - - this._globalRoute = new Route(this); - - // holds onRoute callbacks - this._onRouteCallbacks = []; - - // if _askedToWait is true. We don't automatically start the router - // in Meteor.startup callback. (see client/_init.js) - // Instead user need to call `.initialize() - this._askedToWait = false; - this._initialized = false; - this._triggersEnter = []; - this._triggersExit = []; - this._routes = []; - this._routesMap = {}; - this._updateCallbacks(); - this.notFound = this.notfound = null; - // indicate it's okay (or not okay) to run the tracker - // when doing subscriptions - // using a number and increment it help us to support FlowRouter.go() - // and legitimate reruns inside tracker on the same event loop. - // this is a solution for #145 - this.safeToRun = 0; - - // Meteor exposes to the client the path prefix that was defined using the - // ROOT_URL environement variable on the server using the global runtime - // configuration. See #315. - this._basePath = __meteor_runtime_config__.ROOT_URL_PATH_PREFIX || ''; - - // this is a chain contains a list of old routes - // most of the time, there is only one old route - // but when it's the time for a trigger redirect we've a chain - this._oldRouteChain = []; - - this.env = { - replaceState: new Meteor.EnvironmentVariable(), - reload: new Meteor.EnvironmentVariable(), - trailingSlash: new Meteor.EnvironmentVariable() - }; - - // redirect function used inside triggers - this._redirectFn = function(pathDef, fields, queryParams) { - if (/^http(s)?:\/\//.test(pathDef)) { - var message = "Redirects to URLs outside of the app are not supported in this version of Flow Router. Use 'window.location = yourUrl' instead"; - throw new Error(message); - } - self.withReplaceState(function() { - var path = FlowRouter.path(pathDef, fields, queryParams); - self._page.redirect(path); - }); - }; - this._initTriggersAPI(); + var self = this; + this.globals = []; + this.subscriptions = Function.prototype; + + this._tracker = this._buildTracker(); + this._current = {}; + + // tracks the current path change + this._onEveryPath = new Tracker.Dependency(); + + this._globalRoute = new Route(this); + + // holds onRoute callbacks + this._onRouteCallbacks = []; + + // if _askedToWait is true. We don't automatically start the router + // in Meteor.startup callback. (see client/_init.js) + // Instead user need to call `.initialize() + this._askedToWait = false; + this._initialized = false; + this._triggersEnter = []; + this._triggersExit = []; + this._routes = []; + this._routesMap = {}; + this._updateCallbacks(); + this.notFound = this.notfound = null; + // indicate it's okay (or not okay) to run the tracker + // when doing subscriptions + // using a number and increment it help us to support FlowRouter.go() + // and legitimate reruns inside tracker on the same event loop. + // this is a solution for #145 + this.safeToRun = 0; + + // Meteor exposes to the client the path prefix that was defined using the + // ROOT_URL environement variable on the server using the global runtime + // configuration. See #315. + this._basePath = __meteor_runtime_config__.ROOT_URL_PATH_PREFIX || ''; + + // this is a chain contains a list of old routes + // most of the time, there is only one old route + // but when it's the time for a trigger redirect we've a chain + this._oldRouteChain = []; + + this.env = { + replaceState: new Meteor.EnvironmentVariable(), + reload: new Meteor.EnvironmentVariable(), + trailingSlash: new Meteor.EnvironmentVariable(), + }; + + // redirect function used inside triggers + this._redirectFn = function (pathDef, fields, queryParams) { + if (/^http(s)?:\/\//.test(pathDef)) { + var message = + "Redirects to URLs outside of the app are not supported in this version of Flow Router. Use 'window.location = yourUrl' instead"; + throw new Error(message); + } + self.withReplaceState(function () { + var path = FlowRouter.path(pathDef, fields, queryParams); + self._page.redirect(path); + }); + }; + this._initTriggersAPI(); }; -Router.prototype.route = function(pathDef, options, group) { - if (!/^\/.*/.test(pathDef)) { - var message = "route's path must start with '/'"; - throw new Error(message); - } - - options = options || {}; - var self = this; - var route = new Route(this, pathDef, options, group); - - // calls when the page route being activates - route._actionHandle = function (context, next) { - var oldRoute = self._current.route; - self._oldRouteChain.push(oldRoute); - - var queryParams = self._qs.parse(context.querystring); - // _qs.parse() gives us a object without prototypes, - // created with Object.create(null) - // Meteor's check doesn't play nice with it. - // So, we need to fix it by cloning it. - // see more: https://github.com/meteorhacks/flow-router/issues/164 - queryParams = JSON.parse(JSON.stringify(queryParams)); - - self._current = { - path: context.path, - context: context, - params: context.params, - queryParams: queryParams, - route: route, - oldRoute: oldRoute - }; - - // we need to invalidate if all the triggers have been completed - // if not that means, we've been redirected to another path - // then we don't need to invalidate - var afterAllTriggersRan = function() { - self._invalidateTracker(); - }; - - var triggers = self._triggersEnter.concat(route._triggersEnter); - Triggers.runTriggers( - triggers, - self._current, - self._redirectFn, - afterAllTriggersRan - ); - }; - - // calls when you exit from the page js route - route._exitHandle = function(context, next) { - var triggers = self._triggersExit.concat(route._triggersExit); - Triggers.runTriggers( - triggers, - self._current, - self._redirectFn, - next - ); - }; - - this._routes.push(route); - if (options.name) { - this._routesMap[options.name] = route; - } - - this._updateCallbacks(); - this._triggerRouteRegister(route); - - return route; +Router.prototype.route = function (pathDef, options, group) { + if (!/^\/.*/.test(pathDef)) { + var message = "route's path must start with '/'"; + throw new Error(message); + } + + options = options || {}; + var self = this; + var route = new Route(this, pathDef, options, group); + + // calls when the page route being activates + route._actionHandle = function (context, next) { + var oldRoute = self._current.route; + self._oldRouteChain.push(oldRoute); + + var queryParams = self._qs.parse(context.querystring); + // _qs.parse() gives us a object without prototypes, + // created with Object.create(null) + // Meteor's check doesn't play nice with it. + // So, we need to fix it by cloning it. + // see more: https://github.com/meteorhacks/flow-router/issues/164 + queryParams = JSON.parse(JSON.stringify(queryParams)); + + self._current = { + path: context.path, + context: context, + params: context.params, + queryParams: queryParams, + route: route, + oldRoute: oldRoute, + }; + + // we need to invalidate if all the triggers have been completed + // if not that means, we've been redirected to another path + // then we don't need to invalidate + var afterAllTriggersRan = function () { + self._invalidateTracker(); + }; + + var triggers = self._triggersEnter.concat(route._triggersEnter); + Triggers.runTriggers(triggers, self._current, self._redirectFn, afterAllTriggersRan); + }; + + // calls when you exit from the page js route + route._exitHandle = function (context, next) { + var triggers = self._triggersExit.concat(route._triggersExit); + Triggers.runTriggers(triggers, self._current, self._redirectFn, next); + }; + + this._routes.push(route); + if (options.name) { + this._routesMap[options.name] = route; + } + + this._updateCallbacks(); + this._triggerRouteRegister(route); + + return route; }; -Router.prototype.group = function(options) { - return new Group(this, options); +Router.prototype.group = function (options) { + return new Group(this, options); }; -Router.prototype.path = function(pathDef, fields, queryParams) { - if (this._routesMap[pathDef]) { - pathDef = this._routesMap[pathDef].pathDef; - } - - var path = ""; - - // Prefix the path with the router global prefix - if (this._basePath) { - path += "/" + this._basePath + "/"; - } - - fields = fields || {}; - var regExp = /(:[\w\(\)\\\+\*\.\?]+)+/g; - path += pathDef.replace(regExp, function(key) { - var firstRegexpChar = key.indexOf("("); - // get the content behind : and (\\d+/) - key = key.substring(1, (firstRegexpChar > 0)? firstRegexpChar: undefined); - // remove +?* - key = key.replace(/[\+\*\?]+/g, ""); - - // this is to allow page js to keep the custom characters as it is - // we need to encode 2 times otherwise "/" char does not work properly - // So, in that case, when I includes "/" it will think it's a part of the - // route. encoding 2times fixes it - return encodeURIComponent(encodeURIComponent(fields[key] || "")); - }); - - // Replace multiple slashes with single slash - path = path.replace(/\/\/+/g, "/"); - - // remove trailing slash - // but keep the root slash if it's the only one - path = path.match(/^\/{1}$/) ? path: path.replace(/\/$/, ""); - - // explictly asked to add a trailing slash - if(this.env.trailingSlash.get() && _.last(path) !== "/") { - path += "/"; - } - - var strQueryParams = this._qs.stringify(queryParams || {}); - if(strQueryParams) { - path += "?" + strQueryParams; - } - - return path; +Router.prototype.path = function (pathDef, fields, queryParams) { + if (this._routesMap[pathDef]) { + pathDef = this._routesMap[pathDef].pathDef; + } + + var path = ''; + + // Prefix the path with the router global prefix + if (this._basePath) { + path += '/' + this._basePath + '/'; + } + + fields = fields || {}; + var regExp = /(:[\w\(\)\\\+\*\.\?]+)+/g; + path += pathDef.replace(regExp, function (key) { + var firstRegexpChar = key.indexOf('('); + // get the content behind : and (\\d+/) + key = key.substring(1, firstRegexpChar > 0 ? firstRegexpChar : undefined); + // remove +?* + key = key.replace(/[\+\*\?]+/g, ''); + + // this is to allow page js to keep the custom characters as it is + // we need to encode 2 times otherwise "/" char does not work properly + // So, in that case, when I includes "/" it will think it's a part of the + // route. encoding 2times fixes it + return encodeURIComponent(encodeURIComponent(fields[key] || '')); + }); + + // Replace multiple slashes with single slash + path = path.replace(/\/\/+/g, '/'); + + // remove trailing slash + // but keep the root slash if it's the only one + path = path.match(/^\/{1}$/) ? path : path.replace(/\/$/, ''); + + // explictly asked to add a trailing slash + if (this.env.trailingSlash.get() && _.last(path) !== '/') { + path += '/'; + } + + var strQueryParams = this._qs.stringify(queryParams || {}); + if (strQueryParams) { + path += '?' + strQueryParams; + } + + return path; }; -Router.prototype.go = function(pathDef, fields, queryParams) { - var path = this.path(pathDef, fields, queryParams); +Router.prototype.go = function (pathDef, fields, queryParams) { + var path = this.path(pathDef, fields, queryParams); - var useReplaceState = this.env.replaceState.get(); - if(useReplaceState) { - this._page.replace(path); - } else { - this._page(path); - } + var useReplaceState = this.env.replaceState.get(); + if (useReplaceState) { + this._page.replace(path); + } else { + this._page(path); + } }; -Router.prototype.reload = function() { - var self = this; +Router.prototype.reload = function () { + var self = this; - self.env.reload.withValue(true, function() { - self._page.replace(self._current.path); - }); + self.env.reload.withValue(true, function () { + self._page.replace(self._current.path); + }); }; -Router.prototype.redirect = function(path) { - this._page.redirect(path); +Router.prototype.redirect = function (path) { + this._page.redirect(path); }; -Router.prototype.setParams = function(newParams) { - if(!this._current.route) {return false;} +Router.prototype.setParams = function (newParams) { + if (!this._current.route) { + return false; + } - var pathDef = this._current.route.pathDef; - var existingParams = this._current.params; - var params = {}; - _.each(_.keys(existingParams), function(key) { - params[key] = existingParams[key]; - }); + var pathDef = this._current.route.pathDef; + var existingParams = this._current.params; + var params = {}; + _.each(_.keys(existingParams), function (key) { + params[key] = existingParams[key]; + }); - params = _.extend(params, newParams); - var queryParams = this._current.queryParams; + params = _.extend(params, newParams); + var queryParams = this._current.queryParams; - this.go(pathDef, params, queryParams); - return true; + this.go(pathDef, params, queryParams); + return true; }; -Router.prototype.setQueryParams = function(newParams) { - if(!this._current.route) {return false;} +Router.prototype.setQueryParams = function (newParams) { + if (!this._current.route) { + return false; + } - var queryParams = _.clone(this._current.queryParams); - _.extend(queryParams, newParams); + var queryParams = _.clone(this._current.queryParams); + _.extend(queryParams, newParams); - for (var k in queryParams) { - if (queryParams[k] === null || queryParams[k] === undefined) { - delete queryParams[k]; - } - } + for (var k in queryParams) { + if (queryParams[k] === null || queryParams[k] === undefined) { + delete queryParams[k]; + } + } - var pathDef = this._current.route.pathDef; - var params = this._current.params; - this.go(pathDef, params, queryParams); - return true; + var pathDef = this._current.route.pathDef; + var params = this._current.params; + this.go(pathDef, params, queryParams); + return true; }; // .current is not reactive // This is by design. use .getParam() instead // If you really need to watch the path change, use .watchPathChange() -Router.prototype.current = function() { - // We can't trust outside, that's why we clone this - // Anyway, we can't clone the whole object since it has non-jsonable values - // That's why we clone what's really needed. - var current = _.clone(this._current); - current.queryParams = EJSON.clone(current.queryParams); - current.params = EJSON.clone(current.params); - return current; +Router.prototype.current = function () { + // We can't trust outside, that's why we clone this + // Anyway, we can't clone the whole object since it has non-jsonable values + // That's why we clone what's really needed. + var current = _.clone(this._current); + current.queryParams = EJSON.clone(current.queryParams); + current.params = EJSON.clone(current.params); + return current; }; // Implementing Reactive APIs -var reactiveApis = [ - 'getParam', 'getQueryParam', - 'getRouteName', 'watchPathChange' -]; -reactiveApis.forEach(function(api) { - Router.prototype[api] = function(arg1) { - // when this is calling, there may not be any route initiated - // so we need to handle it - var currentRoute = this._current.route; - if(!currentRoute) { - this._onEveryPath.depend(); - return; - } - - // currently, there is only one argument. If we've more let's add more args - // this is not clean code, but better in performance - return currentRoute[api].call(currentRoute, arg1); - }; +var reactiveApis = ['getParam', 'getQueryParam', 'getRouteName', 'watchPathChange']; +reactiveApis.forEach(function (api) { + Router.prototype[api] = function (arg1) { + // when this is calling, there may not be any route initiated + // so we need to handle it + var currentRoute = this._current.route; + if (!currentRoute) { + this._onEveryPath.depend(); + return; + } + + // currently, there is only one argument. If we've more let's add more args + // this is not clean code, but better in performance + return currentRoute[api].call(currentRoute, arg1); + }; }); -Router.prototype.subsReady = function() { - var callback = null; - var args = _.toArray(arguments); - - if (typeof _.last(args) === "function") { - callback = args.pop(); - } - - var currentRoute = this.current().route; - var globalRoute = this._globalRoute; - - // we need to depend for every route change and - // rerun subscriptions to check the ready state - this._onEveryPath.depend(); - - if(!currentRoute) { - return false; - } - - var subscriptions; - if(args.length === 0) { - subscriptions = _.values(globalRoute.getAllSubscriptions()); - subscriptions = subscriptions.concat(_.values(currentRoute.getAllSubscriptions())); - } else { - subscriptions = _.map(args, function(subName) { - return globalRoute.getSubscription(subName) || currentRoute.getSubscription(subName); - }); - } - - var isReady = function() { - var ready = _.every(subscriptions, function(sub) { - return sub && sub.ready(); - }); - - return ready; - }; - - if (callback) { - Tracker.autorun(function(c) { - if (isReady()) { - callback(); - c.stop(); - } - }); - } else { - return isReady(); - } +Router.prototype.subsReady = function () { + var callback = null; + var args = _.toArray(arguments); + + if (typeof _.last(args) === 'function') { + callback = args.pop(); + } + + var currentRoute = this.current().route; + var globalRoute = this._globalRoute; + + // we need to depend for every route change and + // rerun subscriptions to check the ready state + this._onEveryPath.depend(); + + if (!currentRoute) { + return false; + } + + var subscriptions; + if (args.length === 0) { + subscriptions = _.values(globalRoute.getAllSubscriptions()); + subscriptions = subscriptions.concat(_.values(currentRoute.getAllSubscriptions())); + } else { + subscriptions = _.map(args, function (subName) { + return globalRoute.getSubscription(subName) || currentRoute.getSubscription(subName); + }); + } + + var isReady = function () { + var ready = _.every(subscriptions, function (sub) { + return sub && sub.ready(); + }); + + return ready; + }; + + if (callback) { + Tracker.autorun(function (c) { + if (isReady()) { + callback(); + c.stop(); + } + }); + } else { + return isReady(); + } }; -Router.prototype.withReplaceState = function(fn) { - return this.env.replaceState.withValue(true, fn); +Router.prototype.withReplaceState = function (fn) { + return this.env.replaceState.withValue(true, fn); }; -Router.prototype.withTrailingSlash = function(fn) { - return this.env.trailingSlash.withValue(true, fn); +Router.prototype.withTrailingSlash = function (fn) { + return this.env.trailingSlash.withValue(true, fn); }; -Router.prototype._notfoundRoute = function(context) { - this._current = { - path: context.path, - context: context, - params: [], - queryParams: {}, - }; - - // XXX this.notfound kept for backwards compatibility - this.notFound = this.notFound || this.notfound; - if(!this.notFound) { - console.error("There is no route for the path:", context.path); - return; - } - - this._current.route = new Route(this, "*", this.notFound); - this._invalidateTracker(); +Router.prototype._notfoundRoute = function (context) { + this._current = { + path: context.path, + context: context, + params: [], + queryParams: {}, + }; + + // XXX this.notfound kept for backwards compatibility + this.notFound = this.notFound || this.notfound; + if (!this.notFound) { + console.error('There is no route for the path:', context.path); + return; + } + + this._current.route = new Route(this, '*', this.notFound); + this._invalidateTracker(); }; -Router.prototype.initialize = function(options) { - options = options || {}; - - if(this._initialized) { - throw new Error("FlowRouter is already initialized"); - } - - var self = this; - this._updateCallbacks(); - - // Implementing idempotent routing - // by overriding page.js`s "show" method. - // Why? - // It is impossible to bypass exit triggers, - // because they execute before the handler and - // can not know what the next path is, inside exit trigger. - // - // we need override both show, replace to make this work - // since we use redirect when we are talking about withReplaceState - _.each(['show', 'replace'], function(fnName) { - var original = self._page[fnName]; - self._page[fnName] = function(path, state, dispatch, push) { - var reload = self.env.reload.get(); - if (!reload && self._current.path === path) { - return; - } - - original.call(this, path, state, dispatch, push); - }; - }); - - // this is very ugly part of pagejs and it does decoding few times - // in unpredicatable manner. See #168 - // this is the default behaviour and we need keep it like that - // we are doing a hack. see .path() - this._page.base(this._basePath); - this._page({ - decodeURLComponents: true, - hashbang: !!options.hashbang - }); - - this._initialized = true; +Router.prototype.initialize = function (options) { + options = options || {}; + + if (this._initialized) { + throw new Error('FlowRouter is already initialized'); + } + + var self = this; + this._updateCallbacks(); + + // Implementing idempotent routing + // by overriding page.js`s "show" method. + // Why? + // It is impossible to bypass exit triggers, + // because they execute before the handler and + // can not know what the next path is, inside exit trigger. + // + // we need override both show, replace to make this work + // since we use redirect when we are talking about withReplaceState + _.each(['show', 'replace'], function (fnName) { + var original = self._page[fnName]; + self._page[fnName] = function (path, state, dispatch, push) { + var reload = self.env.reload.get(); + if (!reload && self._current.path === path) { + return; + } + + original.call(this, path, state, dispatch, push); + }; + }); + + // this is very ugly part of pagejs and it does decoding few times + // in unpredicatable manner. See #168 + // this is the default behaviour and we need keep it like that + // we are doing a hack. see .path() + this._page.base(this._basePath); + this._page({ + decodeURLComponents: true, + hashbang: !!options.hashbang, + }); + + this._initialized = true; }; -Router.prototype._buildTracker = function() { - var self = this; - - // main autorun function - var tracker = Tracker.autorun(function () { - if(!self._current || !self._current.route) { - return; - } - - // see the definition of `this._processingContexts` - var currentContext = self._current; - var route = currentContext.route; - var path = currentContext.path; - - if(self.safeToRun === 0) { - var message = - "You can't use reactive data sources like Session" + - " inside the `.subscriptions` method!"; - throw new Error(message); - } - - // We need to run subscriptions inside a Tracker - // to stop subs when switching between routes - // But we don't need to run this tracker with - // other reactive changes inside the .subscription method - // We tackle this with the `safeToRun` variable - self._globalRoute.clearSubscriptions(); - self.subscriptions.call(self._globalRoute, path); - route.callSubscriptions(currentContext); - - // otherwise, computations inside action will trigger to re-run - // this computation. which we do not need. - Tracker.nonreactive(function() { - var isRouteChange = currentContext.oldRoute !== currentContext.route; - var isFirstRoute = !currentContext.oldRoute; - // first route is not a route change - if(isFirstRoute) { - isRouteChange = false; - } - - // Clear oldRouteChain just before calling the action - // We still need to get a copy of the oldestRoute first - // It's very important to get the oldest route and registerRouteClose() it - // See: https://github.com/kadirahq/flow-router/issues/314 - var oldestRoute = self._oldRouteChain[0]; - self._oldRouteChain = []; - - currentContext.route.registerRouteChange(currentContext, isRouteChange); - route.callAction(currentContext); - - Tracker.afterFlush(function() { - self._onEveryPath.changed(); - if(isRouteChange) { - // We need to trigger that route (definition itself) has changed. - // So, we need to re-run all the register callbacks to current route - // This is pretty important, otherwise tracker - // can't identify new route's items - - // We also need to afterFlush, otherwise this will re-run - // helpers on templates which are marked for destroying - if(oldestRoute) { - oldestRoute.registerRouteClose(); - } - } - }); - }); - - self.safeToRun--; - }); - - return tracker; +Router.prototype._buildTracker = function () { + var self = this; + + // main autorun function + var tracker = Tracker.autorun(function () { + if (!self._current || !self._current.route) { + return; + } + + // see the definition of `this._processingContexts` + var currentContext = self._current; + var route = currentContext.route; + var path = currentContext.path; + + if (self.safeToRun === 0) { + var message = "You can't use reactive data sources like Session" + ' inside the `.subscriptions` method!'; + throw new Error(message); + } + + // We need to run subscriptions inside a Tracker + // to stop subs when switching between routes + // But we don't need to run this tracker with + // other reactive changes inside the .subscription method + // We tackle this with the `safeToRun` variable + self._globalRoute.clearSubscriptions(); + self.subscriptions.call(self._globalRoute, path); + route.callSubscriptions(currentContext); + + // otherwise, computations inside action will trigger to re-run + // this computation. which we do not need. + Tracker.nonreactive(function () { + var isRouteChange = currentContext.oldRoute !== currentContext.route; + var isFirstRoute = !currentContext.oldRoute; + // first route is not a route change + if (isFirstRoute) { + isRouteChange = false; + } + + // Clear oldRouteChain just before calling the action + // We still need to get a copy of the oldestRoute first + // It's very important to get the oldest route and registerRouteClose() it + // See: https://github.com/kadirahq/flow-router/issues/314 + var oldestRoute = self._oldRouteChain[0]; + self._oldRouteChain = []; + + currentContext.route.registerRouteChange(currentContext, isRouteChange); + route.callAction(currentContext); + + Tracker.afterFlush(function () { + self._onEveryPath.changed(); + if (isRouteChange) { + // We need to trigger that route (definition itself) has changed. + // So, we need to re-run all the register callbacks to current route + // This is pretty important, otherwise tracker + // can't identify new route's items + + // We also need to afterFlush, otherwise this will re-run + // helpers on templates which are marked for destroying + if (oldestRoute) { + oldestRoute.registerRouteClose(); + } + } + }); + }); + + self.safeToRun--; + }); + + return tracker; }; -Router.prototype._invalidateTracker = function() { - var self = this; - this.safeToRun++; - this._tracker.invalidate(); - // After the invalidation we need to flush to make changes imediately - // otherwise, we have face some issues context mix-maches and so on. - // But there are some cases we can't flush. So we need to ready for that. - - // we clearly know, we can't flush inside an autorun - // this may leads some issues on flow-routing - // we may need to do some warning - if(!Tracker.currentComputation) { - // Still there are some cases where we can't flush - // eg:- when there is a flush currently - // But we've no public API or hacks to get that state - // So, this is the only solution - try { - Tracker.flush(); - } catch(ex) { - // only handling "while flushing" errors - if(!/Tracker\.flush while flushing/.test(ex.message)) { - return; - } - - // XXX: fix this with a proper solution by removing subscription mgt. - // from the router. Then we don't need to run invalidate using a tracker - - // this happens when we are trying to invoke a route change - // with inside a route chnage. (eg:- Template.onCreated) - // Since we use page.js and tracker, we don't have much control - // over this process. - // only solution is to defer route execution. - - // It's possible to have more than one path want to defer - // But, we only need to pick the last one. - // self._nextPath = self._current.path; - Meteor.defer(function() { - var path = self._nextPath; - if(!path) { - return; - } - - delete self._nextPath; - self.env.reload.withValue(true, function() { - self.go(path); - }); - }); - } - } +Router.prototype._invalidateTracker = function () { + var self = this; + this.safeToRun++; + this._tracker.invalidate(); + // After the invalidation we need to flush to make changes imediately + // otherwise, we have face some issues context mix-maches and so on. + // But there are some cases we can't flush. So we need to ready for that. + + // we clearly know, we can't flush inside an autorun + // this may leads some issues on flow-routing + // we may need to do some warning + if (!Tracker.currentComputation) { + // Still there are some cases where we can't flush + // eg:- when there is a flush currently + // But we've no public API or hacks to get that state + // So, this is the only solution + try { + Tracker.flush(); + } catch (ex) { + // only handling "while flushing" errors + if (!/Tracker\.flush while flushing/.test(ex.message)) { + return; + } + + // XXX: fix this with a proper solution by removing subscription mgt. + // from the router. Then we don't need to run invalidate using a tracker + + // this happens when we are trying to invoke a route change + // with inside a route chnage. (eg:- Template.onCreated) + // Since we use page.js and tracker, we don't have much control + // over this process. + // only solution is to defer route execution. + + // It's possible to have more than one path want to defer + // But, we only need to pick the last one. + // self._nextPath = self._current.path; + Meteor.defer(function () { + var path = self._nextPath; + if (!path) { + return; + } + + delete self._nextPath; + self.env.reload.withValue(true, function () { + self.go(path); + }); + }); + } + } }; Router.prototype._updateCallbacks = function () { - var self = this; + var self = this; - self._page.callbacks = []; - self._page.exits = []; + self._page.callbacks = []; + self._page.exits = []; - _.each(self._routes, function(route) { - self._page(route.pathDef, route._actionHandle); - self._page.exit(route.pathDef, route._exitHandle); - }); + _.each(self._routes, function (route) { + self._page(route.pathDef, route._actionHandle); + self._page.exit(route.pathDef, route._exitHandle); + }); - self._page("*", function(context) { - self._notfoundRoute(context); - }); + self._page('*', function (context) { + self._notfoundRoute(context); + }); }; -Router.prototype._initTriggersAPI = function() { - var self = this; - this.triggers = { - enter: function(triggers, filter) { - triggers = Triggers.applyFilters(triggers, filter); - if(triggers.length) { - self._triggersEnter = self._triggersEnter.concat(triggers); - } - }, - - exit: function(triggers, filter) { - triggers = Triggers.applyFilters(triggers, filter); - if(triggers.length) { - self._triggersExit = self._triggersExit.concat(triggers); - } - } - }; +Router.prototype._initTriggersAPI = function () { + var self = this; + this.triggers = { + enter: function (triggers, filter) { + triggers = Triggers.applyFilters(triggers, filter); + if (triggers.length) { + self._triggersEnter = self._triggersEnter.concat(triggers); + } + }, + + exit: function (triggers, filter) { + triggers = Triggers.applyFilters(triggers, filter); + if (triggers.length) { + self._triggersExit = self._triggersExit.concat(triggers); + } + }, + }; }; -Router.prototype.wait = function() { - if(this._initialized) { - throw new Error("can't wait after FlowRouter has been initialized"); - } +Router.prototype.wait = function () { + if (this._initialized) { + throw new Error("can't wait after FlowRouter has been initialized"); + } - this._askedToWait = true; + this._askedToWait = true; }; -Router.prototype.onRouteRegister = function(cb) { - this._onRouteCallbacks.push(cb); +Router.prototype.onRouteRegister = function (cb) { + this._onRouteCallbacks.push(cb); }; -Router.prototype._triggerRouteRegister = function(currentRoute) { - // We should only need to send a safe set of fields on the route - // object. - // This is not to hide what's inside the route object, but to show - // these are the public APIs - var routePublicApi = _.pick(currentRoute, 'name', 'pathDef', 'path'); - var omittingOptionFields = [ - 'triggersEnter', 'triggersExit', 'action', 'subscriptions', 'name' - ]; - routePublicApi.options = _.omit(currentRoute.options, omittingOptionFields); - - _.each(this._onRouteCallbacks, function(cb) { - cb(routePublicApi); - }); +Router.prototype._triggerRouteRegister = function (currentRoute) { + // We should only need to send a safe set of fields on the route + // object. + // This is not to hide what's inside the route object, but to show + // these are the public APIs + var routePublicApi = _.pick(currentRoute, 'name', 'pathDef', 'path'); + var omittingOptionFields = ['triggersEnter', 'triggersExit', 'action', 'subscriptions', 'name']; + routePublicApi.options = _.omit(currentRoute.options, omittingOptionFields); + + _.each(this._onRouteCallbacks, function (cb) { + cb(routePublicApi); + }); }; Router.prototype._page = page; diff --git a/apps/meteor/packages/flow-router/client/triggers.js b/apps/meteor/packages/flow-router/client/triggers.js index 7733332ca513..3f4c04ba32f9 100644 --- a/apps/meteor/packages/flow-router/client/triggers.js +++ b/apps/meteor/packages/flow-router/client/triggers.js @@ -4,109 +4,109 @@ Triggers = {}; // Apply filters for a set of triggers // @triggers - a set of triggers -// @filter - filter with array fileds with `only` and `except` +// @filter - filter with array fileds with `only` and `except` // support only either `only` or `except`, but not both -Triggers.applyFilters = function(triggers, filter) { - if(!(triggers instanceof Array)) { - triggers = [triggers]; - } +Triggers.applyFilters = function (triggers, filter) { + if (!(triggers instanceof Array)) { + triggers = [triggers]; + } - if(!filter) { - return triggers; - } + if (!filter) { + return triggers; + } - if(filter.only && filter.except) { - throw new Error("Triggers don't support only and except filters at once"); - } + if (filter.only && filter.except) { + throw new Error("Triggers don't support only and except filters at once"); + } - if(filter.only && !(filter.only instanceof Array)) { - throw new Error("only filters needs to be an array"); - } + if (filter.only && !(filter.only instanceof Array)) { + throw new Error('only filters needs to be an array'); + } - if(filter.except && !(filter.except instanceof Array)) { - throw new Error("except filters needs to be an array"); - } + if (filter.except && !(filter.except instanceof Array)) { + throw new Error('except filters needs to be an array'); + } - if(filter.only) { - return Triggers.createRouteBoundTriggers(triggers, filter.only); - } + if (filter.only) { + return Triggers.createRouteBoundTriggers(triggers, filter.only); + } - if(filter.except) { - return Triggers.createRouteBoundTriggers(triggers, filter.except, true); - } + if (filter.except) { + return Triggers.createRouteBoundTriggers(triggers, filter.except, true); + } - throw new Error("Provided a filter but not supported"); + throw new Error('Provided a filter but not supported'); }; // create triggers by bounding them to a set of route names -// @triggers - a set of triggers +// @triggers - a set of triggers // @names - list of route names to be bound (trigger runs only for these names) // @negate - negate the result (triggers won't run for above names) -Triggers.createRouteBoundTriggers = function(triggers, names, negate) { - var namesMap = {}; - _.each(names, function(name) { - namesMap[name] = true; - }); - - var filteredTriggers = _.map(triggers, function(originalTrigger) { - var modifiedTrigger = function(context, next) { - var routeName = context.route.name; - var matched = (namesMap[routeName])? 1: -1; - matched = (negate)? matched * -1 : matched; - - if(matched === 1) { - originalTrigger(context, next); - } - }; - return modifiedTrigger; - }); - - return filteredTriggers; +Triggers.createRouteBoundTriggers = function (triggers, names, negate) { + var namesMap = {}; + _.each(names, function (name) { + namesMap[name] = true; + }); + + var filteredTriggers = _.map(triggers, function (originalTrigger) { + var modifiedTrigger = function (context, next) { + var routeName = context.route.name; + var matched = namesMap[routeName] ? 1 : -1; + matched = negate ? matched * -1 : matched; + + if (matched === 1) { + originalTrigger(context, next); + } + }; + return modifiedTrigger; + }); + + return filteredTriggers; }; // run triggers and abort if redirected or callback stopped -// @triggers - a set of triggers +// @triggers - a set of triggers // @context - context we need to pass (it must have the route) -// @redirectFn - function which used to redirect +// @redirectFn - function which used to redirect // @after - called after if only all the triggers runs -Triggers.runTriggers = function(triggers, context, redirectFn, after) { - var abort = false; - var inCurrentLoop = true; - var alreadyRedirected = false; - - for(var lc=0; lc 0)? firstRegexpChar: undefined); - // remove +?* - key = key.replace(/[\+\*\?]+/g, ""); + fields = fields || {}; + var regExp = /(:[\w\(\)\\\+\*\.\?]+)+/g; + var path = pathDef.replace(regExp, function (key) { + var firstRegexpChar = key.indexOf('('); + // get the content behind : and (\\d+/) + key = key.substring(1, firstRegexpChar > 0 ? firstRegexpChar : undefined); + // remove +?* + key = key.replace(/[\+\*\?]+/g, ''); - return fields[key] || ""; - }); + return fields[key] || ''; + }); - path = path.replace(/\/\/+/g, "/"); // Replace multiple slashes with single slash + path = path.replace(/\/\/+/g, '/'); // Replace multiple slashes with single slash - // remove trailing slash - // but keep the root slash if it's the only one - path = path.match(/^\/{1}$/) ? path: path.replace(/\/$/, ""); + // remove trailing slash + // but keep the root slash if it's the only one + path = path.match(/^\/{1}$/) ? path : path.replace(/\/$/, ''); - var strQueryParams = Qs.stringify(queryParams || {}); - if(strQueryParams) { - path += "?" + strQueryParams; - } + var strQueryParams = Qs.stringify(queryParams || {}); + if (strQueryParams) { + path += '?' + strQueryParams; + } - return path; + return path; }; -Router.prototype.onRouteRegister = function(cb) { - this._onRouteCallbacks.push(cb); +Router.prototype.onRouteRegister = function (cb) { + this._onRouteCallbacks.push(cb); }; -Router.prototype._triggerRouteRegister = function(currentRoute) { - // We should only need to send a safe set of fields on the route - // object. - // This is not to hide what's inside the route object, but to show - // these are the public APIs - var routePublicApi = _.pick(currentRoute, 'name', 'pathDef', 'path'); - var omittingOptionFields = [ - 'triggersEnter', 'triggersExit', 'action', 'subscriptions', 'name' - ]; - routePublicApi.options = _.omit(currentRoute.options, omittingOptionFields); +Router.prototype._triggerRouteRegister = function (currentRoute) { + // We should only need to send a safe set of fields on the route + // object. + // This is not to hide what's inside the route object, but to show + // these are the public APIs + var routePublicApi = _.pick(currentRoute, 'name', 'pathDef', 'path'); + var omittingOptionFields = ['triggersEnter', 'triggersExit', 'action', 'subscriptions', 'name']; + routePublicApi.options = _.omit(currentRoute.options, omittingOptionFields); - _.each(this._onRouteCallbacks, function(cb) { - cb(routePublicApi); - }); + _.each(this._onRouteCallbacks, function (cb) { + cb(routePublicApi); + }); }; - -Router.prototype.go = function() { - // client only +Router.prototype.go = function () { + // client only }; - -Router.prototype.current = function() { - // client only +Router.prototype.current = function () { + // client only }; - Router.prototype.triggers = { - enter: function() { - // client only - }, - exit: function() { - // client only - } + enter: function () { + // client only + }, + exit: function () { + // client only + }, }; -Router.prototype.middleware = function() { - // client only +Router.prototype.middleware = function () { + // client only }; - -Router.prototype.getState = function() { - // client only +Router.prototype.getState = function () { + // client only }; - -Router.prototype.getAllStates = function() { - // client only +Router.prototype.getAllStates = function () { + // client only }; - -Router.prototype.setState = function() { - // client only +Router.prototype.setState = function () { + // client only }; - -Router.prototype.removeState = function() { - // client only +Router.prototype.removeState = function () { + // client only }; - -Router.prototype.clearStates = function() { - // client only +Router.prototype.clearStates = function () { + // client only }; - -Router.prototype.ready = function() { - // client only +Router.prototype.ready = function () { + // client only }; - -Router.prototype.initialize = function() { - // client only +Router.prototype.initialize = function () { + // client only }; -Router.prototype.wait = function() { - // client only +Router.prototype.wait = function () { + // client only }; diff --git a/apps/meteor/packages/linkedin-oauth/linkedin-server.js b/apps/meteor/packages/linkedin-oauth/linkedin-server.js index 09a12a528dda..20b6ab3fae28 100644 --- a/apps/meteor/packages/linkedin-oauth/linkedin-server.js +++ b/apps/meteor/packages/linkedin-oauth/linkedin-server.js @@ -44,7 +44,9 @@ const getTokenResponse = async function (query) { const expiresIn = responseContent.expires_in; if (!accessToken) { - throw new Error(`Failed to complete OAuth handshake with Linkedin -- can't find access token in HTTP response. ${JSON.stringify(responseContent)}`); + throw new Error( + `Failed to complete OAuth handshake with Linkedin -- can't find access token in HTTP response. ${JSON.stringify(responseContent)}`, + ); } return { @@ -56,9 +58,7 @@ const getTokenResponse = async function (query) { // Request available fields from profile const getIdentity = async function (accessToken) { try { - const url = encodeURI( - `https://api.linkedin.com/v2/userinfo`, - ); + const url = encodeURI(`https://api.linkedin.com/v2/userinfo`); const request = await fetch(url, { method: 'GET', headers: { @@ -93,7 +93,7 @@ OAuth.registerService('linkedin', 2, null, async (query) => { lastName: family_name, profilePicture: picture, emailAddress: email, - email + email, }; const serviceData = { diff --git a/apps/meteor/packages/meteor-cookies/cookies.js b/apps/meteor/packages/meteor-cookies/cookies.js index f560babbfc90..8694ec04ef62 100644 --- a/apps/meteor/packages/meteor-cookies/cookies.js +++ b/apps/meteor/packages/meteor-cookies/cookies.js @@ -4,33 +4,37 @@ let fetch; let WebApp; if (Meteor.isServer) { - WebApp = require('meteor/webapp').WebApp; + WebApp = require('meteor/webapp').WebApp; } else { - fetch = require('meteor/fetch').fetch; + fetch = require('meteor/fetch').fetch; } -const NoOp = () => {}; +const NoOp = () => {}; const urlRE = /\/___cookie___\/set/; -const rootUrl = Meteor.isServer ? process.env.ROOT_URL : (window.__meteor_runtime_config__.ROOT_URL || window.__meteor_runtime_config__.meteorEnv.ROOT_URL || false); -const mobileRootUrl = Meteor.isServer ? process.env.MOBILE_ROOT_URL : (window.__meteor_runtime_config__.MOBILE_ROOT_URL || window.__meteor_runtime_config__.meteorEnv.MOBILE_ROOT_URL || false); +const rootUrl = Meteor.isServer + ? process.env.ROOT_URL + : window.__meteor_runtime_config__.ROOT_URL || window.__meteor_runtime_config__.meteorEnv.ROOT_URL || false; +const mobileRootUrl = Meteor.isServer + ? process.env.MOBILE_ROOT_URL + : window.__meteor_runtime_config__.MOBILE_ROOT_URL || window.__meteor_runtime_config__.meteorEnv.MOBILE_ROOT_URL || false; const helpers = { - isUndefined(obj) { - return obj === void 0; - }, - isArray(obj) { - return Array.isArray(obj); - }, - clone(obj) { - if (!this.isObject(obj)) return obj; - return this.isArray(obj) ? obj.slice() : Object.assign({}, obj); - } + isUndefined(obj) { + return obj === void 0; + }, + isArray(obj) { + return Array.isArray(obj); + }, + clone(obj) { + if (!this.isObject(obj)) return obj; + return this.isArray(obj) ? obj.slice() : Object.assign({}, obj); + }, }; const _helpers = ['Number', 'Object', 'Function']; for (let i = 0; i < _helpers.length; i++) { - helpers['is' + _helpers[i]] = function (obj) { - return Object.prototype.toString.call(obj) === '[object ' + _helpers[i] + ']'; - }; + helpers['is' + _helpers[i]] = function (obj) { + return Object.prototype.toString.call(obj) === '[object ' + _helpers[i] + ']'; + }; } /** @@ -84,11 +88,11 @@ const fieldContentRegExp = /^[\u0009\u0020-\u007e\u0080-\u00ff]+$/; * @private */ const tryDecode = (str, d) => { - try { - return d(str); - } catch (e) { - return str; - } + try { + return d(str); + } catch (e) { + return str; + } }; /** @@ -104,31 +108,31 @@ const tryDecode = (str, d) => { * @private */ const parse = (str, options) => { - if (typeof str !== 'string') { - throw new Meteor.Error(404, 'argument str must be a string'); - } - const obj = {}; - const opt = options || {}; - let val; - let key; - let eqIndx; - - str.split(pairSplitRegExp).forEach((pair) => { - eqIndx = pair.indexOf('='); - if (eqIndx < 0) { - return; - } - key = pair.substr(0, eqIndx).trim(); - key = tryDecode(unescape(key), (opt.decode || decode)); - val = pair.substr(++eqIndx, pair.length).trim(); - if (val[0] === '"') { - val = val.slice(1, -1); - } - if (void 0 === obj[key]) { - obj[key] = tryDecode(val, (opt.decode || decode)); - } - }); - return obj; + if (typeof str !== 'string') { + throw new Meteor.Error(404, 'argument str must be a string'); + } + const obj = {}; + const opt = options || {}; + let val; + let key; + let eqIndx; + + str.split(pairSplitRegExp).forEach((pair) => { + eqIndx = pair.indexOf('='); + if (eqIndx < 0) { + return; + } + key = pair.substr(0, eqIndx).trim(); + key = tryDecode(unescape(key), opt.decode || decode); + val = pair.substr(++eqIndx, pair.length).trim(); + if (val[0] === '"') { + val = val.slice(1, -1); + } + if (void 0 === obj[key]) { + obj[key] = tryDecode(val, opt.decode || decode); + } + }); + return obj; }; /** @@ -138,17 +142,17 @@ const parse = (str, options) => { * @private */ const antiCircular = (_obj) => { - const object = helpers.clone(_obj); - const cache = new Map(); - return JSON.stringify(object, (key, value) => { - if (typeof value === 'object' && value !== null) { - if (cache.get(value)) { - return void 0; - } - cache.set(value, true); - } - return value; - }); + const object = helpers.clone(_obj); + const cache = new Map(); + return JSON.stringify(object, (key, value) => { + if (typeof value === 'object' && value !== null) { + if (cache.get(value)) { + return void 0; + } + cache.set(value, true); + } + return value; + }); }; /** @@ -166,109 +170,109 @@ const antiCircular = (_obj) => { * @private */ const serialize = (key, val, opt = {}) => { - let name; - - if (!fieldContentRegExp.test(key)) { - name = escape(key); - } else { - name = key; - } - - let sanitizedValue = val; - let value = val; - if (!helpers.isUndefined(value)) { - if (helpers.isObject(value) || helpers.isArray(value)) { - const stringified = antiCircular(value); - value = encode(`JSON.parse(${stringified})`); - sanitizedValue = JSON.parse(stringified); - } else { - value = encode(value); - if (value && !fieldContentRegExp.test(value)) { - value = escape(value); - } - } - } else { - value = ''; - } - - const pairs = [`${name}=${value}`]; - - if (helpers.isNumber(opt.maxAge)) { - pairs.push(`Max-Age=${opt.maxAge}`); - } - - if (opt.domain && typeof opt.domain === 'string') { - if (!fieldContentRegExp.test(opt.domain)) { - throw new Meteor.Error(404, 'option domain is invalid'); - } - pairs.push(`Domain=${opt.domain}`); - } - - if (opt.path && typeof opt.path === 'string') { - if (!fieldContentRegExp.test(opt.path)) { - throw new Meteor.Error(404, 'option path is invalid'); - } - pairs.push(`Path=${opt.path}`); - } else { - pairs.push('Path=/'); - } - - opt.expires = opt.expires || opt.expire || false; - if (opt.expires === Infinity) { - pairs.push('Expires=Fri, 31 Dec 9999 23:59:59 GMT'); - } else if (opt.expires instanceof Date) { - pairs.push(`Expires=${opt.expires.toUTCString()}`); - } else if (opt.expires === 0) { - pairs.push('Expires=0'); - } else if (helpers.isNumber(opt.expires)) { - pairs.push(`Expires=${(new Date(opt.expires)).toUTCString()}`); - } - - if (opt.httpOnly) { - pairs.push('HttpOnly'); - } - - if (opt.secure) { - pairs.push('Secure'); - } - - if (opt.firstPartyOnly) { - pairs.push('First-Party-Only'); - } - - if (opt.sameSite) { - pairs.push(opt.sameSite === true ? 'SameSite' : `SameSite=${opt.sameSite}`); - } - - return { cookieString: pairs.join('; '), sanitizedValue }; + let name; + + if (!fieldContentRegExp.test(key)) { + name = escape(key); + } else { + name = key; + } + + let sanitizedValue = val; + let value = val; + if (!helpers.isUndefined(value)) { + if (helpers.isObject(value) || helpers.isArray(value)) { + const stringified = antiCircular(value); + value = encode(`JSON.parse(${stringified})`); + sanitizedValue = JSON.parse(stringified); + } else { + value = encode(value); + if (value && !fieldContentRegExp.test(value)) { + value = escape(value); + } + } + } else { + value = ''; + } + + const pairs = [`${name}=${value}`]; + + if (helpers.isNumber(opt.maxAge)) { + pairs.push(`Max-Age=${opt.maxAge}`); + } + + if (opt.domain && typeof opt.domain === 'string') { + if (!fieldContentRegExp.test(opt.domain)) { + throw new Meteor.Error(404, 'option domain is invalid'); + } + pairs.push(`Domain=${opt.domain}`); + } + + if (opt.path && typeof opt.path === 'string') { + if (!fieldContentRegExp.test(opt.path)) { + throw new Meteor.Error(404, 'option path is invalid'); + } + pairs.push(`Path=${opt.path}`); + } else { + pairs.push('Path=/'); + } + + opt.expires = opt.expires || opt.expire || false; + if (opt.expires === Infinity) { + pairs.push('Expires=Fri, 31 Dec 9999 23:59:59 GMT'); + } else if (opt.expires instanceof Date) { + pairs.push(`Expires=${opt.expires.toUTCString()}`); + } else if (opt.expires === 0) { + pairs.push('Expires=0'); + } else if (helpers.isNumber(opt.expires)) { + pairs.push(`Expires=${new Date(opt.expires).toUTCString()}`); + } + + if (opt.httpOnly) { + pairs.push('HttpOnly'); + } + + if (opt.secure) { + pairs.push('Secure'); + } + + if (opt.firstPartyOnly) { + pairs.push('First-Party-Only'); + } + + if (opt.sameSite) { + pairs.push(opt.sameSite === true ? 'SameSite' : `SameSite=${opt.sameSite}`); + } + + return { cookieString: pairs.join('; '), sanitizedValue }; }; const isStringifiedRegEx = /JSON\.parse\((.*)\)/; const isTypedRegEx = /false|true|null/; const deserialize = (string) => { - if (typeof string !== 'string') { - return string; - } - - if (isStringifiedRegEx.test(string)) { - let obj = string.match(isStringifiedRegEx)[1]; - if (obj) { - try { - return JSON.parse(decode(obj)); - } catch (e) { - console.error('[ostrio:cookies] [.get()] [deserialize()] Exception:', e, string, obj); - return string; - } - } - return string; - } else if (isTypedRegEx.test(string)) { - try { - return JSON.parse(string); - } catch (e) { - return string; - } - } - return string; + if (typeof string !== 'string') { + return string; + } + + if (isStringifiedRegEx.test(string)) { + let obj = string.match(isStringifiedRegEx)[1]; + if (obj) { + try { + return JSON.parse(decode(obj)); + } catch (e) { + console.error('[ostrio:cookies] [.get()] [deserialize()] Exception:', e, string, obj); + return string; + } + } + return string; + } else if (isTypedRegEx.test(string)) { + try { + return JSON.parse(string); + } catch (e) { + return string; + } + } + return string; }; /** @@ -284,197 +288,201 @@ const deserialize = (string) => { * @summary Internal Class */ class __cookies { - constructor(opts) { - this.__pendingCookies = []; - this.TTL = opts.TTL || false; - this.response = opts.response || false; - this.runOnServer = opts.runOnServer || false; - this.allowQueryStringCookies = opts.allowQueryStringCookies || false; - this.allowedCordovaOrigins = opts.allowedCordovaOrigins || false; - - if (this.allowedCordovaOrigins === true) { - this.allowedCordovaOrigins = /^http:\/\/localhost:12[0-9]{3}$/; - } - - this.originRE = new RegExp(`^https?:\/\/(${rootUrl ? rootUrl : ''}${mobileRootUrl ? ('|' + mobileRootUrl) : ''})$`); - - if (helpers.isObject(opts._cookies)) { - this.cookies = opts._cookies; - } else { - this.cookies = parse(opts._cookies); - } - } - - /** - * @locus Anywhere - * @memberOf __cookies - * @name get - * @param {String} key - The name of the cookie to read - * @param {String} _tmp - Unparsed string instead of user's cookies - * @summary Read a cookie. If the cookie doesn't exist a null value will be returned. - * @returns {String|void} - */ - get(key, _tmp) { - const cookieString = _tmp ? parse(_tmp) : this.cookies; - if (!key || !cookieString) { - return void 0; - } - - if (cookieString.hasOwnProperty(key)) { - return deserialize(cookieString[key]); - } - - return void 0; - } - - /** - * @locus Anywhere - * @memberOf __cookies - * @name set - * @param {String} key - The name of the cookie to create/overwrite - * @param {String} value - The value of the cookie - * @param {Object} opts - [Optional] Cookie options (see readme docs) - * @summary Create/overwrite a cookie. - * @returns {Boolean} - */ - set(key, value, opts = {}) { - if (key && !helpers.isUndefined(value)) { - if (helpers.isNumber(this.TTL) && opts.expires === undefined) { - opts.expires = new Date(+new Date() + this.TTL); - } - const { cookieString, sanitizedValue } = serialize(key, value, opts); - - this.cookies[key] = sanitizedValue; - if (Meteor.isClient) { - document.cookie = cookieString; - } else if (this.response) { - this.__pendingCookies.push(cookieString); - this.response.setHeader('Set-Cookie', this.__pendingCookies); - } - return true; - } - return false; - } - - /** - * @locus Anywhere - * @memberOf __cookies - * @name remove - * @param {String} key - The name of the cookie to create/overwrite - * @param {String} path - [Optional] The path from where the cookie will be - * readable. E.g., "/", "/mydir"; if not specified, defaults to the current - * path of the current document location (string or null). The path must be - * absolute (see RFC 2965). For more information on how to use relative paths - * in this argument, see: https://developer.mozilla.org/en-US/docs/Web/API/document.cookie#Using_relative_URLs_in_the_path_parameter - * @param {String} domain - [Optional] The domain from where the cookie will - * be readable. E.g., "example.com", ".example.com" (includes all subdomains) - * or "subdomain.example.com"; if not specified, defaults to the host portion - * of the current document location (string or null). - * @summary Remove a cookie(s). - * @returns {Boolean} - */ - remove(key, path = '/', domain = '') { - if (key && this.cookies.hasOwnProperty(key)) { - const { cookieString } = serialize(key, '', { - domain, - path, - expires: new Date(0) - }); - - delete this.cookies[key]; - if (Meteor.isClient) { - document.cookie = cookieString; - } else if (this.response) { - this.response.setHeader('Set-Cookie', cookieString); - } - return true; - } else if (!key && this.keys().length > 0 && this.keys()[0] !== '') { - const keys = Object.keys(this.cookies); - for (let i = 0; i < keys.length; i++) { - this.remove(keys[i]); - } - return true; - } - return false; - } - - /** - * @locus Anywhere - * @memberOf __cookies - * @name has - * @param {String} key - The name of the cookie to create/overwrite - * @param {String} _tmp - Unparsed string instead of user's cookies - * @summary Check whether a cookie exists in the current position. - * @returns {Boolean} - */ - has(key, _tmp) { - const cookieString = _tmp ? parse(_tmp) : this.cookies; - if (!key || !cookieString) { - return false; - } - - return cookieString.hasOwnProperty(key); - } - - /** - * @locus Anywhere - * @memberOf __cookies - * @name keys - * @summary Returns an array of all readable cookies from this location. - * @returns {[String]} - */ - keys() { - if (this.cookies) { - return Object.keys(this.cookies); - } - return []; - } - - /** - * @locus Client - * @memberOf __cookies - * @name send - * @param cb {Function} - Callback - * @summary Send all cookies over XHR to server. - * @returns {void} - */ - send(cb = NoOp) { - if (Meteor.isServer) { - cb(new Meteor.Error(400, 'Can\'t run `.send()` on server, it\'s Client only method!')); - } - - if (this.runOnServer) { - let path = `${window.__meteor_runtime_config__.ROOT_URL_PATH_PREFIX || window.__meteor_runtime_config__.meteorEnv.ROOT_URL_PATH_PREFIX || ''}/___cookie___/set`; - let query = ''; - - if (Meteor.isCordova && this.allowQueryStringCookies) { - const cookiesKeys = this.keys(); - const cookiesArray = []; - for (let i = 0; i < cookiesKeys.length; i++) { - const { sanitizedValue } = serialize(cookiesKeys[i], this.get(cookiesKeys[i])); - const pair = `${cookiesKeys[i]}=${sanitizedValue}`; - if (!cookiesArray.includes(pair)) { - cookiesArray.push(pair); - } - } - - if (cookiesArray.length) { - path = Meteor.absoluteUrl('___cookie___/set'); - query = `?___cookies___=${encodeURIComponent(cookiesArray.join('; '))}`; - } - } - - fetch(`${path}${query}`, { - credentials: 'include', - type: 'cors' - }).then((response) => { - cb(void 0, response); - }).catch(cb); - } else { - cb(new Meteor.Error(400, 'Can\'t send cookies on server when `runOnServer` is false.')); - } - return void 0; - } + constructor(opts) { + this.__pendingCookies = []; + this.TTL = opts.TTL || false; + this.response = opts.response || false; + this.runOnServer = opts.runOnServer || false; + this.allowQueryStringCookies = opts.allowQueryStringCookies || false; + this.allowedCordovaOrigins = opts.allowedCordovaOrigins || false; + + if (this.allowedCordovaOrigins === true) { + this.allowedCordovaOrigins = /^http:\/\/localhost:12[0-9]{3}$/; + } + + this.originRE = new RegExp(`^https?:\/\/(${rootUrl ? rootUrl : ''}${mobileRootUrl ? '|' + mobileRootUrl : ''})$`); + + if (helpers.isObject(opts._cookies)) { + this.cookies = opts._cookies; + } else { + this.cookies = parse(opts._cookies); + } + } + + /** + * @locus Anywhere + * @memberOf __cookies + * @name get + * @param {String} key - The name of the cookie to read + * @param {String} _tmp - Unparsed string instead of user's cookies + * @summary Read a cookie. If the cookie doesn't exist a null value will be returned. + * @returns {String|void} + */ + get(key, _tmp) { + const cookieString = _tmp ? parse(_tmp) : this.cookies; + if (!key || !cookieString) { + return void 0; + } + + if (cookieString.hasOwnProperty(key)) { + return deserialize(cookieString[key]); + } + + return void 0; + } + + /** + * @locus Anywhere + * @memberOf __cookies + * @name set + * @param {String} key - The name of the cookie to create/overwrite + * @param {String} value - The value of the cookie + * @param {Object} opts - [Optional] Cookie options (see readme docs) + * @summary Create/overwrite a cookie. + * @returns {Boolean} + */ + set(key, value, opts = {}) { + if (key && !helpers.isUndefined(value)) { + if (helpers.isNumber(this.TTL) && opts.expires === undefined) { + opts.expires = new Date(+new Date() + this.TTL); + } + const { cookieString, sanitizedValue } = serialize(key, value, opts); + + this.cookies[key] = sanitizedValue; + if (Meteor.isClient) { + document.cookie = cookieString; + } else if (this.response) { + this.__pendingCookies.push(cookieString); + this.response.setHeader('Set-Cookie', this.__pendingCookies); + } + return true; + } + return false; + } + + /** + * @locus Anywhere + * @memberOf __cookies + * @name remove + * @param {String} key - The name of the cookie to create/overwrite + * @param {String} path - [Optional] The path from where the cookie will be + * readable. E.g., "/", "/mydir"; if not specified, defaults to the current + * path of the current document location (string or null). The path must be + * absolute (see RFC 2965). For more information on how to use relative paths + * in this argument, see: https://developer.mozilla.org/en-US/docs/Web/API/document.cookie#Using_relative_URLs_in_the_path_parameter + * @param {String} domain - [Optional] The domain from where the cookie will + * be readable. E.g., "example.com", ".example.com" (includes all subdomains) + * or "subdomain.example.com"; if not specified, defaults to the host portion + * of the current document location (string or null). + * @summary Remove a cookie(s). + * @returns {Boolean} + */ + remove(key, path = '/', domain = '') { + if (key && this.cookies.hasOwnProperty(key)) { + const { cookieString } = serialize(key, '', { + domain, + path, + expires: new Date(0), + }); + + delete this.cookies[key]; + if (Meteor.isClient) { + document.cookie = cookieString; + } else if (this.response) { + this.response.setHeader('Set-Cookie', cookieString); + } + return true; + } else if (!key && this.keys().length > 0 && this.keys()[0] !== '') { + const keys = Object.keys(this.cookies); + for (let i = 0; i < keys.length; i++) { + this.remove(keys[i]); + } + return true; + } + return false; + } + + /** + * @locus Anywhere + * @memberOf __cookies + * @name has + * @param {String} key - The name of the cookie to create/overwrite + * @param {String} _tmp - Unparsed string instead of user's cookies + * @summary Check whether a cookie exists in the current position. + * @returns {Boolean} + */ + has(key, _tmp) { + const cookieString = _tmp ? parse(_tmp) : this.cookies; + if (!key || !cookieString) { + return false; + } + + return cookieString.hasOwnProperty(key); + } + + /** + * @locus Anywhere + * @memberOf __cookies + * @name keys + * @summary Returns an array of all readable cookies from this location. + * @returns {[String]} + */ + keys() { + if (this.cookies) { + return Object.keys(this.cookies); + } + return []; + } + + /** + * @locus Client + * @memberOf __cookies + * @name send + * @param cb {Function} - Callback + * @summary Send all cookies over XHR to server. + * @returns {void} + */ + send(cb = NoOp) { + if (Meteor.isServer) { + cb(new Meteor.Error(400, "Can't run `.send()` on server, it's Client only method!")); + } + + if (this.runOnServer) { + let path = `${ + window.__meteor_runtime_config__.ROOT_URL_PATH_PREFIX || window.__meteor_runtime_config__.meteorEnv.ROOT_URL_PATH_PREFIX || '' + }/___cookie___/set`; + let query = ''; + + if (Meteor.isCordova && this.allowQueryStringCookies) { + const cookiesKeys = this.keys(); + const cookiesArray = []; + for (let i = 0; i < cookiesKeys.length; i++) { + const { sanitizedValue } = serialize(cookiesKeys[i], this.get(cookiesKeys[i])); + const pair = `${cookiesKeys[i]}=${sanitizedValue}`; + if (!cookiesArray.includes(pair)) { + cookiesArray.push(pair); + } + } + + if (cookiesArray.length) { + path = Meteor.absoluteUrl('___cookie___/set'); + query = `?___cookies___=${encodeURIComponent(cookiesArray.join('; '))}`; + } + } + + fetch(`${path}${query}`, { + credentials: 'include', + type: 'cors', + }) + .then((response) => { + cb(void 0, response); + }) + .catch(cb); + } else { + cb(new Meteor.Error(400, "Can't send cookies on server when `runOnServer` is false.")); + } + return void 0; + } } /** @@ -484,22 +492,22 @@ class __cookies { * @private */ const __middlewareHandler = (request, response, opts) => { - let _cookies = {}; - if (opts.runOnServer) { - if (request.headers && request.headers.cookie) { - _cookies = parse(request.headers.cookie); - } - - return new __cookies({ - _cookies, - TTL: opts.TTL, - runOnServer: opts.runOnServer, - response, - allowQueryStringCookies: opts.allowQueryStringCookies - }); - } - - throw new Meteor.Error(400, 'Can\'t use middleware when `runOnServer` is false.'); + let _cookies = {}; + if (opts.runOnServer) { + if (request.headers && request.headers.cookie) { + _cookies = parse(request.headers.cookie); + } + + return new __cookies({ + _cookies, + TTL: opts.TTL, + runOnServer: opts.runOnServer, + response, + allowQueryStringCookies: opts.allowQueryStringCookies, + }); + } + + throw new Meteor.Error(400, "Can't use middleware when `runOnServer` is false."); }; /** @@ -515,96 +523,94 @@ const __middlewareHandler = (request, response, opts) => { * @summary Main Cookie class */ class Cookies extends __cookies { - constructor(opts = {}) { - opts.TTL = helpers.isNumber(opts.TTL) ? opts.TTL : false; - opts.runOnServer = (opts.runOnServer !== false) ? true : false; - opts.allowQueryStringCookies = (opts.allowQueryStringCookies !== true) ? false : true; - - if (Meteor.isClient) { - opts._cookies = document.cookie; - super(opts); - } else { - opts._cookies = {}; - super(opts); - opts.auto = (opts.auto !== false) ? true : false; - this.opts = opts; - this.handler = helpers.isFunction(opts.handler) ? opts.handler : false; - this.onCookies = helpers.isFunction(opts.onCookies) ? opts.onCookies : false; - - if (opts.runOnServer && !Cookies.isLoadedOnServer) { - Cookies.isLoadedOnServer = true; - if (opts.auto) { - WebApp.connectHandlers.use((req, res, next) => { - if (urlRE.test(req._parsedUrl.path)) { - const matchedCordovaOrigin = !!req.headers.origin - && this.allowedCordovaOrigins - && this.allowedCordovaOrigins.test(req.headers.origin); - const matchedOrigin = matchedCordovaOrigin - || (!!req.headers.origin && this.originRE.test(req.headers.origin)); - - if (matchedOrigin) { - res.setHeader('Access-Control-Allow-Credentials', 'true'); - res.setHeader('Access-Control-Allow-Origin', req.headers.origin); - } - - const cookiesArray = []; - let cookiesObject = {}; - if (matchedCordovaOrigin && opts.allowQueryStringCookies && req.query.___cookies___) { - cookiesObject = parse(decodeURIComponent(req.query.___cookies___)); - } else if (req.headers.cookie) { - cookiesObject = parse(req.headers.cookie); - } - - const cookiesKeys = Object.keys(cookiesObject); - if (cookiesKeys.length) { - for (let i = 0; i < cookiesKeys.length; i++) { - const { cookieString } = serialize(cookiesKeys[i], cookiesObject[cookiesKeys[i]]); - if (!cookiesArray.includes(cookieString)) { - cookiesArray.push(cookieString); - } - } - - if (cookiesArray.length) { - res.setHeader('Set-Cookie', cookiesArray); - } - } - - helpers.isFunction(this.onCookies) && this.onCookies(__middlewareHandler(req, res, opts)); - - res.writeHead(200); - res.end(''); - } else { - req.Cookies = __middlewareHandler(req, res, opts); - helpers.isFunction(this.handler) && this.handler(req.Cookies); - next(); - } - }); - } - } - } - } - - /** - * @locus Server - * @memberOf Cookies - * @name middleware - * @summary Get Cookies instance into callback - * @returns {void} - */ - middleware() { - if (!Meteor.isServer) { - throw new Meteor.Error(500, '[ostrio:cookies] Can\'t use `.middleware()` on Client, it\'s Server only!'); - } - - return (req, res, next) => { - helpers.isFunction(this.handler) && this.handler(__middlewareHandler(req, res, this.opts)); - next(); - }; - } + constructor(opts = {}) { + opts.TTL = helpers.isNumber(opts.TTL) ? opts.TTL : false; + opts.runOnServer = opts.runOnServer !== false ? true : false; + opts.allowQueryStringCookies = opts.allowQueryStringCookies !== true ? false : true; + + if (Meteor.isClient) { + opts._cookies = document.cookie; + super(opts); + } else { + opts._cookies = {}; + super(opts); + opts.auto = opts.auto !== false ? true : false; + this.opts = opts; + this.handler = helpers.isFunction(opts.handler) ? opts.handler : false; + this.onCookies = helpers.isFunction(opts.onCookies) ? opts.onCookies : false; + + if (opts.runOnServer && !Cookies.isLoadedOnServer) { + Cookies.isLoadedOnServer = true; + if (opts.auto) { + WebApp.connectHandlers.use((req, res, next) => { + if (urlRE.test(req._parsedUrl.path)) { + const matchedCordovaOrigin = + !!req.headers.origin && this.allowedCordovaOrigins && this.allowedCordovaOrigins.test(req.headers.origin); + const matchedOrigin = matchedCordovaOrigin || (!!req.headers.origin && this.originRE.test(req.headers.origin)); + + if (matchedOrigin) { + res.setHeader('Access-Control-Allow-Credentials', 'true'); + res.setHeader('Access-Control-Allow-Origin', req.headers.origin); + } + + const cookiesArray = []; + let cookiesObject = {}; + if (matchedCordovaOrigin && opts.allowQueryStringCookies && req.query.___cookies___) { + cookiesObject = parse(decodeURIComponent(req.query.___cookies___)); + } else if (req.headers.cookie) { + cookiesObject = parse(req.headers.cookie); + } + + const cookiesKeys = Object.keys(cookiesObject); + if (cookiesKeys.length) { + for (let i = 0; i < cookiesKeys.length; i++) { + const { cookieString } = serialize(cookiesKeys[i], cookiesObject[cookiesKeys[i]]); + if (!cookiesArray.includes(cookieString)) { + cookiesArray.push(cookieString); + } + } + + if (cookiesArray.length) { + res.setHeader('Set-Cookie', cookiesArray); + } + } + + helpers.isFunction(this.onCookies) && this.onCookies(__middlewareHandler(req, res, opts)); + + res.writeHead(200); + res.end(''); + } else { + req.Cookies = __middlewareHandler(req, res, opts); + helpers.isFunction(this.handler) && this.handler(req.Cookies); + next(); + } + }); + } + } + } + } + + /** + * @locus Server + * @memberOf Cookies + * @name middleware + * @summary Get Cookies instance into callback + * @returns {void} + */ + middleware() { + if (!Meteor.isServer) { + throw new Meteor.Error(500, "[ostrio:cookies] Can't use `.middleware()` on Client, it's Server only!"); + } + + return (req, res, next) => { + helpers.isFunction(this.handler) && this.handler(__middlewareHandler(req, res, this.opts)); + next(); + }; + } } if (Meteor.isServer) { - Cookies.isLoadedOnServer = false; + Cookies.isLoadedOnServer = false; } /* Export the Cookies class */ diff --git a/apps/meteor/packages/meteor-cookies/package.js b/apps/meteor/packages/meteor-cookies/package.js index c7f19499f838..9ffcdb0d94af 100644 --- a/apps/meteor/packages/meteor-cookies/package.js +++ b/apps/meteor/packages/meteor-cookies/package.js @@ -12,4 +12,3 @@ Package.onUse((api) => { api.use('fetch', 'client'); api.mainModule('cookies.js', ['client', 'server']); }); - diff --git a/apps/meteor/packages/meteor-run-as-user/lib/collection.overwrites.js b/apps/meteor/packages/meteor-run-as-user/lib/collection.overwrites.js index 01603673d4c7..9ea5b18a2d08 100644 --- a/apps/meteor/packages/meteor-run-as-user/lib/collection.overwrites.js +++ b/apps/meteor/packages/meteor-run-as-user/lib/collection.overwrites.js @@ -7,48 +7,40 @@ // This will allow us to run the modifiers inside of a "Meteor.runAsUser" with // security checks. _.each(['insert', 'update', 'remove'], function (method) { - - var _super = Mongo.Collection.prototype[method]; - - Mongo.Collection.prototype[method] = function ( /* arguments */ ) { - var self = this; - var args = _.toArray(arguments); - - // Check if this method is run in restricted mode and collection is - // restricted. - if (Meteor.isRestricted() && self._restricted) { - - var generatedId = null; - if (method === 'insert' && !_.has(args[0], '_id')) { - generatedId = self._makeNewID(); - } - - // short circuit if there is no way it will pass. - if (self._validators[method].allow.length === 0) { - throw new Meteor.Error( - 403, 'Access denied. No allow validators set on restricted ' + - 'collection for method \'' + method + '\'.'); - } - - var validatedMethodName = - '_validated' + method.charAt(0).toUpperCase() + method.slice(1); - args.unshift(Meteor.userId()); - - if (method === 'insert') { - args.push(generatedId); - - self[validatedMethodName].apply(self, args); - // xxx: for now we return the id since self._validatedInsert doesn't - // yet return the new id - return generatedId || args[0]._id; - - } - - return self[validatedMethodName].apply(self, args); - - } - - return _super.apply(self, args); - }; - + var _super = Mongo.Collection.prototype[method]; + + Mongo.Collection.prototype[method] = function (/* arguments */) { + var self = this; + var args = _.toArray(arguments); + + // Check if this method is run in restricted mode and collection is + // restricted. + if (Meteor.isRestricted() && self._restricted) { + var generatedId = null; + if (method === 'insert' && !_.has(args[0], '_id')) { + generatedId = self._makeNewID(); + } + + // short circuit if there is no way it will pass. + if (self._validators[method].allow.length === 0) { + throw new Meteor.Error(403, 'Access denied. No allow validators set on restricted ' + "collection for method '" + method + "'."); + } + + var validatedMethodName = '_validated' + method.charAt(0).toUpperCase() + method.slice(1); + args.unshift(Meteor.userId()); + + if (method === 'insert') { + args.push(generatedId); + + self[validatedMethodName].apply(self, args); + // xxx: for now we return the id since self._validatedInsert doesn't + // yet return the new id + return generatedId || args[0]._id; + } + + return self[validatedMethodName].apply(self, args); + } + + return _super.apply(self, args); + }; }); diff --git a/apps/meteor/packages/meteor-run-as-user/lib/common.js b/apps/meteor/packages/meteor-run-as-user/lib/common.js index e12269db2630..af64b619375f 100644 --- a/apps/meteor/packages/meteor-run-as-user/lib/common.js +++ b/apps/meteor/packages/meteor-run-as-user/lib/common.js @@ -12,7 +12,7 @@ var restrictedMode = new Meteor.EnvironmentVariable(); * @return {Boolean} True if in a runAsUser user scope */ Meteor.isRestricted = function () { - return !!restrictedMode.get(); + return !!restrictedMode.get(); }; /** @@ -20,12 +20,12 @@ Meteor.isRestricted = function () { * @param {Function} f Code to run in restricted mode * @return {Any} Result of code running */ -Meteor.runRestricted = function(f) { - if (Meteor.isRestricted()) { - return f(); - } else { - return restrictedMode.withValue(true, f); - } +Meteor.runRestricted = function (f) { + if (Meteor.isRestricted()) { + return f(); + } else { + return restrictedMode.withValue(true, f); + } }; /** @@ -33,12 +33,12 @@ Meteor.runRestricted = function(f) { * @param {Function} f Code to run in restricted mode * @return {Any} Result of code running */ -Meteor.runUnrestricted = function(f) { - if (Meteor.isRestricted()) { - return restrictedMode.withValue(false, f); - } else { - f(); - } +Meteor.runUnrestricted = function (f) { + if (Meteor.isRestricted()) { + return restrictedMode.withValue(false, f); + } else { + f(); + } }; /** @@ -48,21 +48,23 @@ Meteor.runUnrestricted = function(f) { * @return {Any} Returns function result */ Meteor.runAsUser = function (userId, f) { - var currentInvocation = DDP._CurrentInvocation.get(); + var currentInvocation = DDP._CurrentInvocation.get(); - // Create a new method invocation - var invocation = new DDPCommon.MethodInvocation( - (currentInvocation) ? currentInvocation : { - connection: null - } - ); + // Create a new method invocation + var invocation = new DDPCommon.MethodInvocation( + currentInvocation + ? currentInvocation + : { + connection: null, + }, + ); - // Now run as user on this invocation - invocation.setUserId(userId); + // Now run as user on this invocation + invocation.setUserId(userId); - return DDP._CurrentInvocation.withValue(invocation, function () { - return f.apply(invocation, [userId]); - }); + return DDP._CurrentInvocation.withValue(invocation, function () { + return f.apply(invocation, [userId]); + }); }; /** @@ -70,10 +72,10 @@ Meteor.runAsUser = function (userId, f) { * @param {Function} f Function to run unrestricted * @return {Any} Returns function result */ -Meteor.runAsRestrictedUser = function(userId, f) { - return Meteor.runRestricted(function() { - return Meteor.runAsUser(userId, f); - }); +Meteor.runAsRestrictedUser = function (userId, f) { + return Meteor.runRestricted(function () { + return Meteor.runAsUser(userId, f); + }); }; var adminMode = new Meteor.EnvironmentVariable(); @@ -81,29 +83,29 @@ var adminMode = new Meteor.EnvironmentVariable(); /** * Check if code is running isside an invocation / method */ -Meteor.isAdmin = function() { - return !!adminMode.get(); +Meteor.isAdmin = function () { + return !!adminMode.get(); }; /** * Make the function run outside invocation */ -Meteor.runAsAdmin = function(f) { - if (Meteor.isAdmin()) { - return f(); - } else { - return adminMode.withValue(false, f); - } +Meteor.runAsAdmin = function (f) { + if (Meteor.isAdmin()) { + return f(); + } else { + return adminMode.withValue(false, f); + } }; /** * Make sure code runs outside an invocation on the * server */ -Meteor.runOutsideInvocation = function(f) { - if (Meteor.isServer && DDP._CurrentInvocation.get()) { - DDP._CurrentInvocation.withValue(null, f); - } else { - f(); - } +Meteor.runOutsideInvocation = function (f) { + if (Meteor.isServer && DDP._CurrentInvocation.get()) { + DDP._CurrentInvocation.withValue(null, f); + } else { + f(); + } }; diff --git a/apps/meteor/packages/meteor-run-as-user/lib/pre.1.0.3.js b/apps/meteor/packages/meteor-run-as-user/lib/pre.1.0.3.js index f707a1128b62..2562011c00da 100644 --- a/apps/meteor/packages/meteor-run-as-user/lib/pre.1.0.3.js +++ b/apps/meteor/packages/meteor-run-as-user/lib/pre.1.0.3.js @@ -2,95 +2,93 @@ // until the next release of Meteor maybe 1.0.3? // if (typeof DDPCommon === 'undefined') { - DDPCommon = {}; + DDPCommon = {}; - DDPCommon.MethodInvocation = function (options) { - var self = this; + DDPCommon.MethodInvocation = function (options) { + var self = this; - // true if we're running not the actual method, but a stub (that is, - // if we're on a client (which may be a browser, or in the future a - // server connecting to another server) and presently running a - // simulation of a server-side method for latency compensation - // purposes). not currently true except in a client such as a browser, - // since there's usually no point in running stubs unless you have a - // zero-latency connection to the user. + // true if we're running not the actual method, but a stub (that is, + // if we're on a client (which may be a browser, or in the future a + // server connecting to another server) and presently running a + // simulation of a server-side method for latency compensation + // purposes). not currently true except in a client such as a browser, + // since there's usually no point in running stubs unless you have a + // zero-latency connection to the user. - /** - * @summary Access inside a method invocation. Boolean value, true if this invocation is a stub. - * @locus Anywhere - * @name isSimulation - * @memberOf MethodInvocation - * @instance - * @type {Boolean} - */ - this.isSimulation = options.isSimulation; + /** + * @summary Access inside a method invocation. Boolean value, true if this invocation is a stub. + * @locus Anywhere + * @name isSimulation + * @memberOf MethodInvocation + * @instance + * @type {Boolean} + */ + this.isSimulation = options.isSimulation; - // call this function to allow other method invocations (from the - // same client) to continue running without waiting for this one to - // complete. - this._unblock = options.unblock || function () {}; - this._calledUnblock = false; + // call this function to allow other method invocations (from the + // same client) to continue running without waiting for this one to + // complete. + this._unblock = options.unblock || function () {}; + this._calledUnblock = false; - // current user id + // current user id - /** - * @summary The id of the user that made this method call, or `null` if no user was logged in. - * @locus Anywhere - * @name userId - * @memberOf MethodInvocation - * @instance - */ - this.userId = options.userId; + /** + * @summary The id of the user that made this method call, or `null` if no user was logged in. + * @locus Anywhere + * @name userId + * @memberOf MethodInvocation + * @instance + */ + this.userId = options.userId; - // sets current user id in all appropriate server contexts and - // reruns subscriptions - this._setUserId = options.setUserId || function () {}; + // sets current user id in all appropriate server contexts and + // reruns subscriptions + this._setUserId = options.setUserId || function () {}; - // On the server, the connection this method call came in on. + // On the server, the connection this method call came in on. - /** - * @summary Access inside a method invocation. The [connection](#meteor_onconnection) that this method was received on. `null` if the method is not associated with a connection, eg. a server initiated method call. - * @locus Server - * @name connection - * @memberOf MethodInvocation - * @instance - */ - this.connection = options.connection; + /** + * @summary Access inside a method invocation. The [connection](#meteor_onconnection) that this method was received on. `null` if the method is not associated with a connection, eg. a server initiated method call. + * @locus Server + * @name connection + * @memberOf MethodInvocation + * @instance + */ + this.connection = options.connection; - // The seed for randomStream value generation - this.randomSeed = options.randomSeed; + // The seed for randomStream value generation + this.randomSeed = options.randomSeed; - // This is set by RandomStream.get; and holds the random stream state - this.randomStream = null; - }; + // This is set by RandomStream.get; and holds the random stream state + this.randomStream = null; + }; - _.extend(DDPCommon.MethodInvocation.prototype, { - /** - * @summary Call inside a method invocation. Allow subsequent method from this client to begin running in a new fiber. - * @locus Server - * @memberOf MethodInvocation - * @instance - */ - unblock: function () { - var self = this; - self._calledUnblock = true; - self._unblock(); - }, + _.extend(DDPCommon.MethodInvocation.prototype, { + /** + * @summary Call inside a method invocation. Allow subsequent method from this client to begin running in a new fiber. + * @locus Server + * @memberOf MethodInvocation + * @instance + */ + unblock: function () { + var self = this; + self._calledUnblock = true; + self._unblock(); + }, - /** - * @summary Set the logged in user. - * @locus Server - * @memberOf MethodInvocation - * @instance - * @param {String | null} userId The value that should be returned by `userId` on this connection. - */ - setUserId: function (userId) { - var self = this; - if (self._calledUnblock) - throw new Error("Can't call setUserId in a method after calling unblock"); - self.userId = userId; - // self._setUserId(userId); - } - - }); + /** + * @summary Set the logged in user. + * @locus Server + * @memberOf MethodInvocation + * @instance + * @param {String | null} userId The value that should be returned by `userId` on this connection. + */ + setUserId: function (userId) { + var self = this; + if (self._calledUnblock) throw new Error("Can't call setUserId in a method after calling unblock"); + self.userId = userId; + // self._setUserId(userId); + }, + }); } From 87ad98fbb3d228fc6c6dc83da4b2d41957067e3b Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Tue, 9 Apr 2024 13:01:48 -0300 Subject: [PATCH 039/131] test: Prevent playwright from unselecting options by mistake (#32158) --- .../e2e/omnichannel/omnichannel-appearance.spec.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-appearance.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-appearance.spec.ts index 3e53baec784a..94aa5c46d3b1 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-appearance.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-appearance.spec.ts @@ -24,15 +24,17 @@ test.describe.serial('OC - Livechat Appearance', () => { test('OC - Livechat Appearance - Hide system messages', async ({ page }) => { await test.step('expect to have default values', async () => { - await poLivechatAppearance.inputHideSystemMessages.click(); + // Clicking at the edge of the element to prevent playwright from clicking a chip by mistake + await poLivechatAppearance.inputHideSystemMessages.click({ position: { x: 0, y: 0 } }); await expect(poLivechatAppearance.findHideSystemMessageOption('uj')).toHaveAttribute('aria-selected', 'true'); await expect(poLivechatAppearance.findHideSystemMessageOption('ul')).toHaveAttribute('aria-selected', 'true'); await expect(poLivechatAppearance.findHideSystemMessageOption('livechat-close')).toHaveAttribute('aria-selected', 'true'); - await poLivechatAppearance.inputHideSystemMessages.click(); + await poLivechatAppearance.inputHideSystemMessages.click({ position: { x: 0, y: 0 } }); }); await test.step('expect to change values', async () => { - await poLivechatAppearance.inputHideSystemMessages.click(); + // Clicking at the edge of the element to prevent playwright from clicking a chip by mistake + await poLivechatAppearance.inputHideSystemMessages.click({ position: { x: 0, y: 0 } }); await poLivechatAppearance.findHideSystemMessageOption('livechat_transfer_history').click(); await poLivechatAppearance.findHideSystemMessageOption('livechat-close').click(); await poLivechatAppearance.btnSave.click(); @@ -40,7 +42,8 @@ test.describe.serial('OC - Livechat Appearance', () => { await test.step('expect to have saved changes', async () => { await page.reload(); - await poLivechatAppearance.inputHideSystemMessages.click(); + // Clicking at the edge of the element to prevent playwright from clicking a chip by mistake + await poLivechatAppearance.inputHideSystemMessages.click({ position: { x: 0, y: 0 } }); await expect(poLivechatAppearance.findHideSystemMessageOption('uj')).toHaveAttribute('aria-selected', 'true'); await expect(poLivechatAppearance.findHideSystemMessageOption('ul')).toHaveAttribute('aria-selected', 'true'); await expect(poLivechatAppearance.findHideSystemMessageOption('livechat_transfer_history')).toHaveAttribute('aria-selected', 'true'); From 46c757a25e32d0f8309938eededf6b9c3a868b73 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 9 Apr 2024 13:40:42 -0300 Subject: [PATCH 040/131] Revert "fix!: api login should not suggest which credential is wrong" (#32156) This reverts commit 65324bc6ba3de9f7e60c49c72d6da07a456bf2fa. --- .changeset/fuzzy-cherries-buy.md | 7 ------- .../lib/server/lib/loginErrorMessageOverride.js | 14 ++++++++++++++ .../lib/server/lib/loginErrorMessageOverride.ts | 16 ---------------- .../client/meteorOverrides/login/google.ts | 10 ++++++++++ .../externals/meteor/accounts-base.d.ts | 10 +--------- .../end-to-end/api/31-failed-login-attempts.ts | 2 +- 6 files changed, 26 insertions(+), 33 deletions(-) delete mode 100644 .changeset/fuzzy-cherries-buy.md create mode 100644 apps/meteor/app/lib/server/lib/loginErrorMessageOverride.js delete mode 100644 apps/meteor/app/lib/server/lib/loginErrorMessageOverride.ts diff --git a/.changeset/fuzzy-cherries-buy.md b/.changeset/fuzzy-cherries-buy.md deleted file mode 100644 index e185a148c917..000000000000 --- a/.changeset/fuzzy-cherries-buy.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@rocket.chat/meteor": major ---- - -Api login should not suggest which credential is wrong (password/username) - -Failed login attemps will always return `Unauthorized` instead of the internal fail reason diff --git a/apps/meteor/app/lib/server/lib/loginErrorMessageOverride.js b/apps/meteor/app/lib/server/lib/loginErrorMessageOverride.js new file mode 100644 index 000000000000..4e054b81b2cf --- /dev/null +++ b/apps/meteor/app/lib/server/lib/loginErrorMessageOverride.js @@ -0,0 +1,14 @@ +// Do not disclose if user exists when password is invalid +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; + +const { _runLoginHandlers } = Accounts; +Accounts._runLoginHandlers = function (methodInvocation, options) { + const result = _runLoginHandlers.call(Accounts, methodInvocation, options); + + if (result.error && result.error.reason === 'Incorrect password') { + result.error = new Meteor.Error(403, 'User not found'); + } + + return result; +}; diff --git a/apps/meteor/app/lib/server/lib/loginErrorMessageOverride.ts b/apps/meteor/app/lib/server/lib/loginErrorMessageOverride.ts deleted file mode 100644 index e2a6e0d10581..000000000000 --- a/apps/meteor/app/lib/server/lib/loginErrorMessageOverride.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Do not disclose if user exists when password is invalid -import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; - -const { _runLoginHandlers } = Accounts; - -Accounts._options.ambiguousErrorMessages = true; -Accounts._runLoginHandlers = async function (methodInvocation, options) { - const result = await _runLoginHandlers.call(Accounts, methodInvocation, options); - - if (result.error instanceof Meteor.Error) { - result.error = new Meteor.Error(401, 'User not found'); - } - - return result; -}; diff --git a/apps/meteor/client/meteorOverrides/login/google.ts b/apps/meteor/client/meteorOverrides/login/google.ts index 4e99ac3a281b..2742cade15d2 100644 --- a/apps/meteor/client/meteorOverrides/login/google.ts +++ b/apps/meteor/client/meteorOverrides/login/google.ts @@ -8,6 +8,16 @@ import { overrideLoginMethod, type LoginCallback } from '../../lib/2fa/overrideL import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn'; import { createOAuthTotpLoginMethod } from './oauth'; +declare module 'meteor/accounts-base' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Accounts { + export const _options: { + restrictCreationByEmailDomain?: string | (() => string); + forbidClientAccountCreation?: boolean | undefined; + }; + } +} + declare module 'meteor/meteor' { // eslint-disable-next-line @typescript-eslint/no-namespace namespace Meteor { diff --git a/apps/meteor/definition/externals/meteor/accounts-base.d.ts b/apps/meteor/definition/externals/meteor/accounts-base.d.ts index f51c2f383987..3f0b148120e7 100644 --- a/apps/meteor/definition/externals/meteor/accounts-base.d.ts +++ b/apps/meteor/definition/externals/meteor/accounts-base.d.ts @@ -22,7 +22,7 @@ declare module 'meteor/accounts-base' { function _insertLoginToken(userId: string, token: { token: string; when: Date }): void; - function _runLoginHandlers(methodInvocation: T, loginRequest: Record): Promise; + function _runLoginHandlers(methodInvocation: T, loginRequest: Record): LoginMethodResult | undefined; function registerLoginHandler(name: string, handler: (options: any) => undefined | object): void; @@ -54,14 +54,6 @@ declare module 'meteor/accounts-base' { const _accountData: Record; - interface AccountsServerOptions { - ambiguousErrorMessages?: boolean; - restrictCreationByEmailDomain?: string | (() => string); - forbidClientAccountCreation?: boolean | undefined; - } - - export const _options: AccountsServerOptions; - // eslint-disable-next-line @typescript-eslint/no-namespace namespace oauth { function credentialRequestCompleteHandler( diff --git a/apps/meteor/tests/end-to-end/api/31-failed-login-attempts.ts b/apps/meteor/tests/end-to-end/api/31-failed-login-attempts.ts index 906b19d0a931..7e1019b60ecb 100644 --- a/apps/meteor/tests/end-to-end/api/31-failed-login-attempts.ts +++ b/apps/meteor/tests/end-to-end/api/31-failed-login-attempts.ts @@ -54,7 +54,7 @@ describe('[Failed Login Attempts]', function () { .expect(401) .expect((res) => { expect(res.body).to.have.property('status', 'error'); - expect(res.body).to.have.property('message', 'Unauthorized'); + expect(res.body).to.have.property('message', 'Incorrect password'); }); } From f3e5d777331574232afe6582b01d2fdaf86fe0ba Mon Sep 17 00:00:00 2001 From: Tiago Evangelista Pinto Date: Tue, 9 Apr 2024 14:34:51 -0300 Subject: [PATCH 041/131] chore: Fix some check/lint warnings (#32048) Co-authored-by: Tasso Evangelista <2263066+tassoevan@users.noreply.github.com> --- apps/meteor/app/api/server/v1/settings.ts | 2 +- apps/meteor/app/apps/server/bridges/listeners.js | 1 + .../app/authorization/lib/AuthorizationUtils.ts | 2 +- apps/meteor/app/cors/server/cors.ts | 3 +-- .../server/functions/getAvatarSuggestionForUser.ts | 2 +- .../src/elements/Timestamp/ErrorBoundary.tsx | 6 +++--- packages/livechat/src/components/Composer/index.tsx | 12 ++++++------ 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/meteor/app/api/server/v1/settings.ts b/apps/meteor/app/api/server/v1/settings.ts index 011988f5ba2e..bccfc8d91fc7 100644 --- a/apps/meteor/app/api/server/v1/settings.ts +++ b/apps/meteor/app/api/server/v1/settings.ts @@ -106,7 +106,7 @@ API.v1.addRoute( { authRequired: true, twoFactorRequired: true }, { async post() { - if (!this.bodyParams.name || !this.bodyParams.name.trim()) { + if (!this.bodyParams.name?.trim()) { throw new Meteor.Error('error-name-param-not-provided', 'The parameter "name" is required'); } diff --git a/apps/meteor/app/apps/server/bridges/listeners.js b/apps/meteor/app/apps/server/bridges/listeners.js index b1ee4fd14521..ab2632c912b0 100644 --- a/apps/meteor/app/apps/server/bridges/listeners.js +++ b/apps/meteor/app/apps/server/bridges/listeners.js @@ -7,6 +7,7 @@ export class AppListenerBridge { } async handleEvent(event, ...payload) { + // eslint-disable-next-line complexity const method = (() => { switch (event) { case AppInterface.IPreMessageSentPrevent: diff --git a/apps/meteor/app/authorization/lib/AuthorizationUtils.ts b/apps/meteor/app/authorization/lib/AuthorizationUtils.ts index 41c96bd6fd3a..6ad5cab04720 100644 --- a/apps/meteor/app/authorization/lib/AuthorizationUtils.ts +++ b/apps/meteor/app/authorization/lib/AuthorizationUtils.ts @@ -31,7 +31,7 @@ export const AuthorizationUtils = class { } const rules = restrictedRolePermissions.get(roleId); - if (!rules || !rules.size) { + if (!rules?.size) { return false; } diff --git a/apps/meteor/app/cors/server/cors.ts b/apps/meteor/app/cors/server/cors.ts index b4936d1456bc..309053014016 100644 --- a/apps/meteor/app/cors/server/cors.ts +++ b/apps/meteor/app/cors/server/cors.ts @@ -7,7 +7,6 @@ import { Logger } from '@rocket.chat/logger'; import { Meteor } from 'meteor/meteor'; import type { StaticFiles } from 'meteor/webapp'; import { WebApp, WebAppInternals } from 'meteor/webapp'; -import _ from 'underscore'; import { settings } from '../../settings/server'; @@ -174,7 +173,7 @@ WebApp.httpServer.addListener('request', (req, res, ...args) => { const isLocal = localhostRegexp.test(remoteAddress) && - (!req.headers['x-forwarded-for'] || _.all((req.headers['x-forwarded-for'] as string).split(','), localhostTest)); + (!req.headers['x-forwarded-for'] || (req.headers['x-forwarded-for'] as string).split(',').every(localhostTest)); // @ts-expect-error - `pair` is valid, but doesnt exists on types const isSsl = req.connection.pair || (req.headers['x-forwarded-proto'] && req.headers['x-forwarded-proto'].indexOf('https') !== -1); diff --git a/apps/meteor/app/lib/server/functions/getAvatarSuggestionForUser.ts b/apps/meteor/app/lib/server/functions/getAvatarSuggestionForUser.ts index a54704cf8862..2560d2f08b7d 100644 --- a/apps/meteor/app/lib/server/functions/getAvatarSuggestionForUser.ts +++ b/apps/meteor/app/lib/server/functions/getAvatarSuggestionForUser.ts @@ -167,7 +167,7 @@ export async function getAvatarSuggestionForUser( let blob = `data:${response.headers.get('content-type')};base64,`; blob += Buffer.from(await response.arrayBuffer()).toString('base64'); newAvatar.blob = blob; - newAvatar.contentType = response.headers.get('content-type')!; + newAvatar.contentType = response.headers.get('content-type') as string; validAvatars[avatar.service] = newAvatar; } } catch (error) { diff --git a/packages/gazzodown/src/elements/Timestamp/ErrorBoundary.tsx b/packages/gazzodown/src/elements/Timestamp/ErrorBoundary.tsx index 55ef57ad1dee..453853275f8a 100644 --- a/packages/gazzodown/src/elements/Timestamp/ErrorBoundary.tsx +++ b/packages/gazzodown/src/elements/Timestamp/ErrorBoundary.tsx @@ -1,4 +1,4 @@ -import { Component } from 'react'; +import React, { Component, ReactNode } from 'react'; export class ErrorBoundary extends Component<{ fallback: React.ReactNode }, { hasError: boolean }> { constructor(props: { fallback: React.ReactNode }) { @@ -6,11 +6,11 @@ export class ErrorBoundary extends Component<{ fallback: React.ReactNode }, { ha this.state = { hasError: false }; } - static getDerivedStateFromError() { + static getDerivedStateFromError(): { hasError: boolean } { return { hasError: true }; } - render() { + render(): ReactNode { if (this.state.hasError) { // You can render any custom fallback UI return this.props.fallback; diff --git a/packages/livechat/src/components/Composer/index.tsx b/packages/livechat/src/components/Composer/index.tsx index 2562a40debdc..1f1ce3b7369d 100644 --- a/packages/livechat/src/components/Composer/index.tsx +++ b/packages/livechat/src/components/Composer/index.tsx @@ -6,9 +6,9 @@ import { createClassName } from '../../helpers/createClassName'; import { parse } from '../../helpers/parse'; import styles from './styles.scss'; -const findLastTextNode = (node: Node): Text | null => { +const findLastTextNode = (node: Node): Node | null => { if (node.nodeType === Node.TEXT_NODE) { - return node as Text; + return node; } const children = node.childNodes; for (let i = children.length - 1; i >= 0; i--) { @@ -26,7 +26,7 @@ const replaceCaret = (el: Element) => { const target = findLastTextNode(el); // do not move caret if element was not focused const isTargetFocused = document.activeElement === el; - if (target !== null && target.nodeValue !== null && isTargetFocused) { + if (!!target?.nodeValue && isTargetFocused) { const range = document.createRange(); const sel = window.getSelection(); range.setStart(target, target.nodeValue.length); @@ -241,7 +241,7 @@ export class Composer extends Component { } if (typeof win.getSelection !== 'undefined' && (win.getSelection()?.rangeCount ?? 0) > 0) { - const range = win.getSelection()!.getRangeAt(0); + const range = win.getSelection()?.getRangeAt(0) as Range; const preCaretRange = range.cloneRange(); preCaretRange.selectNodeContents(element); preCaretRange.setEnd(range.endContainer, range.endOffset); @@ -249,10 +249,10 @@ export class Composer extends Component { } if (doc.selection && doc.selection.type !== 'Control') { - const textRange = doc.selection.createRange!(); + const textRange = doc.selection.createRange?.(); const preCaretTextRange = doc.body.createTextRange?.(); preCaretTextRange?.moveToElementText?.(element); - preCaretTextRange?.setEndPoint?.('EndToEnd', textRange); + preCaretTextRange?.setEndPoint?.('EndToEnd', textRange as Range); return preCaretTextRange?.text?.length ?? 0; } From 28b4678d215edbf3053c837b9eec29c10f1044fd Mon Sep 17 00:00:00 2001 From: Tiago Evangelista Pinto Date: Tue, 9 Apr 2024 23:13:26 -0300 Subject: [PATCH 042/131] fix(Omnichannel): Open expanded view (galery mode) for image attachments from livechat (#31990) --- .changeset/green-ways-tie.md | 5 +++++ .../server/methods/sendMessageLivechat.ts | 7 +++--- apps/meteor/tests/data/livechat/rooms.ts | 22 +++++++++++++++++++ .../end-to-end/api/livechat/20-messages.ts | 14 ++++++++++++ 4 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 .changeset/green-ways-tie.md diff --git a/.changeset/green-ways-tie.md b/.changeset/green-ways-tie.md new file mode 100644 index 000000000000..73a334fd32a0 --- /dev/null +++ b/.changeset/green-ways-tie.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed open expanded view (galery mode) for image attachments sent by livechat widget diff --git a/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts b/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts index 516a9bc5081f..ad16dea23e4f 100644 --- a/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts +++ b/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts @@ -26,7 +26,7 @@ declare module '@rocket.chat/ui-contexts' { } export const sendMessageLivechat = async ({ - message: { token, _id, rid, msg, file, attachments }, + message: { token, _id, rid, msg, file, files, attachments }, agent, }: ISendMessageLivechat): Promise => { check(token, String); @@ -67,6 +67,7 @@ export const sendMessageLivechat = async ({ msg, token, file, + files, attachments, }, agent, @@ -79,7 +80,7 @@ export const sendMessageLivechat = async ({ }; Meteor.methods({ - async sendMessageLivechat({ token, _id, rid, msg, file, attachments }: ILivechatMessage, agent: ILivechatMessageAgent) { - return sendMessageLivechat({ message: { token, _id, rid, msg, file, attachments }, agent }); + async sendMessageLivechat({ token, _id, rid, msg, file, files, attachments }: ILivechatMessage, agent: ILivechatMessageAgent) { + return sendMessageLivechat({ message: { token, _id, rid, msg, file, files, attachments }, agent }); }, }); diff --git a/apps/meteor/tests/data/livechat/rooms.ts b/apps/meteor/tests/data/livechat/rooms.ts index a3981a67c3d3..3ae59c626a21 100644 --- a/apps/meteor/tests/data/livechat/rooms.ts +++ b/apps/meteor/tests/data/livechat/rooms.ts @@ -13,6 +13,7 @@ import { IUserCredentialsHeader, adminUsername } from '../user'; import { getRandomVisitorToken } from './users'; import { DummyResponse, sleep } from './utils'; import { Response } from 'supertest'; +import { imgURL } from '../interactions'; export const createLivechatRoom = async (visitorToken: string, extraRoomParams?: Record): Promise => { const urlParams = new URLSearchParams(); @@ -208,6 +209,21 @@ export const sendMessage = (roomId: string, message: string, visitorToken: strin }); }; +export const uploadFile = (roomId: string, visitorToken: string): Promise => { + return new Promise((resolve, reject) => { + request + .post(api(`livechat/upload/${roomId}`)) + .set({ 'x-visitor-token': visitorToken, ...credentials }) + .attach('file', imgURL) + .end((err: Error, res: DummyResponse) => { + if (err) { + return reject(err); + } + resolve(res.body as unknown as IMessage); + }); + }); +}; + // Sends a message using sendMessage method from agent export const sendAgentMessage = (roomId: string, msg?: string): Promise => { return new Promise((resolve, reject) => { @@ -243,6 +259,12 @@ export const fetchMessages = (roomId: string, visitorToken: string): Promise { expect(quotedMessageAttachments).to.have.property('text').that.is.equal(agentMsgSentence); } }); + + it('should verify if visitor is receiving a message with a image attachment', async () => { + const { + room: { _id: roomId }, + visitor: { token }, + } = await startANewLivechatRoomAndTakeIt(); + + const imgMessage = await uploadFile(roomId, token); + + expect(imgMessage).to.have.property('files').that.is.an('array'); + expect(imgMessage.files?.[0]).to.have.keys('_id', 'name', 'type'); + expect(imgMessage).to.have.property('file').that.deep.equal(imgMessage?.files?.[0]); + }); }); }); From 9902554388c3301046d77efa2c4ee16749c76d69 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 9 Apr 2024 23:16:45 -0600 Subject: [PATCH 043/131] chore: Deprecate `channels.images` in favor of `rooms.images` (#32141) --- .changeset/slow-cows-dance.md | 7 + apps/meteor/app/api/server/api.ts | 2 +- apps/meteor/app/api/server/definition.ts | 1 + apps/meteor/app/api/server/v1/channels.ts | 4 +- apps/meteor/app/api/server/v1/rooms.ts | 48 +++++- apps/meteor/client/lib/lists/ImagesList.ts | 2 +- .../room/ImageGallery/hooks/useImagesList.ts | 6 +- apps/meteor/tests/end-to-end/api/09-rooms.js | 142 ++++++++++++++++++ .../src/v1/channels/ChannelsImagesProps.ts | 14 -- .../rest-typings/src/v1/channels/channels.ts | 4 +- .../rest-typings/src/v1/channels/index.ts | 1 - packages/rest-typings/src/v1/rooms.ts | 38 ++++- 12 files changed, 241 insertions(+), 28 deletions(-) create mode 100644 .changeset/slow-cows-dance.md delete mode 100644 packages/rest-typings/src/v1/channels/ChannelsImagesProps.ts diff --git a/.changeset/slow-cows-dance.md b/.changeset/slow-cows-dance.md new file mode 100644 index 000000000000..67097c860cf6 --- /dev/null +++ b/.changeset/slow-cows-dance.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +--- + +Deprecate `channels.images` in favor of `rooms.images`. `Rooms` endpoints are more broad and should interact with all types of rooms. `Channels` on the other hand are specific to public channels. +This change is to keep the semantics and conventions of the endpoints diff --git a/apps/meteor/app/api/server/api.ts b/apps/meteor/app/api/server/api.ts index 08e1ef17e348..0279bcfbae27 100644 --- a/apps/meteor/app/api/server/api.ts +++ b/apps/meteor/app/api/server/api.ts @@ -589,7 +589,7 @@ export class APIClass extends Restivus { try { if (options.deprecationVersion) { - apiDeprecationLogger.endpoint(this.request.route, options.deprecationVersion, this.response); + apiDeprecationLogger.endpoint(this.request.route, options.deprecationVersion, this.response, options.deprecationInfo || ''); } await api.enforceRateLimit(objectForRateLimitMatch, this.request, this.response, this.userId); diff --git a/apps/meteor/app/api/server/definition.ts b/apps/meteor/app/api/server/definition.ts index d2fa248530ff..20374bcb5e84 100644 --- a/apps/meteor/app/api/server/definition.ts +++ b/apps/meteor/app/api/server/definition.ts @@ -96,6 +96,7 @@ export type Options = ( validateParams?: ValidateFunction | { [key in Method]?: ValidateFunction }; authOrAnonRequired?: true; deprecationVersion?: string; + deprecationInfo?: string; }; export type PartialThis = { diff --git a/apps/meteor/app/api/server/v1/channels.ts b/apps/meteor/app/api/server/v1/channels.ts index 1c84926edb63..9d7cd8e231fd 100644 --- a/apps/meteor/app/api/server/v1/channels.ts +++ b/apps/meteor/app/api/server/v1/channels.ts @@ -18,7 +18,7 @@ import { isChannelsConvertToTeamProps, isChannelsSetReadOnlyProps, isChannelsDeleteProps, - isChannelsImagesProps, + isRoomsImagesProps, } from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; @@ -806,7 +806,7 @@ API.v1.addRoute( API.v1.addRoute( 'channels.images', - { authRequired: true, validateParams: isChannelsImagesProps }, + { authRequired: true, validateParams: isRoomsImagesProps, deprecationVersion: '7.0.0', deprecationInfo: 'Use /v1/rooms.images instead.' }, { async get() { const room = await Rooms.findOneById>(this.queryParams.roomId, { diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index 47e2c03e6064..4026f0aaa5a5 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -1,8 +1,8 @@ import { Media } from '@rocket.chat/core-services'; -import type { IRoom } from '@rocket.chat/core-typings'; -import { Messages, Rooms, Users } from '@rocket.chat/models'; +import type { IRoom, IUpload } from '@rocket.chat/core-typings'; +import { Messages, Rooms, Users, Uploads } from '@rocket.chat/models'; import type { Notifications } from '@rocket.chat/rest-typings'; -import { isGETRoomsNameExists } from '@rocket.chat/rest-typings'; +import { isGETRoomsNameExists, isRoomsImagesProps } from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; import { isTruthy } from '../../../../lib/isTruthy'; @@ -386,6 +386,48 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'rooms.images', + { authRequired: true, validateParams: isRoomsImagesProps }, + { + async get() { + const room = await Rooms.findOneById>(this.queryParams.roomId, { + projection: { t: 1, teamId: 1, prid: 1 }, + }); + + if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) { + return API.v1.unauthorized(); + } + + let initialImage: IUpload | null = null; + if (this.queryParams.startingFromId) { + initialImage = await Uploads.findOneById(this.queryParams.startingFromId); + } + + const { offset, count } = await getPaginationItems(this.queryParams); + + const { cursor, totalCount } = Uploads.findImagesByRoomId(room._id, initialImage?.uploadedAt, { + skip: offset, + limit: count, + }); + + const [files, total] = await Promise.all([cursor.toArray(), totalCount]); + + // If the initial image was not returned in the query, insert it as the first element of the list + if (initialImage && !files.find(({ _id }) => _id === (initialImage as IUpload)._id)) { + files.splice(0, 0, initialImage); + } + + return API.v1.success({ + files, + count, + offset, + total, + }); + }, + }, +); + API.v1.addRoute( 'rooms.adminRooms', { authRequired: true }, diff --git a/apps/meteor/client/lib/lists/ImagesList.ts b/apps/meteor/client/lib/lists/ImagesList.ts index 540d5bb1c0f7..cefb1bec9fc5 100644 --- a/apps/meteor/client/lib/lists/ImagesList.ts +++ b/apps/meteor/client/lib/lists/ImagesList.ts @@ -6,7 +6,7 @@ type FilesMessage = Omit & Required>; export type ImagesListOptions = { roomId: Required['rid']; - startingFromId: string; + startingFromId?: string; count?: number; offset?: number; }; diff --git a/apps/meteor/client/views/room/ImageGallery/hooks/useImagesList.ts b/apps/meteor/client/views/room/ImageGallery/hooks/useImagesList.ts index 35f2a8bdceec..c4f7d0770815 100644 --- a/apps/meteor/client/views/room/ImageGallery/hooks/useImagesList.ts +++ b/apps/meteor/client/views/room/ImageGallery/hooks/useImagesList.ts @@ -1,4 +1,4 @@ -import type { ChannelsImagesProps } from '@rocket.chat/rest-typings'; +import type { RoomsImagesProps } from '@rocket.chat/rest-typings'; import { useEndpoint } from '@rocket.chat/ui-contexts'; import { useCallback, useEffect, useState } from 'react'; @@ -7,7 +7,7 @@ import { useComponentDidUpdate } from '../../../../hooks/useComponentDidUpdate'; import { ImagesList } from '../../../../lib/lists/ImagesList'; export const useImagesList = ( - options: ChannelsImagesProps, + options: RoomsImagesProps, ): { filesList: ImagesList; initialItemCount: number; @@ -27,7 +27,7 @@ export const useImagesList = ( } }, [filesList, options]); - const apiEndPoint = '/v1/channels.images'; + const apiEndPoint = '/v1/rooms.images'; const getFiles = useEndpoint('GET', apiEndPoint); diff --git a/apps/meteor/tests/end-to-end/api/09-rooms.js b/apps/meteor/tests/end-to-end/api/09-rooms.js index 92b73f34557e..261d283419f9 100644 --- a/apps/meteor/tests/end-to-end/api/09-rooms.js +++ b/apps/meteor/tests/end-to-end/api/09-rooms.js @@ -1907,4 +1907,146 @@ describe('[Rooms]', function () { }); }); }); + + describe('rooms.images', () => { + let testUserCreds = null; + before(async () => { + const user = await createUser(); + testUserCreds = await login(user.username, password); + }); + + const uploadFile = async ({ roomId, file }) => { + const { body } = await request + .post(api(`rooms.upload/${roomId}`)) + .set(credentials) + .attach('file', file) + .expect('Content-Type', 'application/json') + .expect(200); + + return body.message.attachments[0]; + }; + + const getIdFromImgPath = (link) => { + return link.split('/')[2]; + }; + + it('should return an error when user is not logged in', async () => { + await request.get(api('rooms.images')).expect(401); + }); + it('should return an error when the required parameter "roomId" is not provided', async () => { + await request.get(api('rooms.images')).set(credentials).expect(400); + }); + it('should return an error when the required parameter "roomId" is not a valid room', async () => { + await request.get(api('rooms.images')).set(credentials).query({ roomId: 'invalid' }).expect(403); + }); + it('should return an error when room is valid but user is not part of it', async () => { + const { body } = await createRoom({ type: 'p', name: `test-${Date.now()}` }); + + const { + group: { _id: roomId }, + } = body; + await request.get(api('rooms.images')).set(testUserCreds).query({ roomId }).expect(403); + + await deleteRoom({ type: 'p', roomId }); + }); + it('should return an empty array when room is valid and user is part of it but there are no images', async () => { + const { body } = await createRoom({ type: 'p', usernames: [credentials.username], name: `test-${Date.now()}` }); + const { + group: { _id: roomId }, + } = body; + await request + .get(api('rooms.images')) + .set(credentials) + .query({ roomId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('files').and.to.be.an('array').and.to.have.lengthOf(0); + }); + + await deleteRoom({ type: 'p', roomId }); + }); + it('should return an array of images when room is valid and user is part of it and there are images', async () => { + const { body } = await createRoom({ type: 'p', usernames: [credentials.username], name: `test-${Date.now()}` }); + const { + group: { _id: roomId }, + } = body; + const { title_link } = await uploadFile({ + roomId, + file: fs.createReadStream(path.join(process.cwd(), imgURL)), + }); + const fileId = getIdFromImgPath(title_link); + await request + .get(api('rooms.images')) + .set(credentials) + .query({ roomId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('files').and.to.be.an('array').and.to.have.lengthOf(1); + expect(res.body.files[0]).to.have.property('_id', fileId); + }); + + await deleteRoom({ type: 'p', roomId }); + }); + it('should return multiple images when room is valid and user is part of it and there are multiple images', async () => { + const { body } = await createRoom({ type: 'p', usernames: [credentials.username], name: `test-${Date.now()}` }); + const { + group: { _id: roomId }, + } = body; + const { title_link: link1 } = await uploadFile({ + roomId, + file: fs.createReadStream(path.join(process.cwd(), imgURL)), + }); + const { title_link: link2 } = await uploadFile({ + roomId, + file: fs.createReadStream(path.join(process.cwd(), imgURL)), + }); + + const fileId1 = getIdFromImgPath(link1); + const fileId2 = getIdFromImgPath(link2); + + await request + .get(api('rooms.images')) + .set(credentials) + .query({ roomId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('files').and.to.be.an('array').and.to.have.lengthOf(2); + expect(res.body.files.find((file) => file._id === fileId1)).to.exist; + expect(res.body.files.find((file) => file._id === fileId2)).to.exist; + }); + + await deleteRoom({ type: 'p', roomId }); + }); + it('should allow to filter images passing the startingFromId parameter', async () => { + const { body } = await createRoom({ type: 'p', usernames: [credentials.username], name: `test-${Date.now()}` }); + const { + group: { _id: roomId }, + } = body; + const { title_link } = await uploadFile({ + roomId, + file: fs.createReadStream(path.join(process.cwd(), imgURL)), + }); + await uploadFile({ + roomId, + file: fs.createReadStream(path.join(process.cwd(), imgURL)), + }); + + const fileId2 = getIdFromImgPath(title_link); + await request + .get(api('rooms.images')) + .set(credentials) + .query({ roomId, startingFromId: fileId2 }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('files').and.to.be.an('array').and.to.have.lengthOf(1); + expect(res.body.files[0]).to.have.property('_id', fileId2); + }); + + await deleteRoom({ type: 'p', roomId }); + }); + }); }); diff --git a/packages/rest-typings/src/v1/channels/ChannelsImagesProps.ts b/packages/rest-typings/src/v1/channels/ChannelsImagesProps.ts deleted file mode 100644 index 52c065b7c393..000000000000 --- a/packages/rest-typings/src/v1/channels/ChannelsImagesProps.ts +++ /dev/null @@ -1,14 +0,0 @@ -import Ajv from 'ajv'; - -const ajv = new Ajv({ - coerceTypes: true, -}); - -export type ChannelsImagesProps = { - roomId: string; - startingFromId: string; - count?: number; - offset?: number; -}; -const channelsImagesPropsSchema = {}; -export const isChannelsImagesProps = ajv.compile(channelsImagesPropsSchema); diff --git a/packages/rest-typings/src/v1/channels/channels.ts b/packages/rest-typings/src/v1/channels/channels.ts index 328588ffb58b..6bda71004c5d 100644 --- a/packages/rest-typings/src/v1/channels/channels.ts +++ b/packages/rest-typings/src/v1/channels/channels.ts @@ -2,6 +2,7 @@ import type { IUpload, IUploadWithUser, IMessage, IRoom, ITeam, IGetRoomRoles, I import type { PaginatedRequest } from '../../helpers/PaginatedRequest'; import type { PaginatedResult } from '../../helpers/PaginatedResult'; +import type { RoomsImagesProps } from '../rooms'; import type { ChannelsAddAllProps } from './ChannelsAddAllProps'; import type { ChannelsArchiveProps } from './ChannelsArchiveProps'; import type { ChannelsConvertToTeamProps } from './ChannelsConvertToTeamProps'; @@ -10,7 +11,6 @@ import type { ChannelsDeleteProps } from './ChannelsDeleteProps'; import type { ChannelsGetAllUserMentionsByChannelProps } from './ChannelsGetAllUserMentionsByChannelProps'; import type { ChannelsGetIntegrationsProps } from './ChannelsGetIntegrationsProps'; import type { ChannelsHistoryProps } from './ChannelsHistoryProps'; -import type { ChannelsImagesProps } from './ChannelsImagesProps'; import type { ChannelsInviteProps } from './ChannelsInviteProps'; import type { ChannelsJoinProps } from './ChannelsJoinProps'; import type { ChannelsKickProps } from './ChannelsKickProps'; @@ -40,7 +40,7 @@ export type ChannelsEndpoints = { }>; }; '/v1/channels.images': { - GET: (params: ChannelsImagesProps) => PaginatedResult<{ + GET: (params: RoomsImagesProps) => PaginatedResult<{ files: IUpload[]; }>; }; diff --git a/packages/rest-typings/src/v1/channels/index.ts b/packages/rest-typings/src/v1/channels/index.ts index f0cf81ba622d..981296e244fe 100644 --- a/packages/rest-typings/src/v1/channels/index.ts +++ b/packages/rest-typings/src/v1/channels/index.ts @@ -7,7 +7,6 @@ export * from './ChannelsCreateProps'; export * from './ChannelsDeleteProps'; export * from './ChannelsGetAllUserMentionsByChannelProps'; export * from './ChannelsHistoryProps'; -export * from './ChannelsImagesProps'; export * from './ChannelsJoinProps'; export * from './ChannelsKickProps'; export * from './ChannelsLeaveProps'; diff --git a/packages/rest-typings/src/v1/rooms.ts b/packages/rest-typings/src/v1/rooms.ts index ab0046bdc38b..7e22183f801d 100644 --- a/packages/rest-typings/src/v1/rooms.ts +++ b/packages/rest-typings/src/v1/rooms.ts @@ -1,4 +1,4 @@ -import type { IMessage, IRoom, IUser, RoomAdminFieldsType } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom, IUser, RoomAdminFieldsType, IUpload } from '@rocket.chat/core-typings'; import Ajv from 'ajv'; import type { PaginatedRequest } from '../helpers/PaginatedRequest'; @@ -436,6 +436,37 @@ export type Notifications = { type RoomsGetDiscussionsProps = PaginatedRequest; +export type RoomsImagesProps = { + roomId: string; + startingFromId?: string; + count?: number; + offset?: number; +}; +const roomsImagesPropsSchema = { + type: 'object', + properties: { + roomId: { + type: 'string', + }, + startingFromId: { + type: 'string', + nullable: true, + }, + count: { + type: 'number', + nullable: true, + }, + offset: { + type: 'number', + nullable: true, + }, + }, + required: ['roomId'], + additionalProperties: false, +}; + +export const isRoomsImagesProps = ajv.compile(roomsImagesPropsSchema); + export type RoomsEndpoints = { '/v1/rooms.autocomplete.channelAndPrivate': { GET: (params: RoomsAutoCompleteChannelAndPrivateProps) => { @@ -573,4 +604,9 @@ export type RoomsEndpoints = { discussions: IRoom[]; }>; }; + '/v1/rooms.images': { + GET: (params: RoomsImagesProps) => PaginatedResult<{ + files: IUpload[]; + }>; + }; }; From a895864ae4634136bdbd7398afada23f9eda5dcb Mon Sep 17 00:00:00 2001 From: Marcos Spessatto Defendi Date: Wed, 10 Apr 2024 09:14:00 -0300 Subject: [PATCH 044/131] test: make chat api tests fully independent (#31655) --- apps/meteor/tests/end-to-end/api/05-chat.js | 591 +++++++++++--------- 1 file changed, 331 insertions(+), 260 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/05-chat.js b/apps/meteor/tests/end-to-end/api/05-chat.js index aa0b364a33ee..bc7c1166b795 100644 --- a/apps/meteor/tests/end-to-end/api/05-chat.js +++ b/apps/meteor/tests/end-to-end/api/05-chat.js @@ -4,15 +4,22 @@ import { after, before, beforeEach, describe, it } from 'mocha'; import { getCredentials, api, request, credentials, message } from '../../data/api-data.js'; import { sendSimpleMessage, deleteMessage, pinMessage } from '../../data/chat.helper.js'; import { updatePermission, updateSetting } from '../../data/permissions.helper'; -import { createRoom } from '../../data/rooms.helper.js'; +import { createRoom, deleteRoom } from '../../data/rooms.helper.js'; import { password } from '../../data/user'; -import { createUser, login } from '../../data/users.helper'; +import { createUser, deleteUser, login } from '../../data/users.helper'; describe('[Chat]', function () { this.retries(0); + let testChannel; before((done) => getCredentials(done)); + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `chat.api-test-${Date.now()}` })).body.channel; + }); + + after(() => deleteRoom({ type: 'c', roomId: testChannel._id })); + describe('/chat.postMessage', () => { it('should throw an error when at least one of required parameters(channel, roomId) is not sent', (done) => { request @@ -38,7 +45,7 @@ describe('[Chat]', function () { .post(api('chat.postMessage')) .set(credentials) .send({ - channel: 'general', + channel: testChannel.name, alias: 'Gruggy', text: 'Sample message', emoji: ':smirk:', @@ -79,7 +86,7 @@ describe('[Chat]', function () { .post(api('chat.postMessage')) .set(credentials) .send({ - channel: 'general', + channel: testChannel.name, alias: 'Gruggy', text: 'Sample message', avatar: 'http://res.guggy.com/logo_128.png', @@ -106,7 +113,7 @@ describe('[Chat]', function () { .post(api('chat.postMessage')) .set(credentials) .send({ - channel: 'general', + channel: testChannel.name, text: 'Sample message', alias: 'Gruggy', emoji: ':smirk:', @@ -133,7 +140,7 @@ describe('[Chat]', function () { .post(api('chat.postMessage')) .set(credentials) .send({ - channel: 'general', + channel: testChannel.name, text: 'Sample message', alias: 'Gruggy', emoji: ':smirk:', @@ -160,7 +167,7 @@ describe('[Chat]', function () { .post(api('chat.postMessage')) .set(credentials) .send({ - channel: 'general', + channel: testChannel.name, text: 'Sample message', alias: 'Gruggy', emoji: ':smirk:', @@ -194,7 +201,7 @@ describe('[Chat]', function () { .post(api('chat.postMessage')) .set(credentials) .send({ - channel: 'general', + channel: testChannel.name, text: 'Sample message', emoji: ':smirk:', avatar: 'javascript:alert("xss")', @@ -228,7 +235,7 @@ describe('[Chat]', function () { .post(api('chat.postMessage')) .set(credentials) .send({ - channel: 'general', + channel: testChannel.name, text: 'Sample message', alias: 'Gruggy', emoji: ':smirk:', @@ -263,7 +270,7 @@ describe('[Chat]', function () { .post(api('chat.postMessage')) .set(credentials) .send({ - channel: 'general', + channel: testChannel.name, text: 'Sample message', emoji: ':smirk:', alias: 'Gruggy', @@ -291,7 +298,7 @@ describe('[Chat]', function () { .post(api('chat.postMessage')) .set(credentials) .send({ - channel: 'general', + channel: testChannel.name, text: 'Sample message', emoji: ':smirk:', alias: 'Gruggy', @@ -319,7 +326,7 @@ describe('[Chat]', function () { .post(api('chat.postMessage')) .set(credentials) .send({ - channel: 'general', + channel: testChannel.name, text: 'Sample message', alias: 'Gruggy', emoji: ':smirk:', @@ -346,7 +353,7 @@ describe('[Chat]', function () { .post(api('chat.postMessage')) .set(credentials) .send({ - channel: 'general', + channel: testChannel.name, text: 'Sample message', alias: 'Gruggy', emoji: ':smirk:', @@ -373,7 +380,7 @@ describe('[Chat]', function () { .post(api('chat.postMessage')) .set(credentials) .send({ - channel: 'general', + channel: testChannel.name, alias: 'Gruggy', text: 'Sample message', emoji: ':smirk:', @@ -402,7 +409,7 @@ describe('[Chat]', function () { .post(api('chat.postMessage')) .set(credentials) .send({ - channel: 'general', + channel: testChannel.name, text: 'Sample message', emoji: ':smirk:', alias: 'Gruggy', @@ -448,7 +455,7 @@ describe('[Chat]', function () { .post(api('chat.postMessage')) .set(credentials) .send({ - channel: 'general', + channel: testChannel.name, text: 'Sample message', emoji: ':smirk:', attachments: [ @@ -540,7 +547,7 @@ describe('[Chat]', function () { .post(api('chat.postMessage')) .set(credentials) .send({ - channel: 'general', + channel: testChannel.name, alias: 'Gruggy', text: 'Sample message', emoji: ':smirk:', @@ -567,7 +574,7 @@ describe('[Chat]', function () { .post(api('chat.postMessage')) .set(credentials) .send({ - channel: 'general', + channel: testChannel.name, text: 'Sample message', alias: 'Gruggy', emoji: ':smirk:', @@ -594,7 +601,7 @@ describe('[Chat]', function () { .post(api('chat.postMessage')) .set(credentials) .send({ - channel: 'general', + channel: testChannel.name, text: 'Sample message', alias: 'Gruggy', emoji: ':smirk:', @@ -621,7 +628,7 @@ describe('[Chat]', function () { .post(api('chat.postMessage')) .set(credentials) .send({ - channel: 'general', + channel: testChannel.name, text: 'Sample message', alias: 'Gruggy', emoji: ':smirk:', @@ -657,7 +664,7 @@ describe('[Chat]', function () { .set(credentials) .send({ message: { - channel: 'general', + channel: testChannel.name, text: 'Sample message', alias: 'Gruggy', emoji: ':smirk:', @@ -701,7 +708,7 @@ describe('[Chat]', function () { .send({ message: { _id: message._id, - rid: 'GENERAL', + rid: testChannel._id, msg: 'Sample message', emoji: ':smirk:', attachments: [ @@ -750,20 +757,19 @@ describe('[Chat]', function () { let ytEmbedMsgId; let imgUrlMsgId; - before(async () => { - await Promise.all([updateSetting('API_EmbedIgnoredHosts', ''), updateSetting('API_EmbedSafePorts', '80, 443, 3000')]); - }); - after(async () => { - await Promise.all([ + before(() => Promise.all([updateSetting('API_EmbedIgnoredHosts', ''), updateSetting('API_EmbedSafePorts', '80, 443, 3000')])); + + after(() => + Promise.all([ updateSetting('API_EmbedIgnoredHosts', 'localhost, 127.0.0.1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16'), updateSetting('API_EmbedSafePorts', '80, 443'), - ]); - }); + ]), + ); before(async () => { const ytEmbedMsgPayload = { _id: `id-${Date.now()}`, - rid: 'GENERAL', + rid: testChannel._id, msg: 'https://www.youtube.com/watch?v=T2v29gK8fP4', emoji: ':smirk:', }; @@ -774,7 +780,7 @@ describe('[Chat]', function () { before(async () => { const imgUrlMsgPayload = { _id: `id-${Date.now()}1`, - rid: 'GENERAL', + rid: testChannel._id, msg: 'http://localhost:3000/images/logo/logo.png', emoji: ':smirk:', }; @@ -832,7 +838,7 @@ describe('[Chat]', function () { .set(credentials) .send({ message: { - rid: 'GENERAL', + rid: testChannel._id, msg: 'https://www.youtube.com/watch?v=T2v29gK8fP4', }, previewUrls: [], @@ -868,7 +874,7 @@ describe('[Chat]', function () { .set(credentials) .send({ message: { - rid: 'GENERAL', + rid: testChannel._id, msg: 'https://www.youtube.com/watch?v=T2v29gK8fP4 https://www.rocket.chat/', }, previewUrls: ['https://www.rocket.chat/'], @@ -914,7 +920,7 @@ describe('[Chat]', function () { .set(credentials) .send({ message: { - rid: 'GENERAL', + rid: testChannel._id, msg: urls.join(' '), }, previewUrls: urls, @@ -948,34 +954,22 @@ describe('[Chat]', function () { describe('Read only channel', () => { let readOnlyChannel; - - const userCredentials = {}; + let userCredentials; let user; - before((done) => { - const username = `user.test.readonly.${Date.now()}`; - const email = `${username}@rocket.chat`; - request - .post(api('users.create')) - .set(credentials) - .send({ email, name: username, username, password }) - .end((err, res) => { - user = res.body.user; - request - .post(api('login')) - .send({ - user: username, - password, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - userCredentials['X-Auth-Token'] = res.body.data.authToken; - userCredentials['X-User-Id'] = res.body.data.userId; - }) - .end(done); - }); + + before(async () => { + user = await createUser(); + userCredentials = await login(user.username, password); }); + after(async () => + Promise.all([ + deleteRoom({ type: 'c', roomId: readOnlyChannel._id }), + deleteUser(user), + updatePermission('post-readonly', ['admin', 'owner', 'moderator']), + ]), + ); + it('Creating a read-only channel', (done) => { request .post(api('channels.create')) @@ -1065,8 +1059,6 @@ describe('[Chat]', function () { expect(res.body).to.have.property('success', true); expect(res.body).to.have.property('message').and.to.be.an('object'); }); - - await updatePermission('post-readonly', ['admin', 'owner', 'moderator']); }); }); @@ -1076,7 +1068,7 @@ describe('[Chat]', function () { .set(credentials) .send({ message: { - rid: 'GENERAL', + rid: testChannel._id, msg: 'Sample message', alias: 'Gruggy', }, @@ -1096,7 +1088,7 @@ describe('[Chat]', function () { .set(credentials) .send({ message: { - rid: 'GENERAL', + rid: testChannel._id, msg: 'Sample message', avatar: 'http://site.com/logo.png', }, @@ -1125,7 +1117,7 @@ describe('[Chat]', function () { .post(api('chat.update')) .set(credentials) .send({ - roomId: 'GENERAL', + roomId: testChannel._id, msgId: message._id, text: 'This message was edited via API', }) @@ -1144,7 +1136,7 @@ describe('[Chat]', function () { .post(api('chat.update')) .set(credentials) .send({ - roomId: 'GENERAL', + roomId: testChannel._id, msgId: message._id, text: `Testing quotes ${quotedMsgLink}`, }) @@ -1164,7 +1156,7 @@ describe('[Chat]', function () { .post(api('chat.update')) .set(credentials) .send({ - roomId: 'GENERAL', + roomId: testChannel._id, msgId: message._id, text: `Testing quotes ${quotedMsgLink}`, }) @@ -1185,7 +1177,7 @@ describe('[Chat]', function () { .post(api('chat.update')) .set(credentials) .send({ - roomId: 'GENERAL', + roomId: testChannel._id, msgId: message._id, text: `${newQuotedMsgLink} Testing quotes ${quotedMsgLink}`, }) @@ -1205,7 +1197,7 @@ describe('[Chat]', function () { .post(api('chat.update')) .set(credentials) .send({ - roomId: 'GENERAL', + roomId: testChannel._id, msgId: message._id, text: 'This message was edited via API', }) @@ -1223,53 +1215,21 @@ describe('[Chat]', function () { let msgId; let user; let userCredentials; - before((done) => { - const username = `user.test.${Date.now()}`; - const email = `${username}@rocket.chat`; - request - .post(api('users.create')) - .set(credentials) - .send({ email, name: username, username, password }) - .end((err, res) => { - user = res.body.user; - done(); - }); - }); - before((done) => { - request - .post(api('login')) - .send({ - user: user.username, - password, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - userCredentials = {}; - userCredentials['X-Auth-Token'] = res.body.data.authToken; - userCredentials['X-User-Id'] = res.body.data.userId; - }) - .end(done); - }); - after((done) => { - request - .post(api('users.delete')) - .set(credentials) - .send({ - userId: user._id, - }) - .end(() => { - user = undefined; - done(); - }); + + before(async () => { + user = await createUser(); + userCredentials = await login(user.username, password); }); + + after(() => deleteUser(user)); + beforeEach((done) => { request .post(api('chat.sendMessage')) .set(credentials) .send({ message: { - rid: 'GENERAL', + rid: testChannel._id, msg: 'Sample message', }, }) @@ -1286,7 +1246,7 @@ describe('[Chat]', function () { .post(api('chat.delete')) .set(credentials) .send({ - roomId: 'GENERAL', + roomId: testChannel._id, msgId, }) .expect('Content-Type', 'application/json') @@ -1302,7 +1262,7 @@ describe('[Chat]', function () { .set(userCredentials) .send({ message: { - rid: 'GENERAL', + rid: testChannel._id, msg: 'Sample message', }, }) @@ -1319,7 +1279,7 @@ describe('[Chat]', function () { .post(api('chat.delete')) .set(credentials) .send({ - roomId: 'GENERAL', + roomId: testChannel._id, msgId, asUser: true, }) @@ -1340,7 +1300,7 @@ describe('[Chat]', function () { .set(credentials) .send({ message: { - rid: 'GENERAL', + rid: testChannel._id, msg: text, }, }); @@ -1357,7 +1317,7 @@ describe('[Chat]', function () { .get(api('chat.search')) .set(credentials) .query({ - roomId: 'GENERAL', + roomId: testChannel._id, searchText: 'msg1', }) .expect('Content-Type', 'application/json') @@ -1373,7 +1333,7 @@ describe('[Chat]', function () { .get(api('chat.search')) .set(credentials) .query({ - roomId: 'GENERAL', + roomId: testChannel._id, searchText: 'msg1', count: 1, }) @@ -1391,7 +1351,7 @@ describe('[Chat]', function () { .get(api('chat.search')) .set(credentials) .query({ - roomId: 'GENERAL', + roomId: testChannel._id, searchText: 'msg1', offset: 1, count: 3, @@ -1411,7 +1371,7 @@ describe('[Chat]', function () { .get(api('chat.search')) .set(credentials) .query({ - roomId: 'GENERAL', + roomId: testChannel._id, searchText: 'msg1', offset: 9999, count: 3, @@ -1662,19 +1622,20 @@ describe('[Chat]', function () { describe('[/chat.getDeletedMessages]', () => { let roomId; - before((done) => { - createRoom({ - type: 'c', - name: `channel.test.${Date.now()}`, - }).end((err, res) => { - roomId = res.body.channel._id; - sendSimpleMessage({ roomId }).end((err, res) => { - const msgId = res.body.message._id; - deleteMessage({ roomId, msgId }).end(done); - }); - }); + + before(async () => { + roomId = ( + await createRoom({ + type: 'c', + name: `channel.test.${Date.now()}`, + }) + ).body.channel._id; + const msgId = (await sendSimpleMessage({ roomId })).body.message._id; + await deleteMessage({ roomId, msgId }); }); + after(() => deleteRoom({ type: 'c', roomId })); + describe('when execute successfully', () => { it('should return a list of deleted messages', (done) => { request @@ -1789,6 +1750,10 @@ describe('[Chat]', function () { }); describe('[/chat.pinMessage]', () => { + after(() => + Promise.all([updateSetting('Message_AllowPinning', true), updatePermission('pin-message', ['owner', 'moderator', 'admin'])]), + ); + it('should return an error when pinMessage is not allowed in this server', (done) => { updateSetting('Message_AllowPinning', false).then(() => { request @@ -1847,6 +1812,10 @@ describe('[Chat]', function () { }); describe('[/chat.unPinMessage]', () => { + after(() => + Promise.all([updateSetting('Message_AllowPinning', true), updatePermission('pin-message', ['owner', 'moderator', 'admin'])]), + ); + it('should return an error when pinMessage is not allowed in this server', (done) => { updateSetting('Message_AllowPinning', false).then(() => { request @@ -1905,6 +1874,8 @@ describe('[Chat]', function () { }); describe('[/chat.unStarMessage]', () => { + after(() => updateSetting('Message_AllowStarring', true)); + it('should return an error when starMessage is not allowed in this server', (done) => { updateSetting('Message_AllowStarring', false).then(() => { request @@ -1943,6 +1914,8 @@ describe('[Chat]', function () { }); describe('[/chat.starMessage]', () => { + after(() => updateSetting('Message_AllowStarring', true)); + it('should return an error when starMessage is not allowed in this server', (done) => { updateSetting('Message_AllowStarring', false).then(() => { request @@ -1981,6 +1954,8 @@ describe('[Chat]', function () { }); describe('[/chat.ignoreUser]', () => { + after(() => deleteRoom({ type: 'd', roomId: 'rocket.catrocketchat.internal.admin.test' })); + it('should fail if invalid roomId', (done) => { request .get(api('chat.ignoreUser')) @@ -2056,19 +2031,22 @@ describe('[Chat]', function () { describe('[/chat.getPinnedMessages]', () => { let roomId; - before((done) => { - createRoom({ - type: 'c', - name: `channel.test.${Date.now()}`, - }).end((err, res) => { - roomId = res.body.channel._id; - sendSimpleMessage({ roomId }).end((err, res) => { - const msgId = res.body.message._id; - pinMessage({ msgId }).end(done); - }); - }); + + before(async () => { + roomId = ( + await createRoom({ + type: 'c', + name: `channel.test.${Date.now()}`, + }) + ).body.channel._id; + + const msgId = (await sendSimpleMessage({ roomId })).body.message._id; + + await pinMessage({ msgId }); }); + after(() => deleteRoom({ type: 'c', roomId })); + describe('when execute successfully', () => { it('should return a list of pinned messages', (done) => { request @@ -2144,6 +2122,19 @@ describe('[Chat]', function () { }); describe('[/chat.getMentionedMessages]', () => { + let testChannel; + + before(async () => { + testChannel = ( + await createRoom({ + type: 'c', + name: `channel.test.${Date.now()}`, + }) + ).body.channel; + }); + + after(() => deleteRoom({ type: 'c', roomId: testChannel._id })); + it('should return an error when the required "roomId" parameter is not sent', (done) => { request .get(api('chat.getMentionedMessages')) @@ -2172,7 +2163,7 @@ describe('[Chat]', function () { it('should return the mentioned messages', (done) => { request - .get(api('chat.getMentionedMessages?roomId=GENERAL')) + .get(api(`chat.getMentionedMessages?roomId=${testChannel._id}`)) .set(credentials) .expect('Content-Type', 'application/json') .expect(200) @@ -2188,6 +2179,19 @@ describe('[Chat]', function () { }); describe('[/chat.getStarredMessages]', () => { + let testChannel; + + before(async () => { + testChannel = ( + await createRoom({ + type: 'c', + name: `channel.test.${Date.now()}`, + }) + ).body.channel; + }); + + after(() => deleteRoom({ type: 'c', roomId: testChannel._id })); + it('should return an error when the required "roomId" parameter is not sent', (done) => { request .get(api('chat.getStarredMessages')) @@ -2216,7 +2220,7 @@ describe('[Chat]', function () { it('should return the starred messages', (done) => { request - .get(api('chat.getStarredMessages?roomId=GENERAL')) + .get(api(`chat.getStarredMessages?roomId=${testChannel._id}`)) .set(credentials) .expect('Content-Type', 'application/json') .expect(200) @@ -2243,10 +2247,12 @@ describe('[Chat]', function () { messageText.charAt(0), ' ', ]; - before((done) => { - createRoom({ type: 'c', name: `channel.test.threads.${Date.now()}` }).end((err, room) => { - testChannel = room.body.channel; - request + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.test.getDiscussions.${Date.now()}` })).body.channel; + + discussionRoom = ( + await request .post(api('rooms.createDiscussion')) .set(credentials) .send({ @@ -2255,13 +2261,11 @@ describe('[Chat]', function () { }) .expect('Content-Type', 'application/json') .expect(200) - .end((err, res) => { - discussionRoom = res.body.discussion; - done(); - }); - }); + ).body.discussion; }); + after(() => deleteRoom({ type: 'c', roomId: testChannel._id })); + it('should return an error when the required "roomId" parameter is not sent', (done) => { request .get(api('chat.getDiscussions')) @@ -2290,7 +2294,7 @@ describe('[Chat]', function () { it('should return the discussions of a room', (done) => { request - .get(api('chat.getDiscussions?roomId=GENERAL')) + .get(api(`chat.getDiscussions?roomId=${testChannel._id}`)) .set(credentials) .expect('Content-Type', 'application/json') .expect(200) @@ -2308,7 +2312,7 @@ describe('[Chat]', function () { .get(api('chat.getDiscussions')) .set(credentials) .query({ - roomId: 'GENERAL', + roomId: testChannel._id, count: 5, offset: 0, }) @@ -2379,16 +2383,29 @@ describe('[Chat]', function () { }); describe('Threads', () => { - after((done) => { - updateSetting('API_Upper_Count_Limit', 100) - .then(() => updatePermission('view-c-room', ['admin', 'user', 'bot'])) - .then(done); + let testThreadChannel; + + before((done) => getCredentials(done)); + + before(async () => { + testThreadChannel = (await createRoom({ type: 'c', name: `chat.api-test-${Date.now()}` })).body.channel; + + await updatePermission('view-c-room', ['admin', 'user', 'bot', 'app', 'anonymous']); }); + after(() => + Promise.all([ + updateSetting('Threads_enabled', true), + updatePermission('view-c-room', ['admin', 'user', 'bot', 'app', 'anonymous']), + deleteRoom({ type: 'c', roomId: testThreadChannel._id }), + ]), + ); + describe('[/chat.getThreadsList]', () => { const messageText = 'Message to create thread'; let testChannel; let threadMessage; + let user; const messageWords = [ ...messageText.split(' '), ...messageText.toUpperCase().split(' '), @@ -2397,10 +2414,11 @@ describe('Threads', () => { messageText.charAt(0), ' ', ]; - before((done) => { - createRoom({ type: 'c', name: `channel.test.threads.${Date.now()}` }).end((err, room) => { - testChannel = room.body.channel; - request + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.test.threads.${Date.now()}` })).body.channel; + const { message } = ( + await request .post(api('chat.sendMessage')) .set(credentials) .send({ @@ -2411,27 +2429,33 @@ describe('Threads', () => { }) .expect('Content-Type', 'application/json') .expect(200) - .then((response) => { - request - .post(api('chat.sendMessage')) - .set(credentials) - .send({ - message: { - rid: testChannel._id, - msg: 'Thread message', - tmid: response.body.message._id, - }, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .end((err, res) => { - threadMessage = res.body.message; - done(); - }); - }); - }); + ).body; + + threadMessage = ( + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + rid: testChannel._id, + msg: 'Thread message', + tmid: message._id, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + ).body.message; }); + after(() => + Promise.all([ + updateSetting('Threads_enabled', true), + deleteRoom({ type: 'c', roomId: testChannel._id }), + updatePermission('view-c-room', ['admin', 'user', 'bot', 'app', 'anonymous']), + deleteUser(user), + ]), + ); + it('should return an error for chat.getThreadsList when threads are not allowed in this server', (done) => { updateSetting('Threads_enabled', false).then(() => { request @@ -2453,6 +2477,7 @@ describe('Threads', () => { it('should return an error when the user is not allowed access the room', (done) => { createUser().then((createdUser) => { + user = createdUser; login(createdUser.username, password).then((userCredentials) => { updateSetting('Threads_enabled', true).then(() => { updatePermission('view-c-room', []).then(() => { @@ -2602,25 +2627,33 @@ describe('Threads', () => { describe('[/chat.syncThreadsList]', () => { let testChannel; let threadMessage; - before((done) => { - createRoom({ type: 'c', name: `.threads.sync.${Date.now()}` }).end((err, channel) => { - testChannel = channel.body.channel; - sendSimpleMessage({ - roomId: testChannel._id, - text: 'Message to create thread', - }).end((err, message) => { - sendSimpleMessage({ - roomId: testChannel._id, - text: 'Thread Message', - tmid: message.body.message._id, - }).end((err, res) => { - threadMessage = res.body.message; - done(); - }); - }); + let user; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `.threads.sync.${Date.now()}` })).body.channel; + const { body: { message } = {} } = await sendSimpleMessage({ + roomId: testChannel._id, + text: 'Message to create thread', }); + + threadMessage = ( + await sendSimpleMessage({ + roomId: testChannel._id, + text: 'Thread Message', + tmid: message._id, + }) + ).body.message; }); + after(() => + Promise.all([ + updateSetting('Threads_enabled', true), + deleteRoom({ type: 'c', roomId: testChannel._id }), + updatePermission('view-c-room', ['admin', 'user', 'bot', 'app', 'anonymous']), + deleteUser(user), + ]), + ); + it('should return an error for chat.getThreadsList when threads are not allowed in this server', (done) => { updateSetting('Threads_enabled', false).then(() => { request @@ -2701,6 +2734,7 @@ describe('Threads', () => { it('should return an error when the user is not allowed access the room', (done) => { createUser().then((createdUser) => { + user = createdUser; login(createdUser.username, password).then((userCredentials) => { updatePermission('view-c-room', []).then(() => { request @@ -2752,26 +2786,35 @@ describe('Threads', () => { let testChannel; let threadMessage; let createdThreadMessage; - before((done) => { - createRoom({ type: 'c', name: `channel.test.threads.${Date.now()}` }).end((err, res) => { - testChannel = res.body.channel; - sendSimpleMessage({ + let user; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.test.threads.${Date.now()}` })).body.channel; + createdThreadMessage = ( + await sendSimpleMessage({ roomId: testChannel._id, text: 'Message to create thread', - }).end((err, message) => { - createdThreadMessage = message.body.message; - sendSimpleMessage({ - roomId: testChannel._id, - text: 'Thread Message', - tmid: createdThreadMessage._id, - }).end((err, res) => { - threadMessage = res.body.message; - done(); - }); - }); - }); + }) + ).body.message; + + threadMessage = ( + await sendSimpleMessage({ + roomId: testChannel._id, + text: 'Thread Message', + tmid: createdThreadMessage._id, + }) + ).body.message; }); + after(() => + Promise.all([ + updateSetting('Threads_enabled', true), + deleteRoom({ type: 'c', roomId: testChannel._id }), + updatePermission('view-c-room', ['admin', 'user', 'bot', 'app', 'anonymous']), + deleteUser(user), + ]), + ); + it('should return an error for chat.getThreadMessages when threads are not allowed in this server', (done) => { updateSetting('Threads_enabled', false).then(() => { request @@ -2793,6 +2836,7 @@ describe('Threads', () => { it('should return an error when the user is not allowed access the room', (done) => { createUser().then((createdUser) => { + user = createdUser; login(createdUser.username, password).then((userCredentials) => { updateSetting('Threads_enabled', true).then(() => { updatePermission('view-c-room', []).then(() => { @@ -2844,26 +2888,35 @@ describe('Threads', () => { let testChannel; let threadMessage; let createdThreadMessage; - before((done) => { - createRoom({ type: 'c', name: `message.threads.${Date.now()}` }).end((err, res) => { - testChannel = res.body.channel; - sendSimpleMessage({ + let user; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `message.threads.${Date.now()}` })).body.channel; + createdThreadMessage = ( + await sendSimpleMessage({ roomId: testChannel._id, text: 'Message to create thread', - }).end((err, message) => { - createdThreadMessage = message.body.message; - sendSimpleMessage({ - roomId: testChannel._id, - text: 'Thread Message', - tmid: createdThreadMessage._id, - }).end((err, res) => { - threadMessage = res.body.message; - done(); - }); - }); - }); + }) + ).body.message; + + threadMessage = ( + await sendSimpleMessage({ + roomId: testChannel._id, + text: 'Thread Message', + tmid: createdThreadMessage._id, + }) + ).body.message; }); + after(() => + Promise.all([ + updateSetting('Threads_enabled', true), + deleteRoom({ type: 'c', roomId: testChannel._id }), + updatePermission('view-c-room', ['admin', 'user', 'bot', 'app', 'anonymous']), + deleteUser(user), + ]), + ); + it('should return an error for chat.syncThreadMessages when threads are not allowed in this server', (done) => { updateSetting('Threads_enabled', false).then(() => { request @@ -2945,6 +2998,7 @@ describe('Threads', () => { it('should return an error when the user is not allowed access the room', (done) => { createUser().then((createdUser) => { + user = createdUser; login(createdUser.username, password).then((userCredentials) => { updatePermission('view-c-room', []).then(() => { request @@ -2995,25 +3049,33 @@ describe('Threads', () => { describe('[/chat.followMessage]', () => { let testChannel; let threadMessage; - before((done) => { - createRoom({ type: 'c', name: `channel.test.threads.follow.${Date.now()}` }).end((err, res) => { - testChannel = res.body.channel; - sendSimpleMessage({ - roomId: testChannel._id, - text: 'Message to create thread', - }).end((err, message) => { - sendSimpleMessage({ - roomId: testChannel._id, - text: 'Thread Message', - tmid: message.body.message._id, - }).end((err, res) => { - threadMessage = res.body.message; - done(); - }); - }); + let user; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.test.threads.follow${Date.now()}` })).body.channel; + const { body: { message } = {} } = await sendSimpleMessage({ + roomId: testChannel._id, + text: 'Message to create thread', }); + + threadMessage = ( + await sendSimpleMessage({ + roomId: testChannel._id, + text: 'Thread Message', + tmid: message._id, + }) + ).body.message; }); + after(() => + Promise.all([ + updateSetting('Threads_enabled', true), + deleteRoom({ type: 'c', roomId: testChannel._id }), + updatePermission('view-c-room', ['admin', 'user', 'bot', 'app', 'anonymous']), + deleteUser(user), + ]), + ); + it('should return an error for chat.followMessage when threads are not allowed in this server', (done) => { updateSetting('Threads_enabled', false).then(() => { request @@ -3054,6 +3116,7 @@ describe('Threads', () => { it('should return an error when the user is not allowed access the room', (done) => { createUser().then((createdUser) => { + user = createdUser; login(createdUser.username, password).then((userCredentials) => { updatePermission('view-c-room', []).then(() => { request @@ -3096,25 +3159,32 @@ describe('Threads', () => { describe('[/chat.unfollowMessage]', () => { let testChannel; let threadMessage; - before((done) => { - createRoom({ type: 'c', name: `channel.test.threads.unfollow.${Date.now()}` }).end((err, res) => { - testChannel = res.body.channel; - sendSimpleMessage({ - roomId: testChannel._id, - text: 'Message to create thread', - }).end((err, message) => { - sendSimpleMessage({ - roomId: testChannel._id, - text: 'Thread Message', - tmid: message.body.message._id, - }).end((err, res) => { - threadMessage = res.body.message; - done(); - }); - }); + let user; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.test.threads.unfollow.${Date.now()}` })).body.channel; + const { body: { message } = {} } = await sendSimpleMessage({ + roomId: testChannel._id, + text: 'Message to create thread', }); + + threadMessage = ( + await sendSimpleMessage({ + roomId: testChannel._id, + text: 'Thread Message', + tmid: message._id, + }) + ).body.message; }); + after(() => + Promise.all([ + updateSetting('Threads_enabled', true), + deleteRoom({ type: 'c', roomId: testChannel._id }), + updatePermission('view-c-room', ['admin', 'user', 'bot', 'app', 'anonymous']), + deleteUser(user), + ]), + ); it('should return an error for chat.unfollowMessage when threads are not allowed in this server', (done) => { updateSetting('Threads_enabled', false).then(() => { request @@ -3155,6 +3225,7 @@ describe('Threads', () => { it('should return an error when the user is not allowed access the room', (done) => { createUser().then((createdUser) => { + user = createdUser; login(createdUser.username, password).then((userCredentials) => { updatePermission('view-c-room', []).then(() => { request @@ -3201,7 +3272,7 @@ describe('Threads', () => { .get(api('chat.getURLPreview')) .set(credentials) .query({ - roomId: 'GENERAL', + roomId: testThreadChannel._id, url, }) .expect('Content-Type', 'application/json') @@ -3234,7 +3305,7 @@ describe('Threads', () => { .get(api('chat.getURLPreview')) .set(credentials) .query({ - roomId: 'GENERAL', + roomId: testThreadChannel._id, }) .expect('Content-Type', 'application/json') .expect(400) From a674bb97aff6f996c860b94bcd6f0f957f115f16 Mon Sep 17 00:00:00 2001 From: Marcos Spessatto Defendi Date: Wed, 10 Apr 2024 09:34:52 -0300 Subject: [PATCH 045/131] test: make api users tests fully independent (#31597) --- apps/meteor/tests/data/rooms.helper.js | 48 +- apps/meteor/tests/data/users.helper.js | 27 +- apps/meteor/tests/end-to-end/api/01-users.js | 1893 ++++++------------ 3 files changed, 730 insertions(+), 1238 deletions(-) diff --git a/apps/meteor/tests/data/rooms.helper.js b/apps/meteor/tests/data/rooms.helper.js index c28f763c00f9..e8c2481ffc36 100644 --- a/apps/meteor/tests/data/rooms.helper.js +++ b/apps/meteor/tests/data/rooms.helper.js @@ -1,6 +1,17 @@ +import { resolve } from 'path'; import { api, credentials, request } from './api-data'; -export const createRoom = ({ name, type, username, token, agentId, members, credentials: customCredentials, extraData, voipCallDirection = 'inbound' }) => { +export const createRoom = ({ + name, + type, + username, + token, + agentId, + members, + credentials: customCredentials, + extraData, + voipCallDirection = 'inbound', +}) => { if (!type) { throw new Error('"type" is required in "createRoom.ts" test helper'); } @@ -40,7 +51,7 @@ export const asyncCreateRoom = ({ name, type, username, members = [] }) => createRoom({ name, type, username, members }).end(resolve); }); -function actionRoom({ action, type, roomId }) { +function actionRoom({ action, type, roomId, extraData = {} }) { if (!type) { throw new Error(`"type" is required in "${action}Room" test helper`); } @@ -58,6 +69,7 @@ function actionRoom({ action, type, roomId }) { .set(credentials) .send({ roomId, + ...extraData, }) .end(resolve); }); @@ -67,6 +79,28 @@ export const deleteRoom = ({ type, roomId }) => actionRoom({ action: 'delete', t export const closeRoom = ({ type, roomId }) => actionRoom({ action: 'close', type, roomId }); +export const joinChannel = ({ overrideCredentials = credentials, roomId }) => + request.post(api('channels.join')).set(overrideCredentials).send({ + roomId, + }); + +export const inviteToChannel = ({ overrideCredentials = credentials, roomId, userId }) => + request.post(api('channels.invite')).set(credentials).send({ + userId, + roomId, + }); + +export const addRoomOwner = ({ type, roomId, userId }) => actionRoom({ action: 'addOwner', type, roomId, extraData: { userId } }); + +export const removeRoomOwner = ({ type, roomId, userId }) => actionRoom({ action: 'removeOwner', type, roomId, extraData: { userId } }); + +export const getChannelRoles = async ({ roomId, overrideCredentials = credentials }) => + ( + await request.get(api('channels.roles')).set(overrideCredentials).query({ + roomId, + }) + ).body.roles; + export const setRoomConfig = ({ roomId, favorite, isDefault }) => { return request .post(api('rooms.saveRoomSettings')) @@ -74,9 +108,11 @@ export const setRoomConfig = ({ roomId, favorite, isDefault }) => { .send({ rid: roomId, default: isDefault, - favorite: favorite ? { - defaultValue: true, - favorite: false - } : undefined + favorite: favorite + ? { + defaultValue: true, + favorite: false, + } + : undefined, }); }; diff --git a/apps/meteor/tests/data/users.helper.js b/apps/meteor/tests/data/users.helper.js index d69b0413ae0b..cf6f90b3f877 100644 --- a/apps/meteor/tests/data/users.helper.js +++ b/apps/meteor/tests/data/users.helper.js @@ -4,8 +4,8 @@ import { password } from './user'; export const createUser = (userData = {}) => new Promise((resolve) => { - const username = `user.test.${Date.now()}`; - const email = `${username}@rocket.chat`; + const username = userData.username || `user.test.${Date.now()}`; + const email = userData.email || `${username}@rocket.chat`; request .post(api('users.create')) .set(credentials) @@ -34,10 +34,14 @@ export const login = (username, password) => }); }); -export const deleteUser = async (user) => - request.post(api('users.delete')).set(credentials).send({ - userId: user._id, - }); +export const deleteUser = async (user, extraData = {}) => + request + .post(api('users.delete')) + .set(credentials) + .send({ + userId: user._id, + ...extraData, + }); export const getUserByUsername = (username) => new Promise((resolve) => { @@ -90,3 +94,14 @@ export const setUserStatus = (overrideCredentials = credentials, status = UserSt message: '', status, }); + +export const registerUser = async (userData = {}, overrideCredentials = credentials) => { + const username = userData.username || `user.test.${Date.now()}`; + const email = userData.email || `${username}@rocket.chat`; + const result = await request + .post(api('users.register')) + .set(overrideCredentials) + .send({ email, name: username, username, pass: password, ...userData }); + + return result.body.user; +}; diff --git a/apps/meteor/tests/end-to-end/api/01-users.js b/apps/meteor/tests/end-to-end/api/01-users.js index 1103f8a3cbc5..405eea05ee7e 100644 --- a/apps/meteor/tests/end-to-end/api/01-users.js +++ b/apps/meteor/tests/end-to-end/api/01-users.js @@ -4,7 +4,6 @@ import { Random } from '@rocket.chat/random'; import { expect } from 'chai'; import { after, afterEach, before, beforeEach, describe, it } from 'mocha'; -import { sleep } from '../../../lib/utils/sleep'; import { getCredentials, api, request, credentials, apiEmail, apiUsername, log, wait, reservedWords } from '../../data/api-data.js'; import { MAX_BIO_LENGTH, MAX_NICKNAME_LENGTH } from '../../data/constants.ts'; import { customFieldText, clearCustomFields, setCustomFields } from '../../data/custom-fields.js'; @@ -12,72 +11,47 @@ import { imgURL } from '../../data/interactions'; import { createAgent, makeAgentAvailable } from '../../data/livechat/rooms'; import { removeAgent, getAgent } from '../../data/livechat/users'; import { updatePermission, updateSetting } from '../../data/permissions.helper'; -import { createRoom, deleteRoom, setRoomConfig } from '../../data/rooms.helper'; +import { + addRoomOwner, + createRoom, + deleteRoom, + getChannelRoles, + inviteToChannel, + joinChannel, + removeRoomOwner, + setRoomConfig, +} from '../../data/rooms.helper'; import { createTeam, deleteTeam } from '../../data/teams.helper'; import { adminEmail, preferences, password, adminUsername } from '../../data/user'; -import { createUser, login, deleteUser, getUserStatus, getUserByUsername } from '../../data/users.helper.js'; - -async function createChannel(userCredentials, name) { - const res = await request.post(api('channels.create')).set(userCredentials).send({ - name, - }); - - return res.body.channel._id; -} - -async function joinChannel(userCredentials, roomId) { - return request.post(api('channels.join')).set(userCredentials).send({ - roomId, - }); -} +import { createUser, login, deleteUser, getUserStatus, getUserByUsername, registerUser } from '../../data/users.helper.js'; const targetUser = {}; describe('[Users]', function () { + let userCredentials; this.retries(0); before((done) => getCredentials(done)); before('should create a new user', async () => { - await request - .post(api('users.create')) - .set(credentials) - .send({ - email: apiEmail, - name: apiUsername, - username: apiUsername, - password, - active: true, - roles: ['user'], - joinDefaultChannels: true, - verified: true, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.nested.property('user.username', apiUsername); - expect(res.body).to.have.nested.property('user.emails[0].address', apiEmail); - expect(res.body).to.have.nested.property('user.active', true); - expect(res.body).to.have.nested.property('user.name', apiUsername); - expect(res.body).to.not.have.nested.property('user.e2e'); - - expect(res.body).to.not.have.nested.property('user.customFields'); - - targetUser._id = res.body.user._id; - targetUser.username = res.body.user.username; - }); + const user = await createUser({ + active: true, + roles: ['user'], + joinDefaultChannels: true, + verified: true, + }); + targetUser._id = user._id; + targetUser.username = user.username; + userCredentials = await login(user.username, password); }); - after(async () => { - await deleteUser(targetUser); - }); + after(() => Promise.all([deleteUser(targetUser), updateSetting('E2E_Enable', false)])); it('enabling E2E in server and generating keys to user...', async () => { await updateSetting('E2E_Enable', true); await request .post(api('e2e.setUserPublicAndPrivateKeys')) - .set(credentials) + .set(userCredentials) .send({ private_key: 'test', public_key: 'test', @@ -89,7 +63,7 @@ describe('[Users]', function () { }); await request .get(api('e2e.fetchMyKeys')) - .set(credentials) + .set(userCredentials) .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { @@ -485,12 +459,25 @@ describe('[Users]', function () { }); describe('[/users.info]', () => { - after(async () => { - await Promise.all([ + let infoRoom; + + before(async () => { + infoRoom = ( + await createRoom({ + type: 'c', + name: `channel.test.info.${Date.now()}-${Math.random()}`, + members: [targetUser.username], + }) + ).body.channel; + }); + + after(() => + Promise.all([ updatePermission('view-other-user-channels', ['admin']), updatePermission('view-full-other-user-info', ['admin']), - ]); - }); + deleteRoom({ type: 'c', roomId: infoRoom._id }), + ]), + ); it('should return an error when the user does not exist', (done) => { request @@ -507,6 +494,7 @@ describe('[Users]', function () { }) .end(done); }); + it('should query information about a user by userId', (done) => { request .get(api('users.info')) @@ -518,13 +506,14 @@ describe('[Users]', function () { .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); - expect(res.body).to.have.nested.property('user.username', apiUsername); + expect(res.body).to.have.nested.property('user.username', targetUser.username); expect(res.body).to.have.nested.property('user.active', true); - expect(res.body).to.have.nested.property('user.name', apiUsername); + expect(res.body).to.have.nested.property('user.name', targetUser.username); expect(res.body).to.not.have.nested.property('user.e2e'); }) .end(done); }); + it('should return "rooms" property when user request it and the user has the necessary permission (admin, "view-other-user-channels")', (done) => { request .get(api('users.info')) @@ -538,10 +527,12 @@ describe('[Users]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.nested.property('user.rooms').and.to.be.an('array'); - expect(res.body.user.rooms[0]).to.have.property('unread'); + const createdRoom = res.body.user.rooms.find((room) => room.rid === infoRoom._id); + expect(createdRoom).to.have.property('unread'); }) .end(done); }); + it('should NOT return "rooms" property when user NOT request it but the user has the necessary permission (admin, "view-other-user-channels")', (done) => { request .get(api('users.info')) @@ -571,6 +562,7 @@ describe('[Users]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.nested.property('user.rooms'); + expect(res.body.user.rooms).with.lengthOf.at.least(1); expect(res.body.user.rooms[0]).to.have.property('unread'); }) .end(done); @@ -633,23 +625,13 @@ describe('[Users]', function () { it('should correctly route users that have `ufs` in their username', async () => { const ufsUsername = `ufs-${Date.now()}`; - let user; - await request - .post(api('users.create')) - .set(credentials) - .send({ - email: `me-${Date.now()}@email.com`, - name: 'testuser', - username: ufsUsername, - password: '1234', - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - user = res.body.user; - }); + const user = await createUser({ + email: `me-${Date.now()}@email.com`, + name: 'testuser', + username: ufsUsername, + password: '1234', + }); await request .get(api('users.info')) @@ -696,7 +678,7 @@ describe('[Users]', function () { userCredentials = await login(user.username, password); }); - after(async () => deleteUser(user)); + after(() => deleteUser(user)); it('should return "offline" after a login type "resume" via REST', async () => { await request @@ -848,22 +830,21 @@ describe('[Users]', function () { expect(user).to.not.have.nested.property('e2e'); }); - after(async () => clearCustomFields()); - before(async () => { user2 = await createUser({ joinDefaultChannels: false }); user2Credentials = await login(user2.username, password); }); - after(async () => { - await deleteUser(deactivatedUser); - await deleteUser(user); - await deleteUser(user2); - user2 = undefined; - - await updatePermission('view-outside-room', ['admin', 'owner', 'moderator', 'user']); - await updateSetting('API_Apply_permission_view-outside-room_on_users-list', false); - }); + after(() => + Promise.all([ + clearCustomFields(), + deleteUser(deactivatedUser), + deleteUser(user), + deleteUser(user2), + updatePermission('view-outside-room', ['admin', 'owner', 'moderator', 'user']), + updateSetting('API_Apply_permission_view-outside-room_on_users-list', false), + ]), + ); it('should query all users in the system', (done) => { request @@ -920,7 +901,7 @@ describe('[Users]', function () { status: 1, }), sort: JSON.stringify({ - status: -1, + status: 1, }), }; @@ -935,8 +916,8 @@ describe('[Users]', function () { expect(res.body).to.have.property('count'); expect(res.body).to.have.property('total'); expect(res.body).to.have.property('users'); - const lastUser = res.body.users[res.body.users.length - 1]; - expect(lastUser).to.have.property('active', false); + const firstUser = res.body.users.find((u) => u._id === deactivatedUser._id); + expect(firstUser).to.have.property('active', false); }) .end(done); }); @@ -984,176 +965,135 @@ describe('[Users]', function () { }); }); - describe('[/users.setAvatar]', () => { + describe('Avatars', () => { let user; - before(async () => { - user = await createUser(); - }); - let userCredentials; + before(async () => { + user = await createUser(); userCredentials = await login(user.username, password); + await Promise.all([ + updateSetting('Accounts_AllowUserAvatarChange', true), + updatePermission('edit-other-user-avatar', ['admin', 'user']), + ]); }); - before((done) => { - updateSetting('Accounts_AllowUserAvatarChange', true).then(() => { - updatePermission('edit-other-user-avatar', ['admin', 'user']).then(done); + + after(() => + Promise.all([ + updateSetting('Accounts_AllowUserAvatarChange', true), + deleteUser(user), + updatePermission('edit-other-user-avatar', ['admin']), + ]), + ); + + describe('[/users.setAvatar]', () => { + it('should set the avatar of the logged user by a local image', (done) => { + request + .post(api('users.setAvatar')) + .set(userCredentials) + .attach('image', imgURL) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); }); - }); - after(async () => { - await updateSetting('Accounts_AllowUserAvatarChange', true); - await deleteUser(user); - user = undefined; - await updatePermission('edit-other-user-avatar', ['admin']); - }); - it('should set the avatar of the logged user by a local image', (done) => { - request - .post(api('users.setAvatar')) - .set(userCredentials) - .attach('image', imgURL) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); - }); - it('should update the avatar of another user by userId when the logged user has the necessary permission (edit-other-user-avatar)', (done) => { - request - .post(api('users.setAvatar')) - .set(userCredentials) - .attach('image', imgURL) - .field({ userId: credentials['X-User-Id'] }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); - }); - it('should set the avatar of another user by username and local image when the logged user has the necessary permission (edit-other-user-avatar)', (done) => { - request - .post(api('users.setAvatar')) - .set(credentials) - .attach('image', imgURL) - .field({ username: adminUsername }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); - }); - it("should prevent from updating someone else's avatar when the logged user doesn't have the necessary permission(edit-other-user-avatar)", (done) => { - updatePermission('edit-other-user-avatar', []).then(() => { + it('should update the avatar of another user by userId when the logged user has the necessary permission (edit-other-user-avatar)', (done) => { request .post(api('users.setAvatar')) .set(userCredentials) .attach('image', imgURL) .field({ userId: credentials['X-User-Id'] }) .expect('Content-Type', 'application/json') - .expect(400) + .expect(200) .expect((res) => { - expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('success', true); }) .end(done); }); - }); - it('should allow users with the edit-other-user-avatar permission to update avatars when the Accounts_AllowUserAvatarChange setting is off', (done) => { - updateSetting('Accounts_AllowUserAvatarChange', false).then(() => { - updatePermission('edit-other-user-avatar', ['admin']).then(() => { + it('should set the avatar of another user by username and local image when the logged user has the necessary permission (edit-other-user-avatar)', (done) => { + request + .post(api('users.setAvatar')) + .set(credentials) + .attach('image', imgURL) + .field({ username: adminUsername }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + it("should prevent from updating someone else's avatar when the logged user doesn't have the necessary permission(edit-other-user-avatar)", (done) => { + updatePermission('edit-other-user-avatar', []).then(() => { request .post(api('users.setAvatar')) - .set(credentials) + .set(userCredentials) .attach('image', imgURL) - .field({ userId: userCredentials['X-User-Id'] }) + .field({ userId: credentials['X-User-Id'] }) .expect('Content-Type', 'application/json') - .expect(200) + .expect(400) .expect((res) => { - expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('success', false); }) .end(done); }); }); + it('should allow users with the edit-other-user-avatar permission to update avatars when the Accounts_AllowUserAvatarChange setting is off', (done) => { + updateSetting('Accounts_AllowUserAvatarChange', false).then(() => { + updatePermission('edit-other-user-avatar', ['admin']).then(() => { + request + .post(api('users.setAvatar')) + .set(credentials) + .attach('image', imgURL) + .field({ userId: userCredentials['X-User-Id'] }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + }); + }); }); - }); - describe('[/users.resetAvatar]', () => { - let user; - before(async () => { - user = await createUser(); - }); + describe('[/users.resetAvatar]', () => { + before(async () => { + await Promise.all([ + updateSetting('Accounts_AllowUserAvatarChange', true), + updatePermission('edit-other-user-avatar', ['admin', 'user']), + ]); + }); - let userCredentials; - before(async () => { - userCredentials = await login(user.username, password); - }); - before((done) => { - updateSetting('Accounts_AllowUserAvatarChange', true).then(() => { - updatePermission('edit-other-user-avatar', ['admin', 'user']).then(done); + it('should set the avatar of the logged user by a local image', (done) => { + request + .post(api('users.setAvatar')) + .set(userCredentials) + .attach('image', imgURL) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); }); - }); - after(async () => { - await updateSetting('Accounts_AllowUserAvatarChange', true); - await deleteUser(user); - user = undefined; - await updatePermission('edit-other-user-avatar', ['admin']); - }); - it('should set the avatar of the logged user by a local image', (done) => { - request - .post(api('users.setAvatar')) - .set(userCredentials) - .attach('image', imgURL) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); - }); - it('should reset the avatar of the logged user', (done) => { - request - .post(api('users.resetAvatar')) - .set(userCredentials) - .expect('Content-Type', 'application/json') - .send({ - userId: userCredentials['X-User-Id'], - }) - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); - }); - it('should reset the avatar of another user by userId when the logged user has the necessary permission (edit-other-user-avatar)', (done) => { - request - .post(api('users.resetAvatar')) - .set(userCredentials) - .send({ - userId: credentials['X-User-Id'], - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); - }); - it('should reset the avatar of another user by username and local image when the logged user has the necessary permission (edit-other-user-avatar)', (done) => { - request - .post(api('users.resetAvatar')) - .set(credentials) - .send({ - username: adminUsername, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); - }); - it("should prevent from resetting someone else's avatar when the logged user doesn't have the necessary permission(edit-other-user-avatar)", (done) => { - updatePermission('edit-other-user-avatar', []).then(() => { + it('should reset the avatar of the logged user', (done) => { + request + .post(api('users.resetAvatar')) + .set(userCredentials) + .expect('Content-Type', 'application/json') + .send({ + userId: userCredentials['X-User-Id'], + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + it('should reset the avatar of another user by userId when the logged user has the necessary permission (edit-other-user-avatar)', (done) => { request .post(api('users.resetAvatar')) .set(userCredentials) @@ -1161,103 +1101,104 @@ describe('[Users]', function () { userId: credentials['X-User-Id'], }) .expect('Content-Type', 'application/json') - .expect(400) + .expect(200) .expect((res) => { - expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('success', true); }) .end(done); }); - }); - it('should allow users with the edit-other-user-avatar permission to reset avatars when the Accounts_AllowUserAvatarChange setting is off', (done) => { - updateSetting('Accounts_AllowUserAvatarChange', false).then(() => { - updatePermission('edit-other-user-avatar', ['admin']).then(() => { + it('should reset the avatar of another user by username and local image when the logged user has the necessary permission (edit-other-user-avatar)', (done) => { + request + .post(api('users.resetAvatar')) + .set(credentials) + .send({ + username: adminUsername, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + it("should prevent from resetting someone else's avatar when the logged user doesn't have the necessary permission(edit-other-user-avatar)", (done) => { + updatePermission('edit-other-user-avatar', []).then(() => { request .post(api('users.resetAvatar')) - .set(credentials) + .set(userCredentials) .send({ - userId: userCredentials['X-User-Id'], + userId: credentials['X-User-Id'], }) .expect('Content-Type', 'application/json') - .expect(200) + .expect(400) .expect((res) => { - expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('success', false); }) .end(done); }); }); - }); - }); - - describe('[/users.getAvatar]', () => { - let user; - before(async () => { - user = await createUser(); + it('should allow users with the edit-other-user-avatar permission to reset avatars when the Accounts_AllowUserAvatarChange setting is off', (done) => { + updateSetting('Accounts_AllowUserAvatarChange', false).then(() => { + updatePermission('edit-other-user-avatar', ['admin']).then(() => { + request + .post(api('users.resetAvatar')) + .set(credentials) + .send({ + userId: userCredentials['X-User-Id'], + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + }); + }); }); - let userCredentials; - before(async () => { - userCredentials = await login(user.username, password); - }); - after(async () => { - await deleteUser(user); - user = undefined; - await updatePermission('edit-other-user-info', ['admin']); - }); - it('should get the url of the avatar of the logged user via userId', (done) => { - request - .get(api('users.getAvatar')) - .set(userCredentials) - .query({ - userId: userCredentials['X-User-Id'], - }) - .expect(307) - .end(done); - }); - it('should get the url of the avatar of the logged user via username', (done) => { - request - .get(api('users.getAvatar')) - .set(userCredentials) - .query({ - username: user.username, - }) - .expect(307) - .end(done); - }); - }); - - describe('[/users.getAvatarSuggestion]', () => { - let user; - before(async () => { - user = await createUser(); - }); - - let userCredentials; - before(async () => { - userCredentials = await login(user.username, password); - }); - - it('should return 401 unauthorized when user is not logged in', (done) => { - request.get(api('users.getAvatarSuggestion')).expect('Content-Type', 'application/json').expect(401).end(done); + describe('[/users.getAvatar]', () => { + it('should get the url of the avatar of the logged user via userId', (done) => { + request + .get(api('users.getAvatar')) + .set(userCredentials) + .query({ + userId: userCredentials['X-User-Id'], + }) + .expect(307) + .end(done); + }); + it('should get the url of the avatar of the logged user via username', (done) => { + request + .get(api('users.getAvatar')) + .set(userCredentials) + .query({ + username: user.username, + }) + .expect(307) + .end(done); + }); }); - after(async () => { - await deleteUser(user); - user = undefined; - }); + describe('[/users.getAvatarSuggestion]', () => { + it('should return 401 unauthorized when user is not logged in', (done) => { + request.get(api('users.getAvatarSuggestion')).expect('Content-Type', 'application/json').expect(401).end(done); + }); - it('should get avatar suggestion of the logged user via userId', (done) => { - request - .get(api('users.getAvatarSuggestion')) - .set(userCredentials) - .query({ - userId: userCredentials['X-User-Id'], - }) - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('suggestions').and.to.be.an('object'); - }) - .end(done); + it('should get avatar suggestion of the logged user via userId', (done) => { + request + .get(api('users.getAvatarSuggestion')) + .set(userCredentials) + .query({ + userId: userCredentials['X-User-Id'], + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('suggestions').and.to.be.an('object'); + }) + .end(done); + }); }); }); @@ -1270,6 +1211,7 @@ describe('[Users]', function () { updateSetting('Accounts_AllowUserStatusMessageChange', true), updateSetting('Accounts_AllowEmailChange', true), updateSetting('Accounts_AllowPasswordChange', true), + updatePermission('edit-other-user-info', ['admin']), ]), ); after(async () => @@ -1280,6 +1222,7 @@ describe('[Users]', function () { updateSetting('Accounts_AllowUserStatusMessageChange', true), updateSetting('Accounts_AllowEmailChange', true), updateSetting('Accounts_AllowPasswordChange', true), + updatePermission('edit-other-user-info', ['admin']), ]), ); @@ -1738,47 +1681,25 @@ describe('[Users]', function () { describe('[/users.updateOwnBasicInfo]', () => { let user; - before((done) => { - const username = `user.test.${Date.now()}`; - const email = `${username}@rocket.chat`; - request - .post(api('users.create')) - .set(credentials) - .send({ email, name: username, username, password }) - .end((err, res) => { - user = res.body.user; - done(); - }); - }); - let userCredentials; - before((done) => { - request - .post(api('login')) - .send({ - user: user.username, - password, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - userCredentials = {}; - userCredentials['X-Auth-Token'] = res.body.data.authToken; - userCredentials['X-User-Id'] = res.body.data.userId; - }) - .end(done); - }); - after((done) => { - request - .post(api('users.delete')) - .set(credentials) - .send({ - userId: user._id, - }) - .end(done); - user = undefined; + + before(async () => { + user = await createUser(); + userCredentials = await login(user.username, password); }); + after(() => + Promise.all([ + deleteUser(user), + updateSetting('E2E_Enable', false), + updateSetting('Accounts_AllowRealNameChange', true), + updateSetting('Accounts_AllowUsernameChange', true), + updateSetting('Accounts_AllowUserStatusMessageChange', true), + updateSetting('Accounts_AllowEmailChange', true), + updateSetting('Accounts_AllowPasswordChange', true), + ]), + ); + const newPassword = `${password}test`; const currentPassword = crypto.createHash('sha256').update(password, 'utf8').digest('hex'); const editedUsername = `basicInfo.name${+new Date()}`; @@ -1954,8 +1875,6 @@ describe('[Users]', function () { .expect((res) => { expect(res.body).to.have.property('success', false); }); - - await updateSetting('Accounts_AllowRealNameChange', true); }); it('should throw an error if not allowed to change username', async () => { @@ -1974,8 +1893,6 @@ describe('[Users]', function () { .expect((res) => { expect(res.body).to.have.property('success', false); }); - - await updateSetting('Accounts_AllowUsernameChange', true); }); it('should throw an error if not allowed to change statusText', async () => { @@ -1994,8 +1911,6 @@ describe('[Users]', function () { .expect((res) => { expect(res.body).to.have.property('success', false); }); - - await updateSetting('Accounts_AllowUserStatusMessageChange', true); }); it('should throw an error if not allowed to change email', async () => { @@ -2014,8 +1929,6 @@ describe('[Users]', function () { .expect((res) => { expect(res.body).to.have.property('success', false); }); - - await updateSetting('Accounts_AllowEmailChange', true); }); it('should throw an error if not allowed to change password', async () => { @@ -2034,19 +1947,17 @@ describe('[Users]', function () { .expect((res) => { expect(res.body).to.have.property('success', false); }); - - await updateSetting('Accounts_AllowPasswordChange', true); }); describe('[Password Policy]', () => { before(async () => { + await updateSetting('Accounts_AllowPasswordChange', true); await updateSetting('Accounts_Password_Policy_Enabled', true); await updateSetting('Accounts_TwoFactorAuthentication_Enabled', false); - - await sleep(500); }); after(async () => { + await updateSetting('Accounts_AllowPasswordChange', true); await updateSetting('Accounts_Password_Policy_Enabled', false); await updateSetting('Accounts_TwoFactorAuthentication_Enabled', true); }); @@ -2078,7 +1989,6 @@ describe('[Users]', function () { it('should throw an error if the password length is greater than the maximum length', async () => { await updateSetting('Accounts_Password_Policy_MaxLength', 5); - await sleep(500); const expectedError = { error: 'error-password-policy-not-met-maxLength', @@ -2102,8 +2012,6 @@ describe('[Users]', function () { expect(res.body.details).to.be.an('array').that.deep.includes(expectedError); }) .expect(400); - - await updateSetting('Accounts_Password_Policy_MaxLength', -1); }); it('should throw an error if the password contains repeating characters', async () => { @@ -2232,6 +2140,7 @@ describe('[Users]', function () { }); it('should be able to update if the password meets all the validation rules', async () => { + await updateSetting('Accounts_Password_Policy_MaxLength', -1); await request .post(api('users.updateOwnBasicInfo')) .set(userCredentials) @@ -2253,6 +2162,8 @@ describe('[Users]', function () { // TODO check for all response fields describe('[/users.setPreferences]', () => { + after(() => updatePermission('edit-other-user-info', ['admin'])); + it('should return an error when the user try to update info of another user and does not have the necessary permission', (done) => { const userPreferences = { userId: 'rocket.cat', @@ -2450,41 +2361,18 @@ describe('[Users]', function () { const testUsername = `test${+new Date()}`; let targetUser; let userCredentials; - before('register a new user...', (done) => { - request - .post(api('users.register')) - .set(credentials) - .send({ - email: `${testUsername}.@teste.com`, - username: `${testUsername}test`, - name: testUsername, - pass: password, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - targetUser = res.body.user; - }) - .end(done); - }); - before('Login...', (done) => { - request - .post(api('login')) - .send({ - user: targetUser.username, - password, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - userCredentials = {}; - userCredentials['X-Auth-Token'] = res.body.data.authToken; - userCredentials['X-User-Id'] = res.body.data.userId; - }) - .end(done); + + before(async () => { + targetUser = await registerUser({ + email: `${testUsername}.@test.com`, + username: `${testUsername}test`, + name: testUsername, + pass: password, + }); + userCredentials = await login(targetUser.username, password); }); - after(async () => deleteUser(targetUser)); + after(() => deleteUser(targetUser)); it('should return an username suggestion', (done) => { request @@ -2501,6 +2389,16 @@ describe('[Users]', function () { }); describe('[/users.checkUsernameAvailability]', () => { + let targetUser; + let userCredentials; + + before(async () => { + targetUser = await registerUser(); + userCredentials = await login(targetUser.username, password); + }); + + after(() => deleteUser(targetUser)); + it('should return 401 unauthorized when user is not logged in', (done) => { request .get(api('users.checkUsernameAvailability')) @@ -2512,45 +2410,6 @@ describe('[Users]', function () { .end(done); }); - const testUsername = `test-username-123456-${+new Date()}`; - let targetUser; - let userCredentials; - before((done) => { - request - .post(api('users.register')) - .set(credentials) - .send({ - email: `${testUsername}.@test-username.com`, - username: `${testUsername}test`, - name: testUsername, - pass: password, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - targetUser = res.body.user; - }) - .end(done); - }); - before((done) => { - request - .post(api('login')) - .send({ - user: targetUser.username, - password, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - userCredentials = {}; - userCredentials['X-Auth-Token'] = res.body.data.authToken; - userCredentials['X-User-Id'] = res.body.data.userId; - }) - .end(done); - }); - - after(async () => deleteUser(targetUser)); - it('should return true if the username is the same user username set', (done) => { request .get(api('users.checkUsernameAvailability')) @@ -2598,41 +2457,12 @@ describe('[Users]', function () { }); describe('[/users.deleteOwnAccount]', () => { - const testUsername = `testuser${+new Date()}`; let targetUser; let userCredentials; - before((done) => { - request - .post(api('users.register')) - .set(credentials) - .send({ - email: `${testUsername}.@teste.com`, - username: `${testUsername}test`, - name: testUsername, - pass: password, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - targetUser = res.body.user; - }) - .end(done); - }); - before((done) => { - request - .post(api('login')) - .send({ - user: targetUser.username, - password, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - userCredentials = {}; - userCredentials['X-Auth-Token'] = res.body.data.authToken; - userCredentials['X-User-Id'] = res.body.data.userId; - }) - .end(done); + + before(async () => { + targetUser = await registerUser(); + userCredentials = await login(targetUser.username, password); }); after(async () => deleteUser(targetUser)); @@ -2683,427 +2513,210 @@ describe('[Users]', function () { await deleteUser(user); }); - it('should return an error when trying to delete user own account if user is the last room owner', async () => { - const user = await createUser(); - const createdUserCredentials = await login(user.username, password); - const room = ( - await createRoom({ - type: 'c', - name: `channel.test.${Date.now()}-${Math.random()}`, - username: user.username, - members: [user.username], - }) - ).body.channel; + describe('last owner cases', () => { + let user; + let createdUserCredentials; + let room; + + beforeEach(async () => { + user = await createUser(); + createdUserCredentials = await login(user.username, password); + room = ( + await createRoom({ + type: 'c', + name: `channel.test.${Date.now()}-${Math.random()}`, + username: user.username, + members: [user.username], + }) + ).body.channel; + await addRoomOwner({ type: 'c', roomId: room._id, userId: user._id }); + await removeRoomOwner({ type: 'c', roomId: room._id, userId: credentials['X-User-Id'] }); + }); + + afterEach(async () => { + await deleteRoom({ type: 'c', roomId: room._id }); + await deleteUser(user); + }); + + it('should return an error when trying to delete user own account if user is the last room owner', async () => { + await request + .post(api('users.deleteOwnAccount')) + .set(createdUserCredentials) + .send({ + password: crypto.createHash('sha256').update(password, 'utf8').digest('hex'), + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', '[user-last-owner]'); + expect(res.body).to.have.property('errorType', 'user-last-owner'); + }); + }); + + it('should delete user own account if the user is the last room owner and `confirmRelinquish` is set to `true`', async () => { + await request + .post(api('users.deleteOwnAccount')) + .set(createdUserCredentials) + .send({ + password: crypto.createHash('sha256').update(password, 'utf8').digest('hex'), + confirmRelinquish: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should assign a new owner to the room if the last room owner is deleted', async () => { + await request + .post(api('users.deleteOwnAccount')) + .set(createdUserCredentials) + .send({ + password: crypto.createHash('sha256').update(password, 'utf8').digest('hex'), + confirmRelinquish: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + const roles = await getChannelRoles({ roomId: room._id }); + + expect(roles).to.have.lengthOf(1); + expect(roles[0].roles).to.eql(['owner']); + expect(roles[0].u).to.have.property('_id', credentials['X-User-Id']); + }); + }); + }); + + describe('[/users.delete]', () => { + let newUser; + + before(async () => { + newUser = await createUser(); + }); + after(async () => { + await deleteUser(newUser); + await updatePermission('delete-user', ['admin']); + }); + + it('should return an error when trying delete user account without "delete-user" permission', async () => { + await updatePermission('delete-user', ['user']); await request - .post(api('channels.addOwner')) + .post(api('users.delete')) .set(credentials) .send({ - userId: user._id, - roomId: room._id, + userId: targetUser._id, }) .expect('Content-Type', 'application/json') - .expect(200) + .expect(403) .expect((res) => { - expect(res.body).to.have.property('success', true); - }); - - await request - .post(api('channels.removeOwner')) - .set(credentials) - .send({ - userId: credentials['X-User-Id'], - roomId: room._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }); - - await request - .post(api('users.deleteOwnAccount')) - .set(createdUserCredentials) - .send({ - password: crypto.createHash('sha256').update(password, 'utf8').digest('hex'), - }) - .expect('Content-Type', 'application/json') - .expect(400) - .expect((res) => { - expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', '[user-last-owner]'); - expect(res.body).to.have.property('errorType', 'user-last-owner'); - }); - - await deleteRoom({ type: 'c', roomId: room._id }); - await deleteUser(user); - }); - - it('should delete user own account if the user is the last room owner and `confirmRelinquish` is set to `true`', async () => { - const user = await createUser(); - const createdUserCredentials = await login(user.username, password); - const room = ( - await createRoom({ - type: 'c', - name: `channel.test.${Date.now()}-${Math.random()}`, - username: user.username, - members: [user.username], - }) - ).body.channel; - - await request - .post(api('channels.addOwner')) - .set(credentials) - .send({ - userId: user._id, - roomId: room._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'unauthorized'); }); + }); + it('should delete user account when logged user has "delete-user" permission', async () => { + await updatePermission('delete-user', ['admin']); await request - .post(api('channels.removeOwner')) + .post(api('users.delete')) .set(credentials) .send({ - userId: credentials['X-User-Id'], - roomId: room._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }); - - await request - .post(api('users.deleteOwnAccount')) - .set(createdUserCredentials) - .send({ - password: crypto.createHash('sha256').update(password, 'utf8').digest('hex'), - confirmRelinquish: true, + userId: newUser._id, }) .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); }); - await deleteRoom({ type: 'c', roomId: room._id }); - await deleteUser(user); }); - it('should assign a new owner to the room if the last room owner is deleted', async () => { - const user = await createUser(); - const createdUserCredentials = await login(user.username, password); - const room = ( - await createRoom({ - type: 'c', - name: `channel.test.${Date.now()}-${Math.random()}`, - username: user.username, - members: [user.username], - }) - ).body.channel; - - await request - .post(api('channels.addOwner')) - .set(credentials) - .send({ - userId: user._id, - roomId: room._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }); - - await request - .post(api('channels.removeOwner')) - .set(credentials) - .send({ - userId: credentials['X-User-Id'], - roomId: room._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }); - - await request - .post(api('users.deleteOwnAccount')) - .set(createdUserCredentials) - .send({ - password: crypto.createHash('sha256').update(password, 'utf8').digest('hex'), - confirmRelinquish: true, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }); + describe('last owner cases', () => { + let targetUser; + let room; + beforeEach(async () => { + targetUser = await registerUser(); + room = ( + await createRoom({ + type: 'c', + name: `channel.test.${Date.now()}-${Math.random()}`, + members: [targetUser.username], + }) + ).body.channel; + await addRoomOwner({ type: 'c', roomId: room._id, userId: targetUser._id }); + await removeRoomOwner({ type: 'c', roomId: room._id, userId: credentials['X-User-Id'] }); + }); - await request - .get(api('channels.roles')) - .set(credentials) - .query({ - roomId: room._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body.roles).to.have.lengthOf(1); - expect(res.body.roles[0].roles).to.eql(['owner']); - expect(res.body.roles[0].u).to.have.property('_id', credentials['X-User-Id']); - }); - await deleteRoom({ type: 'c', roomId: room._id }); - await deleteUser(user); - }); - }); + afterEach(() => Promise.all([deleteRoom({ type: 'c', roomId: room._id }), deleteUser(targetUser, { confirmRelinquish: true })])); - describe('[/users.delete]', () => { - let targetUser; - beforeEach((done) => { - const testUsername = `testuserdelete${+new Date()}`; - request - .post(api('users.register')) - .set(credentials) - .send({ - email: `${testUsername}.@teste.com`, - username: `${testUsername}test`, - name: testUsername, - pass: password, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - targetUser = res.body.user; - }) - .end(done); - }); + it('should return an error when trying to delete user account if the user is the last room owner', async () => { + await updatePermission('delete-user', ['admin']); + await request + .post(api('users.delete')) + .set(credentials) + .send({ + userId: targetUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', '[user-last-owner]'); + expect(res.body).to.have.property('errorType', 'user-last-owner'); + }); + }); - afterEach((done) => { - updatePermission('delete-user', ['admin']).then(() => { - request + it('should delete user account if the user is the last room owner and `confirmRelinquish` is set to `true`', async () => { + await updatePermission('delete-user', ['admin']); + await request .post(api('users.delete')) .set(credentials) .send({ userId: targetUser._id, confirmRelinquish: true, }) - .end(done); + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); }); - }); - it('should return an error when trying delete user account without "delete-user" permission', async () => { - await updatePermission('delete-user', ['user']); - await request - .post(api('users.delete')) - .set(credentials) - .send({ - userId: targetUser._id, - }) - .expect('Content-Type', 'application/json') - .expect(403) - .expect((res) => { - expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', 'unauthorized'); - }); - }); - - it('should return an error when trying to delete user account if the user is the last room owner', async () => { - await updatePermission('delete-user', ['admin']); - const room = ( - await createRoom({ - type: 'c', - name: `channel.test.${Date.now()}-${Math.random()}`, - members: [targetUser.username], - }) - ).body.channel; - - await request - .post(api('channels.addOwner')) - .set(credentials) - .send({ - userId: targetUser._id, - roomId: room._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }); - - await request - .post(api('channels.removeOwner')) - .set(credentials) - .send({ - userId: credentials['X-User-Id'], - roomId: room._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }); - - await request - .post(api('users.delete')) - .set(credentials) - .send({ - userId: targetUser._id, - }) - .expect('Content-Type', 'application/json') - .expect(400) - .expect((res) => { - expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', '[user-last-owner]'); - expect(res.body).to.have.property('errorType', 'user-last-owner'); - }); - - await deleteRoom({ type: 'c', roomId: room._id }); - }); - - it('should delete user account if the user is the last room owner and `confirmRelinquish` is set to `true`', async () => { - await updatePermission('delete-user', ['admin']); - const room = ( - await createRoom({ - type: 'c', - name: `channel.test.${Date.now()}-${Math.random()}`, - members: [targetUser.username], - }) - ).body.channel; - - await request - .post(api('channels.addOwner')) - .set(credentials) - .send({ - userId: targetUser._id, - roomId: room._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }); - - await request - .post(api('channels.removeOwner')) - .set(credentials) - .send({ - userId: credentials['X-User-Id'], - roomId: room._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }); - - await request - .post(api('users.delete')) - .set(credentials) - .send({ - userId: targetUser._id, - confirmRelinquish: true, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }); + it('should assign a new owner to the room if the last room owner is deleted', async () => { + await updatePermission('delete-user', ['admin']); - await deleteRoom({ type: 'c', roomId: room._id }); - }); - - it('should delete user account when logged user has "delete-user" permission', async () => { - await updatePermission('delete-user', ['admin']); - await request - .post(api('users.delete')) - .set(credentials) - .send({ - userId: targetUser._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }); - }); - - it('should assign a new owner to the room if the last room owner is deleted', async () => { - await updatePermission('delete-user', ['admin']); - const room = ( - await createRoom({ - type: 'c', - name: `channel.test.${Date.now()}-${Math.random()}`, - members: [targetUser.username], - }) - ).body.channel; - - await request - .post(api('channels.addOwner')) - .set(credentials) - .send({ - userId: targetUser._id, - roomId: room._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }); - - await request - .post(api('channels.removeOwner')) - .set(credentials) - .send({ - userId: credentials['X-User-Id'], - roomId: room._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }); - - await request - .post(api('users.delete')) - .set(credentials) - .send({ - userId: targetUser._id, - confirmRelinquish: true, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }); + await request + .post(api('users.delete')) + .set(credentials) + .send({ + userId: targetUser._id, + confirmRelinquish: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); - await request - .get(api('channels.roles')) - .set(credentials) - .query({ - roomId: room._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body.roles).to.have.lengthOf(1); - expect(res.body.roles[0].roles).to.eql(['owner']); - expect(res.body.roles[0].u).to.have.property('_id', credentials['X-User-Id']); - }); + const roles = await getChannelRoles({ roomId: room._id }); - await deleteRoom({ type: 'c', roomId: room._id }); + expect(roles).to.have.lengthOf(1); + expect(roles[0].roles).to.eql(['owner']); + expect(roles[0].u).to.have.property('_id', credentials['X-User-Id']); + }); }); }); describe('Personal Access Tokens', () => { const tokenName = `${Date.now()}token`; describe('successful cases', () => { + before(() => updatePermission('create-personal-access-tokens', ['admin'])); + after(() => updatePermission('create-personal-access-tokens', ['admin'])); + describe('[/users.getPersonalAccessTokens]', () => { it('should return an array when the user does not have personal tokens configured', (done) => { request @@ -3118,8 +2731,7 @@ describe('[Users]', function () { .end(done); }); }); - it('Grant necessary permission "create-personal-accss-tokens" to user', () => - updatePermission('create-personal-access-tokens', ['admin'])); + describe('[/users.generatePersonalAccessToken]', () => { it('should return a personal access token to user', (done) => { request @@ -3228,7 +2840,9 @@ describe('[Users]', function () { }); }); describe('unsuccessful cases', () => { - it('Remove necessary permission "create-personal-accss-tokens" to user', () => updatePermission('create-personal-access-tokens', [])); + before(() => updatePermission('create-personal-access-tokens', [])); + after(() => updatePermission('create-personal-access-tokens', ['admin'])); + describe('should return an error when the user dont have the necessary permission "create-personal-access-tokens"', () => { it('/users.generatePersonalAccessToken', (done) => { request @@ -3296,338 +2910,84 @@ describe('[Users]', function () { }) .expect('Content-Type', 'application/json') .expect(400) - .expect((res) => { - expect(res.body).to.have.property('success', false); - expect(res.body.errorType).to.be.equal('not-authorized'); - }) - .end(done); - }); - }); - }); - }); - - describe('[/users.setActiveStatus]', () => { - let user; - let agent; - let agentUser; - - before(async () => { - agentUser = await createUser(); - const agentUserCredentials = await login(agentUser.username, password); - await createAgent(agentUser.username); - await makeAgentAvailable(agentUserCredentials); - - agent = { - user: agentUser, - credentials: agentUserCredentials, - }; - }); - - before((done) => { - const username = `user.test.${Date.now()}`; - const email = `${username}@rocket.chat`; - request - .post(api('users.create')) - .set(credentials) - .send({ email, name: username, username, password }) - .end((err, res) => { - user = res.body.user; - done(); - }); - }); - let userCredentials; - before((done) => { - request - .post(api('login')) - .send({ - user: user.username, - password, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - userCredentials = {}; - userCredentials['X-Auth-Token'] = res.body.data.authToken; - userCredentials['X-User-Id'] = res.body.data.userId; - }) - .end(done); - }); - before((done) => { - updatePermission('edit-other-user-active-status', ['admin', 'user']).then(done); - }); - after((done) => { - request - .post(api('users.delete')) - .set(credentials) - .send({ - userId: user._id, - }) - .end(() => updatePermission('edit-other-user-active-status', ['admin']).then(done)); - user = undefined; - }); - - after(async () => { - await removeAgent(agent.user._id); - await deleteUser(agent.user); - }); - - it('should set other user active status to false when the logged user has the necessary permission(edit-other-user-active-status)', (done) => { - request - .post(api('users.setActiveStatus')) - .set(userCredentials) - .send({ - activeStatus: false, - userId: targetUser._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.nested.property('user.active', false); - }) - .end(done); - }); - it('should set other user active status to true when the logged user has the necessary permission(edit-other-user-active-status)', (done) => { - request - .post(api('users.setActiveStatus')) - .set(userCredentials) - .send({ - activeStatus: true, - userId: targetUser._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.nested.property('user.active', true); - }) - .end(done); - }); - - it('should return an error when trying to set other user status to inactive and the user is the last owner of a room', async () => { - const room = ( - await createRoom({ - type: 'c', - name: `channel.test.${Date.now()}-${Math.random()}`, - username: targetUser.username, - members: [targetUser.username], - }) - ).body.channel; - - await request - .post(api('channels.invite')) - .set(credentials) - .send({ - userId: targetUser._id, - roomId: room._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }); - - await request - .post(api('channels.addOwner')) - .set(credentials) - .send({ - userId: targetUser._id, - roomId: room._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }); - - await request - .post(api('channels.removeOwner')) - .set(credentials) - .send({ - userId: credentials['X-User-Id'], - roomId: room._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }); - - await request - .post(api('users.setActiveStatus')) - .set(userCredentials) - .send({ - activeStatus: false, - userId: targetUser._id, - }) - .expect('Content-Type', 'application/json') - .expect(400) - .expect((res) => { - expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', '[user-last-owner]'); - expect(res.body).to.have.property('errorType', 'user-last-owner'); - }); - - await deleteRoom({ type: 'c', roomId: room._id }); - }); - - it('should set other user status to inactive if the user is the last owner of a room and `confirmRelinquish` is set to `true`', async () => { - const room = ( - await createRoom({ - type: 'c', - name: `channel.test.${Date.now()}-${Math.random()}`, - username: targetUser.username, - members: [targetUser.username], - }) - ).body.channel; - - await request - .post(api('channels.invite')) - .set(credentials) - .send({ - userId: targetUser._id, - roomId: room._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('not-authorized'); + }) + .end(done); }); + }); + }); + }); - await request - .post(api('channels.addOwner')) - .set(credentials) - .send({ - userId: targetUser._id, - roomId: room._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }); + describe('[/users.setActiveStatus]', () => { + let user; + let agent; + let agentUser; + let userCredentials; - await request - .post(api('channels.removeOwner')) - .set(credentials) - .send({ - userId: credentials['X-User-Id'], - roomId: room._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }); + before(async () => { + agentUser = await createUser(); + const agentUserCredentials = await login(agentUser.username, password); + await createAgent(agentUser.username); + await makeAgentAvailable(agentUserCredentials); - await request - .post(api('users.setActiveStatus')) - .set(userCredentials) - .send({ - activeStatus: false, - userId: targetUser._id, - confirmRelinquish: true, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }); + agent = { + user: agentUser, + credentials: agentUserCredentials, + }; + }); - await deleteRoom({ type: 'c', roomId: room._id }); + before(async () => { + user = await createUser(); + userCredentials = await login(user.username, password); + await Promise.all([ + updatePermission('edit-other-user-active-status', ['admin', 'user']), + updatePermission('manage-moderation-actions', ['admin']), + ]); }); - it('should set other user as room owner if the last owner of a room is deactivated and `confirmRelinquish` is set to `true`', async () => { - const room = ( - await createRoom({ - type: 'c', - name: `channel.test.${Date.now()}-${Math.random()}`, - members: [targetUser.username], - }) - ).body.channel; + after(() => + Promise.all([ + deleteUser(user), + updatePermission('edit-other-user-active-status', ['admin']), + updatePermission('manage-moderation-actions', ['admin']), + ]), + ); - await request + after(() => Promise.all([removeAgent(agent.user._id), deleteUser(agent.user)])); + + it('should set other user active status to false when the logged user has the necessary permission(edit-other-user-active-status)', (done) => { + request .post(api('users.setActiveStatus')) .set(userCredentials) .send({ - activeStatus: true, - userId: targetUser._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }); - - await request - .post(api('channels.invite')) - .set(credentials) - .send({ - userId: targetUser._id, - roomId: room._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }); - - await request - .post(api('channels.addOwner')) - .set(credentials) - .send({ + activeStatus: false, userId: targetUser._id, - roomId: room._id, }) .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); - }); - - await request - .post(api('channels.removeOwner')) - .set(credentials) - .send({ - userId: credentials['X-User-Id'], - roomId: room._id, + expect(res.body).to.have.nested.property('user.active', false); }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }); - - await request + .end(done); + }); + it('should set other user active status to true when the logged user has the necessary permission(edit-other-user-active-status)', (done) => { + request .post(api('users.setActiveStatus')) .set(userCredentials) .send({ - activeStatus: false, + activeStatus: true, userId: targetUser._id, - confirmRelinquish: true, }) .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); - }); - - await request - .get(api('channels.roles')) - .set(credentials) - .query({ - roomId: room._id, + expect(res.body).to.have.nested.property('user.active', true); }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body.roles).to.have.lengthOf(2); - expect(res.body.roles[1].roles).to.eql(['owner']); - expect(res.body.roles[1].u).to.have.property('_id', credentials['X-User-Id']); - }); - - await deleteRoom({ type: 'c', roomId: room._id }); + .end(done); }); it('should return an error when trying to set other user active status and has not the necessary permission(edit-other-user-active-status)', (done) => { @@ -3699,6 +3059,7 @@ describe('[Users]', function () { await deleteUser(testUser); }); + it('should make agents not-available when the user is deactivated', async () => { await makeAgentAvailable(agent.credentials); await request @@ -3732,27 +3093,135 @@ describe('[Users]', function () { agentInfo = await getAgent(agent.user._id); expect(agentInfo).to.have.property('statusLivechat', 'not-available'); }); + + describe('last owner cases', () => { + let room; + + beforeEach(() => + Promise.all([ + updatePermission('edit-other-user-active-status', ['admin', 'user']), + updatePermission('manage-moderation-actions', ['admin', 'user']), + ]), + ); + + afterEach(() => deleteRoom({ type: 'c', roomId: room._id })); + + it('should return an error when trying to set other user status to inactive and the user is the last owner of a room', async () => { + room = ( + await createRoom({ + type: 'c', + name: `channel.test.${Date.now()}-${Math.random()}`, + username: targetUser.username, + members: [targetUser.username], + }) + ).body.channel; + + await inviteToChannel({ userId: targetUser._id, roomId: room._id }); + await addRoomOwner({ type: 'c', userId: targetUser._id, roomId: room._id }); + await removeRoomOwner({ type: 'c', userId: credentials['X-User-Id'], roomId: room._id }); + + await request + .post(api('users.setActiveStatus')) + .set(userCredentials) + .send({ + activeStatus: false, + userId: targetUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', '[user-last-owner]'); + expect(res.body).to.have.property('errorType', 'user-last-owner'); + }); + }); + + it('should set other user status to inactive if the user is the last owner of a room and `confirmRelinquish` is set to `true`', async () => { + room = ( + await createRoom({ + type: 'c', + name: `channel.test.${Date.now()}-${Math.random()}`, + username: targetUser.username, + members: [targetUser.username], + }) + ).body.channel; + + await inviteToChannel({ userId: targetUser._id, roomId: room._id }); + await addRoomOwner({ type: 'c', userId: targetUser._id, roomId: room._id }); + await removeRoomOwner({ type: 'c', userId: credentials['X-User-Id'], roomId: room._id }); + + await request + .post(api('users.setActiveStatus')) + .set(userCredentials) + .send({ + activeStatus: false, + userId: targetUser._id, + confirmRelinquish: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should set other user as room owner if the last owner of a room is deactivated and `confirmRelinquish` is set to `true`', async () => { + room = ( + await createRoom({ + type: 'c', + name: `channel.test.${Date.now()}-${Math.random()}`, + members: [targetUser.username], + }) + ).body.channel; + + await request + .post(api('users.setActiveStatus')) + .set(userCredentials) + .send({ + activeStatus: true, + userId: targetUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + await inviteToChannel({ userId: targetUser._id, roomId: room._id }); + await addRoomOwner({ type: 'c', userId: targetUser._id, roomId: room._id }); + await removeRoomOwner({ type: 'c', userId: credentials['X-User-Id'], roomId: room._id }); + + await request + .post(api('users.setActiveStatus')) + .set(userCredentials) + .send({ + activeStatus: false, + userId: targetUser._id, + confirmRelinquish: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + const roles = await getChannelRoles({ roomId: room._id }); + + expect(roles).to.have.lengthOf(2); + const originalCreator = roles.find((role) => role.u._id === credentials['X-User-Id']); + expect(originalCreator).to.not.be.undefined; + expect(originalCreator.roles).to.eql(['owner']); + expect(originalCreator.u).to.have.property('_id', credentials['X-User-Id']); + }); + }); }); describe('[/users.deactivateIdle]', () => { let testUser; - let testUserCredentials; const testRoleId = 'guest'; - before('Create test user', (done) => { - const username = `user.test.${Date.now()}`; - const email = `${username}@rocket.chat`; - request - .post(api('users.create')) - .set(credentials) - .send({ email, name: username, username, password }) - .end((err, res) => { - testUser = res.body.user; - done(); - }); - }); - before('Assign a role to test user', (done) => { - request + before('Create test user', async () => { + testUser = await createUser(); + await request .post(api('roles.addUserToRole')) .set(credentials) .send({ @@ -3763,29 +3232,10 @@ describe('[Users]', function () { .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); - }) - .end(done); - }); - before('Login as test user', (done) => { - request - .post(api('login')) - .send({ - user: testUser.username, - password, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - testUserCredentials = {}; - testUserCredentials['X-Auth-Token'] = res.body.data.authToken; - testUserCredentials['X-User-Id'] = res.body.data.userId; - }) - .end(done); + }); }); - after(async () => { - await deleteUser(testUser); - }); + after(() => Promise.all([deleteUser(testUser), updatePermission('edit-other-user-active-status', ['admin'])])); it('should fail to deactivate if user doesnt have edit-other-user-active-status permission', (done) => { updatePermission('edit-other-user-active-status', []).then(() => { @@ -3916,10 +3366,7 @@ describe('[Users]', function () { userCredentials = await login(user.username, password); newCredentials = await login(user.username, password); }); - after(async () => { - await deleteUser(user); - user = undefined; - }); + after(() => deleteUser(user)); it('should invalidate all active sesions', (done) => { /* We want to validate that the login with the "old" credentials fails @@ -3957,9 +3404,7 @@ describe('[Users]', function () { }); describe('[/users.autocomplete]', () => { - after(() => { - updatePermission('view-outside-room', ['admin', 'owner', 'moderator', 'user']); - }); + after(() => updatePermission('view-outside-room', ['admin', 'owner', 'moderator', 'user'])); describe('[without permission]', function () { let user; @@ -3979,13 +3424,13 @@ describe('[Users]', function () { await updatePermission('view-outside-room', []); - roomId = await createChannel(userCredentials, `channel.autocomplete.${Date.now()}`); + roomId = (await createRoom({ type: 'c', credentials: userCredentials, name: `channel.autocomplete.${Date.now()}` })).body.channel + ._id; }); after(async () => { await deleteRoom({ type: 'c', roomId }); - await deleteUser(user); - await deleteUser(user2); + await Promise.all([deleteUser(user), deleteUser(user2)]); }); it('should return an empty list when the user does not have any subscription', (done) => { @@ -4002,7 +3447,7 @@ describe('[Users]', function () { }); it('should return users that are subscribed to the same rooms as the requester', async () => { - await joinChannel(user2Credentials, roomId); + await joinChannel({ overrideCredentials: user2Credentials, roomId }); request .get(api('users.autocomplete?selector={}')) @@ -4017,9 +3462,7 @@ describe('[Users]', function () { }); describe('[with permission]', () => { - before(() => { - updatePermission('view-outside-room', ['admin', 'user']); - }); + before(() => updatePermission('view-outside-room', ['admin', 'user'])); it('should return an error when the required parameter "selector" is not provided', () => { request @@ -4135,13 +3578,11 @@ describe('[Users]', function () { describe('[/users.setStatus]', () => { let user; + before(async () => { user = await createUser(); }); - after(async () => { - await deleteUser(user); - user = undefined; - }); + after(() => Promise.all([deleteUser(user), updateSetting('Accounts_AllowUserStatusMessageChange', true)])); it('should return an error when the setting "Accounts_AllowUserStatusMessageChange" is disabled', (done) => { updateSetting('Accounts_AllowUserStatusMessageChange', false).then(() => { @@ -4281,10 +3722,7 @@ describe('[Users]', function () { userCredentials = await login(user.username, password); newCredentials = await login(user.username, password); }); - after(async () => { - await deleteUser(user); - user = undefined; - }); + after(() => deleteUser(user)); it('should invalidate all active sesions', (done) => { /* We want to validate that the login with the "old" credentials fails @@ -4353,12 +3791,8 @@ describe('[Users]', function () { .end(done); }); - before('create new user', (done) => { - createUser({ joinDefaultChannels: false }) - .then((user) => { - testUser = user; - }) - .then(() => done()); + before('create new user', async () => { + testUser = await createUser({ joinDefaultChannels: false }); }); before('add test user to team 1', (done) => { @@ -4403,9 +3837,21 @@ describe('[Users]', function () { .then(() => done()); }); - after(async () => { - await deleteUser(testUser); - }); + after(() => + Promise.all([ + [teamName1, teamName2].map((team) => + request + .post(api('teams.delete')) + .set(credentials) + .send({ + teamName: team, + }) + .expect('Content-Type', 'application/json') + .expect(200), + ), + deleteUser(testUser), + ]), + ); it('should list both channels', (done) => { request @@ -4437,15 +3883,10 @@ describe('[Users]', function () { before(async () => { user = await createUser(); otherUser = await createUser(); - }); - before(async () => { userCredentials = await login(user.username, password); }); - after(async () => { - await deleteUser(user); - await deleteUser(otherUser); - }); + after(() => Promise.all([deleteUser(user), deleteUser(otherUser), updatePermission('logout-other-user', ['admin'])])); it('should throw unauthorized error to user w/o "logout-other-user" permission', (done) => { updatePermission('logout-other-user', []).then(() => { From 4ad56e90b85557f03b62b9caac950aa7d673b72c Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Wed, 10 Apr 2024 09:39:39 -0300 Subject: [PATCH 046/131] test: added e2e tests for Livechat's hide watermark setting (#32132) --- .../omnichannel-livechat-watermark.spec.ts | 73 +++++++++++++++++++ apps/meteor/tests/e2e/page-objects/index.ts | 1 + .../e2e/page-objects/omnichannel-livechat.ts | 8 +- .../e2e/page-objects/omnichannel-settings.ts | 21 ++++++ .../livechat/src/components/Footer/index.tsx | 2 +- 5 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-watermark.spec.ts create mode 100644 apps/meteor/tests/e2e/page-objects/omnichannel-settings.ts diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-watermark.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-watermark.spec.ts new file mode 100644 index 000000000000..d0dd0c76f297 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-watermark.spec.ts @@ -0,0 +1,73 @@ +import { faker } from '@faker-js/faker'; + +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { OmnichannelLiveChat, OmnichannelSettings } from '../page-objects'; +import { createAgent, makeAgentAvailable } from '../utils/omnichannel/agents'; +import { test, expect } from '../utils/test'; + +const visitor = { + name: `${faker.person.firstName()} ${faker.string.uuid()}}`, + email: faker.internet.email(), +}; + +test.skip(!IS_EE, 'Enterprise Only'); + +test.use({ storageState: Users.admin.state }); + +test.describe('OC - Livechat - Hide watermark', async () => { + let agent: Awaited>; + let poLiveChat: OmnichannelLiveChat; + let poOmnichannelSettings: OmnichannelSettings; + + test.beforeAll(async ({ api }) => { + agent = await createAgent(api, 'user1'); + + const res = await makeAgentAvailable(api, agent.data._id); + + await expect(res.status()).toBe(200); + }); + + test.beforeEach(async ({ browser, api }) => { + const { page: livechatPage } = await createAuxContext(browser, Users.user1, '/livechat', false); + + poLiveChat = new OmnichannelLiveChat(livechatPage, api); + }); + + test.beforeEach(async ({ page }) => { + poOmnichannelSettings = new OmnichannelSettings(page); + + await page.goto('/admin/settings/Omnichannel'); + }); + + test.afterAll(async ({ api }) => { + const res = await api.post('/settings/Livechat_hide_watermark', { value: false }); + await expect(res.status()).toBe(200); + }); + + test('OC - Livechat - Hide watermark', async () => { + await test.step('expect to open Livechat', async () => { + await poLiveChat.openLiveChat(); + await poLiveChat.sendMessage(visitor, false); + }); + + await test.step('expect watermark to start visible (default)', async () => { + await expect(poLiveChat.onlineAgentMessage).toBeVisible(); + await expect(poLiveChat.txtWatermark).toBeVisible(); + }); + + await test.step('expect to change setting', async () => { + await poOmnichannelSettings.group('Livechat').click(); + await poOmnichannelSettings.labelHideWatermark.click(); + await poOmnichannelSettings.btnSave.click(); + }); + + await test.step('expect watermark to be hidden', async () => { + await poLiveChat.page.reload(); + await poLiveChat.openLiveChat(); + await expect(poLiveChat.onlineAgentMessage).toBeVisible(); + await expect(poLiveChat.txtWatermark).toBeHidden(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/page-objects/index.ts b/apps/meteor/tests/e2e/page-objects/index.ts index 312b133bf93c..b8f335a6f92d 100644 --- a/apps/meteor/tests/e2e/page-objects/index.ts +++ b/apps/meteor/tests/e2e/page-objects/index.ts @@ -15,4 +15,5 @@ export * from './omnichannel-custom-fields'; export * from './omnichannel-units'; export * from './home-omnichannel'; export * from './omnichannel-monitors'; +export * from './omnichannel-settings'; export * from './utils'; diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel-livechat.ts b/apps/meteor/tests/e2e/page-objects/omnichannel-livechat.ts index 1029c8ba2819..cbaae75d212d 100644 --- a/apps/meteor/tests/e2e/page-objects/omnichannel-livechat.ts +++ b/apps/meteor/tests/e2e/page-objects/omnichannel-livechat.ts @@ -46,11 +46,15 @@ export class OmnichannelLiveChat { get btnChatNow(): Locator { return this.page.locator('[type="button"] >> text="Chat now"'); } - + get headerTitle(): Locator { return this.page.locator('[data-qa="header-title"]'); } + get txtWatermark(): Locator { + return this.page.locator('[data-qa="livechat-watermark"]'); + } + alertMessage(message: string): Locator { return this.page.getByRole('alert').locator(`text="${message}"`); } @@ -134,7 +138,7 @@ export class OmnichannelLiveChat { return this.page.locator('#files-drop-target'); } - findUploadedFileLink (fileName: string): Locator { + findUploadedFileLink(fileName: string): Locator { return this.page.getByRole('link', { name: fileName }); } diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel-settings.ts b/apps/meteor/tests/e2e/page-objects/omnichannel-settings.ts new file mode 100644 index 000000000000..1747499e51a5 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel-settings.ts @@ -0,0 +1,21 @@ +import type { Locator, Page } from '@playwright/test'; + +export class OmnichannelSettings { + protected readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + group(sectionName: string): Locator { + return this.page.locator(`[data-qa-section="${sectionName}"] h2 >> text="${sectionName}"`); + } + + get labelHideWatermark(): Locator { + return this.page.locator('label', { has: this.page.locator('[data-qa-setting-id="Livechat_hide_watermark"]') }); + } + + get btnSave(): Locator { + return this.page.locator('role=button[name="Save changes"]'); + } +} diff --git a/packages/livechat/src/components/Footer/index.tsx b/packages/livechat/src/components/Footer/index.tsx index 0d85b4deec15..0f466e1f460f 100644 --- a/packages/livechat/src/components/Footer/index.tsx +++ b/packages/livechat/src/components/Footer/index.tsx @@ -20,7 +20,7 @@ export const FooterContent = ({ children, className, ...props }: { children: Com ); export const PoweredBy = withTranslation()(({ className, t, ...props }: { className?: string; t: (translationKey: string) => string }) => ( -

+

{t('powered_by_rocket_chat').split('Rocket.Chat')[0]} From a5873f22a750a4ad55cd7ec382e68b85ee8bf111 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 10 Apr 2024 08:32:23 -0600 Subject: [PATCH 047/131] fix: Don't streaming inquiries to client when routing algorithm is not manual selection (#31969) --- .changeset/clever-guests-invent.md | 8 ++++++++ apps/meteor/server/modules/listeners/listeners.module.ts | 6 ++++++ 2 files changed, 14 insertions(+) create mode 100644 .changeset/clever-guests-invent.md diff --git a/.changeset/clever-guests-invent.md b/.changeset/clever-guests-invent.md new file mode 100644 index 000000000000..317ea3eb3d4c --- /dev/null +++ b/.changeset/clever-guests-invent.md @@ -0,0 +1,8 @@ +--- +"@rocket.chat/meteor": patch +--- + +Avoid streaming inquiries to client when routing algorithm is not manual selection. Previously, all inquiries where sent to the client no matter which routing was used. Inquiries are not shown on the UI or interacted with when the inquiry is not manual selection. +Moreover, when the algorithm changes from Auto to Manual, the UI gets the updated list of inquiries from the server, cleaning the ones received up to that point. + +Change will reduce the data sent over the wire and stored on the client's db. diff --git a/apps/meteor/server/modules/listeners/listeners.module.ts b/apps/meteor/server/modules/listeners/listeners.module.ts index ecaab84b2fac..5b1e1259f13f 100644 --- a/apps/meteor/server/modules/listeners/listeners.module.ts +++ b/apps/meteor/server/modules/listeners/listeners.module.ts @@ -209,6 +209,12 @@ export class ListenersModule { }); service.onEvent('watch.inquiries', async ({ clientAction, inquiry, diff }): Promise => { + // We do not need inquiries on the client when the routing method is not manual + // When the routing method changes, client fetches inquiries again and discards the received ones + if (settings.get('Livechat_Routing_Method') !== 'Manual_Selection') { + return; + } + const type = minimongoChangeMap[clientAction] as 'added' | 'changed' | 'removed'; if (clientAction === 'removed') { notifications.streamLivechatQueueData.emitWithoutBroadcast(inquiry._id, { From e373bc12f473acf4b16f831bcbd49ae213b85a5f Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Wed, 10 Apr 2024 12:58:08 -0300 Subject: [PATCH 048/131] chore: bump fuselage packages (#32167) --- apps/meteor/package.json | 4 +-- ee/packages/pdf-worker/package.json | 2 +- ee/packages/ui-theming/package.json | 2 +- packages/fuselage-ui-kit/package.json | 2 +- packages/gazzodown/package.json | 4 +-- packages/livechat/package.json | 2 +- packages/ui-avatar/package.json | 2 +- packages/ui-client/package.json | 2 +- packages/ui-composer/package.json | 2 +- packages/ui-video-conf/package.json | 2 +- packages/uikit-playground/package.json | 4 +-- yarn.lock | 46 +++++++++++++------------- 12 files changed, 37 insertions(+), 37 deletions(-) diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 5240f55b08be..444a56dd836f 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -242,11 +242,11 @@ "@rocket.chat/favicon": "workspace:^", "@rocket.chat/forked-matrix-appservice-bridge": "^4.0.2", "@rocket.chat/forked-matrix-bot-sdk": "^0.6.0-beta.3", - "@rocket.chat/fuselage": "^0.53.2", + "@rocket.chat/fuselage": "^0.53.4", "@rocket.chat/fuselage-hooks": "^0.33.1", "@rocket.chat/fuselage-polyfills": "~0.31.25", "@rocket.chat/fuselage-toastbar": "^0.31.26", - "@rocket.chat/fuselage-tokens": "^0.33.0", + "@rocket.chat/fuselage-tokens": "^0.33.1", "@rocket.chat/fuselage-ui-kit": "workspace:^", "@rocket.chat/gazzodown": "workspace:^", "@rocket.chat/i18n": "workspace:^", diff --git a/ee/packages/pdf-worker/package.json b/ee/packages/pdf-worker/package.json index f7c471e20ac7..f2be21dcb1f7 100644 --- a/ee/packages/pdf-worker/package.json +++ b/ee/packages/pdf-worker/package.json @@ -34,7 +34,7 @@ "dependencies": { "@react-pdf/renderer": "^3.1.14", "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/fuselage-tokens": "^0.33.0", + "@rocket.chat/fuselage-tokens": "^0.33.1", "@types/react": "~17.0.69", "emoji-assets": "^7.0.1", "emoji-toolkit": "^7.0.1", diff --git a/ee/packages/ui-theming/package.json b/ee/packages/ui-theming/package.json index dbe8a1394761..0906c99154f8 100644 --- a/ee/packages/ui-theming/package.json +++ b/ee/packages/ui-theming/package.json @@ -4,7 +4,7 @@ "private": true, "devDependencies": { "@rocket.chat/css-in-js": "~0.31.25", - "@rocket.chat/fuselage": "^0.53.2", + "@rocket.chat/fuselage": "^0.53.4", "@rocket.chat/fuselage-hooks": "^0.33.1", "@rocket.chat/icons": "^0.34.0", "@rocket.chat/ui-contexts": "workspace:~", diff --git a/packages/fuselage-ui-kit/package.json b/packages/fuselage-ui-kit/package.json index bd215f0fd8f8..d7874d5dd145 100644 --- a/packages/fuselage-ui-kit/package.json +++ b/packages/fuselage-ui-kit/package.json @@ -64,7 +64,7 @@ "@babel/preset-typescript": "~7.22.15", "@rocket.chat/apps-engine": "^1.42.1", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/fuselage": "^0.53.2", + "@rocket.chat/fuselage": "^0.53.4", "@rocket.chat/fuselage-hooks": "^0.33.1", "@rocket.chat/fuselage-polyfills": "~0.31.25", "@rocket.chat/icons": "^0.34.0", diff --git a/packages/gazzodown/package.json b/packages/gazzodown/package.json index 5068fccc13f4..5128f2841730 100644 --- a/packages/gazzodown/package.json +++ b/packages/gazzodown/package.json @@ -6,8 +6,8 @@ "@babel/core": "~7.22.20", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/css-in-js": "~0.31.25", - "@rocket.chat/fuselage": "^0.53.2", - "@rocket.chat/fuselage-tokens": "^0.33.0", + "@rocket.chat/fuselage": "^0.53.4", + "@rocket.chat/fuselage-tokens": "^0.33.1", "@rocket.chat/message-parser": "workspace:^", "@rocket.chat/styled": "~0.31.25", "@rocket.chat/ui-client": "workspace:^", diff --git a/packages/livechat/package.json b/packages/livechat/package.json index 9c210c2bfe32..677aeeab6aab 100644 --- a/packages/livechat/package.json +++ b/packages/livechat/package.json @@ -32,7 +32,7 @@ "@rocket.chat/ddp-client": "workspace:^", "@rocket.chat/eslint-config": "workspace:^", "@rocket.chat/fuselage-hooks": "^0.33.1", - "@rocket.chat/fuselage-tokens": "^0.33.0", + "@rocket.chat/fuselage-tokens": "^0.33.1", "@rocket.chat/logo": "^0.31.30", "@rocket.chat/ui-contexts": "workspace:^", "@storybook/addon-essentials": "~6.5.16", diff --git a/packages/ui-avatar/package.json b/packages/ui-avatar/package.json index 084b0c77f480..4621e593c6e5 100644 --- a/packages/ui-avatar/package.json +++ b/packages/ui-avatar/package.json @@ -4,7 +4,7 @@ "private": true, "devDependencies": { "@babel/core": "~7.22.20", - "@rocket.chat/fuselage": "^0.53.2", + "@rocket.chat/fuselage": "^0.53.4", "@rocket.chat/ui-contexts": "workspace:^", "@types/babel__core": "~7.20.3", "@types/react": "~17.0.69", diff --git a/packages/ui-client/package.json b/packages/ui-client/package.json index bbcf316d34a3..cc0a44c94806 100644 --- a/packages/ui-client/package.json +++ b/packages/ui-client/package.json @@ -6,7 +6,7 @@ "@babel/core": "~7.22.20", "@react-aria/toolbar": "^3.0.0-beta.1", "@rocket.chat/css-in-js": "~0.31.25", - "@rocket.chat/fuselage": "^0.53.2", + "@rocket.chat/fuselage": "^0.53.4", "@rocket.chat/fuselage-hooks": "^0.33.1", "@rocket.chat/icons": "^0.34.0", "@rocket.chat/mock-providers": "workspace:^", diff --git a/packages/ui-composer/package.json b/packages/ui-composer/package.json index 3c484c984afd..12f7c7253e35 100644 --- a/packages/ui-composer/package.json +++ b/packages/ui-composer/package.json @@ -6,7 +6,7 @@ "@babel/core": "~7.22.20", "@react-aria/toolbar": "^3.0.0-beta.1", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/fuselage": "^0.53.2", + "@rocket.chat/fuselage": "^0.53.4", "@rocket.chat/icons": "^0.34.0", "@storybook/addon-actions": "~6.5.16", "@storybook/addon-docs": "~6.5.16", diff --git a/packages/ui-video-conf/package.json b/packages/ui-video-conf/package.json index 5bf2f7b2b797..44e96dda7de9 100644 --- a/packages/ui-video-conf/package.json +++ b/packages/ui-video-conf/package.json @@ -6,7 +6,7 @@ "@babel/core": "~7.22.20", "@rocket.chat/css-in-js": "~0.31.25", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/fuselage": "^0.53.2", + "@rocket.chat/fuselage": "^0.53.4", "@rocket.chat/fuselage-hooks": "^0.33.1", "@rocket.chat/icons": "^0.34.0", "@rocket.chat/styled": "~0.31.25", diff --git a/packages/uikit-playground/package.json b/packages/uikit-playground/package.json index 8b885bca304b..f4646e3d3eb1 100644 --- a/packages/uikit-playground/package.json +++ b/packages/uikit-playground/package.json @@ -15,11 +15,11 @@ "@codemirror/tooltip": "^0.19.16", "@lezer/highlight": "^1.1.6", "@rocket.chat/css-in-js": "~0.31.25", - "@rocket.chat/fuselage": "^0.53.2", + "@rocket.chat/fuselage": "^0.53.4", "@rocket.chat/fuselage-hooks": "^0.33.1", "@rocket.chat/fuselage-polyfills": "~0.31.25", "@rocket.chat/fuselage-toastbar": "^0.31.26", - "@rocket.chat/fuselage-tokens": "^0.33.0", + "@rocket.chat/fuselage-tokens": "^0.33.1", "@rocket.chat/fuselage-ui-kit": "workspace:~", "@rocket.chat/icons": "^0.34.0", "@rocket.chat/logo": "^0.31.30", diff --git a/yarn.lock b/yarn.lock index 09fe4fb8dc9d..7e5cd62ab589 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8724,10 +8724,10 @@ __metadata: languageName: node linkType: hard -"@rocket.chat/fuselage-tokens@npm:^0.33.0": - version: 0.33.0 - resolution: "@rocket.chat/fuselage-tokens@npm:0.33.0" - checksum: fee164884da145fdc1e121f4c96c46d32106cd04ed5d1901634552cc3a0d512f9e41cf7905bac0dc9d4212a3d984db4ff23c3227005ba347782a72851cbc7277 +"@rocket.chat/fuselage-tokens@npm:^0.33.1": + version: 0.33.1 + resolution: "@rocket.chat/fuselage-tokens@npm:0.33.1" + checksum: 0c320995b2dcc6f114982308401479184a03778cb5f539a3f711e89f50aae1ce5680b23bd427950372afd24cdbc81eabf4f9a5e726672816088ea266241a6cfa languageName: node linkType: hard @@ -8741,7 +8741,7 @@ __metadata: "@babel/preset-typescript": ~7.22.15 "@rocket.chat/apps-engine": ^1.42.1 "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/fuselage": ^0.53.2 + "@rocket.chat/fuselage": ^0.53.4 "@rocket.chat/fuselage-hooks": ^0.33.1 "@rocket.chat/fuselage-polyfills": ~0.31.25 "@rocket.chat/gazzodown": "workspace:^" @@ -8801,13 +8801,13 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/fuselage@npm:^0.53.2": - version: 0.53.2 - resolution: "@rocket.chat/fuselage@npm:0.53.2" +"@rocket.chat/fuselage@npm:^0.53.4": + version: 0.53.4 + resolution: "@rocket.chat/fuselage@npm:0.53.4" dependencies: "@rocket.chat/css-in-js": ^0.31.25 "@rocket.chat/css-supports": ^0.31.25 - "@rocket.chat/fuselage-tokens": ^0.33.0 + "@rocket.chat/fuselage-tokens": ^0.33.1 "@rocket.chat/memo": ^0.31.25 "@rocket.chat/styled": ^0.31.25 invariant: ^2.2.4 @@ -8821,7 +8821,7 @@ __metadata: react: ^17.0.2 react-dom: ^17.0.2 react-virtuoso: 1.2.4 - checksum: 3dd1464821330cc353fdb3de585853e7d41f181e87a90c8281d8ef28ba719dd95ddc92f048a321127e800a9287638f77d5549412548b443bceedbefa4d728e40 + checksum: 3a926f6f29f8111c23d423d056f51f13679f329cb58b418a9b2999ceac54897232f6078d656e3c7858d52dbe5fa55137267397feaa24b4271cf10e1e955891b6 languageName: node linkType: hard @@ -8832,8 +8832,8 @@ __metadata: "@babel/core": ~7.22.20 "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/css-in-js": ~0.31.25 - "@rocket.chat/fuselage": ^0.53.2 - "@rocket.chat/fuselage-tokens": ^0.33.0 + "@rocket.chat/fuselage": ^0.53.4 + "@rocket.chat/fuselage-tokens": ^0.33.1 "@rocket.chat/message-parser": "workspace:^" "@rocket.chat/styled": ~0.31.25 "@rocket.chat/ui-client": "workspace:^" @@ -8983,7 +8983,7 @@ __metadata: "@rocket.chat/ddp-client": "workspace:^" "@rocket.chat/eslint-config": "workspace:^" "@rocket.chat/fuselage-hooks": ^0.33.1 - "@rocket.chat/fuselage-tokens": ^0.33.0 + "@rocket.chat/fuselage-tokens": ^0.33.1 "@rocket.chat/gazzodown": "workspace:^" "@rocket.chat/logo": ^0.31.30 "@rocket.chat/message-parser": "workspace:^" @@ -9192,11 +9192,11 @@ __metadata: "@rocket.chat/favicon": "workspace:^" "@rocket.chat/forked-matrix-appservice-bridge": ^4.0.2 "@rocket.chat/forked-matrix-bot-sdk": ^0.6.0-beta.3 - "@rocket.chat/fuselage": ^0.53.2 + "@rocket.chat/fuselage": ^0.53.4 "@rocket.chat/fuselage-hooks": ^0.33.1 "@rocket.chat/fuselage-polyfills": ~0.31.25 "@rocket.chat/fuselage-toastbar": ^0.31.26 - "@rocket.chat/fuselage-tokens": ^0.33.0 + "@rocket.chat/fuselage-tokens": ^0.33.1 "@rocket.chat/fuselage-ui-kit": "workspace:^" "@rocket.chat/gazzodown": "workspace:^" "@rocket.chat/i18n": "workspace:^" @@ -9709,7 +9709,7 @@ __metadata: dependencies: "@react-pdf/renderer": ^3.1.14 "@rocket.chat/core-typings": "workspace:^" - "@rocket.chat/fuselage-tokens": ^0.33.0 + "@rocket.chat/fuselage-tokens": ^0.33.1 "@storybook/addon-essentials": ~6.5.16 "@storybook/react": ~6.5.16 "@testing-library/jest-dom": ^5.16.5 @@ -10075,7 +10075,7 @@ __metadata: resolution: "@rocket.chat/ui-avatar@workspace:packages/ui-avatar" dependencies: "@babel/core": ~7.22.20 - "@rocket.chat/fuselage": ^0.53.2 + "@rocket.chat/fuselage": ^0.53.4 "@rocket.chat/ui-contexts": "workspace:^" "@types/babel__core": ~7.20.3 "@types/react": ~17.0.69 @@ -10101,7 +10101,7 @@ __metadata: "@babel/core": ~7.22.20 "@react-aria/toolbar": ^3.0.0-beta.1 "@rocket.chat/css-in-js": ~0.31.25 - "@rocket.chat/fuselage": ^0.53.2 + "@rocket.chat/fuselage": ^0.53.4 "@rocket.chat/fuselage-hooks": ^0.33.1 "@rocket.chat/icons": ^0.34.0 "@rocket.chat/mock-providers": "workspace:^" @@ -10154,7 +10154,7 @@ __metadata: "@babel/core": ~7.22.20 "@react-aria/toolbar": ^3.0.0-beta.1 "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/fuselage": ^0.53.2 + "@rocket.chat/fuselage": ^0.53.4 "@rocket.chat/icons": ^0.34.0 "@storybook/addon-actions": ~6.5.16 "@storybook/addon-docs": ~6.5.16 @@ -10246,7 +10246,7 @@ __metadata: resolution: "@rocket.chat/ui-theming@workspace:ee/packages/ui-theming" dependencies: "@rocket.chat/css-in-js": ~0.31.25 - "@rocket.chat/fuselage": ^0.53.2 + "@rocket.chat/fuselage": ^0.53.4 "@rocket.chat/fuselage-hooks": ^0.33.1 "@rocket.chat/icons": ^0.34.0 "@rocket.chat/ui-contexts": "workspace:~" @@ -10289,7 +10289,7 @@ __metadata: "@rocket.chat/css-in-js": ~0.31.25 "@rocket.chat/emitter": ~0.31.25 "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/fuselage": ^0.53.2 + "@rocket.chat/fuselage": ^0.53.4 "@rocket.chat/fuselage-hooks": ^0.33.1 "@rocket.chat/icons": ^0.34.0 "@rocket.chat/styled": ~0.31.25 @@ -10334,11 +10334,11 @@ __metadata: "@codemirror/tooltip": ^0.19.16 "@lezer/highlight": ^1.1.6 "@rocket.chat/css-in-js": ~0.31.25 - "@rocket.chat/fuselage": ^0.53.2 + "@rocket.chat/fuselage": ^0.53.4 "@rocket.chat/fuselage-hooks": ^0.33.1 "@rocket.chat/fuselage-polyfills": ~0.31.25 "@rocket.chat/fuselage-toastbar": ^0.31.26 - "@rocket.chat/fuselage-tokens": ^0.33.0 + "@rocket.chat/fuselage-tokens": ^0.33.1 "@rocket.chat/fuselage-ui-kit": "workspace:~" "@rocket.chat/icons": ^0.34.0 "@rocket.chat/logo": ^0.31.30 From 747103fbdaeca381ce8a7322431db62543c18d71 Mon Sep 17 00:00:00 2001 From: Martin Schoeler Date: Thu, 11 Apr 2024 10:06:08 -0300 Subject: [PATCH 049/131] fix: Livechat allowing file upload when setting is false (#31765) --- .changeset/fair-peaches-cough.md | 6 ++ .../livechat/imports/server/rest/upload.ts | 8 ++ apps/meteor/tests/data/livechat/rooms.ts | 4 + .../tests/end-to-end/api/livechat/00-rooms.ts | 93 +++++++++++++++++++ packages/livechat/src/i18n/en.json | 3 +- packages/livechat/src/i18n/pt-BR.json | 1 + .../livechat/src/routes/Chat/container.js | 15 +++ 7 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 .changeset/fair-peaches-cough.md diff --git a/.changeset/fair-peaches-cough.md b/.changeset/fair-peaches-cough.md new file mode 100644 index 000000000000..34f7a319924a --- /dev/null +++ b/.changeset/fair-peaches-cough.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/livechat": patch +--- + +Fixes the livechat client ignoring the `livechat_fileuploads_enabled` setting when uploading files diff --git a/apps/meteor/app/livechat/imports/server/rest/upload.ts b/apps/meteor/app/livechat/imports/server/rest/upload.ts index 2d4b021ffd94..14db8f20afcf 100644 --- a/apps/meteor/app/livechat/imports/server/rest/upload.ts +++ b/apps/meteor/app/livechat/imports/server/rest/upload.ts @@ -14,6 +14,14 @@ API.v1.addRoute('livechat/upload/:rid', { return API.v1.unauthorized(); } + const canUpload = settings.get('Livechat_fileupload_enabled') && settings.get('FileUpload_Enabled'); + + if (!canUpload) { + return API.v1.failure({ + reason: 'error-file-upload-disabled', + }); + } + const visitorToken = this.request.headers['x-visitor-token']; const visitor = await LivechatVisitors.getVisitorByToken(visitorToken as string, {}); diff --git a/apps/meteor/tests/data/livechat/rooms.ts b/apps/meteor/tests/data/livechat/rooms.ts index 3ae59c626a21..ff7819a6365b 100644 --- a/apps/meteor/tests/data/livechat/rooms.ts +++ b/apps/meteor/tests/data/livechat/rooms.ts @@ -63,6 +63,10 @@ export const createVisitor = (department?: string): Promise => }); }); +export const deleteVisitor = async (token: string): Promise => { + await request.delete(api(`livechat/visitor/${token}`)); +} + export const takeInquiry = async (inquiryId: string, agentCredentials?: IUserCredentialsHeader): Promise => { const userId = agentCredentials ? agentCredentials['X-User-Id'] : credentials['X-User-Id']; diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index e99c893abf9a..bc3f412b69f9 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -32,6 +32,7 @@ import { closeOmnichannelRoom, createDepartment, fetchMessages, + deleteVisitor, makeAgentUnavailable, } from '../../../data/livechat/rooms'; import { saveTags } from '../../../data/livechat/tags'; @@ -1033,6 +1034,7 @@ describe('LIVECHAT - rooms', function () { .attach('file', fs.createReadStream(path.join(__dirname, '../../../data/livechat/sample.png'))) .expect('Content-Type', 'application/json') .expect(403); + await deleteVisitor(visitor.token); }); it('should throw an error if the file is not attached', async () => { @@ -1044,9 +1046,60 @@ describe('LIVECHAT - rooms', function () { .set('x-visitor-token', visitor.token) .expect('Content-Type', 'application/json') .expect(400); + await deleteVisitor(visitor.token); + }); + + it('should throw and error if file uploads are enabled but livechat file uploads are disabled', async () => { + await updateSetting('Livechat_fileupload_enabled', false); + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + await request + .post(api(`livechat/upload/${room._id}`)) + .set(credentials) + .set('x-visitor-token', visitor.token) + .attach('file', fs.createReadStream(path.join(__dirname, '../../../data/livechat/sample.png'))) + .expect('Content-Type', 'application/json') + .expect(400); + await updateSetting('Livechat_fileupload_enabled', true); + await deleteVisitor(visitor.token); + }); + + it('should throw and error if livechat file uploads are enabled but file uploads are disabled', async () => { + await updateSetting('FileUpload_Enabled', false); + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + await request + .post(api(`livechat/upload/${room._id}`)) + .set(credentials) + .set('x-visitor-token', visitor.token) + .attach('file', fs.createReadStream(path.join(__dirname, '../../../data/livechat/sample.png'))) + .expect('Content-Type', 'application/json') + .expect(400); + await updateSetting('FileUpload_Enabled', true); + await deleteVisitor(visitor.token); + }); + + it('should throw and error if both file uploads are disabled', async () => { + await updateSetting('Livechat_fileupload_enabled', false); + await updateSetting('FileUpload_Enabled', false); + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + await request + .post(api(`livechat/upload/${room._id}`)) + .set(credentials) + .set('x-visitor-token', visitor.token) + .attach('file', fs.createReadStream(path.join(__dirname, '../../../data/livechat/sample.png'))) + .expect('Content-Type', 'application/json') + .expect(400); + await updateSetting('FileUpload_Enabled', true); + await updateSetting('Livechat_fileupload_enabled', true); + + await deleteVisitor(visitor.token); }); it('should upload an image on the room if all params are valid', async () => { + await updateSetting('FileUpload_Enabled', true); + await updateSetting('Livechat_fileupload_enabled', true); const visitor = await createVisitor(); const room = await createLivechatRoom(visitor.token); await request @@ -1056,6 +1109,7 @@ describe('LIVECHAT - rooms', function () { .attach('file', fs.createReadStream(path.join(__dirname, '../../../data/livechat/sample.png'))) .expect('Content-Type', 'application/json') .expect(200); + await deleteVisitor(visitor.token); }); }); @@ -1086,6 +1140,7 @@ describe('LIVECHAT - rooms', function () { expect(body.messages).to.be.an('array'); expect(body.total).to.be.an('number').equal(1); expect(body.messages[0]).to.have.property('msg', 'Hello'); + await deleteVisitor(visitor.token); }); it('should return the messages of the room matching by searchTerm', async () => { const visitor = await createVisitor(); @@ -1105,6 +1160,7 @@ describe('LIVECHAT - rooms', function () { expect(body.messages).to.be.an('array'); expect(body.total).to.be.an('number').equal(1); expect(body.messages[0]).to.have.property('msg', 'Random'); + await deleteVisitor(visitor.token); }); it('should return the messages of the room matching by partial searchTerm', async () => { const visitor = await createVisitor(); @@ -1124,6 +1180,7 @@ describe('LIVECHAT - rooms', function () { expect(body.messages).to.be.an('array'); expect(body.total).to.be.an('number').equal(1); expect(body.messages[0]).to.have.property('msg', 'Random'); + await deleteVisitor(visitor.token); }); it('should return everything when searchTerm is ""', async () => { const visitor = await createVisitor(); @@ -1143,6 +1200,7 @@ describe('LIVECHAT - rooms', function () { expect(body.messages).to.be.an('array'); expect(body.messages).to.be.an('array').with.lengthOf.greaterThan(1); expect(body.messages[0]).to.have.property('msg'); + await deleteVisitor(visitor.token); }); }); @@ -1179,6 +1237,7 @@ describe('LIVECHAT - rooms', function () { expect(body).to.have.property('success', true); expect(body).to.have.property('message'); expect(body.message).to.have.property('msg', 'Hello'); + await deleteVisitor(visitor.token); }); }); @@ -1231,6 +1290,7 @@ describe('LIVECHAT - rooms', function () { .send({ token: visitor.token, rid: 'fadsfdsafads', msg: 'fasfasdfdsf' }) .expect('Content-Type', 'application/json') .expect(400); + await deleteVisitor(visitor.token); }); it('should fail if _id is not a valid message id', async () => { const visitor = await createVisitor(); @@ -1242,6 +1302,7 @@ describe('LIVECHAT - rooms', function () { .send({ token: visitor.token, rid: room._id, msg: 'fasfasdfdsf' }) .expect('Content-Type', 'application/json') .expect(400); + await deleteVisitor(visitor.token); }); it('should update a message if everything is valid', async () => { const visitor = await createVisitor(); @@ -1261,6 +1322,7 @@ describe('LIVECHAT - rooms', function () { expect(body.message).to.have.property('editedAt'); expect(body.message).to.have.property('editedBy'); expect(body.message.editedBy).to.have.property('username', visitor.username); + await deleteVisitor(visitor.token); }); }); @@ -1299,6 +1361,7 @@ describe('LIVECHAT - rooms', function () { .send({ token: visitor.token, rid: room._id }) .expect('Content-Type', 'application/json') .expect(400); + await deleteVisitor(visitor.token); }); it('should delete a message if everything is valid', async () => { const visitor = await createVisitor(); @@ -1316,6 +1379,7 @@ describe('LIVECHAT - rooms', function () { expect(body).to.have.property('message'); expect(body.message).to.have.property('_id', message._id); expect(body.message).to.have.property('ts'); + await deleteVisitor(visitor.token); }); }); @@ -1380,6 +1444,7 @@ describe('LIVECHAT - rooms', function () { expect(body.messages[1]).to.have.property('msg', 'Hello 2'); expect(body.messages[1]).to.have.property('ts'); expect(body.messages[1]).to.have.property('username', visitor.username); + await deleteVisitor(visitor.token); }); }); @@ -1414,6 +1479,7 @@ describe('LIVECHAT - rooms', function () { expect(body).to.have.property('success', true); expect(body).to.have.property('history').that.is.an('array'); expect(body.history.length).to.equal(0); + await deleteVisitor(visitor.token); }); it('should return the transfer history for a room', async () => { await updatePermission('view-l-room', ['admin', 'livechat-manager', 'livechat-agent']); @@ -1460,6 +1526,7 @@ describe('LIVECHAT - rooms', function () { expect(body.history[0]).to.have.property('transferredBy').that.is.an('object'); // cleanup + await deleteVisitor(newVisitor.token); await deleteUser(initialAgentAssignedToChat); await deleteUser(forwardChatToUser); }); @@ -1526,6 +1593,7 @@ describe('LIVECHAT - rooms', function () { await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); // delay for 1 second to make sure the routing queue starts again await sleep(1000); + await deleteVisitor(newVisitor.token); }); it('should throw an error if roomData is not provided', async () => { @@ -1615,6 +1683,7 @@ describe('LIVECHAT - rooms', function () { expect(latestRoom).to.have.property('tags').of.length(2); expect(latestRoom).to.have.property('tags').to.include('tag1'); expect(latestRoom).to.have.property('tags').to.include('tag2'); + await deleteVisitor(newVisitor.token); }); (IS_EE ? it : it.skip)('should allow user to update the room info - EE fields', async () => { @@ -1659,6 +1728,7 @@ describe('LIVECHAT - rooms', function () { expect(latestRoom).to.have.property('tags').to.include('tag1'); expect(latestRoom).to.have.property('tags').to.include('tag2'); expect(latestRoom).to.have.property('livechatData').to.have.property(cfName, 'test-input-1-value'); + await deleteVisitor(newVisitor.token); }); (IS_EE ? it : it.skip)('endpoint should handle empty custom fields', async () => { @@ -1691,6 +1761,7 @@ describe('LIVECHAT - rooms', function () { expect(latestRoom).to.have.property('tags').to.include('tag1'); expect(latestRoom).to.have.property('tags').to.include('tag2'); expect(latestRoom).to.not.have.property('livechatData'); + await deleteVisitor(newVisitor.token); }); (IS_EE ? it : it.skip)('should throw an error if custom fields are not valid', async () => { @@ -1760,6 +1831,7 @@ describe('LIVECHAT - rooms', function () { .expect(400); expect(response.body).to.have.property('success', false); expect(response.body).to.have.property('error', 'Invalid value for intfield field'); + await deleteVisitor(newVisitor.token); }); (IS_EE ? it : it.skip)('should not throw an error if a valid custom field passes the check', async () => { const newVisitor = await createVisitor(); @@ -1780,6 +1852,7 @@ describe('LIVECHAT - rooms', function () { .expect('Content-Type', 'application/json') .expect(200); expect(response2.body).to.have.property('success', true); + await deleteVisitor(newVisitor.token); }); (IS_EE ? it : it.skip)('should update room priority', async () => { @@ -1813,6 +1886,7 @@ describe('LIVECHAT - rooms', function () { const updatedRoom = await getLivechatRoomInfo(newRoom._id); expect(updatedRoom).to.have.property('priorityId', priority._id); expect(updatedRoom).to.have.property('priorityWeight', priority.sortItem); + await deleteVisitor(newVisitor.token); }); (IS_EE ? it : it.skip)('should update room sla', async () => { const newVisitor = await createVisitor(); @@ -1839,6 +1913,7 @@ describe('LIVECHAT - rooms', function () { const updatedRoom = await getLivechatRoomInfo(newRoom._id); expect(updatedRoom).to.have.property('slaId', sla._id); + await deleteVisitor(newVisitor.token); }); }); (IS_EE ? describe : describe.skip)('livechat/room/:rid/priority', async () => { @@ -1948,6 +2023,7 @@ describe('LIVECHAT - rooms', function () { const visitor = await createVisitor(); const { _id } = await createLivechatRoom(visitor.token); await request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id }).expect(400); + await deleteVisitor(visitor.token); }); it('should not close a room without comment', async () => { await restorePermissionToRoles('close-others-livechat-room'); @@ -1957,6 +2033,7 @@ describe('LIVECHAT - rooms', function () { expect(response.body).to.have.property('success', false); expect(response.body).to.have.property('error', 'error-comment-is-required'); + await deleteVisitor(visitor.token); }); it('should not close a room when comment is an empty string', async () => { await restorePermissionToRoles('close-others-livechat-room'); @@ -1965,11 +2042,13 @@ describe('LIVECHAT - rooms', function () { const response = await request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id, comment: '' }).expect(400); expect(response.body).to.have.property('success', false); + await deleteVisitor(visitor.token); }); it('should close room if user has permission', async () => { const visitor = await createVisitor(); const { _id } = await createLivechatRoom(visitor.token); await request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id, comment: 'test' }).expect(200); + await deleteVisitor(visitor.token); }); it('should fail if room is closed', async () => { const visitor = await createVisitor(); @@ -1980,6 +2059,7 @@ describe('LIVECHAT - rooms', function () { // try to close again await request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id, comment: 'test' }).expect(400); + await deleteVisitor(visitor.token); }); (IS_EE ? it : it.skip)('should close room and generate transcript pdf', async () => { @@ -2040,6 +2120,7 @@ describe('LIVECHAT - rooms', function () { .post(api(`omnichannel/${_id}/request-transcript`)) .set(credentials) .expect(403); + await deleteVisitor(visitor.token); }); it('should fail if room is not closed', async () => { await updatePermission('request-pdf-transcript', ['admin', 'livechat-agent', 'livechat-manager']); @@ -2049,6 +2130,7 @@ describe('LIVECHAT - rooms', function () { .post(api(`omnichannel/${_id}/request-transcript`)) .set(credentials) .expect(400); + await deleteVisitor(visitor.token); }); it('should return OK if no one is serving the room (queued)', async () => { const visitor = await createVisitor(); @@ -2058,6 +2140,7 @@ describe('LIVECHAT - rooms', function () { .post(api(`omnichannel/${_id}/request-transcript`)) .set(credentials) .expect(200); + await deleteVisitor(visitor.token); }); let roomWithTranscriptGenerated: string; it('should request a pdf transcript when all conditions are met', async () => { @@ -2115,6 +2198,7 @@ describe('LIVECHAT - rooms', function () { it("room's subscription should have correct unread count", async () => { const { unread } = await getSubscriptionForRoom(room._id, departmentWithAgent.agent.credentials); expect(unread).to.equal(totalMessagesSent); + await deleteVisitor(visitor.token); }); }); @@ -2144,6 +2228,7 @@ describe('LIVECHAT - rooms', function () { it("room's subscription should have correct unread count", async () => { const { unread } = await getSubscriptionForRoom(room._id, departmentWithAgent.agent.credentials); expect(unread).to.equal(totalMessagesSent); + await deleteVisitor(visitor.token); }); }); @@ -2167,6 +2252,7 @@ describe('LIVECHAT - rooms', function () { .delete(api(`livechat/transcript/${_id}`)) .set(credentials) .expect(400); + await deleteVisitor(visitor.token); }); it('should fail if room doesnt have a transcript request active', async () => { const visitor = await createVisitor(); @@ -2175,6 +2261,7 @@ describe('LIVECHAT - rooms', function () { .delete(api(`livechat/transcript/${_id}`)) .set(credentials) .expect(400); + await deleteVisitor(visitor.token); }); it('should return OK if all conditions are met', async () => { const visitor = await createVisitor(); @@ -2198,6 +2285,7 @@ describe('LIVECHAT - rooms', function () { .delete(api(`livechat/transcript/${_id}`)) .set(credentials) .expect(200); + await deleteVisitor(visitor.token); }); }); @@ -2276,6 +2364,7 @@ describe('LIVECHAT - rooms', function () { const result = parseMethodResponse(body); expect(body.success).to.be.true; expect(result).to.have.property('error').that.is.an('object'); + await deleteVisitor(visitor.token); }); it('should fail if token is from another conversation', async () => { const visitor = await createVisitor(); @@ -2297,6 +2386,8 @@ describe('LIVECHAT - rooms', function () { const result = parseMethodResponse(body); expect(body.success).to.be.true; expect(result).to.have.property('error').that.is.an('object'); + await deleteVisitor(visitor.token); + await deleteVisitor(visitor2.token); }); it('should fail if email provided is invalid', async () => { const visitor = await createVisitor(); @@ -2317,6 +2408,7 @@ describe('LIVECHAT - rooms', function () { const result = parseMethodResponse(body); expect(body.success).to.be.true; expect(result).to.have.property('error').that.is.an('object'); + await deleteVisitor(visitor.token); }); it('should work if all params are good', async () => { const visitor = await createVisitor(); @@ -2337,6 +2429,7 @@ describe('LIVECHAT - rooms', function () { const result = parseMethodResponse(body); expect(body.success).to.be.true; expect(result).to.have.property('result', true); + await deleteVisitor(visitor.token); }); }); }); diff --git a/packages/livechat/src/i18n/en.json b/packages/livechat/src/i18n/en.json index dd852048222b..f55d592d15a3 100644 --- a/packages/livechat/src/i18n/en.json +++ b/packages/livechat/src/i18n/en.json @@ -32,6 +32,7 @@ "expand_chat": "Expand chat", "field_required": "Field required", "file_exceeds_allowed_size_of_size": "File exceeds allowed size of {{size}}.", + "file_upload_disabled": "File upload is disabled", "fileupload_error": "FileUpload Error", "finish_this_chat": "Finish this chat", "forget_remove_my_data": "Forget/Remove my data", @@ -103,4 +104,4 @@ "your_spot_is_spot": "Your spot is #{{spot}}", "your_spot_is_spot_estimated_wait_time_estimatedwai": "Your spot is #{{spot}} (Estimated wait time: {{estimatedWaitTime}})" } -} \ No newline at end of file +} diff --git a/packages/livechat/src/i18n/pt-BR.json b/packages/livechat/src/i18n/pt-BR.json index b6570ca1f72b..d805a4ad38b2 100644 --- a/packages/livechat/src/i18n/pt-BR.json +++ b/packages/livechat/src/i18n/pt-BR.json @@ -28,6 +28,7 @@ "expand_chat": "Expandir chat", "field_required": "Campo obrigatório", "file_exceeds_allowed_size_of_size": "Arquivo excede o tamanho permitido de {{size}}.", + "file_upload_disabled": "Upload de arquivos está desabilitado", "fileupload_error": "Erro no upload do arquivo", "finish_this_chat": "Encerrar este chat", "forget_remove_my_data": "Esquecer/remover meus dados pessoais", diff --git a/packages/livechat/src/routes/Chat/container.js b/packages/livechat/src/routes/Chat/container.js index 19886a547610..506d6ce1e76e 100644 --- a/packages/livechat/src/routes/Chat/container.js +++ b/packages/livechat/src/routes/Chat/container.js @@ -23,6 +23,7 @@ import { getLastReadMessage, loadConfig, processUnread, shouldMarkAsUnread } fro import { parentCall, runCallbackEventEmitter } from '../../lib/parentCall'; import { createToken } from '../../lib/random'; import { initRoom, closeChat, loadMessages, loadMoreMessages, defaultRoomParams, getGreetingMessages } from '../../lib/room'; +import store from '../../store'; import Chat from './component'; const ChatWrapper = ({ children, rid }) => { @@ -189,6 +190,20 @@ class ChatContainer extends Component { }; handleUpload = async (files) => { + const { + config: { + settings: { fileUpload }, + }, + } = store.state; + + const { dispatch, alerts, i18n } = this.props; + + if (!fileUpload) { + const alert = { id: createToken(), children: i18n.t('file_upload_disabled'), error: true, timeout: 5000 }; + await dispatch({ alerts: (alerts.push(alert), alerts) }); + return; + } + await this.grantUser(); const { _id: rid } = await this.getRoom(); From 7523786762ae8bc30fc657f11e702e690e3b1898 Mon Sep 17 00:00:00 2001 From: gabriellsh <40830821+gabriellsh@users.noreply.github.com> Date: Thu, 11 Apr 2024 10:49:49 -0300 Subject: [PATCH 050/131] fix: Team mention triggering bot message for users not in room (#32112) --- .changeset/lemon-schools-double.md | 5 ++ .../server/startup/mentionUserNotInChannel.ts | 2 +- .../meteor/tests/e2e/message-mentions.spec.ts | 67 +++++++++++++------ .../tests/e2e/utils/create-target-channel.ts | 8 +++ 4 files changed, 61 insertions(+), 21 deletions(-) create mode 100644 .changeset/lemon-schools-double.md diff --git a/.changeset/lemon-schools-double.md b/.changeset/lemon-schools-double.md new file mode 100644 index 000000000000..b0f623e8d647 --- /dev/null +++ b/.changeset/lemon-schools-double.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fixed an issue where mentioning a team would trigger the bot message warning that the team is not a part of the channel diff --git a/apps/meteor/app/lib/server/startup/mentionUserNotInChannel.ts b/apps/meteor/app/lib/server/startup/mentionUserNotInChannel.ts index 160defcb94ed..962691a78bd8 100644 --- a/apps/meteor/app/lib/server/startup/mentionUserNotInChannel.ts +++ b/apps/meteor/app/lib/server/startup/mentionUserNotInChannel.ts @@ -62,7 +62,7 @@ callbacks.add( return message; } - const mentions = message.mentions.filter(({ _id }) => _id !== 'all' && _id !== 'here'); + const mentions = message.mentions.filter(({ _id, type }) => _id !== 'all' && _id !== 'here' && type !== 'team'); if (!mentions.length) { return message; } diff --git a/apps/meteor/tests/e2e/message-mentions.spec.ts b/apps/meteor/tests/e2e/message-mentions.spec.ts index 7645d5b14470..01fb2dde4217 100644 --- a/apps/meteor/tests/e2e/message-mentions.spec.ts +++ b/apps/meteor/tests/e2e/message-mentions.spec.ts @@ -2,7 +2,7 @@ import { faker } from '@faker-js/faker'; import { Users } from './fixtures/userStates'; import { HomeChannel } from './page-objects'; -import { createTargetPrivateChannel } from './utils'; +import { createTargetPrivateChannel, createTargetTeam, deleteChannel, deleteTeam } from './utils'; import { test, expect } from './utils/test'; @@ -45,16 +45,20 @@ test.describe.serial('message-mentions', () => { targetChannel = await createTargetPrivateChannel(api); }); + test.afterAll(async ({ api }) => { + await deleteChannel(api, targetChannel); + }); + test('all actions', async ({ page }) => { const adminPage = new HomeChannel(page); const mentionText = getMentionText(Users.user1.data.username, 1); - + await test.step('receive bot message', async () => { await adminPage.sidenav.openChat(targetChannel); await adminPage.content.sendMessage(getMentionText(Users.user1.data.username)); await expect(adminPage.content.lastUserMessage.locator('.rcx-message-block')).toContainText(mentionText); }); - + await test.step('show "Do nothing" action', async () => { await expect(adminPage.content.lastUserMessage.locator('button >> text="Do nothing"')).toBeVisible(); }); @@ -68,7 +72,7 @@ test.describe.serial('message-mentions', () => { await test.step('dismiss', async () => { await adminPage.content.lastUserMessage.locator('button >> text="Do nothing"').click(); }); - + await test.step('receive second bot message', async () => { await adminPage.content.sendMessage(getMentionText(Users.user1.data.username)); await expect(adminPage.content.lastUserMessage.locator('.rcx-message-block')).toContainText(mentionText); @@ -77,7 +81,7 @@ test.describe.serial('message-mentions', () => { await adminPage.content.lastUserMessage.locator('button >> text="Let them know"').click(); await expect(adminPage.content.lastUserMessageBody).toContainText(getMentionText(Users.user1.data.username, 3)); }); - + await test.step('receive third bot message', async () => { await adminPage.content.sendMessage(getMentionText(Users.user1.data.username)); await expect(adminPage.content.lastUserMessage.locator('.rcx-message-block')).toContainText(mentionText); @@ -94,13 +98,13 @@ test.describe.serial('message-mentions', () => { test('dismiss and share message actions', async ({ page }) => { const mentionText = getMentionText(Users.user2.data.username, 1); const userPage = new HomeChannel(page); - + await test.step('receive bot message', async () => { await userPage.sidenav.openChat(targetChannel); await userPage.content.sendMessage(getMentionText(Users.user2.data.username)); await expect(userPage.content.lastUserMessage.locator('.rcx-message-block')).toContainText(mentionText); }); - + await test.step('show "Do nothing" action', async () => { await expect(userPage.content.lastUserMessage.locator('button >> text="Do nothing"')).toBeVisible(); }); @@ -110,11 +114,11 @@ test.describe.serial('message-mentions', () => { await test.step('not show "Add them action', async () => { await expect(userPage.content.lastUserMessage.locator('button >> text="Add them"')).not.toBeVisible(); }); - + await test.step('dismiss', async () => { await userPage.content.lastUserMessage.locator('button >> text="Do nothing"').click(); }); - + await test.step('receive second bot message', async () => { await userPage.sidenav.openChat(targetChannel); await userPage.content.sendMessage(getMentionText(Users.user2.data.username)); @@ -126,15 +130,15 @@ test.describe.serial('message-mentions', () => { }); }); }) - + test.describe(() => { test.use({ storageState: Users.user1.state }); test.beforeAll(async ({ api }) => { - expect((await api.post('/permissions.update', { permissions: [{ '_id': 'create-d', 'roles': ['admin'] }] })).status()).toBe(200); - }); + expect((await api.post('/permissions.update', { permissions: [{ '_id': 'create-d', 'roles': ['admin'] }] })).status()).toBe(200); + }); test.afterAll(async ({ api }) => { - expect((await api.post('/permissions.update', { permissions: [{ '_id': 'create-d', 'roles': ['admin', 'user', 'bot', 'app'] }] })).status()).toBe(200); + expect((await api.post('/permissions.update', { permissions: [{ '_id': 'create-d', 'roles': ['admin', 'user', 'bot', 'app'] }] })).status()).toBe(200); }); test('dismiss and add users actions', async ({ page }) => { @@ -165,11 +169,11 @@ test.describe.serial('message-mentions', () => { await test.step('not show "Let them know" action', async () => { await expect(userPage.content.lastUserMessage.locator('button >> text="Let them know"')).not.toBeVisible(); }); - + await test.step('dismiss', async () => { await userPage.content.lastUserMessage.locator('button >> text="Do nothing"').click(); }); - + await test.step('receive second bot message', async () => { await userPage.sidenav.openChat(targetChannel2); await userPage.content.sendMessage(getMentionText(Users.user2.data.username)); @@ -181,15 +185,15 @@ test.describe.serial('message-mentions', () => { }); }); }); - + test.describe(() => { test.use({ storageState: Users.user2.state }); test.beforeAll(async ({ api }) => { - expect((await api.post('/permissions.update', { permissions: [{ '_id': 'create-d', 'roles': ['admin'] }] })).status()).toBe(200); - }); + expect((await api.post('/permissions.update', { permissions: [{ '_id': 'create-d', 'roles': ['admin'] }] })).status()).toBe(200); + }); test.afterAll(async ({ api }) => { - expect((await api.post('/permissions.update', { permissions: [{ '_id': 'create-d', 'roles': ['admin', 'user', 'bot', 'app'] }] })).status()).toBe(200); + expect((await api.post('/permissions.update', { permissions: [{ '_id': 'create-d', 'roles': ['admin', 'user', 'bot', 'app'] }] })).status()).toBe(200); }); test('no actions', async ({ page }) => { const userPage = new HomeChannel(page); @@ -211,6 +215,29 @@ test.describe.serial('message-mentions', () => { }); }); }) - + + test.describe('team mention', () => { + let team: string; + test.use({ storageState: Users.user1.state }); + test.beforeAll(async ({ api }) => { + team = await createTargetTeam(api); + }); + + test.afterAll(async ({ api }) => { + await deleteTeam(api, team); + }); + + test('should not receive bot message', async ({ page }) => { + const userPage = new HomeChannel(page); + + await test.step('do not receive bot message', async () => { + await userPage.sidenav.openChat(targetChannel); + await userPage.content.sendMessage(getMentionText(team)); + await expect(userPage.content.lastUserMessage.locator('.rcx-message-block')).not.toBeVisible(); + }); + + }); + }) + }) }); diff --git a/apps/meteor/tests/e2e/utils/create-target-channel.ts b/apps/meteor/tests/e2e/utils/create-target-channel.ts index ce145f4233bd..d5b10aaa9e1a 100644 --- a/apps/meteor/tests/e2e/utils/create-target-channel.ts +++ b/apps/meteor/tests/e2e/utils/create-target-channel.ts @@ -14,6 +14,10 @@ export async function createTargetChannel(api: BaseTest['api'], options?: Omit { + await api.post('/channels.delete', { roomName }); +} + export async function createTargetPrivateChannel(api: BaseTest['api'], options?: Omit): Promise { const name = faker.string.uuid(); await api.post('/groups.create', { name, ...options }); @@ -28,6 +32,10 @@ export async function createTargetTeam(api: BaseTest['api']): Promise { return name; } +export async function deleteTeam(api: BaseTest['api'], teamName: string): Promise { + await api.post('/teams.delete', { teamName }); +} + export async function createDirectMessage(api: BaseTest['api']): Promise { await api.post('/dm.create', { usernames: 'user1,user2', From f279609466e016a91f11474896e781c6a672bcbd Mon Sep 17 00:00:00 2001 From: csuadev <72958726+csuadev@users.noreply.github.com> Date: Thu, 11 Apr 2024 16:40:58 +0200 Subject: [PATCH 051/131] fix: omnichannel department names overflowing (#32007) Co-authored-by: Douglas Fabris <27704687+dougfabris@users.noreply.github.com> --- .changeset/soft-shrimps-beg.md | 5 +++++ .../client/components/AutoCompleteDepartment.tsx | 2 +- .../components/AutoCompleteDepartmentMultiple.tsx | 3 ++- .../components/Omnichannel/modals/ForwardChatModal.tsx | 4 +++- .../client/views/omnichannel/agents/AgentEdit.tsx | 4 ++++ .../views/omnichannel/analytics/AnalyticsPage.tsx | 10 ++++++++-- .../views/omnichannel/departments/EditDepartment.tsx | 5 +++++ .../realTimeMonitoring/RealTimeMonitoringPage.js | 4 +++- .../additionalForms/DepartmentForwarding.tsx | 3 ++- .../cannedResponses/components/cannedResponseForm.tsx | 6 +++++- apps/meteor/ee/client/omnichannel/units/UnitEdit.tsx | 6 +++++- .../page-objects/omnichannel-transfer-chat-modal.ts | 2 +- 12 files changed, 44 insertions(+), 10 deletions(-) create mode 100644 .changeset/soft-shrimps-beg.md diff --git a/.changeset/soft-shrimps-beg.md b/.changeset/soft-shrimps-beg.md new file mode 100644 index 000000000000..74bd810a93aa --- /dev/null +++ b/.changeset/soft-shrimps-beg.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +This PR have made enhancements to the select and multiselect inputs related to Omnichannel Departments, now the options properly display the complete department names, ensuring clarity for users and added text wrapping for long department names, enhancing readability and UX. diff --git a/apps/meteor/client/components/AutoCompleteDepartment.tsx b/apps/meteor/client/components/AutoCompleteDepartment.tsx index 4688899890fb..6217f1d99610 100644 --- a/apps/meteor/client/components/AutoCompleteDepartment.tsx +++ b/apps/meteor/client/components/AutoCompleteDepartment.tsx @@ -51,8 +51,8 @@ const AutoCompleteDepartment = ({ return (

QR6ny)BCr2j91^*~%7olBA zj8t(d%wAIwQjzj$Uroud@@;3&Nb@?HfNY3p{wCt$_q6`E;QfOov|JN-T+j$a&J^Xu zY|E$Z^D+b-VvEaf_yZO6?Z5w$T`shKd%!fBG3_yA9UWT_e}lto3Ase5bwk(&3UzST z9f($%LZG&{atmMlD*jJP zoi?)FbZwIVwJ?Cx08>UZ2dN4?#<>)O)#|9^;*7*iE}ih`%jmRA*@ujRekt8$Of1_V zy&p=^wB{`dEYy6Tx3hnkXkl^jT$1y3qs1%I1W^JJcc73A4otFvCTphLziPsou_A=+ zDI&&4H#ltarOVj3rOw9c7^FlgGAOn%-7*6lDf8V{ifHY9C337~6mVGA;-NIy(CadG#7sCu~uX zi{Pxg138jN*>F0M$to=^@^fjYR_-$alh4&CDC7BY&rs!mtagA-AFc*Zjwx!vlpr_e z-?xuXUT+b*5H*cvxR9|>l;iEa2zHwM8c$*qGp62;MgQ%OvvPX1A@9z`=SoPuQk(M4 zv!K3`D!_BU(Dw>KHm(8C07%jQ8rx1y_0TY*Qzlsb>^dn`)iiZz?=BQ=Vn4gCjmBzY z@FFKIuF!BKlD7g{QB$avIP~zqMmWXzAB7VpEAu|SA4M`iRy++{34+q)FQu1{l!q~t zI1g9Gxgz{^8)H{3dbmhJM?XCyyk^l6Ad2hbkno-NI#>t)@(lhny@xsF?||e0tgO-W$WQ!1I?{SyKn_VQUkIxTQ!cADP+2a^vzZBQ*@2 zjaXnsadZZdn&M{V5vo0fYj%T{S8S#IAo6%rZP>&0*RN(LBblezRHdN;^MhLwh{}E1 zS!wf$9kDrKVFqi@A4bCJRik9yDyStSBD^dN6@|S0Wgw77IROi@SXxJT(!aWRRv*OG zndzA6;6ywt4vuT7VRoW*?!?$@=|1A<`hvVfrpVcy{%kXYA&oBDoAxgQ#^3>!hT^1|uB$KE(MS^%a=2oR}}M)Uh5)xb3YQWTtwKOEZ@b;*>!`qk>p$k=n0j{Kq={ z|B=PN{NOF~ztMG(Ql`y4AwA_oW$wu^P8IJA2?& ziYY`jHT`UyZMWw%u_i`|X=XBd^fTdu2Og7|1-0&CezEOFn$2Z0yt-TuiRdpGqcx!7 z6-3t$9;UBz`(?P7NZI?Rj@fqrs;sns#h6mG?vJeR0v=v`z}Y)(_XDg1A%&6)-Iwf~H`{MCwpYa@eJG(Y zjL}=fBFwY?Qf`K=taeQkoz^^MFr@t+DN!n=@@w>$G)cc6v4sXt`3m)yp|%O-Z?FA4C*sUCKn1 zm@tHzyU985Y22?yaU#(k7l3V4!Y&ep|6WJKf zN3rqJnNsOy>d2(dF`scQ}H1#0EvQ8H+CyG16$bgj`+! zyDv`o1b95|gFMc=71`FH(tjO|gla6;=8Y z0dNx^OtG~sq3~<-XL2t#yGm^ZB);y7awk=|gz~Y|3TIB1zHEC2@?fCFr(ah`Kd9pN z(*A49!xEw&7KKrPmHm}nLFO7bVoIEqQrjBqHB8+q$C9oexom%|7lbLs%!thY)sn;7 z-0&>@?w0;|mj%Q2A$BXSSgrhh5_twVEeB_c)-8(99cMqtwLXmVVX)7BN7a$&U_Peq zow{yB{G;&Lugo#;r5Xu!S=T*+f}ykdW@;abLR%ETa!{}`1Rcf+pj5V&5Y z)JVs?W9=zGwl*4(ELUP?(a7fWgKa3dv$uB`%=pxp;?b+) zYaqA8{Bh}(bl8TM8vlBejt$q=&N9j%DQk7*!j(CYMKmuoTku2U^IzpQP7>^8T0Npo zoSExWz7Nmg^sJ#uJ6r z6SN4{`D4aQ+23i&dAyt0-U~1?{wzfl6!Z7U6G2qZ3j^`_@!Ebn`yZm`56n{OIBfV1 zI66|pse>q{!V8YR(37N zngwb;tuObn;2Q5f>eidy?8tg@Fp*_HM@#Z>_C2;-o(P7>At&6~?2{`NE=5W|(&UQG$Q&=7{0u3LgjWq*;&-jhJ2-t~12)7zH9Z*NpM z;tFIT?-fC~c1xxftm=v(4xXx%gp*vFaqShgHiTdM?w}e0WYyTq~~3_XO4lgaPzF(yyIK3`|3~5^Xt`h9&3F_J5~;sAo6~;+`b}IU`=h|? ztS^5L7B(464Eu^lgv7zp0Y@C}$aC3&ENdyzIY5ldz)4q{vpy8O^*Is*=y>K8{Z+c2 z<+DWuZ8X6f!uh7i)1UB~iflq~I908)p^#kHzc8$IOZLXx2=J4+9_{zFwIJ&nDvUeg z56(BcEz-_F+b;Wjc6w84ILAsiH>Tkd-hA+Ir%ZowxOpVH*f56b`rAatkB6O0`yiZL zM*`5=9tsHai{ZK2>HYNH(Rt7>bm}Vo+4ycGy;RwyDfN7uk~8Zn&jW+{jd??~$YiwF z_~>&W?y{J&`gyk&48l`k%(kZwV7({HZj#vrJ8Adi^hn6SD~51IPm5uA;19pV4B>R< z57hbL+egy4jFbDqPnYE@8%NY?l)87X}O$EUxnw~#_d7enK(@3sD*Yjivy-j|k zIE}*u7Pr{J;{b1c6$Bi4@=3t}h3R@uV0j5GyruK#h_(vby!w}2y^x^V<|3S^x!wHE zev_0s-fml}xg}Bz`}d*4iROG(x$GgH)6qN*v+{9dz>IhW`%=2 zzj($Wu?KCU7)uf!r-bEWEM1=C4=04GicogtN1?gL4*6z8ccb z>*xes`+NHbc@!x9*}p|~n!T2^{)}gg;LV0dX8277lt_q)AhYIi(?7P?8Vl~hD;Onq zkw(?-KgS*?dx3alrQhROzx@y14%CoOcz{3(^F2QREQ=pRpb@neCC?r#oaOT)Xjuth zWN+~M@zrYe9mi8|k==8CHmf~6dXkiBI0^Gx{X5K907625MaNOZk1*#7qGbnQ z*4o4Hu10+@R2gxypGwzebS-NLyS0vtKui3y4$MfYE1KVY)>Nr&YpTNVYH+}u>rQ|Y za|F41HZUq=b{j&zU%aGtD>Rm$CLG0v%6B zx!_~VLE=7}lTICq=YdxapG33k9kKraVjtff>R#S68!B8{DYX_99zA1F{i}s}(jbjK z-i}{ZA6wE@RyFG3>2k%ZIUVoMLYHMDsp6?qlF@4VWB(Yu4rbgE#+2DdbrOvr!EwQZ zdKnI^9xU)57Ku#X3WAHxePT&l35i-xN4vTYo3SnxkezqYM0(l|I18U}uqq$viTkzR z0&3s?$pir<8(QvHRV0YE8W=#)UeKwAYu(AY0oN$Y8zr=&5IgUOCwX>pjf+;F7X`Zm zc7sFd>-YAxzRxCok`XTAv)$P?TinSEiuGE|b3_NUy&i7^7H8S#mf_3!jp~e}u`Wq0 z+1H<5MfIY`5ezJ6P~=Bi?Y@Rp-aPYM3Id^1zWUff_r7B$G-)j#M`9I?&MJ7Xd(|A{ zA4YS+MTT7X=vv-6@Jk1!b72iaoXx3PL~CZ)Uy&$S~V<2Q;$C;-u3A(i_Fhr{mwEg`dC4 z4KRW_8@lT{zJeN%pIuBkm(I8aiNYa*pa_)Qy0*apFi-_d)I16F0lsAvv+L8H;0i?OB%=*7Dv; zI7C$cSd-wkra0 zCqj+Ul_*}3P*X6R>=!?=;$*&==-$JI%opa~!9SEv0t$~n*y;BD7)!8m<|CJVK(rJD zK4r#{#5f4}Gcg!+bdB1Hncyz+zd8aOD8&(Lm{4|vQ0QvK$^8JXBMvVJztnLY_L>VN zIFh;$YUwTrhaJfdy8<;@;M60@*%=i$)wB`~uH$oApTywYGIy+I)WPOtb?+C(eS@-);jdWRIw_*b2M#^4}lZ2rtGaj@+ArWZfK|7IMs zbw=0bz-xcEL%IebtFseMtsGSc7I=P!$~)8bL#l^VuIX z-i0M5`T@M?C`+x-a?1Z7Vml~hmfj<0s2ya`wA9|PYS4nFY}o!Gwx%Q=3z^GRMi-I3 zFXR&Ny?%+HC$ShOpL!xiES&73ZjAp$#vw`H{Jm}Qs%^;X^-`erYpqE<&&HPe&!pDi zo>UddLKd%L3&@LW-RUv75!u5UB_J}fK6^via^G`%dQM&Y;Nj>gCDNiU2tt%Pc$A&h zMgST1Z}Lu3Z+AVpg0x({*Wo#HA7$?6v2qOY^-kAub1;nQR}H#;Pxdc+=&r2;;PO~c zsOAvYQ63TNusQvvjy85OVd|NAA@|Iz;1TwbH|QB?pkQUcVt?gFABkZQuq8+gE_w#q zq-6Uu%D7Hc(rXQFQvk9t&>lU89J+EOEwHg^?uXUxbp)JR?eO^^!zJ)7J*s=2L>>dT9QsP5Vf`~pP(vIB zu{ru^@I|gggVfkBYOV&*0ye8fbnJ^rtx2M40&Q_vdcZg3Eynr$6~Xs;GXj666axyB zv%74ecRs*NT{{ck^py(Zhf5XD*)|8*NnO1aj;#8*)2hCUx8$%XCEsqc>zQSeH<2qL z4XKp0Pj;D3efb~K43!k-jRFP&Teqd~l{1Q+BOcr$9C`#LV=@n=wOx)3o@Htf%5p&z zYCIA{tX)ebBC&NwqlWz7#o^OYcXMWb0J^qMh~HRzqBDb-{RJ)nPLB;}E=7DQ%O0c{ zasJ7HCOE+C%_*|0mnQ6Iv06;THcj0CHE}seb;x%m{XFrn2vgisYjDdo5_k#Bimj3> z>lYWnJkaod^t#NOu24Tgt)zG?k&TXFx^Ezp%w-?rUBSIs(1M&f#ze&WCl|+A2B1eP ztCO*=M~>9)2Y}O#V~WF9XF@GoWii5IUD3~U{-T$K{{Fb%wIEXosC<9)tsc?j@m+mW zVSmy)~Hbs&Alg|=9% zZ-{-i+?$Sk|FBDDU*7_2p?UHGvf{M`Ly;gifKF2CzwiSL8-Bz95XvvZB1g8d|Gg#wpj zf0MCOcHDgZ$xQXo?5u<{)W8N|xZ9Z^u?Rkq2o~?Nv`f%rQ~hPejg^Pr=}gJ}uCx-B z$AHUkFBQYnWNjZgD~`#;RRyaDjXJhov26+sjYdk9MU`$K%o4ajqkL*<^wH&2FD-dr zIEB3V51j<0hDy18s#N?y3R4C2LHPxMWN#M*_`o6hol7g@kxnT<eAN<*;C~Y3cfM zYGxmjFuSG4QzTOI_}a@jKtPemTZREW?ynhn*}JcfE3d-XJ3?y*_S9`BQFjIF=?iJc z@lhLdtn>$i7o}Gd9Y#+MoEfy!PmxEEHxjXuaMj_|4R?4nV8xS=pw}J#^3kLFxUM)I z{#69sx#K{Zg4SHnDKh^M2O>GZPzIsvwpX`0^YS>~nc{ddo!%T9VUAPW7$w zttBVw0Ahkq!Os+98jm~R37Q}??qM3Fg^GZ>Q^&J83Y0!6X$NxI)bI371c)q9f@_E+-*Q+rzO6o$`AcoQ5rLw=-yN4o?A>u~cOB9RjvkwBUFB2uIAUQR%@!(~ zvp)fNio)1wIlS82^8uD11?&CtuZk;lTDXbFo*k}2xVLX|Oh+b06V4UY=h(<^u7u5L zhr!J^fkL^~Iep$L+_Cp6e39fC-Q1=7-;Vt^tcA&sPhqyi&0x>G?q6zK-( z7~L?CX25`vA~_ni{f6()_xk+-?BaS}&vT!1?sK2}ys}P82+ezW=9%DbjZDoBp}|uH zFXJdpgFCW2r#BBHp#5X1N8_dT531HL$CtP@9CZc^7J`I3sQ|p^5)@`yCOCK-rm?VX z{Prf~x}BIe8V}e2Z|(!&d#3pP8k)-PlN{J|PiD#6oPilLz=MQe*)f9NMM}R+ePB0d z!j&HtsQtE1A)uGs>X)m>q2idC?h<-`SGj%P!2Gx!=k3?Ty%fIo z#8u!ln4!fWH3S*Qu1NgQwoT5Sim#+h z{}zRbc=NV53Z;^ztaGY8{qvSRSEFX6qQ> zreCD=`o*nsBF9+`u}VEZUK%b1Imz_1jE^x`t_GdZjTyTB_FiIoc7HAg{F6q)ce;GA z8zNphpNB6(!c0ty(US2kY-O~vNk0AlYKpLsSqmP`O#Hxji9h||q5{uidcvMpb0SmTst`;He>#XrQ>nD7 zj>AGLAMWr!=X~X03CW?o$F(kcn>b!>PUop(p}ynu8-a%%?Z3u=m0U!2djAE@uVuc@ zsQqg_FvZLGP|K&EWKzB~lKYP zXOCXhbNW1K7l*YicjX&!K6Y6jK@I6eaHJm;w#xmm(nbC3u+O^mfp9Ci)BP8sj`(?= z8`EWVuG4bd%o~xgY%}<;azB;dt0(a0imXZ%Y=(4QCGfn!cj?O(!F2n-HPaSWqFNxT z!fkUawAY~c(Yfgg+a02+_ib9baS4kSWHY_zC~$xL%1!YXHk*I1oG!{Ucndpu@z}q+ z(%AGgdk7#Ivp&d)Z_U6@%;Wtl{x1(ML?W_pqV5D%MS09qBEyi8h+^dKHbx(3rXSzn z0)SkYV>FwiV{_>W`rl&<`M>#4DQ8)~bDouU(Pd;ILU-T_>@wa{tg{j>#iOcnnDs}R zkxvBJP^4yFBx$USqz7dSdZ}GYGe^xRenqxR)wKLknN4lTNPnWg@AC>cPk{%T@p8!DgA`)6?;)p90|7y?*DLJC>S&rp@yo-wtAmm`X?MLI^ngLUhVv?F z?Q`kS|A~+9^U~s|3-BUD3x`5j@%urq`0=8x`w8*IE?$0N8Rc$sEX;fPz6|?eUHuLE z;B+D|`?&qK)h?Z0#v1YK07HN~v6Kpqd1boQty)D>D(GR33j_dVv$hQVmSFQYt*AA2 z8O9ppvaE+9!^!od_BP9;nv9L8PcwYn-2gd=k`p=6zVK2nq4nYhWiSPUt=$IN1I!K1 z_Pg}+#Xt-C?ovwr99BF;Dt&c_&|Pq-^zpyjGq{&G|9F(XMTYv}G3ywt?ldtsWpy{v0;kbqRQ-M0Zz<)kuqf{e3jlp zc`exI&3Z`^W&Nb+lhH*^+)pBF$v&|jOr00Unu8%(bou;YHe zM}qr;Z0(1S^^aXcZ16w3xg%-^b6ee@J>PM)2G^}9^346yo0enT68x8TZyn-R2GJ*M z%tx4%kSB*HOUxcZV@sZ-aP;X;V99G4;!c3*thd%J36@5_6r$ary&f6bLrs?QhXtIJzrM5E2}N0Acwi@hN8e@f(<=Eh2Jj^RT1WYv(z{iYd;b zKbNd5NQia=D*b-ttjFwJ?G;XKtWOKc?3b&3O{wj7W9^h;uxF$wU=72%>+8e;wJs=% zMLJ!-n{%P>oH=m<7L+{2^j*21Z~=DdS zn#|*dtZXeas7@&GK(eDj?T7|)=i#%idh+>c`-gL9mvGS+Rk3~gpV&1 z^-rpj=G}Wgu!eD5ok-(LyEL;A+2@_ypeCZ1OYh~YzH+jDS0~)YH}sfw$kOm4*4uS6 zYdZw|X$)<@<#@wSe^BKa`tH1oa~z-hyfVx_|f;| zw4>%&ws|9l1UCv`RtfRJU=Ms~*pzIhObpzERkQ|4c8;*uziyZHs|66MCNs@m@1&ji znk%r|mGt#Nkw+BBI;MSdKdsX5OBdKp_8)x|kGqkvhqK!;R}=?@Q*gyQd&H0icM)xy zqr^l4N~$-&&+O7mUIo7pV4w0#GzP&!?R(1Lc#WdqbFKGWs2A<6E*6BJUdg#tE}|6n z5D!fKKh8P{v6ON;;ysXJ@xVzy@P85JKY_gR^9KOzY(n~kizJ@HhA3$O@h>kJ=>uG* zY3Mm+Q$)rqm2tjyYKhx-dm1~I``*1sjqMr(d+JB9U3uRmt%#%Bk?&w&>G4OWj`d9I z@80MoHBM%dep3?%r3iWM@9oEZoGX5{gm*Rp#;<>NBB``(P1}F;x z84E@f*QN#?V|Gd~0#3S~%U;9}28%Hr6ZKBAEpjfS(w#b*yS*kwyV@&VqR|B+7vPqR z(5;&t$l?!*HLpBzHyoe+Q1nZZsLd<6f+LOlwa<$id9?_)m6$}ZHK<;$b^z&&2;tw< z@rv~Y;<3Hem|VyRN`g?})+ca~Z6AvT(UJx6ANslMOw>%V-`V!A8GiZYUz>d46o^#5 z)C#|+_e{=Qk}-WU6ue~jJ2HCB=@eFg^z!p3^6dhJSeHnmfYB?AYUKc2oi-ic=bDgp zag$R1`Aj52ejflx38K2o`GGxGRx>2b-r^RjgO0Kr{+{UxhTnA0P6|ES_!3z60Ftuo zK1&~bJ+9gYJ;43(H}GU>&i)HSYCDAW%O6{_txoAK$CBKt;$lIs+^DHdPhrcyq_bN? zHF6VA%?6A9R_{{%mt5RbY1!mAEocBE4Q^Qt+w&k%$PPxa0vYS&bBRO?Wy8)lTvG!@ z{6@(={>)XbOZ+`-q#+C0zE$9kYI_Pc{Ts-;?)s zcDT$$u)2s@5s};GzKYZ=qrCEv2p#o2F{eG;52vH`dI@W|c238h?)g(eM#?}TVKp06 zwu~=mqq2vAKq==-1Q-P0@!hDXt2;f}teX7blO8#som5?3v3eKPvcn~CX5!_vl_TM_)Uwe|UM#P|bl;N$hZumKxYsqV_>A9N*t-50x<#>R z5^dT@Q6N+3$#`iT3T@p&TF0INnXrv2nT_nQJiE8E{|vwZ`~V>cmKuh}?qXo<&Wdf& zCB@g%KN2}O+M+*;%x%bxwyvS5dA1&nER&>vA0H=!IWaxK|M^ivj-t|z)}YB#S=U0w z6sG|T#92@A;VaC;#;(px4%bgx&Ua3)!K$`1CRlkMRwJI%R^8AVu~^_f+8mmqQsQ18 zg#N`zE!2%y{F>9&fl1rq7GIcIYs=^CoxUqOh@#QvIhMnDreYr_mkC2`ys~0!E!QqB zaZ^E>-FrTZ>~2#c91zR=BVOG>*P`;^e`~rwky1dM+5x~HOj2>(62Q$Gc|_%b=jz{B ze$t@|e?o@w-%+k)Wqk_6ZU8c#AFR&^g#K={;%A@(pHulmuJ^I#i#`K_lUk;~_qN!k zX$DkVv4yzWbFl6Eb7js|Yfhz9xmfDNWCMj%)*P`Hb@XauY_xG10gG~jl6$`gCRTq^ zI|1+VP!2qN4S#f#Z~&S40eq01YX|}j*-V>6obFsd+dSa40GkQH4knLWFBCNW3KZmG zZ5-(MLPA{E8iB;VZ2bIO-c8eI;qivHD!BM$^xUm4E?Wt3%YV5w?PF+i6`-d#t%>+i z&BY_}E7CSuQ}{Ut>Bt@0j2A1?&CqxKfDJz>fb&7igbT^Q7)nWDELCKtqIS5naLByT8`<)h=fq;C_)mt~R~``tB(_ z_l(Z|pDZ-BJQ*@$fooeSBxlX5@*hB}{SDTaxM}r(i6WI&2Fzt`vrjx)%{vz^ z2M4FcmO3;%e-0U0l=boZ5PWE1ls8E=lQePtUMs{17rMt~QLz9Kp1Pma2^Z)ES160i z@T?%aB&U<}Ix@4-nB)DnB4wW@7HZz-+3>E+$%=f-noK{f}J1LOrxDN zPuhE=S|QHUX~l)Zk|v9l2@)VfVs1Dg;Dmiv=?8ly&I*%lg+EyK#u@>T`DiS|QZxe4 zPWei*Xgl=}!BZO<>%F2*+FOJPC;gC5MzcNqaq$V?JEv^mw?EPd7P9bqG?zX4lIL(U zrwl--r|)W-rX z_{P6$`soxxn?895QNd(epd0TR7|Bhq_d^^PGMpY~idW+r{z~z6w=EO0CyITuV_MIV zerVs53ZM3+d3NL^p_XU;w}lLrr~9@m=U^w5$Kmg@xLFf8!I>*jw;{o4Zu*tZHDt5mr*J&}(DKY8~I)C^mo)WJ7 z4P*n#WKki~ezW-RZ0((OuW&*d{KX3fA4Hlu%+>|==n8CR%sY@JHIm*+{ZOZ&OYZTg zv{?Y^EFJ!*?rxaCuMWasyJm1NjPH}_tnz&DkY2$lj;beq@$0D6bNTk1MTi0fZOqph zT|xH^pIO*t`JKJ_P_hul1sJaI?3PaSUipt{DYvzkQAURUP!@MhMIqRx%L}aU{v7HE zeF=sTvrdVr@~{I!`#FOOuU|fUww%kiDe3M8kKWl9JYLCWr-DH>#XdT3{?&?S31%t} za<^=r0!ZI%Bnte+*)G_N);zgXXh6XK7{F#=+0Jm7{};e`fpWVQ(ETyc^Iav~(?xk~ zMU`|H1vkZQftd4RrO##->W8Hxa6n2eE>#M7%anzwMOJvDHr;m*LHn1OOdoJIJCePA z`{3(XAB(NeH&4Op&|8CxlM;V?l~i*0}abC zepNY9iUQ`hekY>LoFV;@Q-<$`ABu@E^?u(r**@VVt>EJT-48racWv97UR?3APx$BQ zNiBQ2B30Df%;M~3r}s(poxYfUXb9J1Zl9kVo{)~6io7o*_wmeJ`ufkF&@TOx{N3<5 z+5FG204D7lfE9vZ;%1Jq<<7-tS9Uum=FTl;Hd>#&8_S99TUyHCzM*~(cD5Mp?<`ZB zEcQ!o7I?aGrVZUv76CRmv5Z={<63gWs^8M+An(oXj2uvhNeIM7n>@Oe*vCVBlTrkg z*SqhGwTr0Am_L#?Z-XXuh){J^&HEmg&x)Q=AI4=#R2u8H(MET@!^X|bm}f)IzCh!q z!G74y;~gYM5q^ZX=b*&;hEU*~Me0CI@13=QXY(TXSu>*K?D$0U%w>AEeUmMJ?|2_K zna5^t|6HW34U&VK%wr#y9erQrH(^_TzHJUBF%N)2@nP-T6iQS z-Nj`>twc-9K^*vDw-<||>W%0hjH!8{>u}e-bN$ts!MxUrH4xx}p{ce~IHp;7 z+5)e)Vx5^7zLNhMt>l&-JrLvcAbhEcse!}F{)T+OEwKTJNfw-;6(KM*XxagB41+Fa z5GvENKHg{!ZCpPBU9vORJI=2k_8N0FTgYWhLwQ^PLgp+MW@G$iUSlxL``->*9C7Rz zJZ*9dVy38sBQQM6jfXONC5A7g#0#fK{k!~`@xs^T&SU~57{_>iw7;230Ta%hP2;U2@$W#7}&w+(tS zyXxKRP2k}!$MsmRxcW`Tfng4tN{PSt!$b%6fg>+?a9P&Q^Ct=Le?E(uSNj^{EPkZ; zT@;VX?cJReo0wo&18l_Nh@iU+>N|cTj=1GR{v8RsVTgHPMlOcB{yg6`1m>QEQ4Y9u z1W10%e|z26@q(lo{kklQ(QNIzYjl~fsj#N-om(c`;glb42A-gCitV_IreXO-viIR2f!B+YbNPY&iyW08$cx(U6j>LCcEdqqPA$Z$~7?e(?B#%aV&*k`1Vw)O^(8~``f$p40A$T zLBvsrL>Niu|21rMl+%U(b6xmVq}O{V`KTQ~Eul3@Ik(;7iZ;WXt+up^_A4D@^+AhG z;-f^(a7I_OVUJGA z*`fI@Sh*20f4g9hfCSPa%b6u+RpujL6}!qa_<1Ii@Ulk_icMkmB&+@xIgyMdf__Z_})VjACTQmG^SaP$0_o{9b&0sJosjjElS z6_`}X%Q5c7-j2=>XerA2$lHsHOAJkx+D6T$M@Ft=W^`rK>zt*jOz-ze>%A=UlGt^0 zTv$NVTR1PR$CM9ylQV_@Ra`Xh)X)E!4s~6bu zqFf5u^n!bWB)TswL0dg1ng&9p^9qPZRfB7vxB4C|lU-EhonD#j5^A1dmR4}Av62KQ z1?|eh61E05BYTF%+NDCJjb_(aoXvs$_+Q(pA))gU`yD;I^J)6CR6@(j%mgU z$n}zoi6L0B_70v;wO&!Y2uexricpDY!1M;Z-}8sa?jjm+pByw^vRxe{<>D{3@&oey zQA)uxQBnjVJBzXV_Uq|zNN%)Can-^rJ>(NA-`crPs_r2#p1e_C{7&&jb~6t5Ch+T; z!a$ktpE6lVQ+{}(!j%%=Zjys(B2^+7?jS(NH=Zv(IZyQmReS9|p1$l)csu$JT5yq2 zOuPUaW+rpA1K)%u_uUaHS|+e?wrrZixSQSiv(a)*e!lLTHx-ONrxuF4n=d3Cyb%0y za^4HTYZ&qLsEGSPxSBuqx%=!ZYDiq&&e~}OwF0Hn{aNna9w_$VLtRx*ZJ;uSp#>49 zo7Bxm`C|Hvl{Z~E?DJHKzd}gb-m&Ed`uVBqx8ztko(=82v4W~ch@ErXo>6u3cF{i7 zihvCT6`RoaPnpMcBHb}?{dI}fE9cQ4q7m#^S1;h2A0U^|{BQr8ATv(z70Wnpq)%`A zzl&>Jgfg$h?NKQ4j-n=-F8x@9RA9QWV66NR2}reH1FF59mw~>K{}`Kjof*@6I+l|3 za=3FWF~KII4GKPx9urKH{dgl3vP3IW9lo6Q!Mb2e%{cLRxhX7e|$WSBT&xPylD|LSrD||WXTc|8Wq^R zuRAYHu3N604ZvU}CZ02G!*zltl5Ws=bBriDQJ6D_3Rr8K)@i7SBTvWBWKTS?nC`W z>$Y{Vmiv+VKv+Il^fq|7N_=jU)Q*tNj7#8U=q*9(Q9*6Xo3z8Rz!=vV#64>kSzYQB zj3)bKJTLNDa_537xjUKgV3beQj$k@FRMn?=$K&{^uGEhYMAGZ&?ye29i?wZf>Z5S< zYrV=f$MwdscVwQ|Hn@M}p!!VY3S|hYVVXf3z3RZHtVA7%Ofw4Gwc@9N!X(MHRL~dY zwo^~HwT%*;q)^gA&v|bbYiNQMsh&|O{+l?sIbt!%_u1IL4qz{2C{&>!I}ZG7ryE$# z_7b6uYFk|8*jucjIE>l+Dq7f#Jd)d88Q);n4mAZOXVx}h0X;##=$mCta7Qc=CLXGT z?d!umO$yJnXCK@i2-ozGaWlrp$AJ13{*s?;21`ro-bASSEctg(KY1)Zf zB^-*7k`HPVJRu7JK1pWEKD@%3aXTQTUHjA4ONmYmW8R7FOM)Ktpu3#?A`(QLSS0Me zxj9{wcjMdv_8+n5%l^e8Sv&6z;W{6mb80f)*&UDKqNP1sXk3{3EuP^&^?fojHCDKr zO!}JY%B@|1jJFukKMSu-%Ia+VYMOg|f<12#%Qq3UzvrFxlkM zi^@1xAn?-zwk-YI-L|~HF1defKNfVd8k5O#V5HLIgEnmV7LGkxk?LnYB9b)xm)PB$ zDJTrPgn`63z{{MEop;Kwo4ourEC!7E*wuF@w#Rj9lioHqntG&KHW!(D_g-Cah)*YO z_^*#(VaQd9g1bYMlwr}U$0LBiPfuF$573m1*-o0gbS_pk@$vmU7 z;PJ`DgthNsZ6hmK<%E%#6SQ~N)kMiGWET|QKb3dy=@aHGY+Sa`$+fS8oR4B#Q`YA{ zp92c&coMJZHBFk+7R*m0mI?!~!?6=9qi3;LlIyhAwU6c|KIK@+x@W!(H@=3fT*GSG ziuL<9c@XKh4m)ad68|WwrN*V8SO$)eO@tGo77CsU8aRH6PM7%n#=*peJ1aAbk7j>^ zSb?>HiptL3AwCvsg!?E=kR+|@O^Kjr9`oP7M1vjW@!J^#h_6Fg-~F)+9f`rTbt26g z&Eo2kN|{2rT)Y;50{@?iaK5QUwEl%QT(k6b-{$tAuN^Gv{P#L&;{DapD(z(bDE}()u0U-adTWJwy#%iTC4-<>{CTPA0E4r~mP!uNzyxb5?TY z0N;h7zMX7ni99v=V_|pqx$h_7`WlSgB#zCj5|nXqPj(h{z_=XmWya@8*YvHWgQ}sb zA8&FeuaKui94y(3zC5j$Q-B|xdadH-?qcl<=G783#H$M+`+*L@QTJ58ztYb$wi=foHIc}({|+)#44hj zm7FNybkcZFPFHIx!$79?Tjch)2|FPxkBQ_VL@-Tq(TPvH%-Ecuj%dgQbQwpa4RlBs z_-2-{>`Ok~>tPILea&A;WspBtyv<&SM1*RZEE9{eZary zcAx*gaFRQgV;7CNzVbDNu))eqK_WnuYB_wjI!m8?GvsFnRo zH6_z-zS0U#^1M}>%R+(5hl@^Uc6<#NZsbF>Z_CGkMr1@fjR;g@h1mk|8e9jU{PF-w zNp8UU=hfSJ)(^03D$Db|9p=$4SSK|#4M$3Itl{lTCD-^hpt`Lx4rpmA%4#_9Q0fb`JCtAt7uE(@R@qECTfO{ z6|CHsZt1W50OU`lDDanTk(LQpE59b!FFDyS*hHSh!FMV+vQf$DQ$gR24RT&q(48&( zm_`X%>Z2OYM%}=?k?p8uK56`{yYG&!6=~NfR+n9Sc(m1Ure-EtzGr18CHe)5^*d&6 z+xV4be@>JM6a3B+ul6UU&!G6n8ODt!&iI?{?rZoRG_liHi54NwI`@YB2y-z4JRPa?+&QPRKt8dNPRW4xq7ZBaelqqNZa_su@m zJyy3Le-Ra_&a?95k4u6DOP!!g0YY{1a zC>t&11=Q03VB2cfr3Mf0orfh-?8C>s`IOJy*MR@S02Sr*Pycn5?W5T6w0`P*rDwJT z8EC6@$TBbecu_uO85?<#NFfk^q&4uw#}>*yFV^}PRTefki(*xj=lbCG)C9=O&&7Lo ziNV%luCMjIH};W&=Zk>-k-K@VS*`2$yMFIC5n1{gUATF>K&_pp$k`#`UQ8t9Kg#Fg zBfE?3^68q2hzfD4zJSDXKd9+`EJop?&1*+du~oeU03y<4)Ssr>;&m;|B{z|dHIZ^Z zcb#RuCbXbpeV1w@%H8KL`KGZDkl?_}RP1&aYp@NTkwbN-qRuxXoBYWV{zQcylyQ8d zf5X+!GnhlhTQ^^m^@823V;CPvXM41)F#X+?yGNOOa&Nf&=G!orKzeZ(90FS;r&Q2_ zx53jdD=lprjNEfVuY|?}@^ju<1%)LqqNI*i4(97Zs+>%+@9p9ZRJ(d4Rma!VhNQgb z+%i8s#f@FljG?AaY}SvI7;XF*;o%~+-#;q*(HlDfDR-c`)CaY@sbhynD_?Yoq)&|g z!2~})=bu%UD?eW-%X#`?(`D<4lsmnUhg$<;b&K-FTI@?IMQ)BXzh|T-Qr8%hJ}LzU zsaxS%A`kboIi(auw!GKPV{V*#BNBI%>klDLHONCfI6S-lyeJCb083`A96cPt*B58F ze6+nq%?VPhq^=*8{Cowh|C+_P^39H&2?UH-Q+QUU#ZMvT8Z}wzX+-f9l8^S>L`sba zfu`+&(@(UP;AU;Q{bbk4*&U#kpkX-F>!h(>B2Fm7RRp+hN-0BKtA`MMW1Al!9*8p= zoBdr8gPH3aiWL=q43%!<^t=N&CGsR zno-2oT*+930CcChT3!A|wI2!pTc>%GFFeb}E>16)Pi@BCKAZtNxvl~b_bBU~2}oe~ z288YntjT3$#^RmNX_|dp(mDe59g?Y(A9gxC>Z~@u4*>b5*Vj4EIuhRug(oDIdtx$9 z5y-i=PcXlTXLOtWLdd95+7b{Dw5`AO2uD=c}x zdhno^5)B@~NYRnj$-3EO0Ul1>f7c#a^<(NqpWor>j;Wl{MPsr)AR+A&3nvW|<}9Dn zNTe@vQ$5PtSu=S=6|j&f49fMY6#S`Um3sP5 z!tk%1`Q<;+X%$eApM!e03~E+kjA*&q`3T@}C`ddyd8-dKsL9UU+yKGIQ{@;iWXngy zUq`9$F$?jM{o0iT1aGpG>ubIs0}b|fLz|p%98oH+*_=fBOQYq_ywa!dBr#>v60}_x5trcUfaw4xKS^!yV@vBc7gZM zww^X>xT};p z(&_ox`A!x9?+H{+pd`f*-^>Gz7m>|PcTJqUN}O=c$2be}isVWwJqfuN{CsmOmN=@b z=Z}YWqxt@&E20i_W_%)p=&P_b1R6mk6SjCC5rODMj}Cqh)4T z%7Rmus?b@mv-LW@%^=b63K9H%_!bbK@bzi?U>QdKT2pq_#?&1C2kEA?dzw0bh}fye z&RsUmJs^eO6Pn}sEYF%_9&Bg0MFn`=sCY75YU28@YbnCdMNWKzOhy?^doH$}Pc0LJ z8*Et!9lrB5h2f{Zs~<3XbyaKjSAG`C7Z<?XUm7%b(P=Nz#e_vm~W>0r`<&mYw?4RT(!Ds7| z@}|KqiSy0|na=d3c}5hMFa`0mp|UIVSvCX`vjA}Ler+|bMi(lr9r70WwP*bZe`t+3 z0mbXcE-(6t70j9;4YZ&?Rv(vj34d{qC%h+s7+<~hGb;6KnUf8o%*cHjzpd+{zM1*E zeEV9yN=GM8Wa#9G0qs;X<1%}qs`PL;Co(VN7oWA7mH2CtvLM`oOsqLH&S$mU4ar?0 zXX6iDx@-Dl0s7;7p zKhBGXpR+W?_=;MljUCgPO%HXZ*Yb1_Iv^^Z0*lo5;Y;J6s1ZJS(yuG_ig_pOUNY6a zWfv)?X~Cb$H7{aD7RNPI?*$>-aR3?l)-`UV20Uw=fv4n7OTGKyX;H$=$vef4*}wOWq+O!C1b zl8m$F%(!|snuFhpZ`plX7u2aWSh0^)P|V#?Ke#%}YRenvQHun8t0z?5_FG~r@+!XY zW0vv_T>BmcOIS#g^K0P+a^e$BPEP%cOStTU447T@2|^B38uSPLd-r5Xm})yzw>+x! z6hQU&YfEwYgFogDcIReL&@2@veaZ8|U(tP_Qs%Eb*&H0iVg4Wp&$t_Z#FDi&wc@H5 zpc_|t>4+p*i|#~UUx7{yV$re9+giZ>R=MXQCCth{>q71C#HE~vd4{t;>ky@(W{T$^ z=8$^yMY>h+003=8gu{{*;h9wPe{ z6aXN0exxjMY(T2U<}^^QXi%pebr-)ng`wd+ZbzT}djoVsqr~HRyfE>?(sxW?_i|& z5!*&**=!q^f1GL!+2&HQcYMTy>G7NHq@_!G4s7fM%_3pH?4Y?|y)uT+t}Q!wTAPwi z3f`P{+y4;Y?oBd^k=ba&>WN>PAfuC+fUA2epx;Y`xybNiA^A|$amfUAaJ3JHHS%xC zIkAEq$Tlg0kiJ=pWU2UX@xw1A(JQzE)h>-6L`_5E7>l!J5DXRJM83CmA^vdb<+exME$O1>i#QFms%XsYQE zpM$%zB+O5qqZK(2FDJ9^Xo5v}@uQ9^N3fO5krtnr*_wVc4SzpOWK}L85(*S?D(y^{ zmF@&6E@@^PKatW=F}TE>?-x1i?J6ueYpW5L1F`J|4G2t*g`d$%YdS-)F2u6Xp<5O2 zMESWITWX`=-C)@r9BhYumaw|?Ct#KAE^>Rp zZy_N3Q7`-%Idy~_;C{3rjZ3Y=6}atw(WZjY>4|HP=j5!XZx<5jP9sU4=E2fkl)p$o zajhvA>-ChFm2th@X~lllOqsWwK*4!o zD>fNt3m|N4;uVzcxF3WKPy)N(5nu%q9Zhu%r4EGaoRISV)M=rlbVFZEUGe#im^Cf~ zvj$q#;f;eI;S&=z3>+BXCd(-cu|~WJq(jXa@0ZDhj`vOhA8iz#1W}Uv`St!BRCIEp z@78doY_b>I-hu6Qf9nc#OCv^q0qjdFxyw-VScQb)1z9itlprOr$q~PbMLA-TkUk#3 zsG^9;_OsiBgQxzqNl%-UIZd;_E1;S{14xMM+>UnRmZ zkP2r=%%V}D0EyKAyw%GFJeO{dsGpv-9A$jl42m3Ej>K|z4NcJq9E@}&@eNQQQ?J&~ z&Y{St^WDvv>K{P{uXQ~g3b))wW6fWmwqf@-LnWP0F1i~6mUq`_$wBRzl7m;}i{F*4 zYigyWYk;xMxP?Bq$)53|?ex-+lZ{E86`nk@>b+QRMx|;x;oQ#UM0ihes`(o_yZe>+ z(|?UARCIocR2V{vo@~zjRHQF@uf>-*^`6fP21 zxws`-jKi8tQ_`~1X0Ao_`g=Q0$3Otx+<-LXt>Tz>eRS!K=l+dI@#SY(%zOq0Gv#dj zy3bdslzR2Z;`b1wmW{>CGktW+<}imc)-n_3pQeRx;l9SG{Zwi+KcwA+^+QG(9i3{# z@EWxcZXq%mqiPjNUvHlKVc$tbT$mhyDo5{_C3NoxTD9`cXb@asseJtN_(~=laY!hC z+();96i`H-h5fy|$BPnLK0=ZVv}SK6hr*!pLdRBy z9)VzlqarZ#LK(j*sDo8*W(Ktsi?m#2`-u4CXq!9!p+g-4p=daev65?r6dwW4C@)&s z_%*;{O6v0F7MiGY+S{nw{A~H zpoktRed_ItYafG(n8D(-p~7h8rH%;>SoA}P5IMz^AN(td)t+UEjQM6KVH zvHs-ZmnZAFiA=c1FRh&3fFoNJNwm^9g{Zu8t$1pdxj^;md_V6PF4$7iJpy!#g=bjX z+wi;`PTpA0qm3S_=1h5nBvA&6@H1^BA7iuOGZ!=RcJq6_Hb08%@W*h)J?}FQ@@fN`T(BH44Qnr#^}({~;<&a0oJffI^aluB*$}5GuKB%mf5~l=R@NYRT?$4bT7@`eDqNWA$;^DFXBAYNjr(><|?(X(^n{TUi_U(JH z&@J|QH=W~GTa%KCPRivaD9T>mai8Zuer1WEs7$C{XEoB{S>hYeYQ--T-(goC=z%Tc zx(H1pWSF{BJKUMBAg>YN4**4`t=_Zjpp6y-&&aEbBPv#}JFBv&+XEdjeO=*evU^I8 zY=rWGYBl=R%1R%{WtKl}e+yT_zOF|cqrEZI(6^;c{xSv7?APoF?(Rbb2i;JXnpO5A zyH!!D`UH~Xdl9fO&I%?s-S~$E+KiRkmDC!~3234XX%Bum^rfMUM+?fu4c_qX_-`y+ zQ%g6Pqn)MCjV$YPaIJ)s#tIEI_8by5g?W8-k!`oHh!dBR+3M|G#Uw!O4^N)h+oMulgbtNwD@nO3B`ukAsRL88H?3B)of{h-32iZGjHXxUaO)lXqC z);S2a+qFMa;q;jhQ1(#KS*F3YLHG$*#T;v`E)7{+--F!n9L(9nmD zVDcEnNOt@08OQa~58PIt&V*6fa{5N~uik}=?Vm@m9VMQMe#6oS5_8IQwxG=m^D8Wx zI52##)qDe=~EUZLDVqe>_&C6D-DOpj7zf z@L=d+ljYroqGjqc_U)`2bJ2-M%v?U%V00T|*DOoX7y$U)O-~eEyl~r0f0+@14uhN= zk3ml#(~itoo6kZ({{6HCsp)P%!5RS_lm1Ec6jO%2-lz;+9fL0b`6_`c`T1sn zVLI1KSvBTyK-7KByY?uCo#j#kpk87p)`e)|{`~_87W7t{9V4UHECV{7lT!8`a|C zuY(@2L+hs+_Vy>I_H`d50oh3s7c3#s0w+S#<>wn*#N z&@_FbxR~T>XqClnaKDj@N$_(Hhv;q^V+r*CaQ`h;x=6aiR8;|v+mwkJu zWAZ?dQ7B(uuMq;ov~&kt<-mABc0LQQ;@uc=Da%t{Wt70@=3@$zr!| zodeK^+|N_{W$3Xi1!nQsdx9VJYq58(+2U053`#P^8Yh$K|H81&<+)d?m#oa=rv38Zt~*5^;#%?1(uDUy!x1Q8Ib=4%KA%2)jy#2 z)@^mSgLbJG2EGo13^;(5V%EF%@i|Ff)n9_=7ioVQr^q5%Ww{p+yBDD{P5nd+!m=-8 zru~M8MZ)(`dc|8Si0hgE!&c4*yzd`Am-7>gw6GxhJR|t`vj zkZJd{cjV?L3T0p&nH0XH(|=onGmwK^pM}%rGo+@khX(7Ck#!TWqwmx_w57AvpAy*n zcV%zt+fje=5Om8qR;7R=zZ#uqYHGlR_*yLYIP>GY$7ibIR&D)1_20RMlPS(}ir(n0 zyl;@)pPxn4uF17m>lf#|=~x3uXOZAchG!z!F1=Axud`?3b`GS*-G#rotL zkDW<%-zSDUV&lB{JvY9%I4K6jix3WcA`}Tp+)yRSa5s-VFK@vh z*se_t0eAGc^t`q80-TsOyBW@eQ5Orjo_vWCyK5gmy1C>sg!ZLJ@e@R^%>FT8aWE;K z!d=&wk61S|)zkkpHk~~D__=aA)@!|g5_4+CwNK45#nU-(hSe@rDjQ%_kVQs5 z@D{znjV&Jk>4%*5R!=N4Uf5Nn{i*6|9u05e4cbE^F)rq(el9QB1Fsu@HUI1^G-Q9d zeyiw7ODr=X9#`$_neQrM4+`@s-3b24asT5FPCh{xuav3-{z;zoEz_~vzCPGUR8j@J z6e?-(z~)F!{fkb1-3(}!@GTL?>zwlGh8|)^rraqt0G|5vl-i%lK-R35S09&>JuM)u zd+hb+=-;#!8s+TL7(erD*bGN@1&0qpPruMUbU==lB1@S#X`^hVFIy6}&U7euj-tK; z@NHwa3i?{#BrA0&by(^&rTN!E;PZ!PVTW=_ZY}d4s#tC4o|FI@Nr_pn9GbAqXt^Fx zA)wm@Arr^lzd2!2FE0nmK(Y+Wh54Hc$r%`Mg^Qg-iJiVdzvz{eG>!P-bO+(VB}>v* z=P)O`>`YcWw(YkT`z$U>LzJCYPLG{BC%pTpH1CbT)`x<^9&frcx?Q;VKI@^V(Q|qW z^P9HjSVkV<-VLkt(fY7UwAKCOef|~G8l>BDaV0yJJ4K3@XB$w(u_@TeE9PBq`VyUu zH~|I)S=$@G0)*6E6mk=M4bn-V*cNTD`Nt~Yx-*WeGz9&a>^RsCG&24AqG)4&>SMpp zG{Q6nmFZ^`#VfltdN4vcXuY%}$|w2JcLiW)ITX9wIDyRivM-2?zoc1`2P69rj475C!1(Ag=kabCTdJM!q%qtMrXUOU4 zSn9Ssf{4gj1;ypda8GC4If?K(5No2{`u-w*P}TyvPN}M1pK-Xq3z8ji88Kp=jiQs6 z8Lv21@~$|9q;R~@aOt&i#6qy?x4hAoul(QL()?bjKCW}z6(mZa@W`ulGCusjGnM0K zzRO9PaEit{SLB}`!m{kDEA$%c!kq%8X!-&E)Zm(l4Vgz6`_AY`(Lou-YNnkPFQ4%C z|1txr{UWmT|L`}we${+0TjOEi&6EF*BwqcrWM?K@ z;X!L-h7N%2y-=^)tll`t(x9y!y4@GB3Oc9QVK87p_P?Al34QJvaytGEwC%JSIG^sb3h0>yZ1{r_^M_E# zJOSe*{l&@nab79i(|XpgQec3_2iMK6c+?@5olCNDYQiV*(^q314mBRrW&&J6^&4C_b9t`D2_3@KWi?+t_RybrjNA^os%LRWQ4+VO`JPWIozjE< zl^{6&;q0H?8Swr;qD&EX4Eed#;`uji+B7|#YL8Tu*+zkTVWP4PPI)N%q?ASCMXCniBg*_oNi| zx6AZqr~`TCzd;)=%TDE0qOeeB&;r55(GJ;AN4Pdi?*d%*aY5n~+&%_NI)ihCM?O<>Hd<8d+HlGn*vyDtlgA z*1aLb#kH>y*L7X4aqs=!SMSgF5AcI?&V7!@^D!v2s74B_9-jrM->As)yEa0A!uOyU zG)CRQsaqUyxOGefLubB19wsFf|J?H4NXe^NsCO6s391wy*h#snEvAcHB^ts@&a9T# z{j#;l&{>^O{SkWSwAB_@`AfVLwcz;IIcNT0WeG*PV=@{O4hJ6dF~5xFdHvPIi@wNs zcH(fcIK%bQvo}@GC2NmX`6M5Wm2%wKLnHHM@KDxD2O}uQ5hXGnHlMuRpECqnCry@-K^*AOSHv}Ob$hbQZ2-fB5ySPerl37ARTNi0L*`Z)h=d;26DDl1f zP)KtH9)}CZZiWh3*cOHF9S9a`hdB9`c{}AaKgW_Ug_>ui)ue``s!J`QbdGlp978XS} z1~tsrGI(&3nFD_nJt<_bnafb+aZH|1;Ay%W7YpZhUOX!|;M1+R6AW1~)@l71wh=cmTd7XEG}pV$E(p6stNS$@kGL{+KFZnS0GoI(?10DP0(EmQLii{HbZaz z%E6cf^xon9|FSe$?cDSKpozG*QX6RXB6o#{SY6ch(12~c#av)dG@Q<&H9%dDYe zNp3fM|5UL0y(b@jB_%UJdqeSSVZrAwY6gEaJU!s>@4>x!;$zA2s7JOd!~2Tw`s$g6 zsbE@PJTG@eqMuJt^zskw%6ukG!AK53nBJ}K55XUtFqQCG6t3%ZZFR$_;yUG?RW8r# z0H5I3+>5GBex}d9LyKN3z=CG>^1e{r9DT7^^CxUjeav3=_?a~iU8vzk{#F)_WQG7^ z2}BZjTfKe-bQy52A*H$0aX2@*g!mE6wbB6MGAWk;WlIOpsZ3WP%0lM|X)irbygt=> zf`rjocPFUyT)Jb6zkm3P%jrjrx(!y=dQ&;JksP2wA@qT<{>|oZ4U}3QRrVP=E+^*6 z$1Et^_%o|Tt>>w*XrQnk5XHYTVi%M3_(b;-aq1SPI< z>}Exbx_yE?ag)vT{1{?_F^kwzSN&vJlC5#%K4X0EW6)T5(m`>#^XZ>|vMUuy&KFh) z-07}uFs9XB4eMaE)BzxbY!L&E(-~1I7Oq1XHv!yl?=!GG)3O61`RkI62gREr0dJqh zwuz`Tce64Rgl=_zwk$!GHQk`*b~? z$gFzU3UkD%VEnwT2@$_s`jm{~=$yh)xgm@&3r)aUU#u;+5@uPGK?WBr?FzPsh$6%uQ-82q=XG7EFiXYy>4W#)IrWw^#(xAxGF%qGVxO%R+BfeCkPQ zgqJkz5u-AtI%(tDX8tuFGh6? z{sPO1%TGOetI&82ps;T=YPR>beJl5=A3jx0=|Dp#AkQ(evLv9B-7gdOt$!?1?Y}-} zus6|=FH(Pyzj6mU`l;x6gdK!`#BKBNNtc%&>CEs)!KZY(fgY!u1{uSGv_3g z0M+{2F)iS^(2dMm`(A(Tc3S!~`+>_!=K@;y6XtoeUHf-6K8!w=ktUKte&?BBL#mM1 zVlUYi0K~Afbl}j%F%x&(x#sts`|M^P=)aH^lQJB?9^s92c^+GTm8WYfcd7gbotaB= zzrx8mVF_{w3m`WzskN&NG!%(!+yh^GcYWl|D$g)A7e6$N?LNYey@Z9k+B^*8mby0Fd+>(N7OVM;&X3msSu5HhTjoY)p#Yy z-Lfx7880Fj|7!8JRPEIay|}c&(dHWV1a>}H8}6|)AkSZjmjr>4c%g$9?wk)7r+;5w z3)a-nWprh_2LwT}q$#3A5OP*ayK&ae#>KWn0sxqkw5p*{)q!)iTFA&v;<$HQlh%)A$eP5|wYf)jxd4nEX`B#BvJJJKP6oxVn@halSU_4{$2YYxbRK zf5wksON3Z#y74gVn$_3hv@H?VjDT7de$IJR`{H}1Q@Xm_LdFNISNc!a;E2=HbId-J zAAp1+H$SN`SZ&?rla7$X0(@5~j?MrLI@Ad_kr^oM{_gfy5Mb&4C0*vdP*Ip3;qsq8 z3IQ9G@($Fob9kf7NbBu*j}#;HO5@2=W2bxJ@Mf@n9yVoPX93c0V=7M(nbqg)0qhLS zS*GD}P%((R@s<3;zsYGq0@glX@M^E9o=q_x`4vc_K($1r@CYpM^86*;PWzI*%ExSV zs7Yde=1iYp&R7z4)AEgc%3NUCy(p$0E@$UNeM0Atu%N66VT8(p7$uaOv1tnZHk!1h zhbE>=fb;i9?3nk!qqvsgM-&g^o2HT=9=`-e*tj~c9r@X~{m-&jLz4fc6_fzJ?+a`7 z3U~0`O|1{}gSH$k{ z1pJ4aBgWoc5Q(ZJUg9ja>R9Zno#eBssXZ)`k7{r3huxYwNK9ehkLf`W%p$07bp5a+ z{vZbJ=$?)Kvl;q;3Y`1(aQ;0YKZKQ6rG{|ko4<1exJ-^n&{FEW5{IqWid;P(FL`s51H@iz9l zmGBGMws#hgcn#otY4z-?+MNH7?2yggRQ{R0k^2?zH$~pS@f|aZz#fRb{{pSPL@2t+ z+fVUTI7;^o0g>Cui>ddR`>9A)l6Vtpaur{5rF5jkcrYF&>ECl(;PT4Ej0X46)d|03 zDwaDB4@IKaYSn#ygJy8TfWDD8)?j@RBLL-O&-R50-BU z95Ft)|0DzI<>mHAKeFs>$LeFwLme*5`;pmx{Vv@x!NpE^0>~<45#R3p+tzs3^T-G2np$uJ;T({Gwo+z32C0w_UYIdSXswU-!~;^ z;(mzBvu`2s9e9*$+!f4XrJ`XUg7*=Hw5B&Q#^2uR&MlV7WUhzdP^lVWwZO*>fS0?^ zGum)H-fd;=3`nWaLjF???ZdV4o*@)g8MEo*yWV#X6f$ zNv29^cYCQ56`Rw=#QnmCVY2O_^eYv+e7o7OL-N0MS4&dBAV8({LN1NXkpRL3L&Aff z|J5!3C~EDmYSJO4LB|=vLlpILp3n!@CvQv^-x`wXOBlH<<}-S8kj2{6H(&2R#*M6f zLi6ijG`t^I{sg22a9Azf`zYR3cLh}B1RCB5Hg#SwZa-J=ny{wi)srT7`Wed$;h5y^ zu&UCl{sCz^L!grU&Wza`8~u&HBPne$%CaM67$a#iN#tOoIWKN8{!WL>O59^g8X3*P zKT2c2{YBY?pU1|nwow`FfZSZVqkPMp@E1YY@YBj@kVFhi2x)LK*x$O45C-;Vc5wCk z#>uBlS{fYdeHuxZGjPkrkJjaO(5}Bo(Ew#yEM;p!s^1r6IROkE{Ca`4!*8azUpHpH ztE90%=cm&EzTh{86_lw-yiUG}&5>o&9T+eDIHPcGplpMk9oL)edC zR|<1UPCfS-LJQh{$s2Vw|J>$L2?q*5r9g3U3Hf4RuPM0?31m`O%fD$$A#+QS>!5?h z{Ly&d(EDdv0KmQg+=<6IjdXHCba@fD$k_mPUyw^zj9 zqCPoCQ?vcNB+Lvc#FE)lMF1YB#p+J3q|Nn%(s>X*L|MIt0Q!PkVt-hK%QdyM%}|0U z5Y7>ux$-X~6~SrSBx8Y_|5+`T3Gfg!NJDGhRgNE(b0)>#I(Be?X3qhZL;J!M&xL>4 z&>&4sofI|&bI}KaowI@J!)D0o+ERzzU2%WCs*e|5vDpv7d~moEcjKpfZL?|?U$@l# z>`83=zWQ*vSCtRmvMN1})!_LiQhdm#2tEl4)a$(_<8Z2JlB^lDc&`8A!M}w%s5kNtWh zm56Vtl73uEbTtkC-HnVo_ty|L7}SuuQW@~eQ;Nk!0pEu~TZ5&G7W5>=Osk#)ALT(u2FJQd)@Hv&@wikLNf;N$R$ z!?oeQ&6ROhHqI8*fyONv6(708cwPZ`)}_%p=g9e!95#WVr(`79H+7dyeagwARvGhm zV*C#U{yefw{75rv^nsap$KJ*G#eU>rZSF~vm`L0LqO(m8XDiD6+Rh0n=0UpnT5u_T zBftqV1qG{HbOUxG`T|Z%j%Rhjg2n8x8a6L4(3`a)HTB$9yO8m?2KlLN+HTorYm3z6 z2A32glzV*I=HQj%KYIaAXf;W&i1tJ-y?=#8Eo|vmN+DW((t>UBUN;yTid`E~s?;<8$IVyN{_LR6MrC&8$(yi?OhRrV0dV#0 zrqng7jo5=7*_8(ELG0zOCq4c2wcl6}+eLFf0mKHG8-Hqj!MWo$DWVi{%(VB@5(WhzmGCtws4l!}KogPb{}m z;lMH$c-{Jzz-1u;>HQj0p%-jvauQQ(Q|JS<$md9k@InHi261+bm`dCWqjE}WPU*8! z%~rODQ>k%J=w~lBfB4?)vF_&=&DzyUV3&dZCBKf?sFDS@Hv~Mc8Y?F^nh$oubjg^j zVlXE~uAxy^9UoSJF)@;pceJc?n}dLq-F;6oD$cuTGiPL=m`2>E3y)bukjZ zy-_*Kd*N>3G)1V~*@+?H%J0(hjrV1-KMlZebKC!1*nnnf2p(mN^K)3}R~LrmakPA( zH%Z31z^s~b|Iw&rCHcLs3hw>Q2|+8*w1i-HYdM_v$0LCbtp{OTgL{}kNU(RH8gWgj zqn!2KWNjr{<-;Lr>@rQgVWE^#%unx#@h`W!^JA5}#ji>%?m5n((t;zVQ{Bzwb@~0Z z;@|+mA2Lqn=(lxmW{cbhR@nlq@-}BS{*K;hHQ3w=LBHJKwl_u6lQelGl{4Q5eYHY! zc@(^Tph0rS4%P?%U0k)MfqakGXyWpl&cA3b>^X1@)GhT&4+YvIT7uP~_SjS1pOio6 z>U}WLS+)jlmfEuT&yKs0F5x_5@GcOe}hDE;@7R*{z9m zxt_kYzO24;pzy>{ZoJ&%BdZpRWW8MQ)qV9>d>e=Fe9DkVy9n6+2_)h!$l`aaF|#9t z75BD*4K%3rpeW(7#1cudlO#f#n&~$A5F7a`Y>%RgDfm_YE7=hC1jkF_|5j%*xMSp^ zTiBI{QEpqdlzew5U72a#{4W3F7GLz0=X`L40&cg%*QR=)PEBw+gF4F)wxp6 zoQGYYr9q&AIu(6I5%0zJH)Z@c@~+70UbfYvqoJ!|0~1h9fy7VAm_CQ|P)rrY#(is+ zbdi>Vu+{^(1~>Tk^4d@#>o9p5?1$Mab1wu77Jfyu#aegOM%>XFQ&94TDjiw- zBKmA;lbt`33Kvs9vTGO`{ z`s-tUcrg+q$9YqmH7g6bseCQ&Swjr%!a0<##fa5~`@c$<{mKf{Xo^uRJ)lteZ+V1l zjC+Ckm0rRB%b6hn08D;{Pp2N?&4HQpZ-KleiV`Z zyl#a%wkos8R!O)YC2@%yz&L%9hdBvvtd+G?xv$NL?Dev&^%qWP;M{oK6RsU0VD7uN zaR_kAxVglR$meybXGrv{k+1yIsGErt@YDM2J|rwEk*SfwpR#jCN; zl$kk&A=u9`7ZSzp!`;haN@RYsCx}@tX*F#4D2Bp;KY4G=AU&Uu=`151#Lr456c#1? zczAqD(l}eOJ=w9u&aU2fpnYi*?)L2O3!KGLRL`rJYm#!la%UJoWbEJk=Djqj((u4L zLfL02Z(oFP^k#wA)- zYLEHWtD9nNpm|&Ey;Tj-$bU;Cxn`3WMZ_AyVL0q~&EoTB@Y5n(>&oU_YoV^`nqB2$ zg=(-JV_r%})N(6g|HoppD_G>&_hGKQ7}=RS)-u`euRe*PW4D%?Cl4c;kBgEox>VK* z6S3ist5g~Qs_oZ;?&B;nZbm--VWHct!Ni?+3sslmj=oSE(wQkafBQM6@r z$!mzrjFl+t4RvyKR6YK1*_prPuTsBVQS2qND^d+uv(E6SO*}WB2A>FTSOoW}66o%V zm0iX!JKx{aW3C)|+x(9IB}Y>Ea|mIM2}j$XLWW@-zP`bf4SO7V$vz5GouRkqNNLc$ zg`#q}Q^7iG(c{FAU02FJ1j_GjAlXZE)1~v@&=eZGafuW-t!eI5)=!%bJzeml( zo1Mqx7(X%IEPXz>D&IiuZSY`Zo&IoVYKEEC=DlKB{-BGv7}&F<}%O+WYbpfD44(+N}2U-7%`!W_K~ zEo3sKrSnT@WR6MrA}2GF(BP#~(aFMQUVvu=JpA#YgdK-P+Ix+`fTLnw!vHnJl?tUl z*3pmP>36SRX`bDWjSt$i>L8U8NPog)5)ev=NH@7inyGJ8%2Af@$i?L5us#%$hP3xA z9bzAKY^yOEr6BETCF@)~uJTKKx7;DtopY1t7qeCSpj6UeB&CA#WMlUDB`YxSblkR? z0UG5c#9@Gf4$OoG?Sv9uIAFhh1Dp)xOlJxdP-s{1${0om{_X5wmwTBiWVLj%PkpCk z&I%~*I@~#392)DU%2ihMUx_By5=rfX!uzahP@%E@tV01sy)qU__-7rq$nB}~zPs#v zG?;|GSiRpoBw??F)-Vb(-(r^?cMkhuOh&!X5XT@g-Bb%@r*{6!u`0Y~ZS?@;i$C;R z>BfUv;EH6XWp0As=>=J*@{{sj!<|b4!s+?$pb}PRqdP}ZI5Tglafxuj*#V0*7Si!v z?$DJPQq_f~Cj3o8Rl@fn<>VO&!y5BqZ0GHVR*F z$I0cMRi7&**zLWpoZIwwowhKm`O_$J^tH@33dQuq`y@L!5WW8QPrfnNJcvQQvHp&q zxhO-W^>nFdzItR+?6S0{WtU1cmktMtF4btC{KbsPYynw3MFs~KCaKktFCM?Kw7 z9EcUAn-mG=RJ>zLgD#ItSLshBHFY+^n=Vk$L0mAb(aJ)1h%DdUN&tr#E~a7ecPYUYd#bxmA|o#Qpo3b=GkFB35nbVACY2qYk34ozHo}f z^i`d!We(tHO8ptPsLT)q%`{*N&Z2#Kqv9$cV6xR!c~2Dz3WT;^Xb&uQLicz616QtPA zNkSeAA}KL?v2guZAn>fNxV1|$HQ8hPHUQP7NAg)X@0EWM>Mgf9T|0c&<4Q^|*@ z*~a;F)x}@&W(UIeDN@0m z|KY#Ejx2o_CA%7sG!ZZ#(-X+OR6)m+wsGy$Jdf~L~z4J$0n@RG#{%PR@uI3Nvk3kjxz-DsAV5cbP@^@n2mPmSclg~@ntn0!kf^IsK`K|k+D0VrHm%?n8n5wqyo?KSj}(-3~F3zgfHx3q6)T!dgTYn=H*RQa$bI}J3Kzd>l46_d3*Ln*4$B?hXEKnV)-OEzJ&E6 zG-P*euZj>%EW|adVqvvK8Yl04lyiaU8{EkG!_y^D1SKe={OwA?X1Q>CpqaAG@;5Rd zYrr<9_Y7MZtzIL{ZKH2ePVRvZKU`fY7X1ln-ad!rPRmF*&Eo7)<{C&dxrbhENaKK& zm5QhzZjG!Pg=B^iYr?>G3q|&={26aSI}1P;=owFj1G2cq>grP+Qgf4;*Ji-D+?Yf4(d5vh|rT_%ZJr$b3R=55*#@f;~XA_2fy7@7qhOqoW9D!-6KscwJ>~kCr-ZsV?zj$&a5nv zf>{0hU(554i&B14ss}vzca6ICpE{WU1zY6>VhU7empZ(_mjh@67K{SHgIHM9H$ML> zVN4>UD+P8lwN>$yp@u_A@oa@MEOPK{ps`QmFWJ#R^Y<*|r+I@ss*#4KyEI=BB9L&* zIc6=)XoUiQVHE2se9hu%0y{FvA{xIeSeL?_*uOHGmX&a$f6FthkTHHkFh4AtLiyIw zRn0ZbKG{r(D1pF}42B<4j4yj4`VgtJ$i|AAvD84hk_PACu&q2;8pR3GOoE*8gr#T( zg*jf_=;S(<;rMG$2AG)=7F1rfuZSX%=y_kW&>m|OqTdvrfsxY-)t(7{ z@e|#NPFCmY#R(G8)7*5-Mmz8)oe4S>Hj*QWv87If#6!#g9>$hgyYrPc@Jd(n!svd5 zPdroJi)Zo3*cga2bw{^Cy3bep1L@Qvi$<+y1H#fuwX}LY{mD%dH~tUv;Grlex=#{L zFVbn{+^$@~m$y|h@-lzhlu3IY#`~V)nxv4I6;Tt3Z*E4+pJHkY8lj0*mCTF2NRvY~ zNN<%|#=_c|1|QcwVGHgZgjMO~3 zd)9S9<}DtD*&Bo%7ivhY)L@$!4Gsjl1@d$`d1<%*#0w9WmQ@hi_{6oboEc5N^Ls zlt20va)lCh>CHp^@cgA?K%6rNJlI030d}fP-aqU`r^T<6IB+UZUK{Zjra`MNv?qVV zOk`pTmLKu$3tsUoC1o_kkDV5>f}Xb=fT$n6TBd5&ra-Ui233bD9QNM6ng$+fwndfg zb`cc=gWAY%fZXrZDZR;2z`?uw5(MZGi5TooS|$AcQ%; z0-KHhI}XxFO8)nV;oA#4^^m7{jP2;!!)|V$5BGuLb7pDZQ<0ltnkqhui(7k3DqZYZ zBFY_`-+zTIls&nr;qCc}{|AsG-@ulwBHL1sObSYmQPqR&U7YW(;R_)e$U>x?1q)@n z{fEBhxcZVYDsh4K`spYUs9O7Wl-ad-?|;GAta$a#EYR3b-A!<3$1 zzUEU#B6)PCy!BACJWXS^D=0_y1&V7GE2#nef#_p6BFB4>oFK6Ya(b8L1drwvUHC4X zxG(=Ij_))H*cNEiPNA($W|Qt+NqH_siQ`*#C`iSO5Ef37WfTq~qPj zovNMVk=-J`o)z6=(@s1ebI!&po~*_7Y0bxQ6PW@M$@IbXwV%pZ&?YyYdhBM7P2?Eo zkK4(^bag`f?+zu1$V3qSczf?G+}F4%Qk7^#ImH#fjPX?^vESup&hUxEEF5{3hc0g% z;u&iA1LrV@K{AQ_kO^(~F{IH(I(f}QVvxU2&==StD9`hK`$*LW;ggN`jO_(s^~&tU zvHK#t3Lg$&-{aZ)YJ8WuBf{R|lE$AiP`h#DSndpwlpgS2j$wj%$n6{!zdi z5q_?Mo&C0(k7U@~Fl^^S*wCMho<)H%#?JPE{9s&H;Kym@s}@6 z_HDg=juQX>;A*S=x#Zuih2~c`yUfM?WoP?KpP)&iuOnq1_*(iZI*w{;x(7zv`*e0H+Mbye!7`XkKxsm&h)vf8RF?tn?$DMlG)&JIWZb@E#c9suBC>k9RrjmOy+sA_`;)QCT zM4&(QI>H&JruNS8x-bXjQ~Q{V_LtCzsnW7_M#LLt>BR$r@KvAem*g{n!$&mO30m7l z`f?F;wtu#0+1yVll~<&?aYyjJrSy{&0@H)zz4NFE3*|PN@zVUgbL}zImSU@2pdy>( z@9UYqfrht*#BEYBU?(IBomDe>K~x!j5>j{Pbl-7_HsTsk(!>80ah#evNvpAt)2jL? z=JsAY?-YVoUq$+7zGQ7}Qm&1c==Ipjgn`I&U9uLR-Ra~CR zy7IEXtS=6m(bK_ZY%i;PpN~kU^kDzE*$Ij6rVRT}Pz%<%OkGVMQ!VBvr8f2tPX`7j z8GX@gh5Ts!Saxr9MX1iMq2@t*YqjFuwaYhwfqi$+VQxO^ zYAlL-vYU&IQ2#plr+m-p%>0Y98?E?KR4 zvqO);BL|l6)Rc!d?~=kgd<>tBzhHi`UvF<#lO^vwn7WN7)n>-;Dj{pywrsPr}P;ulv_>=HStl z63d&16hmp(bJ>10jrW3=+7AOBjwQemMAD3DJtAip{z>931xl@p`E#n7liE2^E@y#B zQ5nYMtlX}EQ5<}dFsZ`+Qy-RAxl)yg5#YRwbr8#Q$@rtD#X7ZZVTu%OxNb)@904LIXg#z4(B{lXtv8 z68#`CCvu$gXyx5?Fn#&Mk{Ui=!h?~Ax`q^L zk~blW;Er+M&zm5XY0GnLH+9!DQp*aE>r_fLXO}xmra4gJ#a{aYfoj?=W0Z%Yv#~u; z!h0=Z!YJW>bY*N&oef=`SWd04K)4&$H+1JP&~Ia+gyZz8o&wqT7@0CxGazbygXYc3 z{YKjxGz`04T`i! z8E?rGc=luETr}dI?c(!6<>^06q4?2VH{Fip)8#V~;xvFW}^6`8@FctG9Rh78)|Nv~$c@ zWObJ1w;T-5i2s0)|CIlgeuAi7TjcecpvBWBlQw|bX<>s3x_2pRDApz-X6eSSYp$sQ z?kU>Kf<~|kES`y$KO$@M5_Tz4TRw70XADFBa0R^eloG&Q1mAB3N>fj|_^I(nmpjUV ziqzM~LF2#MaL0Thmsjk)=a+(j(htp8nfHkE*cbf#tZRoQvI%j~)2wx6<{wC@RfP~+ zLPU5+K%aWvytyjzN3xssBX2#bR%x|)AQQKLs=LsN9H^%7C;8nBrP_JkW9zJq&R*3j zc9C&bCszw~hBK$G<{&SZN8i3V6PY*79JVn|OO(2H7qrIompL8;u??#lxD(;W{G;4M z4ENuZ4H$(g3O*C<;fh#l*#ylrvj->L*FsN)6Pl*ueH-d-x;_ZKD5zsU?#vu!dqnhuuh=_^Ll2`(14TV#VKW4(E{ zG^qOSZm?X^xN)nvVoV{19m+g$8YVXBr5kY_*5_Bb8>Y~-sO$Qy1BD%RTRl(@J{r&5 za1pMSTz_~L9$1CL?d`(O+U@{kw^PI>y{RAi8pe1!d6O@jO33>wueD#VSggbrzv0^p zZOpJdSJ3`r5y~maoFAe{9X;8&sOQoh|5n@pJ%t#uMQTic%uvTo?r5P;)d_@BxW?H~ z9riQ3jud*4n%&emlG_h@u2U%~)mhsUP>^U>#& zv1RhK+Ju-plXd8 z$l99h&Gx>`7j>_E3Z!0H;R~>2KDtpi$gS(>pY5=#$}0iO$18ui2N zn{$3`DKC&C)RY%sRwCrw$M>N|WwcNd9IbPsdHhf7^=EpC)&6UvnPjL=p2PNjVD9$r zDkNi84Gi4$rDr1Lg$0RQiY&b(SfKFAIwDdZ^i5saM+@&ca1Vcb=c|-zSf!t#Zsn^9 zL<7T`hd=%_AR9ap3l_0|aX5RJ>S_d-{u9Mw^ozAfmm_NVr*pg?OY<%pW{1zeYwUNt z>wD)^L{65K?b5%G`ahEvt?Ndt1Wm9l620pf2Hdj518`^G&QWAa6T1{p_vg&iA@kMN z>3b=z&ANqsNh<^0%7?)elb0^XF39`556-!BIfaaa8qx#WSajqZ5o?97y@REyp&uM#EDvg>&LkfB$-UVUQe5A+>5kz-Ez5>SEBmf z<(^WZ(-1Ne08rzbuKKERgg8d_;>-xV^}Pqsq($;duW|Db^?D%>mI!EXw57D3Qr-ap?A_Lb~*l5Q5GfDY`PETz%BrMzaT zA78Uj=f);fpyk{#?YF`9n^qy}?Fnu7uSv)*<~a4oj$4g80v+H<*EQAGVVPT{5_C<1 z1X~kZk#cj0`e_`Q5kKo1FAuoF?%-S(nbMta9Chc4bUAna|8eldwR351K4KdnQ6F9@ zx&G#cceh~mj^G%5*U^F1pfL@nbN8&<)u3kP1NSNNsp=-o&klQjR)LWlCqSwy9*ZBa zmrSfIjq94)$DP~>Ch;}yN?plS%!nYN@ip7$Z^qCuwT1hX%~-?{1!Xz@x)@!Z=!$6x z@7v%{N`#1ruBG_}bXh#Wvhw$O+`)VR^|LFo{N7xo!B8t$nn=#(xQKzWU8{)X24hFf z=4-AtZ@fSvpYtc#kMw_a3%Nsynrfi|*l@@Cp&tOJH2ZT}muuHy6ZuNp{>^_E>w}w% zP{FAM5%0G_>Gio2#m}S^zKcsEzi597l+HGGw#Xz86*d8M?+K9hL)5#GCKLh_Xc&cc z4A`j^d^edh+}VaHyT2ir*v~WRw?m9L+u7gwq|xG?Qwwg$zvsTF`?Tm%Oe6T)vZzN3 zOEDQZ9%5~Z!^8PF7s&8j=>uP-xagZzq(!0V;v3(h&C|bq>$9SScOIu4c1O!@iOUi5 zO+5aQ56H#=JPAXni_nd8o-oWW0JBqdI65nGT`5f&h1EcWzJZI1^{ya7i5I?~z9-3k z3xGUYmju)Shr{iw+cY{YGdIn$OZt1!IM&k$?8ghBZZI+P? z1Tg#4ELakm@|Uo$uV?d{|x@Ays*K>l|SzBQpJ-@Km{44 zbAD-+7LnTN1rK#sC*$IeXif+Spr+z<^H~}Ra(;Mz8VZwO#+}7VEuQFyeB}M`VSj!B2w3w|&Of)#23D_0{NuxH;H6ju(78#j_E? zWgkb*0LmRN{(4P~Vls4TYoE30tlh_6)LEQu{36%tgrD{id8vziU%}RVhR(sC2iRLS zkRzs1zr#EB%0<8IKqb1eI)|d*S!~_oEAZUpO2-H~-{i1DJvQ%elHbf9@z4);-99_T zuPJN+$dsj3pSYhw0C2vT1?>64Nz-ws_w+h8$joLF+p*-NnC{NmzmJzcAUg8(Y|aNf zsP%neIC1H3k(~zx*$NVK*t&{~fk%!cL=m$0%<;|X9vtS~$&haO?%97r9r7SS)=&%Uk&`@G$uj~^ zwYDk&>=+2Cs$SRv(9ZeUsfM@idDkFXRZm(CGS%vV1|#nPMcK}~SH2Y`n7y9@52>op zL5=4$U09vXdNbfoOhM;(Z0FORs*v|&M@k=VU1wf^Os&9_BZR|9*xFuaCA_(#@4qMe zeIS)nRc&ZFLNn=z`00Q|7_GY39%peqNHX~+z(GjafI_ysKoGeGmpn6C+`4o|T27V6 zf85kG&2zP=C*nUhJj2_g>bge{RA`)Ir<4)~!30v@0j*7e(>~oyJT=N|#+=;J+*n1I zR7Pu*lh+33ce>Ftlk7E+JZK9?3*l=Cul(K2`{8J`gFa)rA31S~7iW3wL(ehAOUDVk z%-Ab){VyHYb1J5-r^rlw69D9kL} z|5MR-lG|VGPNxtot!bM2yl?6i@7!vt7bvY75}%SS<@GRA|erVp8rVU1RaSd7}Q!NfWVmb;-Ys3!3m@xhX>zg^b6Duu|$z)Ib^gmb6HwLb$m zWS&=7q;6rfw#@MC55N2`GDt_MU;dzwHLyVIavn+VE0JLG6d+yP22e=( zheVJWx9@(d4z_0PF~esi3N#`;;sOB}#z+7P{6}$T2NA!yyfLKg+RVM6oGO)#CzI@z zyWBO5Teec=8*2OKg%?5(Qu?`OPdH_|WyLt$@H3b;M z57AQC`rfNxyEHjh@4ehpyCwI+nd9Vp+4Go&7mFR#^)=N73Cp_tIYmDfdfm6W1QRU^ zW<5XFij{CIMD;5sbmxbm=CEG2_m}tD6HcD@OGL6&|NZl4;kBdRA=lu6Cq%JC{)dTN z$IAn@?__&z?&nsJq4s-Hfv|eM^8M+%F?;@KFdJCu1cATs5AfJj0-sw_Ab-*wm9tSZ zDZ6c|PD}>1bps}T$yb5-WJ5zSgP`g)O|ykW?Il(Hrd*c|aHm0Yb;>RywEpso!ANsb zC!~cZe=ceMf%Nm3U(M8Cs%{3wmn;7dySytYpmqJ0@OHn~Jjmi(>d?f=g6%&daPK z&tuBlG?13yLq9*|E`9^#5oxcHtqZ zG$J8LDIwk6QUX#^N(u_n-7y-Z5fP9Ek!FN+3`ANO(mlxu2%~FkdmsJ3pZ96^?|#mG z&ULQ9W9uoklNu_6_g{pr1}r7hA`cybq56ge7^*kHwa?aYR9*Qqe;CL7s;ATJNy=$v zr|D&QcJ&ONw<(*Vki99)DbohkYUQ60G*vDrwQnP;F=4jk*K}=wLe}%;U*6;jt1Jh~pcU-MR@PcqZHk+G7l1@e^c99V1xv`<57zti?gUb3 zN%-;ld^e^>A$EyaHWmAQ#>x`oS{7{VkD4}!jh-(E+j0Swgqi8tcpk2+uq1Nh_{%7U z`ClT8z6K{f%jPeOWhSAeftzu~Gu znqeVBuQU480-qUKr-X%c)uPcj94cOxaW6AW2BIBxWPTHbXlUCD(avF#-=aCBM~s;2 zkHv_TbfJ5eI&5t-+GZyGm^XGA(%-|R`>XZqWsu1^!axtA{NS}oibx9WzE}HsyUU~^ zcNP6gn-Ft^btx-rYcOHj2BArvze~==u^-ZfX--{gY7#eeP${$^G>ZA&gm)wyayF%P z`*}QJey4}IYm=p8%gm^vcg_7NBCP>Pn8y9foS-C+v1bQO>x|3sQw98%L%(wNrwaCy zPn!s(f3g6#nzh=wMEKs-<-yhTYDLKGGtqRAbI`Sp(vaaA9|<2-QH|Ey%2_`UV*3~N z%<2&S;Vh3$$B1E4bYL%n!Y}y2>_h~|dZNc?JP7OxBbMtmQ1`}SIs@nY>uSr_K}aJO zA%J~n+k&#%Ru5j*S5FW!A+||VtN4-^chCH$TbATsgUrUu#N~GiUk}2xcMj(o`5i_? z?jJYKxve7dbe@;xykF_~K&Ue?Y3l3=6ZFD;Kc_U?FyHxeJ#pf9RFk@B;rfi{Er4wJ zEf3HBw7@SZ6b;FTh>eYc4ko$urApN9FOd;sSNT{VB|XItTvl4)U7HL!`CitZt$;>u zIx`2Smx!nw&Fuf0iOq!=YhB`2AT|GD@e|4kt+}PXR?vGpYRKC5ys}5K6{!Jfy zpB+#|&6@8+ql5si9~ryPGM-IO3b335!{{>xe6pXdI(#c7%)2%Le`Ur%kazaO!kVMX z1nlnqIq&C_X2MqJG3d8F-X_SP(EDttI+Fj{p>7~N3m_&uz`D7j8;r}O5MB_fxl~d8 z(@Mqe8=RtHn;SID*U`r!itU}fME&;#YnNY}U&pL!w-xWad(k7xIYj>q_g}xp#rvS= z-hFyFxC624(R>M@G;;hYoF-2Z0VSE+0gI@>@W%H$-hbwoHUolF9wJUITfMx3C+}Ia zK}7C+tdmu;XXb2R?Ta@68jU}X&5DA$FOZ0X{ch)|67AROU zv<=uf+Ri|3FcFfrRT~C77A{Bk%OJykHw;L*8G2l3ovxN{X29};0W^G+sqsTOYI@^eVTVp;O*E0*^8adY`H!KIHpuxdX>VkWz3Ad7ycR}SE zW%)davvXaO8ka6dP$JV;iaixiZPq?bp`nly-zjjhr*CJU@A(Gu2BNTvM)16qy`fki zlv^Qk+^}3@C`=F7&CwHTa9NBuRK<;cYk;Wg`MvTS|M@W& zrQ2|WBje81nm{&1AZbGHPsIhndd;pC#^b11ma5%-I6}}F#EYF;C z=f!NbRbAG`fYIg9^e(VdLdEPEa_2MN4ZB5A;Ek7Tum?hFD)}rAXX3$JZm^It7ZaTU zu5L4GtgnFc-8wgGUwZoYW1RlyD*IBa)+`$4Jd|{Q`GOf782J*Ku3guvz*?~|u%FE6 z*0yKCAFMHS{!CDjb&oUEf)Q@^B`BzV^|u|PC; zfdTVuw7x9<+-^t-Cgzmv)NK2H4{H=avT=v+$0GlELW>YTpJBHR1rejrv5sJ+`=4^c zNofbREt_9&&;Knq1$n7=Xgkj4U~}1Hj-S(gKZGNHTmL%r!fxDLKg4|Y*K)fGiGg=~ zR)FDFut(X7$5wuc;++qf@7*c{$&SV3Qnvw%imVFCyYkZGq5MrmV#o@a9?boPFm3<3q?#J2mWqW0}`@|SuN;zCarh)gDmeWKKEJy;zZ)8s_W=w zMHgY8o1)t5d)m*&as%9{^W(ouxL3CC9&9e>v{T($cLlPR zQBO4ATAhEYc5Gb~;Ym7jj!HDKa1Qg_(<9f8+OLZ)4VI0b@O7Y=O13r@6HcKW6EAFCJpS!&?UKBPfYp?pvE$*WMjw!Y4wAtWwNd96~=Zr@>KZJ&K5rSK0 zdG^rm9GzpHLNY%u9T52negWPkFh0uH&zr-B@AvK;6PQyrj8h%ZjlT{!;TEXrDOC$~2X-rMMI4uN6T zMw;N)u*s-TfA=eZ;^eI-d^USIZ`=UEyZqa{oG<70Tcp0Qf|Zv!hCGcVlNLcdJw6uqO`H+?B}dKr2KSzJFHm*YxQPx z?#67rzItiACrodm<@OsHY8!mTuavY8KRy-23oimB@R(4zRepQd!R4}DZBrs;;8v`t zON*0G5Ure|2y3VMqp(ji>jHX8)!`5LL5lpKx88}1q8h58wGspK>afOtO#mY=pGNOp zxRtBqxk>zG)hHrb5jS=9mE0=#(a7bjO8tm4{2M|*T7wHgtGA3b$4>G7w z!rRr~>k4`To`e4+HST!^N71}eP91-httIL%^Q&b*{M&F^C46tN#`~n8tUzpb&BTWb zzwxthAHHX~Kz9k0WXg}xd8zTF&g06qRDk?0<4MT<$H|Fzed6L~CCs~Z$WsP4mDxSn zp90Snv9gu~2DSC`#-|&5IeCY}{u6p35T3Ek;z@g&Kv>G- zHI|_d+=%ql>xe3H)2e-fkLr0ndaXLlAW1&E*fd!^j_3cit^b1ez>u>!^JkcqkmKFs z^*0mDoB>A67@XXe{9Zjuj${#Je^YrkjsTgJ&^7pS`Xn>V5UG!E^Oc8q!DyVD6+bBq zpqPP3=xwp5(4|WVLLue+YGd3) z`XJe>#9ONO{4~n;zL{^*k~oin$8_n>kwqiH_Rq7PWTu3Z_A({?4S&b7Y$60&WUWiN zQYu3dewWt)Bc3+5Oxbp&d|W`17MoC7w6p8r#f_a$m)-EgdfqSRZ4-Lu-0eZ1YDUh` zC1~?wKxvZi;PUFa>PKc~@L>iu%aMhA;-gMP)CW3Nk>S1_%U0FgEKY~Gobd3L#A4E_ z?0BE7O3`-l-X%32?&*<#tV>`PyQ5T)0H~I*AR2cdB$ey!)RW808<<$Y)!aa(UFKY3 zxanS6;mgOJ3W{R~sG$SZu88f_fHy+n4txf`ttzYWhfe_yy`9SodSJ=ML*P@SqItFl zD=^1kFpF@bE~oqDWRpjYYAneOt9>ETI#0|oe+qm!N=RFxePI^4HQfbx6?9sWY|_Zg>sY#izbb4_Ys_Jb2wX+S!f^)t+~m4~P}rMy1@%Bckeg zTuJn#7oRCE=}p2{{JNfWpA{|^zWa6~h1}kxE5O?lDixJ_S7tll;^fOg>&i$5H<#7% zj~+jRqJ71aEM9Wjk?@L&QfcEH%B#0#azlkyunLtGD)@e4TedVB>gYum^~ds$+U_( zzd|FMKaPTMb@qDblP)P}PnjI#tquZKp-p=zYYg_!15T}v%&XXa^p_vh;%TdgpXu(% za6;L3?{F{?#qM8Tz)zNzu5W@v3lLK4D&^!>N*m7kq9v?)0z?(4fdS#cciKN|>&Ru#;_19w6n z8nJJ3==mPoFS*g2!oW~;sD8n<+=+Mro;a_utVOY3y~s?%%S`Fk1`TaIrtUC7(FLyK zy2t-@tz_D~4obwQd)M;9D@_m0{l8iV#hiSnvI~ku=KhYQ*y+1Q{jlJPe%M!0yx@@UW!nKWQ+*J&FmcJ5_rP>7&i%KPmV_Jz#CgY!*(`@!-bckxS)^{a(XE5N*NdtC#vnsM$kMHlddSTLz!Snx12r zncYs3xvO#a-?mE54vcl?bu{mLzjaxuB(uz7>_VL*T{dy{hTq5pnn;*+`|*C7Ry#8G z%_2>E41D*>y?0WsL)*^LUe8)?7y$(3)_Y(McLT*U<}D(tCUxrS_0YFU9X+ zymSwhjkD(Zw7c@d;j69FkB@S>`gw7x;cSO03a*`gXKrFBu)JK;*y>X~X1CO%W@LW> zXXx0?$NkPf?u0z!tbw%s1LMz&qjsGK5C2%pV&0pTGbTU8!Bt`qWGc=4=vxe*LdykX zI?XO2WgCD&C%Kiw{=veFKg++jBo3Bq1iepjN(b_=_34wM6G;VO6%3T?EP+mSIgoGF3#JpO@%OUbNrHyA-Q_uS>Dm5WJ#L6W)Eu<85{df`ZF9_kVOsP9Mx4Mtua-S}Lr ze|yQ&*1EDN`!ubH^9}L)xF=g9tv@noq?$5%ng!N z$NC{gFKL0<*1}o?%$$;kkz8*%zp?M@Zup9Wk&?VYx*Trx71$OG4($`J^=Kutc}4c0 z!qe2^?Yy=wNQ4rOZnG-lHFMHyifX*u+puau2h=K*F)dt-_P+%MoRC!`%PYQBx!vGj z)WPeRtOOGuCh)O<(mW;9Z$dW`b3J~()B8y4>-nM@;4qL;rTDFP>>0RLZddWd=l+(p z81&nyEVr4)aiwG7gVi6X2hmr0w~q?py#0ddi(cO!BA|Q@S|hYr|89McH(%vcZK!p! ztR-nzr_27nC7(g6td+Zyz%#wTOfZ)+$D^} z*Y(XKPy7x)L#NNTWm6MG^K5SOwYJ|%{rWM_oltOEi#VKJNaBdu!KNdms8~0_uyRrn->|ddrs83ZXY1*OZ3xwdeo{# z0k>O<$dxa!(6cklN-G8qB+bpT8}&{z>JTrTU(UC6L0|m*;l1oOFX5TH;d_oonUR}s zb3Sfm5YO#GJ*yiW6-(Y``*{j*^Y%#}m4LOb=JJNKXP>_Cs68N6xOn^kHh$D=HIKE& zx;(|$I;q_Rv(mMvq|uNe)Nt+^~H8gM?xrgC?ihqAn{v7sXPFaecsf>{R41YW2{huQO05aA8 zZGokevWZN7=G~$O&z&fM_f7_LSDneKW?MtxpmEu5tx@`Sd^kbLPIk%Rs=h~`!pAEv zel>g5p7y>O@Bg_lDHtUrHGak+Wd{`FlActwyv*6<%-;5LK)*RC@Y-k$7SOPD5FjOE zAum(bL#Uf;E7X>3=t-X;a^km=Qi~m)AwZI4qjYIUKMhW9PPI{^6{hw#j{2+$QsldH zrug)j#B=`KUmlao?aq{OMZfSOciB6RGL^+2Q7+1>pn>@jm|3qum?FW~7IMUthdw z)05~Sa_`y62C}wA);&|loa3S=1cGmDl4`rTJ(@v9WB8Iby2;ETl8UEk_Q~2IXBs_Mv}H&Xsuo4As_>v8x&D_d%( z-^x>kUat3YUf6TBYE}p8-wMSMf(qzgCQ17kKq4ecMkrq`7<9Pj;|Tg(v?;GHG63 zz~gP{2E6cnq^oiR13@$6D%IU*d2d8k32X7l)AFJDux z^&MVb-~?m1JLAv_1#VNYdkQJC_H#=|83C1*G#fahQDepev}?aEkM@!k1Oxn|oOH8l zuQv`i219O{`yX!uKZt=Ld})p2HZEGWahZO_R)g1Ia)|TB`QCWI*^v>YsdO+Y?P?Cg zat-S0*>cU;lG^#ftHkk~LTT^c_vt=G6Qq8oSD7zND`e3)V3#HsX-J?Fver;e8x%UM ztnj&`xcOvbQo4FP1lHN7omKXs4}01kGQ=cK5f$}hm5m}VJR@Wfp9vUoLOr9_Ngo8u zL9f6ENP56wrQ5IIR@-FlWO0_PyPw?N$Fmgbi!g0cN@_M33qUvd|ck+sz`4> z$&G)qX8H17KfPv&X`lG<*#9elh*Azj^z*lT0WALE4izZ8mtzN&TE;8C{`;pAbyk}M z$iXYaQPN`f(C_vqH%7@JD}t_9fU_f)v$xmP_0h&|Q_>#$(--B|5&U11u&+uXqj-sA z$3H5tT6(8?f?B_pPmP;K4t1gLo~u8@HxeZ@rhc|v%_ipX__H5SD$tZ~kFa9rJF-~I z?lT45SzAd#w%l^a!PgZphF@NXKI+~_zYJ%u-f2t55;%T!57uvuU$`XrU`wJnP_Hc# zU%}F5@5qM+A7fCcBdCIGu>T(X_pn{xx2cdRTRvcsc|{sHE)g@ZAGQVs&+sUPt(_Bg zU{^3H$Zjdg-HcmqHWc{-ncfS4CEJ?DPhw2L#(s_C#d60uXVosG|C$-Hd(yEgX%&qa zx3j`YbAh8R@E`qVu0K@-J$=wf=^gaBZAL^r)o>);A@@^dgFhh(NUjM6YhK1fU+O%h zdiKZeLDP2}7ytX}XkaIS#TxAT#lQazd&bHzpda&tfoMlJl5+@$SE`2zH~4MN!M4?Z z&SB!bTtE|uRbN7uzVQgi6LIPcTZCEMn}Ymtha+(p`czSvn|q4*>~gDoe|nB0)2(9& zVM0c|QH1*0gRE56uct?V7Q(|ZG6XsZwJ;7qS(^liTNQQ6T)P`jrGaUHCnQE`M-e~}E z1D9%>rQE5H>1q*AFsIiml*HCSZ`TLobC02^a`beOP%@|Xt1nv+lR7* z;lI2d$kVS~x~AZ)#p+6u_U;a(dP0vd*!dayO66jSNTu?AQIbBa?jLY2Oc@UkkFntv z!rca^7o>K{Uw-ItwUaN%R!m##AIi{kDAi`Y)F^c4KfFPfm!(t!d4@d4yk0#qHoNCt z^r%3E*|0B5z^$iKV15y?GXbCFe`~G!l6;CYgYFyDu({7ChdgHfmJlGGU^9Pee{@hV zevMqY^gx5}SB|;jUf$5gx%*vXSH6R7{NYI9n|A-*DyJ$>R~;rY>hHns#* z?#SLWF$pe^98lxsju^eV0u#Q|7K$>m7ZTFP+1l8ywmhNbmz4~^i>E1P>A`W}RVvUi z4PjJ7PR^{V@X0l~TuU;eFezR8WzVAS>SHCYbiQ?vjNV^Q z?8At9UoMf>Mh_iyaJ48Y_P!^PdC*LPuca%6v3Q4DLBKr}apyIkHRtDJUUQ&wa|W`@ zq4#cf8|T2)bEoKo+OiP65NN08xwx8a(zsX?zHf~F@u|z z7qCFKKRr9*&~X`XEN{;t?UUc9+{T>e)~&h zn6WkY)o+I52Uj+NYeaPq|K4B|_+Umz`-#uh&0WcD0Jn3LkRFDPVmZrKyI^OMM$|KZ z$ddF})oxqf)kR_l+4v^f4>-NytAG=DTJ+FzViOHO_C|?8A(`I`+}cYmxD*~%Zwh77 zx*`-3NBO1t`DX?Rye}>-IG>utjkApwJLrxvY0s&Ju{>O%puC57K?RH$%GAm3BNK+) z_vq^O_hrr}qB5yCAvG`}I&`B`0eR;uJ z4x7iwz4*%&Cy|K9-lc>W=o6S~<-M*QS5eDW{ZK;!nhDxzoET9(HZvjw%2`J1c0N(A zMW9DvnvdgN)D#o~F-#*zsdT^y%yY+t>;MgsUp(J& z;G?0$yu*QPa_}<7oVZFp_O~u3_$r8wIwp-*Q$FXdK@-(&oOV6woSXK`@Toa*oj>trjul}#ct)V91^24NS@{pe?(&PZ zd1r!?gNI*3^h3?=?_G22F6GKQ=Vak<^Dlr7 zn3M|%hkly`>|CdW*PfEHA_(Tgto{{bkDUkhCRjl+5=7?nMFi zc*GDSrK|lSXVIPAL7DWPcQY3dnIfuEBInvNGnZt+mZ{mTV&#^hv*wCp>ry+`;@OekuE;_-Eq&>rp{abJf*0j;f(mb?(>xtc0({ zc-lf=Bb`{Eg@=dXxy|bkeEm?Qc+@`oHS^MQfY z&r}N4X1-|Y*zas_QFQSRiL6=5&bRw>pHRMT)*req-!y|7x1Ab4P!QY1Q?s(BZ?iJ| zxQkcQdL!`a?vppgxy|Rxorp2FFpyv4Pst)rA9kW04coj+y$p~Z9f99EwONRIJKGaK zRJHrS+MQ?}BC&2$?EHn?V)mph9tV^7zhh@=PTX87 zf9H)8Xvro5ndP4FhY~R^jXPZ4_d7?wtUPu#hMbtnRZ|u2?lvAMThN_R`^5$n z8X+PLDNLyAW%}0sD%9<`GPUOqyFeG*?uJ|%-+CXphOxpXCl*v-SN2X!#G^KLHhR8# zexMQFt*PKktRwelokYCxduRLEO2Mva|L@_VB`oDxG6cae>}cMvbrRLlJZe(qLQ&CS zp7wLe?+@|js=tZ&ay8@kL2#l~;-s?_nIt=pfr3q=G4`-+W50mj?S73*Rq0*a?QZc9 z{*47;(iZ&mMI57xPm!(VvP4Yp)${{RWHz^C9(0lo_?`9g)y+JBYda{b?nAmSN|C2DX8xH z;z&4l0myVPu$+?fRY^&5j21R>4joHY8CXuqfa#&AwER7fzm2f*-)~``E#b4Z?%l90 ze7hzdZ7vt&7YJdrtI~{CFONK}@MEUN90?M2)GZ#ycZAHbJ-U=6@~Kn|>ln_tj{G-n z18yjzoEXX1>zaXG@cTxVFN*2alkp_5o6whcv!TyAH9q#8r7v0f%m~p%eJ5ky=o{=y zJ61NHnR&IXkf^FjT?2Wkw(mQC6&%YPf2O%?2dt0o;+Yo0TUG-T_f}2rV-%Jivs?Cc zG}A31-(d(jVG%*Co*O^j>4H42nz*|xeF9uOvf{SrM@?Ibc(PbTwjP#CN1T2U^NjTb zVqoa|ud0#VtsCbUH(|9l@{YZ0)KX#{6E zJ|tg0>OB9e4vD|O_i8i^_x=um%#0i$t~V=WA%AI z^spYEt<)XAn9&5zetlU!v0!Z_MFVroJ0s~}$j!~k+3vWFSWvqvpa>}j#S9|Cob6NR z3p0dIFP?xKE3k;fx6x3H)f^i82bF&Gl2o~OcY4d9iPz!evwPH~%BjWTxBqGe(24BO zo?^h#8;hhDwcNjR%v8jEH{)L8U>TJ%w=BqTI22n}Y7e%E$h$Cqg6|lCn}J1^$)&;x zVbkE8hPSP(E0ZJ=dZ35gQSBT-yY@Q%13XC^xvZ^T! zi40-?9r3=1Tk(J_Esb93y|I*X^g3sIVW`bLSTHX>>}t42S@I=Q)KI|#Kaao z1SU2Qn*Iz}S|~2Ge4~!1Qw`JU1!yaehB*NT zFWAgVHe6zMtENfxi4&P*jQ@?-rPL+tiw82u)^2HtiRR&rPI4^drW<+$MJbFPusS1@ zgp*3ddJvBeUL?m@Mz&A&;}F%czI9xe0mMuvl_~PFx{f!PKkW&^KZU%gy3=yi-2OgQ0siB`PC|IeQ}UL!_eDD#RJr^i-YJWc4|}<~nn*Ld#5t7pLdSMW3somqYEC@YbvE zp@WTy?WNbOIMKG2T;0pKwdwxA`DZ$oy&pvK4$x9KJB5%vLx zn!4FJ+bES{N?ZdOt1@_bV=wk>fO?P4VBKMOO1_+Zz&-}2|Hv<(zTs)g-r(=`9jWoh z)tLrW09vb1*kuQ*EHV$-p|&e7UMF`oi6x{-e3jl!RPm)d<@VrLX5C}Ht0jyAvhX)e z?th9Ipo9-|q6NG7FS`02pK6n1u-Gy&B}Sp?#Z9{j2D+E8-$i6S#b#bZXV-j#LhLuG zcJb)h->#_sB+N?oa;+HA*;D;gwH6z4(m*a>pZ!s=_y=dvKnVc z2RQJjpUu-g~-20k%pHGn8CRru+>sz!2_qZ;OE>?R?Z6%w1g4| z7S3-XVj?b9VFnL^&=>MSaO@WLIW}`)?}?LVF}mh%f{bTO3g^%R?-q(7(K&I> zK&lFygW@OTbDTH(h~GPA{{BwPph2OxV> zb=A|{0#LA|Y>Xn_{;P%wK|RV70;~yS)qpaF;?}pK@D`Kv=6ohH?EYOkyOc&^MXV!mDjJst zSXwSgj&%9Kayu$#)c)AWsPbBrskwWH&=&*Y7n344m-{!e_TPXHaGE zx+Y1&;$?^)A^-ara}(E76^=EylxT1Ni^{ZUY9ZZpi_lBWKi2Y3_3BhrM5%VrKnH@W zxR^4QNXdr8q)w>^Gd#LvW+9i( z{#+j$Pg3nZY_ssX48_2zqXr&CfvZO3B(tFviS2rrfVFYvA^fsWA~E#QQ~1V5%?WvA zjJlbGXTQw*$M;e=M?b+I>!%DJW%Cy-v}KP=mkXmc0Gj5Tr%;8IsW(qooh0kBKjEOn zd%#>If>z)Bi>Y!FuwTtsNDED_!1aP5c1|X`_ha*0`@fxIE#M@EiFpb85_l z>>kyVMTPPQUk;p9Xsj$Q4YSR{@a1OR|3)nvW-YzK;8GGXh*a3*yE$(ayFw*Df^Bw!-yX4Q; z5oEWGc<9mT)NFyinjYV~@z$Gl_dR>J`T zlYAyu^!F@*R*qBY6D>e2#tjVtQR$)0Z#dZFW-?ZtNC*4_o^NvZlFW;Y2@ZaaE(RE} zT#5ZpP_7<zjGaAd%L@m*4Kbxn~mHx$srsP(>AAFsR~9@B;ZJirE{6xtLgXbi19YC@QfO6B63 z8B<_}O`qFKG7w2>{ZyXO)wbJ)DikLez*P(G-8RGTWBUcHEK%5&C1~Z(m8T~+72ZP< z&EbA2-Mm_%-{&-Md$eRI_xChR>KgBC8%`2@Qc2AmF>TshX^#TZKk6IwZK%&N84l*t zCil154|?P<-^pffrnBUU4kk`{>ElaWC_G83__*(6zp`4p=t2#eeh?!frBIdo#vXaX z$;!oW-{=KW&mw5I-SP!B?o~6FCz)b8%9ipvvK7|UJ8vQ6guOcOi33Bi^*q&SJ%w_w zB^S%Jhdi1KKV*1N7X4Ep0ygr*gcv3(1GT`mwi-?;Z-qH*p9o3Z%YYARpsy>I(j{C> z3S>`G_;naDItsL8vh?~vQ}=SUr_Xbfsi#+JpHt|qyW;_u*z^7L_1yvZy?`<8ImE@I z_+UcFUR&Fe8Rn(cd8OAC-H+XuV=`r#5s1hA>m`aE^g0qB5B}icPQ+nqPvX1mBThyR z8gS9@-Sbsl87El4IAilYBLlW6Hp4AXIxX=rl(VA2WJ$4Wa$~4p*;YXoXo2$Y%^gvN zmbDzt7c`jDB$4lt zeNI<~W*_Q8ZW&Bw%iWsC?+Yb1SQBz@{s#)N@jl|sP^P~^98EAWS|z6r^c{rm3MK41 zBIx`@jiU_<@*YMl(bWtAh_!U~# zrqVChjq;8sT}8w1A+OJOUe+4Pg+djSQ)NF15N5Sp+wP-Zy-J+NU?J#O1I6}{q!-i= z51vsz>fz-3lKc5gghQkpUCGt$8|L8vQZEBmf=poGlw5x;LlCMwGh|C=S)cv*fI z%UojJzMgO#v~`UllZ@0~!OvO1R+C%jR_ow^qz}5zpYt!S?Xx(L*K&V8VF?=yNefzo z0<9Gml=^kA1Tqlbf}3nTkwp#qDX@YKD{!7u|BThr1u{YVgQ!JSdcxg!X)3(^?l2>$ zkfN**v;}^44IeWVW!?&4bMG;GW&ZRQs08%cXm^D1$3(kiCigQ86cKVXU?ukjs(Xsi z6O^CLxg;gz87^6m#yV;#0A0OX-5aFTvqxvrec^E*nvZG|-F+r+v3&L6HOUpjTLjjq zd-r)i93ievNd1CF)&{$dj4B7>UZ*Nh%ws>0Z#O@&0YNThHPQqNZm@@5;!g@LtYqOK zCi#Z%LUL+N{63vZ=sM)MBlO7I=8HD{(6T7tgeN@dzEU7B$ii6_`vg3s;$HcW>Mg^- z-H7=JBKnYXYij~f9gdE~75+)G>y=l~yfqF1NvyHe=)ds-Xc&gJ9s)Q+Xlpk&q|2*X zGPjHs?z}HjR&J&(LHPXZslX6?iX0oo0frw;jjmvg$gR%v_VS3=i!#)i813?ixW0XP zlHC^;WlABeVo-51cr?g3Tb8tE+0_~F(}-{=IOQj^s7np@hs-R#4p+v@Jr!lEA5v)S zZ6zsUHcHmlvy+SZ9-SLJrd00Hnh&5?=+2M@nMEYx2xi<>=zCKN#$F3rhS@SvN1SYL z4ECOeCLKBOs77qduq)y3;Q}25F;2cAz>OuT*CYu3+ajLD*6KE)O^2YEe0qWN6xRc;qzlmY^4Pu#JB4!Q=Np4nicmP0A=pK+(kFx=YAdDtV zTt-X70m`y5nnAmyMjH)oO)gRU3OJ?ZPHhs8T%l3y$1#NmjZCBVIuxwJOu4jU3W#G}tfjmbu8ws@-*d=|02Y z+1nRS>|BH9jW8y#MIFk^ltE8FQW=C=P;7M~g8{U0dC@|Ot&wKx(J@R1Ulek0DdYPp(oYz{@`T504Xo8!BP zM8f7OC~b!iF$h%RY?U;EFaoJw0(!&Y;)E-%@3PtX*B6b@Ruh_Xgk`kY%49q_B)vqCik!Nq=$<&>m5a7BGJF>Pj^pj`z6}Rin#DsI4MLjd1A0F- zRb&uYflDXNe0JEg01h0O0-o>GrL66|fZ|;cQOnR>4#K797xRc(J$hUZfOrjjxjm+t zVKGm)xxG~v1c6tqCh6k_YX##8T3HW-ONbv&Ms0}+xK1*}r(JUusnlt@ld2!e@-{~B z%ea&6q}x(c2m3v93uY#@d)A&CN0)7fUh7km7CL0jl3LWs7X{NG5PmVSHnYKiOZP$D zsGu%rwZjHCp5OuTqVa=Jf7~ZbG(Xess^S>kMvG;o;zYnC;?a=Cnn1s|CjZo3Kq&xn zijDuHz5)hxL{kjc7^yuY5?5h!VP}PoC*=CgUaMo#hb%Sb0W+_&bZvN%>x=Jwlceqr z|E9kkMH67uc>tl_;qQzxC2tnhtiSde8*|ff6Wi$@mO_lGNW?P7*{4bc)mry`Q+5DS z>&_P@+Ck(GHMlO1$WsWfJ((29v$|XZKmHgn`#qvhUCbkf|Jt#hn?|M0!0i(2da`s+ z#1Gsu__ZiiAyW06WAG944R(N(4m*#9uVPK==pPY#nSM~SYid$53zc7g7>{0qpy8O> zZYQ--09e?wwJZ9d6NHY?1KkP{7m1?q)$NRsGWZ`+Z{ZMC`@9eD0@A6pODal9H%NmB zNQVj%A|YMUuplW2h;)}I-6^p&NQy|u(hW;5u(10(_ues)$nGCli zP=Q>KR)a*sC#SqwiYI)l8z=g520r4rLcqT2b)m6&$n7S(*NeiI|ElZ*ecL&tKJ6 z?vf$nbn?-qw-I+1R#;3h+$>rEu_4iGcg36zdH0K!DKD>5z*Sh>^ zvOX8~F@xkW*~uf!+^a3vy&KCpC=&Hld*UQvi!>|=S-f)2EEPuxYKk+!sSd_F&;g3e z-c`lF)S(()ZW|uG_0<*5p3s(MS;qZ-2H^=$eo^VVUhR9jsdJ@_TtfZ?m&cq^f>sqb zF2hd0zw8b>Y2#(u+jj!bQlf9aR@A{L`KoC{C`N0Z&Fnt8u)uwLcZ-y;TS5 z0{NN6O>e{eyep%jbjddP-nq^Eu@8l8{|GW*SQmT5m?RXs>uLICLnrl7wJvsM`BnO} zsdlxM*b-LBog(uf!w1~75PUiT%%5HaYz+)GBnja4Yr?;c1Qz+((ol($meQ}9t4QjF zmxpcCibNK2stgk`QSv38GRAu3h>&;g3&%CPX<|0{)sdJ-eJtt}z8dRxw7mRep;gWa zzA(oPJ07cR#5wA;o|f<2$J&3t5NpGV{OjLRcXup{)@aVNsjCG6yF^k>B~`|AIE@|NCa}2G zt;2^IaT!>|1xE_9Hr<=Bxw)yn+|-CU%rBB(wG}UU>lop(EZ)NPKh+wiT$&LuIiCiD zZSSPD%1p+{My#luq%sd)GBRr`W_oMiGayEpPpN2wLjnvwVt+4qPk1+^<)=lb?T!U* zHif^8q$Ik>DIXMrx0$Nbp5NKG>1<-fS>LQmN8_yB?rWp=dgEmk7z4xza%cCbhSAq^ zwl1a(t3i5XHCITUBd-v*IDLJhV}Q!ds&HS((r&-IaFvZpDD1NprjezgKVan5NqXYz zkMuDxXVe;}0IC|BYfic!revIAYKs#quO3BhOvQZOYw?)WZH`gq3`H!i(i#!)q&+XH zL_{q!y<>GqGcitg>3I!MI0IpSSYSt+?H~~8>Ph6A0SA79)g@=}L_mQ0e%|kad2hbF zggX~=`j@6NP7wL4Cj(LKX2oi*^?{%+=05OKzq9LWy6Kp4y)z5dbTp^r-=UU?;%`L? zFem~TCB(ZJ%ccc;vBw~*@U;7Fmt%}BDFoN*PbUQYr29hASjqdma(;U}yu{Hl3zs z>HxJ^@lr76D<&xmi-7l4mXdki4!Ij-UXisuq$+P-F)To4zf{TObiHS35L|q?-M5LE z>!Q{T-;?XIx*K=j=A)d+l!>swVGFK}^$S1Ywd6}gUQ5T`3#aJ4VwT=SU|HPF(<%+O zPouE$qUSG}dcLDaA793v$Q{LCv4yw2bi3z^Qw%HpSS{x#jL(G2gFIt z*-mo#2pd@xLM;NHa^jHA${h(ARDx;x11ZD>Ou1+K<|3|S-M|mgCy72I_-L-|vFvD2 z0Cb1Ccs*suF^Q)3aJLd1%Az_J%n;i6SVR7g%LPVGa@!|bR2<#p&8@m+`nVJzD91>G zes$7aAA>FqT%d9vZ=S}Q8vVzqq@blHE3 zo_*oCAT^?j`?U65TC;5OVIG;U0Pj29p-3dKX-25bcP<(YbyZC?KfivAfBneR1&9mB;B14>i`-8^$choqq@-OWCJl#{}qb+gM~W zwvomxijTz`1SyMyE6oE$(^DbDlGud>%U|gECzNXSQ-97yw>d_98&bu6LlaN=;^#EL z-OoaSoBwGZ5zG6l54AzBqQcxQp;8^)L|jfp4tXV8c?)HZC(9dVyu;EFb4Re6vt4WL zlV5)tM@=pMmp~B8WqB)6aR2M2B?fF=$3nL{I?SWL!S$gxa?{8r?3c_DaUj;xzW0hR zGBf#IiPB3$3aRoz@(O*!zn}F@wccHbTfU6^EG9;~-Ym1JPV1>>mhvv5ohu_kDe%%l z>6c2y+zssTXOS#^&Yd={cTYMD-s0^QVFi7{71c$d$BMKSZ!(Ib<{F&HJ=@CQk`0n6 z>{LdZ@m*a|%{A^4gLvubF-JRqTn`BlU+ zJSA*Rd7yflu2o3`G#FtN^&uUggzY6yOyGrS(`QnK&x*IyPCf&#Dbs~;eSJa#$oJQU zzbo}Z7W0x^#4fM~uZA2_zVU-6?dvxL={p0_B$cL7Q}5PBnc$13xx2x$c%_W&@2MV4 zHQWFecMWz=Fm?34TyFoef$2k6p!@?y4V%zVUu1Zt1A&)X)7*RgZodE-iqW9q zUtMwC|B()lxS1{a@4}cQ7QSuzyY)re@rq6bB53Yo!gocS;^Gfx z>{rxL@3jJi^Fo|d3eSjsq79k@0>+ODIg5q0GlyB6Y~xjWlbS0!`5l?^Fycy$@>mZP z&tLk*Xz&d79yPDHtd8g=;e2zs@WVUwx@;87khW=>c|P}#Y5;b-yw~@G22?*uSor(% zb2_-|O#Nhbp`5NX_ys;f*&G>vHgy7W|?Vezy)`L(G44Vy3 znnnqVWB||qqHuE3N3G(FO$wQ_!H#a;xUpR%KJ}B*v^v0Rm7#?Q(jiFEDZ=a$l%hjV zISI2{z1Y{6kUouK4_Qe2h-2_3d%rk?ZleN8j-}5iP@BQS+cBY>d$CX_J|Fdtkm}3| zM5*nt4_fvIJ;QPQtHDy#3G541ZrV&J=&J`CB1h%w^-QLk$0>o+ce=>9TdDk6(hH40^>F&L}P!13Hm2_d!Dz*pz>p9Cu& z{-bz-2M~a>QvS8xQhR=@lC#VaY1{n3qc@HAnMZhMrp^*yWEr%XD+_looj)K4HKP5qP3R6 z&^rW1v{!PwWTG-bUN&}9EGO{n?D{tXs?#?+S9W0%f>1Iuwty-yd6Lk377Wt~%38a0 zUl4wFG<uQ~Ay2YQHnU(ig#EIRg-AdAv_^tX)cDX-j?Kb~j?b?>W*HwG--i_6?E(q%UQ;*A5QIh4^S&%YOQFW zVqA_0ujH8#y8P6hp7E=n*@pe=Tq*E%i~kex1y+b%^KD4 zuaCtM$``1Lm7>mYq?z2c<+5IZWye!81A&s(>pxYck9?ilgj3zIfi(_UPBZZBGn8N3 zc8uOV@rr9<%*ltko=;ewu-1*w>3??1Y#-|?lKdA!v4CspuUr;tk`3WnIg#EPc;l4o zN;-8tV=ymT7p<0-x#9xMxvojPFb&j$)O5}{nqW`wlQ1fAQ{qMz#dG%Lm&eYccdz=D z0#6i_o<~#+U01K`^Vmx)%Az-s4e2)rAF3W+)G-+JQ!7=3Q>q)yH|H9ek8Yy-){I0& z8_UvrrT7`X>9(bC(Oj=(|DoTuOMX_Dt!4EX_OvD9Xgf(}t}@Vdf` zx;U86pa26J3PAih(g5?Zo1j|P0=I^2&K~12mjxKWPjj9SU2q|uISmxKN_z!NswN^Y z?ig=c1Q%&@AKl0T{-I4k+$c53pmp$6({aywhB6lPDS$6Vh&xPk6sWbMpAU^EZk zN)&g)MP%WFH`e!LroD___U-vkWQiNfWTRk~eG9es8*_@@tW|fgF67v94{QbJmKp7C zc&?9>@SmV%Vj3shdWb6CtSUv0`O(4RzXP+>4%ZBdt(v`#S5|vECPk@Dtk_hezUF47 z>mRgltodQ56~2B5&|mc;-gIr4KoIC&_XQbh54zENht?!c*}{#QN?(c63PiiCCHMDj zShu1X-KG-Qk$O&R&3N0s|J3;V@Fo10y_s88^_Yg|?X7RotRf8+S{@&63YXw5IdbCe zB9^ghF5s&=Iz=EM2K{(W?$^HGO&4gESFaK1GSKK8m{fPv&9u^emjF3*LC)_D$y>LRDD=V?Aa4;IcOAn-p}HN5FLj z2u7DL!g?W|Pf;ZaqXC|2qkJtUXKYFaAGD!_*u+P_=vKnDEgsFHjFWEmOB@aSm2L{^ zu6zv9$A_ME;rE)RXKa3KR_p<5d-wy;Cw4FoqlauvenS9eTt>3tibi{|-PH@gB*fZ^sG>djwWfsHdaXR<3)xu5Nt z%yMo!d+r}$uzcr@t4NGF(GR2eu~en?M$);dD#+Ijmgo3*>e_VIhC%q1jC(c!3_d|( zx?7!m7Ur_VU+9!V8#b?yCP>r6`(vmuHU6J-5zPvu--^+GY0+93DRJk`lQ&xAIB4*w zBWKjJpWj`q!>jW9M8+bs}^*nNdSZAu2Qa z>nDoDTf|0fh(H(o@i45_|16R8YCW;!p{2jRwNo$r4Yw8U5Q9wfO+IJC96I)ZSjemz zS0THE@&_rwzu*hn0bDoPVjorsx@*Q%F;KD$UiNs@9d#YplXZ{nVQCl*+@WFL0siOk zipg@xf5h{djAZ9w=nu;7F`WHr`-SF!1GyTTBrjDQ6&7G-Ks0s!gNBOT!OuTS<6*J> z1{IIUHkKEPnKFo_VuCQp4lwTK$b^MKw)xe10aaVbK!h>T$;8#_U8c+Y^EcsMui%O2 zy-_-GrhN?~F2m!VTv^Qx_c%eAQX80(1=%hY4(Q;!T`IE}UmAtxOo9?#HC6MAqsMQU zuBK_8A;+s1<-s>khlI)$4Sl6~Azll~n?wH_KUb<}_J3|^XypPwMQA~`Eq^BaUIiL;w0Q!>o zfoiP}=`{tbS9%neoD@gd5GhE3KzuN+eaWCf*bch?sRYw3M)$|A{RgQYyLkcRH;)vp z|HEE@I3yEjWd0O&%jRg}H!d9$Mje78+ce)g_Xn{cJ;TD37+*bc2m&44-iH#Qi0n!b zh(tP%CUm-^*r{&nqj{9P2EVMf5^~O=PTOCWdcu)qV-4gId)=y`Q-F+ksWbeiM~q^v zan_BL6yO|yIJai&d3AXUbXH)ty?1J9mi=|Px2RVml6hD*HrGJMZYy-lN{B&ewr-c; z3uzPU;ERhEVmlpUTS~DbxApeR4Dr98B8=()X8b`*fSqm1!(Y$o*aO4`k}=RL#+?MFVSBk;ln7kckg<>-)1xG9s^fFN1Caw}*&r?k;>D z2$5|KJ)^(PDkB@oh1CzQ!6XKA0pi-S%-!VD)PKRsRFDgxBi4$uSeLG3;>|{VUR1=# zLWtLiA7szIn}uox>I~FAyoF2=Iqw(T@HK&j2@hM%0Uh-U~ zx_V1X$J)^bqwLY<`myWDotI+a#^>^FwjB)$V`9`s>dzM}1dk05URm zoAMNMP^gm;tX5b!?=lF#i{tQAV2GXm02i(;8Z=Vy$|IlSi|w)SQplW{|GM$#MyVMJ z4FSn3Oe~`AZ`;m{w_TADLs6ev4AwVZTej!j3KMAZ-WMS5;=#8Z+iJ~0TXQg_txIAV zx~8jP+X?NJ28AsxoGS^oF%;>qnrV%b0uG()2f0(G_nh zzk7f0n#nf#j+)o;)V)w7`?NxOE+Ux`gEBobG&CI0IercaZRGNLQ_Pe_m`5=?|4xk+ zp8rLcYEw7NP_vS=dT4IIbwSr{b+iUHH9lzcM}F+W3onJhb)+VG~(u9-Nrs;GVNu}b3Gz{uQC_i(uUVW zd`Xxvz@+?tIU&{hwd59`4hv!q*stAL?Qv}L_IEE<0~V@slP-)uqtNwB1X!bjU7C*H zG4}uK3IM=G%fIRm==3&GQ#k26^d*^m)uQiDVNG?TJRh6U|wR#dWV}V2%-5Zra9>0qLUPweVWO=*?Z76zWKBbe* zf(|{B*@Mo;$UIco%|t<5YqA%R4T#10g`U|jNfbn)j?>Hn5-1`yZP@gf^If&w%?ser zd|sZsXx)S%TgCT89#-y0Kfs_cu!?Ig1?W+0$fFV2P_!=xc)#uAn^oAt8F$A7aQRL4 zjwn>aCOEY~Y-B`z+BVEsyX>WuHig-q*lM^8+LY4+ia{Sk1uX1rOG?CcKfSJLoh`yY z?xA&U=&;?ex*2dx5^-T@lQ{%|ur~G_Nt0&w2PB6F>|xK@Nld7;9WIuY)k8j5*hqw6 zeF_|<`&&}VIGX+8=|KFs-SB>;`{$s4PI}DEZhI_f+5q4i+;mh65>s|S_oGvo-s4(q zVe-4Ti<_%UqGoth$I+!|Unk#$Lcso4sT&h{ZV!P2e=ZalFOR6$_nzW>V=cW{ z=lL9;9xuT6u=j|T8k=d!+a+c6Pi+vP9p-Lj*WFjo=q@J%KjrUVntgBNy3f|z&DsXP zygg^w6IDKVRyQnZoi=H#bWg}fC2fmK&*hDpB$@V4f^uyM`G=m=&%N1FBsLC4nH>Ja z=#F3YPgyY4{&P(qI*gNaAPD3>l9Prlx_bXrv_b#!De+cRds$nF?I1nAl1j%Aj|4W^ zcVO5|${Ql|i~OcCV(yIN#J(4B5oX(V5LwOl6`M7CY(GBb_m` z!_2r&0w`rZh*gQ(pKD|H}0CpMDFY>wY9T|{hAF&Q)UEeThyDDC&?9?9GIMFI;F_<~aIV@_~{=L2nB6?uA(Fe7`odP7Fhsg0^l_BpA76&;9_G|%< ziSn}$66^cNSP#oBc@#rMi?09@F}JGROvD9->Go>+4xJ^v*GC4{oZTOy zm|jo!BO}XhKO~Kw!xMeBCAzO6oWxg)m}zRZtxRi04WsJoqqhe_*~FH$jLJwdp1EZM zF#`|bIU4t~?VO7HSpObu*OB2RE?6g!VL1^wrgTs&uNCs?z4%WW(c!um!q@3_Sc5MT z1`{Rz71V)GVl9`J1MJ!~y@M&&x6Z+`S|;O^ zzj+VygGlFvXaT*brW0hWjp|L#l6E{!)}wkY9kXwk5n~wz3xPY9WeAq^lep^N%^%cq z_FQG|uzH)83Qs)2ZzwLIst)Wd}D=;Kn)#TbT% z9$bWuD#uPul^AzN8wmm2&)wcZ*Er3i+a!#3X2YvjbtyZLxB&;kkT3jFh}`YiZTX7{ zIEz+hY}4YUy?YC(6Ip)B`f>dowyvEGva;&)q-oxc53(=E9Pg^am@bIgLAauq^?dL6 zZE*X@5bd&;R+KW=n5Ea@4QRWGJwc*t{@#aop>E@yJhW?Dje%S$ej2y)CFpq~mRVc; zZVwc$Ins24Y$Ll}ML^Oh0qn?kpNt=KrKxFZ3h$Beq+DA^BgG{>KhOjcNTT1`j~fZo zYwWX_am@BceI&>dzX5)SadLt*D+Rp73LE3FfQRYa(1ByN4`Ar>0DN=*2Kd`N27g?q zmc5Cu)jD|(o<#{+ZvDd&z^!MK_bU63vhkwj`men2?a<`kZe*6{S~v=<43}6GHT} zc;52qf$}A=qV_-5b`7`lURfLR@-i{ijSi9jap|(%H2hr5>O`(oK*fA}gnoZ~JQ^zt z1eTclb@JX>AyvgCT``{7#Fgu#ec3dTk{CV6q8oa9k+JYhQ}0Xz)k@gRmqa|Js^VG& z5|GFF1Mrk}(RVlQ$uwlwP{?Ba71sPC1WH@|7CxMf$$f-h%rrQ-=?yG)K$&)FE zZO&SjR^vOm`u8!F-CMOmG?Mq(hLo+hkAYXX@U?ckP|lPjOt|n4Mo)it7FiDS#-;MI zC>Z;L+FZPB>04~c_OENL-7vg9k)r*y?JE56+5Rf4^Zy6VLdEG2=RoF&a~4E8gR#3l z$)cTbd=xpSBIuzw^zI0A$83bp3(B;9on=a@LHc}mPvxuXz2pm4%r=}Q--HR+x1cyZ z^DX=$V9wE?X<>N4WwM|Cp-;QX$wKSc%ZK8s7T>Sa&O+WP$`0}NUSY+0fwYzI_M5@i zA;#`PiJefJ%!-*Nm%>lyy@>(Y6{m@!t_{geLiMua&BqGf2fx-H;?z+xGn;{vt&vq9 z9ghoh=?t;i_d5sH3K>5=F(Bbv;RS)8y0in8WY4NR<8EsyC=e8IBeKKM&h^K%4O(&1 zS?2!E1cBa*rmZJT{zydKIeMyM*VhEzupeHrpYCyxLfQ?R6Gm#TqW5b-06a^0+ih~Z(yGZ-ORk*&YSp1L)+AmhE^oODk|!sNQ`@fRzZ zlU14Q#Xx3WwHbT$_@89k0jAhe`vcL=vt?gANbVB&y4}S~OfAA1;ePXcU?L9?KoHTl zs2^Qn9CkA%^ugT=CS?9n4na?m4p$YlwT-E$t-KnChVi# zj@CCnkfy3Uod#%&)vMyIvI3f3&MlV?qgN46$-cy(w?tyKNq*~j61Q?=VfuD#G|!=3 z=+Oh|mxc~XLyUrNapI#2Ig&`eO0wKa`E0@&;&2}7VcG$z`|xg~0D0{m$LRN{TK* z;sU%by{MXImnl1+>4m{15Wl{@Uc|$Whi&5##^&>+4(_TM#^h0$Nl(vc z=m^64kiT9DXwes_NE+2ivdf5D6Vh=+xVxzGb<)aT@#Ygh!5tof*1O(6cXC*Fm==9L zw=-%S8HKRr&@G;+I8z=m0Ekgr9j=orOs8etqRXIBT5=>)ekV3VSa&UWbAn+FCsbDF z2N$+*S4qUP?MJ(wJU4UlUOO)?MAX=^!syXS8Nthr7>t<5`X}&2X!~ zeuVJN-VY}8grgCdigRc%hU~-_+Jsr2f6daAQYT0Ow&MJiS$){p^0oQpOfjpV(e0&7 z-N!&JPxA)t3FFZ8ryUF6R7a+Kj4_zVJkyTO)dH8y85k^yxw<){ahU^z(e*hG(FP}C z-?!%fCsd~eXJHg!Br*r3cedVUZ9HefI*+j0PUr~&0l4qIffqUlbT??(0Vkm0Ec=>d z>Kcy3fjYi`UoW0u&dxF87(BQ_BsS9x6t;u(H^QX8eMU?|1z!&bt~47@FzACxIoreBVOp?mYZj6$GMZ`AW8= z;P*vL2AE6_wF}_)&C#~Z7m3Ng-V7*FcH4JxG|Ci|j z7PT`x+693?P-#M<>+@#10`E4@5n8yLUpS8jl$1A_5rT$fSh+T}9$lU+4=aIMv5TJ5 z)*o6STdWmuZlxvFkZ!}fu<={h86#N(VbNWK?kx(;k@jcf`XrWfJR>D8Q`L(zS_VX% zvvULIn5bDP9=Ghy&Fe2y9iD8f*|WQ5X3|K6-=ZnU+DQmWbWAza=?b0BA$BA>YM7~0 zO-8hAfPKF^2QgCZL9p*?@Z=a0n{uJdy!xxBgZ|A3GIN~6V?oqsesW`}+C2)=bjwP3 zi0BdM+|=Dhh}tepK&#MU%FY+i!QOL%>LDZIef(g~C&ct_wXs!1?4@ntQypE)zwG8U zYJ>}RpAd#@NRvJYXM~6yUU~aLyu<+}IUg+o^!k1 z4_N`3UEVj1av9 zb4O9#Qhb%d4)2-5kJbm7v+)2oIPO{PuOkw%ur81gG;d{VNK)di&{SbfdpV zT+=OM;ZT>%P2&Xysfo<>uMv-|YKDRuETE4A&;~adRh9PZt_tsID;e1x3}s9&fTY~| zm&Hi;l^$g94*91T_arY4*Dpl2>K2)y&s zyR3n}Ixh!QQXwVFTQt%;q}Y6R2|R2<+IRL3pO-d^>J!&)Y)D&UsKJ;r9W9Zmfw;EP zc_g<1Fzoo|>|@luar$pNp5OW(cH$|ZnU0%LL;oW%-pi$}+|F%$jKTF&Z6{2fTI#|2 zbN1v(>Dwjzkwc;JgE1{Xm7R`(cGoZpZ`;qz)nf0g-fEvF1jQ@5hnGHZ-i$EEnk|@m z3Yvg@x#Bt?9s06)*FM1Uk-#{6hWF*z*d2(R`fJUkec$=psAPXEc+h?UqsQ;v*l`=Pn2;)V6|5HzHQgmq`1Z$Mknm$^ zp}~Y<;=SN&nUx@u3MdL>HPC2VCY^o*zlLpNE-^-!^*!j#(^22qO$#ed)1$Ln!2tZq z%7$ihf7juB!baa=83%_$pAv`QDV4smxdUxW1``d-AO+9}r?Hf=xGewIb!BxJ{~O=` zUl{07+t2<20x^`T6kLx?PNy?gTF_&We5q{xr2^t>kl-DeXYY(s2=AwJ-EbLw{pzK) zQSmG9f*1U5-b=-VA8*UsGa7Y1=v+cj3ts4>z*k$Cdb3l`sQa@%?X&}+ynB!;Y?b%* zrVDE)_+4LT8WI~U-WL4uUjG2*mR)aXa1gZ@IdDyXQ9&)F5QlFWFtY{Z)~o?m)8r~L z0+V1@Wcv0)Q61wY*SWT-t<$LIXxJg^4?lcaRG527hnU^7Nu>WRmsc-Ta(Y{G!L5k- zEwmnT`pl1WMC8kTpnaCTBgFa#ZQm8FU4?_A&mOnfVvj)DbK)lU)aQ%V51xk#dI$Bw zpD1=u0P>&6!pV61F56{XruYOfv2Zg9($|+9OT%Mq^>SQE(YA)In|UYl>uL_wzR&Ay zz9Tuee|oF~Zg%x`g_G4=Bc5b8XXYP6Y}*z!$&@-cExu@HQCUWVV@HW>iClW(;42qx z=AD~QtiJ9z88s$Vh_VN9ydna!M46Xk&Xg1k-RQFyjyj2)$0olmytcTP-**Rs*8O3c zvzdtSUUfTq)-q^R-EPZv#QcV)<$I6fdla!TCGQFi{NudBfWe=70akw`Ck-ol|EMko zy0p9e#%p%?^ogS&jxwbN1E736rlNWUq04lZ5Pe9$AvYNmJPtXS|7bG_{#&c zM6lF8D$HX}OJbgUj6(jA=wHe^r}~*W2?;8Htj<47ccd1Nk#qKhu(p4wW-a7QhHV08 z;eOwRFI558Wm=y;`i1CFmwUOzC$~;So(abE=qC(sbp392GN=@8FMn#_q5Mp=r{~wN z!7sxG`%SvEY28K4kx4L_SpZG;i-q>v z_)9FBoiH&#L1;wJ^YN1p5-4`!M%XijlaDf!?~_i|msNiG81KPxvI{#L>Uj_3tA{qm zLtLiz=*>&nXX@ro@uP|yTdOy*Xs4YsVEq6vDhjg)g-+jCC{;e^HG%i&n;LECyB#GJ z=?BW}$8=)4cZ8VKX8S3coe0g9?qWP!cI@i`;yKrue8#1CO z4Nn7N#ALK?jSp4{Yu(+KIfPoh^F6cpI)&7v<_jAolI`z+H+dQo@%f{WEWRwJ7PEla zu-#ddinAI_1ZH1B4I6=aYY(Q0;DAI}zxS%T?<|w9Mxg>Twg*K*k1$Y-rZtkWYUir} zRWOPzY>BUHOp>K?Q(4m0`d?QEP(x+6S9FfPF$n~mUNbQ>J2T_Cr9RaX^B+Dc@mJI3 zZvwihT-w)%?t2nIuvJr|g4BF|I&}l~^rhzsQP64g!DzFCKzS5ZGF@NL(34OQGB_D} z%on6~PtVVKr482B<%Sdw(65tU-vHKzCwHx^ZGhK9zp*}g`_8kfCZX{0+$uV#G=~l3 z@r=QAGZ=jdro#%ZxywByM)Z&(eL_hVe8?Ft%!*+;fZ$iXC>%{ zKcvtB6x-rjsr}(caApvQE4w_;ZIEbbUGH#|a(~4&oanIadZ%AP`-{s!iR*q>-}m<^ z+GBN7zGxWQ^Z>JtfWGaYNJeGetm*uzHsk{KrlJ>tBl9G~Bwd^lZB$2sc9d#;&|Q~w z^LB^XSJ%uK^bIG?<$H{ttTdgNznpEr7BY{U_V;!gtpOH)7~0l9SSf5fz(jTbOz zs9VEAlY3-o4TOL475}^C5!>i9Gb~lo5ehqkLOa&>VOb#pcL12NM|>w>L);Qzc=Jvz z=&>l2&44O<&+p&Q`xPgjwL7{6OQfA8FZhThY)JG$1Nj(^+`3M2pgI(0@_;n%Eo12Z z)J-4=2VX<#@y+!^q;z1A&nA92GZM2aXu@fwCEn6;>hv3xHI`BQ>&sj-l&Xua`mR+- zDxQT>Q}_ET3;?A-UdHVV@eA!r&|U%=G{?l4zv*6<$Tlo=Unc4T7TYCWemqjnr{T`z zySZ*ZZGZO8t=4Z4{Etlf;EIU_q7}VUfV~sy{k~LR=u%jSmEdUCrQlsZala7=)9UaA zj0^)bD0i=SuMe%CkQCofcMxoYjL3-!<8&oPr>S_J0D%JQ*;RvMOFzP_aXGrlLJ4=i z(S#P>HQdh!f)UBjca|2maEhD_ilVc}8Urf#&H|sTfdXb^c)kBaVz+vLPUX-xD1dK? zfp$!pU8qGJRCM2~?m|psZ$^(+Tb480a4bvsHKl0`hveM$vUZI})8%@@OwZXk`ous9 zsQ&>>cW&+i;tDtpZCA~tT|dNwi(aVTR|)ad;~Y0<)!lx(xiOR~m}NUw`yGQqWo`W$ zMP(t7#SuikFONNrpG5SUi&I!wHsOyV;n#psNu*N1RZf%41UVBcfl{uPfuNC62NAbX zLSCP)Nnm1#(M9WzCzTWg0{)co**7Eb z-AjSx%*N0>@(_7&)+>&$MwzcrBbWoEl7=xtTTA3uFP`kzKgE#R+C40{s#@~$fZJ0c zlfC6d9VE4?9A2Qq^+}fFKeA%k8hgP zmRCv>*8b0~Yk*z5pTjPIP=)YX>6|ys(NgN83DEi;r3{q?^^^3c1f38m+;?bc(xE9M zIDMd$-seD9((_7MX7L=;jEO35Aq4_P3H_5HMbCYVqo{j2$hI1gC&7%vH&K|Mk6XFh z@)+GHIHG^Ix~!2&BFG+Rj~w4@!cT&Ioee$E<057we?EA|p6o}HZ^>sfT~Nq^$YyTR zWf||)sU&>!GKZZBP}Ib7uDj~CGfo`HlYp!{I zI=Z(~)Br$Xu{Yd3PLm1fKE%V=!;0i`ng=Yz{Ga1C%Zv_f_D?#@HTomsN0Bi9^yAj)JA8`@YG3 z%e}#2^qX3zY*7%lCJy;+_4w2M=z2upmPUEQbMY6@dA7gko-fS-Nz(_PUcz(Yl3mFe zVzf{iLJiTHlzDZ3f@tfviqi`wMC8dLSrxAWf`|!E!F=P`kgA?$qhk-S%2vMKlBHd-`r8&jug!L})WV&}XY$-qZlp}Lu0Qx_MuX7HYTxp_M% zhhNE1Xp<(Ec=H%|3TfJi@x(D^JdBlUy$cZna_0^jR8Ujp6#pFrjDOe4tzgpT3wfEzjmk9BdvY( zj{Gy!5%~Mp(wOP`mNt>i7>avbGJHx81$zT)1&E!9cl^Un1XDwQEFSn~Oun)lwS4O; znEwY_h|eGex%vY7?@$3DA5fE^ysra6pv3^C8WZSO5!B*C+%O_sDpMQt%sNx0`pwWC zk@}DNGr}LN_#j8G#0RV;UiWZ$a{vmm36i^;tUvZ|!Ikaa)s$Q&tR{;tg~IFmS66~~ zj8nr1$}zyvT2xdt=(oEU)0O+_DR`=6Dj%58)IwPkNX$^`+Q30|V+|y1q)FZZHEMU% zC)8_-;2}q%E~LEVE}r6W$fgueEb;b+`^8hnd-?h8+lQAOY|p~{mX@V^hn{l$pa-Th z?J6j0>yu@oWkDdfMYt4F(w*8TkWab;ZA7G&PT4zLF#KNdr4ujycy`mE1NrgMF`AcG zhM89VjgfQpY&+JNbA3js(_NOU0N?@dRxp3Rf~UIBsPoMX!OYF@{<)#U>qlODTbr8_ zj7-4kTQnuh9Bo-xatGEFSi!Zf;17k>r+!#U&b2>S`^U%v5d8BW$;?}}(k9GsDTpNQ zf^24)=3;{1CeU8*VxE7bbSENT3URbK#M#0brcYd4h)oV7thgJ;I7GtRY18fyEu<&r z;pWi1KX&*QQ1Z$VkXs1CZhOcXxYIInl|o@CBOaDK9WV4V!zc?zP=3t zzJh(I-69P5pAa=1ysc50;ecaj+U4Hs>kNf{wT<<@@ZiCAXJ^;t!B4``#x0uI>EVp3 z299lzONe4kAtg_)*jJ#Gv!WJPHTXuTkR{{#I`HL|kHl^1{34YKiM;)WlHL__ z4MD#}HcY&KEIOPoU}wzn_?ME1|B!r+daR&zb6z+${gWoEef~*1>L4`SO3&lG@GG;y zvxp8YiCrukme^a8>Ai}zIZf>%g_B$TFrr+ph@;&!nn=~&?z&F}(*29W;LrHF!qWKr z%9iJY2v*0(tqx!zjkQRu)Bhw7w*u5Wqt@G$?oswLMBN*dB3q!*m}885f!m2ZSGVnB z26B(w*HB;T`@xZvnE}SXwuH8{2;5Y2fIVb9aRIOFa%0Ma(o;|Lxr&Z&5;`+fCQYWLLPs6kP zVKf-e=?)^2KW{DZ^tFo2Bctt{nCe|Z4g;thuUCx|9ZQ4Q;bC!K};lTGq9Kz z1m3G(NPm#r5@@Nf%DY-LMAZ03iO|X*lBg{)BPS(*0a)$8HaCev|uQ9n@{Kkot%Cx!kyX@S=TrX9e2nEXft{7SfNt>vchz2L_~?+bJPeQxBI zhPxPhdynLyz+YN3|E>25*ocxq4fj_+S?3Z>zNI<-*;lY;nSkV5rfs$mV~2vy>_M+h7f4jAZPr*%BzilA%w7TLvCsjF z)9FSQsFqmmuSqBE4loiL**?zSIn9Fz=}`x2ht}%*pW+IGn&1(*w6nAm4yXFE(EZ^R zgx~_(d7IF+c^&Q0x_nsit6P7gV=U|0vaie?E5Oe#EXkQ9zBd0nhV0BplpyJvgev`A*mjVFaLg%L{0kK+d_Bf?2M~Z)wLJORvf*YRkc8;x zlY`%$v>7L4G8ye5^Ss7u2k^Rqwdm`lMI+A#$xoQ1w-4iUQQzx7&wMwAX@UQSjRF!& zhAo>E{)mWC=rq6=OgMPYGBF+mW1J@MSvR1h5jB&8qgB{aWi`|X{=d73xP|_bSDvI` zpbG)da|ha4>`!kV4>TN0%BDRugu!wE8mIQVR_pK2)Lz~5Dep6B!5M!MbPo^H!{-VM z^LCrCG+pCNcyY#zj-Aal7 zbS4|z%-p967ngt=Od4g)me!-Co_uea&05>(ej#o;BUpAM{4C<-1!uo8F!TUJI+A25 z%F(5g#`vOV=EKcS%6RVqP<#7ORh_Y-ATt>VQk4_P@O9W*>-wN^SRWRsUO zNZvUU^aU;R2KoJ_+4J%m7T_&~cz_6;6)_WsMz?WgrmdBfMSopYh)9$<0tg3&c&GA6 zB}&3Cb^LlAACXF;)T`ck8?;L)L#J&O*3pg8*+7duz}BGi>%eqjpcKBL{tocv@b_*& z#Fnd=5RJ|Pe$V}_m)H7gU+>umKD$FJD=3X37r(3!Zvc8wpG}4N@;$|VAw9&`5L6cB zAkgK&x4|xlL*9BM&6^&}W0BDPcfjjf{BUDsd3YdXjI{}!4xCu7o22sOabk(Z0I`Wa zNKA2vK#2fV5QQ-|A=T?(3Gze)p>`lPw;Qql;Nj*jOlF6tk-3Yr2if=!yxhijK-LSL zng^1S06qC-qJt}v+Fo<$&OE(9!w#*s*PUghI(-8@ONDHTF(#S^cr&j2meZb9vCZrM zkEpj`h^qVEhKG_4m2N}?>F!PgC8R+b32BfLn4wWp6p=0k97?1mh6ZV*b7+w6o;lAM z@8AD@KLEo%d#_&Cy4F*vJ9Jetm{8jRlv}#}V^C&pjiXY(fAlrNzZ$p&>{TAz$9aV> zdm~Rmu`d;E=|$1fo)d8{O-@TO_woic>JV87Ki5*m@^cWrzOl7C9@e#WcoqbjOnN*(EG2a)??C4*o zqe01iIT4g<|Nf32%|D@qK<;B@zfwiuBUz>D_gdcl^_%nS+I#Z#&ZlXaJ4a8C-Hv^5 zbk15K2mgF(wgSf7=Aq8g{mABoBDqAZ532vn6*cGdi>5_c52O`d-8mV2IX_1!AvP_{ z(>arHQ?%+5S%8$EDkHq6EN5VGe21}wk-0c zzQy@-#$6iLwkT&m*zEfd3VL~Oi%e>yTBGCwt2!l~iQV*b=?U&AesGq3{IlWgXCD|I z1lPL7Cj?yQ%~H?;foPs@SBmJi>JmQmeD&w1A}Q9*7)^66O?_G3ANgP%`)WW=gLJLX zpE;6>rIF%|PrLiCj7@NpNVY`|1VVnvh3e_LOJJjYB$-zBZ7k4?OZkNayZ5?!LinRt zb2(P7Li?64c~S!=FN9vXSC*L-^{u@AXY9caa?v5XLINlQ^r%}5vuQQ5#48HxfU>|v z6fm^MQ8G;wE2nn$`Rg~>j5Yy>;y94Bmz4Gx)yVht9*702{Iqj=13F?=2`yFvTX%@( zXz1aDtHbn{HeWXE#N{&)d;C8)OMaX$xl+Tdg7x&cq8w}@4aYx=nZ_33qSrVFoP@&`=}qO9?faONPJYBfaA2TY zA6_%(R3#P+@62 zkr99tGxOi7wn(&ap|lG&@;w|JCivcqOF^I)4=>NB=Ob}%i)kB%fVe&VxS=cXN>-Vn zQvzmj?Bk z1;HR&n&Gkftg^VA znL)ZF3*G)9?36`@<+I?Qd8H%Rlb_<}JpA5T$YXg5BV0XPp{ZB({H4}!wjY$>MRiO` zA!qAvi7LG6@jZ2!>HY~;MWkCy+GvKrC&04EtPFh0_6vJ!H(T1^wJp7JAAOj3>*I{t`Pl!bLVEYvV?>AIso&CFO}UDf70+9#X9jQ zNt}}+)673Z{14Z?^<{$&EDDi~De+%O%NLcUXJzIhD5luOhmCy8@hy&uB!n@g)%MfSH~Km$26B4#qk>g9JeytDEqr+;@Vwa; zZg`19bIIjOmcG^JAb?Yz5ps&d@#8Tc`d>*x&rF+l>v_aJSWHB|nkE>FuV7&z>wc{3 z_k9y(BmV5q`pDU#VY7lU30MbbEPAFx+LYTs?e(}iIL;QR@N(mQW?irX`_Fyt?`1$2!N&v! zpwo7;r|rJ897j)QCQ`gZKAle+vx9&k*1t@Nj$*mxXQ#I|YAI{kl~p!X+>#K%UWL1z z4F8PqD(^$yPa?<1Ffe;1H?C8oy3vUA!=(+fGRgL--q08(Y7cCOK)|+0KC{G#nqo3u z-FPyMy_C}Cf$2BJ8Fr#8yUMr?FGkZ)=do^aarToMb%Og*ec@FEB_0A4_?&CogQZ6 z7Ued`fR;|e&UFN=fw}0mK+1>irS>?z_9&2k7|JY*#r{GD{=4)!R(AqR%0PP*WEHd$ z25D5=n5rHA4!b0bkC`(;3O;=iAn=K&~uIaAg%XIfeqPtBIrCUx4w5{wpgR71A~~MGC-v{ziG6 z;pBor*nZ^s!&LV^I|ARF|1%7RO6l)F!$3%uWJ%EO;}F`r7WiF^M0n3oVoQ5O(q=?A z=*SQEN0E|$%mdR^=e)eZdx=GZ*k21Qd0QX5;(c^Lc$sr4Yvx*bLTKDvo_Y{?J^3cs zCB$j;&v5)vL5zZ5M=qbX&Qx(l9jOpo**j}~eb_szLedtyPb6H$g~z4cA&|eha7WaO z5M0Fht3WLK`z7D2$HSlo80CK0#+c!tX7ke`B@znZH>pqb$O*N6Y+7X;=UNR*ZSPCS z+0jb_2&baijg!S%kw*+Rwo@hxoq+kTehJijyUH?o4S z#m%7hXlNF5*?&QTGm6VM_vvhYI}&tf1=AiPay)8akdaJ19kx_e?$Br}wQ9Q{V|@qn zNV|^Lbn1!k&4U?{{DHXdHLz6Fu5=P|kgv~uy? zkN?_7SIF7^C8(%+JU5rgO2itQr4gfL9zv$YSM0=Bw zhTQpB)^4^e|K;s0V_Dh!*RSn4eWpjGr2_)Y$)kpJ{C`PZ1(*%G)h^*ngpCZ0_BZPI zFh;Jpt}i^+2@}mZRIx6hTDgKjAT=oNjmZ(~%*=ycD${Rb2Z00n1$58rpu!TV&o46) zvU^tAQ@UePeR*;yh;6u|8#FNu;|F%ZNK5bxlP$P~qPa}{HFi}O2D5t}6F0oNb{kTg zblD}N$UT7<&Fy3^S7ZgVCydv7!E~*Ux0%rBAC3Et4cdPSH^BH3>rah^cSD(srZatq zx+L-%ae>>lPkFA)&KODY;ZiuhWu}nZ3L9SX`>~roj^kd$f8hrvKN(X7%Im-Y_3|3) z=GHr6Mw5o|l`Ya&-qv&$@e(krNL=SUN~X)T)JD>Y?)%@IDW4LcQuS9?cJuZ%I5pV7 z^rXajQY8g*0ozBG{@J2~a&gH^M8w-k3?qK}O3I98UuoAFFQXU^;Kx45T96#Of4=(1 z{Ew+2syTf2*FKLvizW%m4z)-kafw`i1$2pLnX@>=^q$( zEpc?ytbvrx2bnzghZN4B-Du<+;4(@gaX3_+K4497P=ytYl>Kt^ipuUhJjUowjaNLm z=tjY49xZc=0y{wSGxLI=Q&?}}Ts~kas$I-^k~C^YtaLJICS7gu7++Yj_OztgbOQUq5`>XP66<@F8zD(&qMoBw-tq!xA;M#=>7cYlV?aiRb zOJ+g~2**Fsb_0cF=;~(Z0(Inf+e}iY|8Y zhw)fAzfRGgj2O)MlM~voJus|!-}#6%!C7_J7>Z8`w~->yFtI9Lac8lW>s&WaR%83nX(Jg1huOacdZhDV(8cQKcI;Irqkoq1&U{0j*+Dyj+K z0UAcF4-*pb@!OY<-Gdb*Xvm)S&-dy?aDOF(((qgGD~2keqocjpQoBzVoDSAsrc)0z%zkx9 zT8(Kb$@R;|%)p2GmRJ)LJpU~{f5OY<>g~1iJ>dI0T|(XH5t1^gdkl;2CfQ~kQX_vC zYRk~#jGX1WHKbRDDua)36_cb|xWXB}x6DvLSm>Ord4}_1<&cbbq?}-2MJ2Z4=LcmP z5P~DXf6M{-RN*YC(6?#ug%3hIMMSdN_)>c;nJ$XVN`JSoDLzpGfp)XF=>~`a7i(h0 z`?P}(B40#b7U5QtGHSd*XKx=449Wi;*faR5N6HhPC@|7Q8F!NRnues@lybiEKAvhc z#KBR!)>C}LII_+rAnjfVnT);x3JLCCQzN~zlKb6T;0z7wB7Tn}x7S{t!|fj*R8-hX zDeGE)%VudL9&caWy!d!N`U_pJAm#hUcbVXO%mb1`#>-|etVhHQ57ktZz98cPOKtbXv{8sRWk{)Ma`e?`Y5ssd_iE_su zvbtEc4ozf;Xu01<@mtKD1427z*IDO{;Fe-jdZ8z(&{~VafI`#t>+SUQ$u6fzqo)-< z5Occau<-gq354%bA=nU+X;FW#sPNcbMKPQaI(Yp!#?2mO@fQm}VT9}Jm5&fx9rnFo89?|*;&w)ptg1ok-^{quISVk*~) zeo*$+;^P*?7I0U|NEe5~C3RJMrCwK*G}_oXJw{MRc;5BkGo}TE^@x|vVY2(;wDZ_k zpWD`zs|0pIHkW^P#b^S27k z=~oMK%B{|TEi zNw`<2%&F6eju9$DLbpz@=eu@svbgV(mfvyP_cUHj z>=yZpIh|GckKR{xDF%VKSzP~N+#rzWO4)swl?N;q9d-D&WLI^*hKFM{_7mh=tPD?< zovYtI!mF~*2sZPqUDKeJ^wYPcs4TGh_RaT;G)0Bv{4wv#pX#Ou%S?3aBxbezZGND+ z2Io%@NHi!ug7 zhwM>q`-$L6XOKm6&0?0uSV;NMfq-bM!;H1zMLgB}SAXsRRoD+!?$EfHWFG|qH_-DT zo>YaaAoH9#uJ*v#dh&1nyc`aRw$CKD>A^uq=o%8kE;2Ea7o38-;kNStzkc@aM(0x< z_MxHv^}n0aWa{fk^6b=Muak;iH%%W}_O!}3RpXJDHx20^F%)YF2veA~U^L_OC&B;TInCHgbKaAnV+fjXFp5JCWw2BN3I-Zr7) zXcg(ZZjh(Jy>7@nzZCr-n{N3~AtCjhU40(_WdB`U8T!x%457)Mo7G8W%>AXe_1fr5 z`|v25ac-^+O!kLoaGw!RK*3y{Q9g=zH3~FU{=;^A>SH#bpb4)*my&Gmj2 z4usgLId)5oW73KcrR!cZN?D8{FDr7WVWUGTU=H!oX_$TxdDRlCy7eaCpVcvv1(=|; z)2~^HFh&N^&QK3alb^mldK121pF_gD2z!18GIVwSi^w~gbC(EVwDY$kZQqz`bd@A= z6=BZ_*SzNWTc7PyiICyq`fif@RBI{&V&C)0<_+N*f11ozABrL?Q z7;t;|Eke8F)rwjoqVAG6_&xVw6ofvB>Rn`~LEQY~se9Qt_w|$MzQ#`kB zn}W&)84&@kvhib|@UQ--zrQ`szHA!WnTeiAe(1H*vMhCD<|hdb8M2UpzzD^uY1h!Z zhlq>De`^_na$(NmdT|IGWGWzp9yOT4}_0gW5{#I!o-9;>}I6iKj?pgJ%mp_p- zc8+Mdj*AW8xzMs0%X!9M3Riu=uud5H7WIizVjg;Wtx%0V78kfzWR#{{H|2C7ru(m= z#bv+IZ3%(!0-C;SLGi;r8~ld)-lG!6rm@N8;pYX~JOMU=?`hWW7x=YGd`07-BC1=Y zZwPK^EhO1Lq;fYJO+Tsyp-rH+iyPo?z;A0QJK6`Od~&M%=(e2CrJ*>Qnac zcLy?y(M@A@ex2=`iM6ZEs}kO|OjV8hmvr8MS<_e3zW{g;CLm`=*%I##5i6x6SAk@Z zP&?l1L-=ePoU4}M&2?&C&TYJ0GMYUgVsOPvy2*jcSSTmpXdIR%3=#ekfZ(B4qja;9#9`E~Ae5db&*O6>AGG zshMUWo3rybZc1f9D|-?AB6_OCy3Nw8o4R)fZy1$xq(y43iu9g6TKimj!I&XAwjDV5 zTzOE{Oq_K8fmRwqyZG@BE=&U2Q^qdPjXA=JR9HW7{Y4>_`^0m}%pFPpD)I8b!)v2G zn?0MKj8RIenGBptTe8^hR^q*z^NZy#^JBm>cOc$J-1y;2_bONxC@Pz6nJlylvSh5o z|LF6^$12zmOaYT=b+Wv*@m0Mv^5)7Ee?d)d3uF%%)g(Wgot#a|+W7JhDuu=g#^d59zcV18H1_LpLkh=zwp%Q{fY973ioV^2cqY}F3gZ-jyhA%HCU&f25 zl7x*VB!yJk;ZC}04BfBnu=NY6y5qGDY#~d9;pylQ;{D4T@KHoS*?2Vc0K9>-eZ`Z~ zjcGSFGCgWNpErKKONkz^N7h{V5sM|aEI%F=;dF5|U)~)TaQl-fac=OPapOz3^zr{y zwD_>HcT^Elo?k3hMDaFXefVaN?RaHw)l<#>(EZ!&#;=yzqc+6LnlbX&SIM{)5Hu*u z-h#QO`#0c!-d?IbTc7Tc71SL(VM=YLuC!M3uv>5N zY8D}sY)BcSJ2uIJRT&re@8fTS-I^OH3oF=0WN;$pk0iveDSBT~U0N*5uV=+A*qdj? z7+Rtq{-bRUPVBU2)Ly* z`9|{0s!yKEd(d#h!8X+A!&kdV{Wo|^e3~7`gkd>uYaZNwQINY5)`8iTozKlgx&DF} zZ&jm_=Sg=_fC5-xAl0JxeQNBLeFg2YK_@0m5wbfz`Cy2wTE8YIZTL_b+-vuHZfeze z&1KGcZq3zG!Ug$bw!vLUS2)%Wdemw(9i8%@;ANE~osfx8Lfl_0UeeBN=L_*{LDjaj z_8;0GP%o))c+r2Ji`*Vn3!lxed`oI_((-!3`YNFRLk!kY2e4GeEM_y=yp8Ia$N~0E ztrgG(MJANj0Q|e^n*(gTQTeeP&IPvw2^M$M?~!b@#(JyeIB(xDUYjNS96Wm(@3#0K zbvLFCRi%r@XdvT5FB*%L=$-7KWTMF%HSWJx&C5g5p&)Jv2T~KW)$`JziD63r1CrWS z=b|r6N0LKSADkGvzQ@N`dR@hL!jl!Da@Xq;4~n-AHLPip1q*AXz~G_8xkE9c zycW|pf#Q$7^6+TIe|_JOZoT5RI_U$+S*N>++EgZ3APvT8V5lO?{F#E8b|jznTo*(x z$7h&IRYnkGt9SSYXn}GrCy~j^Ecp=YFY>dEz=Q6TU<%_A7v*QvGgMv(X?)KN_dE$A znK#M)w1AJ|LFi@@Qyo(z+okpQ&i~4L5C~i;eIGN3DgfYRNKa9-x`d~5_cz|nX z)%d<}Dx0_DjQaA`tU?B{Q^@kvL}FuuUa;eHrooQbXp3Lg&AjQhe}dev5XZ9Go1 zk>bw8^4x;G-}1*B0xUVsDYlP+*L#@+~s%< z7Mu_w0pb>}=2;iwt(q8c?@B1Fx%_i1HIf~-@1CA9jKf{L-(Oar*awYMAiCq z-{NZTCLQp}Kd3B?`xIk=I0YSb+xw|iaKTo><>y()h_l&X>c&t`&XI782pZ>=l>u`;uCKAwD)~-27#LtB^?(a z+MVQwxJYxtuw9T+Bu%8(8XG0;adbqxOjS)y+b!ul^H;Kjq zNzIt|d+Zm3o`(sbXzrV*d)zcKuJ|&`n-h}-HY;#SL}i1e!1iS*IVViE5qHZ?b0;Q} zdvq$X+MZ5V7iDAny{CK-YS4&D|2I5bIhE6eI43hv` zJ3@Mry`w~G#o(pjigT&Dm4U$uG{q$4xn{^CqHqUe{@Z!%$hyE?(@!Lm?4_e79k9;W~ap$3$tjE&El2^wJz;)8ZxQt zN3ui28Pf>gkAqTg&$903=l=0;geA2V&G5SRckmxLjJWgHiIT>pt=vOoVp1uR)|&qB z!(2H!5YLj$Vn&x;ycmy~d?7OxhW$ZUSQzEhT!2f#BI*&u#P%+|YlE zGoDy;|C|5wrm+a*%7?kRrq>92S?VWO6q9ku9@Enby*CX40SgV|4k7dI7*%euaJg!jrMRAF`8eM_QooTbj4U> zRTg2X#;~@*1tN3%CsXXB1u6HE);OZw{|ga8+#gmJnJR)3Vf0Y0_NG4oK2&ZIJ}$w- zSz=-qO8${mT2*3sD(Hb(*Q3{cl`C|9(kA}?T2pmW`qbY;z&M9X^Hk;>D1bm91zvM! zgN^||N0dno$EcUoMNKmeXI=F143@MjBH7d(5(E94pOc7a2xQ-T{qU0s-9*8gA}UL8 zMGuvY&96u0SI6ia2!!+Q%i8m}AO{}{nA%-^f8NKqHWkl4no2Qic6;1EUpn-xW1lJxWspT53HpJ5R(q zU{iwkh@UG+&tdLRclV>}+INxykUEK;L8wP9SPm!xjqLB;1|$lP;K_1fq(KvjV*h@S zB9;NIP8?R;sp;>vE2ZAGBdNEw>1X0uJi7TxFzl`j!{0kGF%>@-!|hQhD0rJB#2d{+ zVOB*gpE%Ao^dm&lvBnMF2#$^8)rqZGGn~49_aBhDFb&0r@)a5*ng~vGKlQAvUtFr8 zbsvG0`YLwAbG9n~vZ3@h0SZpP6@+2oBvvkzc#Gs%*}Wx|Z6(TA%;v&cZ=ZdeQ)kw^ zU{U$)89ATBLEG`G_fp!)3p;zk*NI+Vp%4a=!zE03LY0?&lUS5c**B(uTQS=0LgB}~ zZ`2CofB?XG26O^_NcRUkFE)*u{Zev+rD0U5U6Ht!|E_iu93^Rce0&W7_stZ4Hr<~! z{Zc3$#%Z8cL^wt4i66tNAGtff(cQnGFTGW z2Mr?mv}~~Reebj1jdYt8SSz=+#EZ40582*hKTB&&OY?~T4jsaom*um1OYl?E(lC}H z{}$(Oua>Z93^on~(Qem6n8Y9?O5SU~>FNn&Y;fI!M_MvuR$pHqX5ojom-hyb%Kgai z_}GJt5xZo!5kkGSvgQ*x)0LWh^)=Rryy`&+-Q?*3n3j?Ad)aU0P{#`5M7~#oeW4V| zrx$GDs^aUG79ig5=j+>0#8c8+fce}2qi*HBw(9eZ`zoRDj=&Yc%>V{Jc`GJMus@ul z#>yo>n(2Hg;@d-9S{sn#oDQ%V-v(qGe`*6>(`co=fTlD4bPX%d;<%i;y&^q_ssGJl zr7NY~mb*)2gur{9(0gSe`T};R&L&Q|vf|seTRXqEx0Y`d@tP;8RO7w!~`JawV*MH?rpUMz$kj!|yvR6SYViR(` z`1?(UE>hr%>FvgZ_tOH!25WpZb9vaegL%yt@7gE3IqCW=R5#qMG}1@vEp4{ByAx`yF5ZX^XUlUt9UdUYBzke6$s z6lJ1Bn*28HhTl)JlMH+UL3YsxtyC)7E3 zpb=FIW%osbVBCP?#sv$BFK#ZgO)^$t=O2YaRwQv;hJrbxb1RbX*9 zvTn}=jGm8#y)ZV(CVMDI%l`lv9xUM_U7Q%^zEj$brD#jNz!>Q7MLvl@dn~P!b0$im zL1%x-T<0b?I9Tw!Vz zXJn;cKdvqjVnJGvM(CeUWN$r0Ok*4?aox>&yL4{(=cI&2td(-~s**0j_<(X;kXHTTvK~m-! zdF?;ghU6G+o+#7G`u+L(@zLwUTOU|z?LA2ZDjK2JKp~3d9QqsJYJaX?Df06;ljxH} zeEASqjAZ5lZ{!-i2oilUtH_=NzkS`uoy=-|txBOhgPoI4(NI+On!!vd!d{ymI=O%`I8GfD{9=M^kg zm~7!bAGu6bk!ibez|oiGhFHGv%t_SwwAt+4ThcbX6t>_}vQ^zk*YIrp@Ge+8V2&KR zt1xtkDFkeB3+Om~A7Z)0{I1wxcn&iIj}x(b-nmNVlGm|R{a}`BRMfDymzN*^ zbF_BYJDW(~ck1pxVr6>`wW;fDF(7==VtJ$`Cd<(7z#=^$5P0V@Du;i6GI#V4IO{{P z4Z2;Ufs3GU8yP;$Am9QygP#5Pbyz#9K1s_SK$yNSeEf>9fVH^vT~?vRjWg)|hMxx& z`Xi5{XxW+H*xJ4h(#NZ}$gEjY#H(qb8;_tFH!!}KOHohVfoe8J^ zZ#9hwR+gYDf=wtPRFRY%uFI|+|H5Ko=Iu{r_hlN7*99}88v)GDstJ27Z+|vQp01bA z^@aTbNlba2!(R^#VT`NIqd@(QL`|XdVyp~O&NmwEHi>wREz=J|TXAMm>T$Vt(yi|* zs@$E9eV58hL>{{)z;qAvy$yFYBLT!Y81!OKsqFC_!ttJ~Jc_L#gT(uc(c+mY7rS(o z?KH}i=gaRQ`Cl3?U@3m0pi{!Cv(kRW$ZFbu_Wa2H`R2WnXSgEZ`ccr;-1;%ESHExa z_ff#(E$r}G5-F?9Q+;(N8SI5$ja>O8?mM+ISGz;ecPh;cc-3%{ha!TSeil*t5sQtZfImO{+7juvN?+qpl43@dqIhu=x+~xh3<9xi$v9>wn-`w@5-=~+Rs!DvDeDJgT~%au zdt|^XYb&FMnYP`8!d|}QwCqh1%)IRrKK!U5#l`IGKih%vz*UlQLkYuRS=}`M*VeGR zTYno-7~hWl8q;7c0k$wJ4Q{7+ptYJA+^%!1bhI&_>lwO&rMe>zyxKkna5DP8UN4D* zFSPF*+M9ec_pQ+=QSw{O3|S?CX15w$nAfX;-9bZ6pMAqR*VWzmMv9x0)9jIFNiCI+ z8xp8&9N2IL{);wQt?WRSKWd}b-%5QT6hp5&Zr>7prSWJs7-WJc3gc;uaT}r+Xg(m> zMF^&)=s?~vWwdo!a!qAMcfzh!Z1ZBZ{wp(Yx*`zJsQM#OS(mOc^M}dCtCXf{vpUQ15mUQ7X>I~G#_SC%#6uxmGYDHiKHIvWIumS`6!|2KBH%F9+% zyU`6k&z<0pw!b}j;w0ZF-=}PSEKEs-c*wP{tSKBIAD(cL<%hD!ZECBm@#o>B3fViGL8 zQDA$Wsv{&`&urTBpy-8c+f8{}nISTZ`phEwmNox{g@L&kUfsxi5LUG6Gm*5S2oY|T zkBcjPDJjfaFjg+@SGsgJK(Hc)#EX>-SK12FS6G{ z#n7Eany*_!h~URPF6UY)Qov#e3`el45Q*3C<|B2b zj8yKMx_?ljt8<4eEiKi(GE0G%GrNKJFtui@eRK41f7g}7vD(7HCCJn!lP&Sy1H0^87& z&U*pT&ZU;03rw9Wm$khrDj(zjYY`0|IXEN&&Gim){m;JY6?T<1y1?OF#j>IAiw=8p ze5fHm(1XIk@jq{E@kZ~=N@YXB)5R}k|2gkX&{Hf+1DNtII*FE)UgQEiy9jbt$@;_f zmET88_0@OsAao=3)|fpSU_L?70Rkl2TerT|A^ET2)SIJcS;_ ze@i!t^oR@=ANQV@3@MxlmwzR0bvuC8`2y^y?zfHGDt1W;|FGrfQalNpKIC-kboOQzy|&eL$BSYyg`Ff!C|#k ze%-%}*bH&{d~KHn$E#?Dmk&V(i`|QPg=_CA2RaeuXcrpjj{G4L+J6}xZCF#FL!NGt z_OHyND%C%P{12ztK7|s09e+Qj5JUXG!;U_jfJ}^UNp^=yJZr3sbAd?jFCfGDt&*mCxpHTSMNT3-xAl83FD9jCDUN3er|wTH`` zm4mO!lP$T=?*9IPb7n{GNsl#MmFX2i#)0TZ)AU`&BHU%H+r;`zIZqPp0$!I%qicTC zP0*ykx7YMK_Kd)sms;F9HWqLMz^@{s+SQGgZ5LWFJTeTJbg$>Tuu;2!F|=C*okvny ze9;(axjbZIxXC;an0VKY?J3voWal%u`IeqZTOIli01t9kF>Rko`O`B&Z-p(TeqH_6 zz9^QM6os&`MX%qol8591RVQS{Y<0mA@9S?}pZ~eU+$gw3dKZ}k)@1IWU1tt%z6x%e z9+1uLY5T~uU|+$QTuFBOnB6$#{Z9uFe{p1*)(r=os#kK_M&ie0hQWt(niv(y^dO5Y`@%1)pYcsE-_gX&3_nC_FcpA*pv64 zTtXo7N?E#URI-RWMpRA1=>yx=bB`=-*d-RL%R1mR!uV4qrwvOjw_K-Y%yj9 z_X}q(^Q#M;Y3`rIxZ*;N&WT=O|6FhC9}yi z`6T6?70e-P$NHR$qKI(Q;G^OgJxpg1#8fvhuxYFLBX1y(Mx1JZ#kut9&FBFU{3liK zutNYh$yQTC@W)73j}0^#W4%XUU464`U7e@9%yNAKU%YO=ok?iFqg4smU<2D?;638s z`J0f?HvNt`L?v=rR=7Z&&2KQ>*j|kT8 zY<(Jb;}SqD69H7$_1s*x>Jr8XjI~% zOG{uZYKnKN2W{~t=7tb%iD12p)Zm(LcCAsM>=Ao$dk(5646BmT2 zb>L`-%y`VKJ-5BW^>+2}XfBj3hi~1lQ zhK7U$jF~26bV-_VZg=nbxIli(I%B23ScbW8h80bnr-ZZT?6bHQ&st~Dx%GA>`znBw zI`nLb?u#mxKe4`)E9cT|n!MWfFka1N$go+DPjXQ4NfvtbZEW zRU;dG$tF}-Xr;^3>Z>(p}Xm53CgApk7Qw13MD6@ zyA;PXdRj4B1C8bpb1FTIPDm#&h_kCs!AF9M{4w10r5D{8U;kvbz;7#eX2{q3uC%EC-!tc7* zW*^%aQc+u$c~z6zSbt@C5197x&=+MMa>GScxDFtZEsVE1(t;7EKsm}QSEgR9F@#7` z8DCNvGPX!jc<`Yt(&mq0(hbQ2{45z~$ZyvrK`b*cLz*YO-~I+A2Z2C-+tlub?p@Tm zorSch;D=;9eDfi`p0TsU3`pH;oZfgG$&($6!!6u9+|Sn|(gtfTDTl|l6{a!pCgvGp zuvu71-4-qifp+HXtSUtB1&D-dVk=1!r}%1OA1I*Ez6Z|R+7iG>PLi8v_kx7OyUZUSy zPiiglI3epBFKNQPI*<_(OO^K_A^yt#l>YLQUwItl4#(ufqBmrRWwe2^2gb1y9LBRBDQkv!0Dt9Ul2N&eo+!kdX!YWz>1C zFK!Hdj3Q%%th-<0INk+8r}9SA1j+2Pn6Z3tz*Uho+8zLu&J-R4M?I_3X*`5NI1U0i zHko?-CssEBc+8!{ORJnXod=N>}*;l+Eb)L@7nIFrH-`bK&|aE;!$ zeyuIH1{_t-qpa@<&;Urc3&b+Gq$Y$81RHMwIiO=2Tl7i7E)*cpBl(UKNJMD~Jpo!y zocu0Y)J9?&=gw$8sN^<6Ws{Jiyv-qPkvVej>v^<^)?J<(p}pkSFzDua>aY z?0(lnvh+--(HlwX_)-aTV()sGqKLreX?2Ak%WJb(AjXy_?C`! z=%T0TAwy})PRmg^VNw6Y9s|C0I2Qs^FcsQ2-5WWTT1qW_yPUYyL$D)?p)+XZjlu$8 zc!oGRBkWFaWNh2qaht94BzwFlE7FU#b{qzhpB;c5Bsv=h$w%PQI!v#En)1)wZ8l(_3P8!IW5x6zQEnSS*vN?B>Sf@i=Dp$$ z0(gv<$69N@KHpr?0NC&dAqC$Sd4?&td&fTbCku+L*i4AQ*j61 zSkJFP4nPIX>UN{DujM|HAI<|Hp_8d%X39G8XY8&%HMvHNBHm)8UCHFIx?U!XT%h=3 z{Uk=6EvV|2QgXF^%F1o|8V?0uD#02XKn1ASt=3s4T#QSa)OK$9C$ZoOJ}Ra8{70FM zH!4eLZyAzv%WnU;B$b6b;o5daxM^fdJ)5)Ct8rRcp8HlBeJdkXSR|qK(QS^>8P}2* zrFgZPfhSJEFCb&g86~y+Ehp3$xE34z22rQ$2)w_Z1rhRNIOd5_vTnY*Its$xol!>R6ckNOPo9Lo*3V}AW~)V2p-9V8f^JjRF)^SitehVxz# z9lVfl&kUYyLBM(C-nq~#TnK18McQ#_d3GUPIYppxCuzIlD&m6|w+Q7&U`3ptfX!{|X z26){Rep>Mz47XCFS#p(%Fa|lZ)eNUQcw_AsEsJRdPySe*a_OUyfZOq_1rObd_wwB! zM)N{OdzveR?H{FK0Lxx4dZ+xQ;D@OPu7j($-nRu1p&?zrO{#rtxqG5NklcT{8Lu%r zix3)F#I(4u?(=flezv*!Z-Ab2Uaifc^IL_>Zue@g!ib8Ri`!-QYjWASx#2O& zv%57p#OVMs*)(m9m~uDY-C_zrX&oLiJ4T9LxUVj1b|$0)$h5wF4d#` zr~LW~oddqO^`2xJi7L?ogjRry?&IcD?qilk^a*71&}Eh{aCp={t4-A~rc&~`)+ebs z&^tGQ-q{ao3I1V>T5V;}>F}GZ^&E*RBfe%W!7vuI!b%z9LT;h;b8qs?0GM~aR7U-5 zQ2h+0Rj|Izcm!=El||f%Et!vK=qHo{D@Zv?0Y&2D!U^eTn&5uW3zOO2xgP|d4C?`V zK+7PJ>21z(&G!wtVKV#H&U^CwW^;39{&j)%WUl<2w=jPC-|eo+p!YG_-IsAG;~P{n zIRZ$@aW;5JO=cs1a=z^&9e#n&ua)+T>aO7;z(_tMf$7C8lIT8=%|y_UL^df`79>*~EWS|njo z@=*5o^0*&$5c{YG@He{nGIJC2&X7h-H!UIE{bmg1x6lut8%QkK3UGW2HA`2Ie${l{ zcuFIdZfDoQ|EU7Zr5qeN7_wQwS0InRVj;igmyjc4B_*Umy1P4ubmz9$?={|^@9z)T z9=qq>-FwdSJfF|=?-uJ$Ko4f&%0Ycl%Gm%no~1PcUtfl;Acd`0Ezcdafv0+Ljt1B6 z*1<%3zENv2U2?yBX20c5<#0An=lxvbTft?-ysK=aD|ho(6rsb}O3A>v$VbI_#Y-y!z8@#*SgXzOho%0!1pO;8r_t>t3G6klGy z{Q--Fubc-|6(;3qkn6iaG|y6bQuzW7fi=M3JSW$v% zWNjM+OlY1kM8&mm%9~+f3A|vu(?|-6{p2!V|FnS#OUb0zg3RtH!QBcTJ$8@?UIwTL-QmSc ze3~S#CN-`X zu1A=M7G_%H3UcQ*|LY80o{9X2}UD*o7&e`0R!l>{eT(}n8A|f z#K?^XSzhMQta85&?%|%#fg^-u-a>jvH(F+0`e#}oe^ZG1Bklty(B@RG9E9mjZ9hGT z@t2GRwpaAZ>`kGizIwJN4 zWP$BjT75~sc7K=V&_nNuE{sS`YsZPnG2wh^+_*_8wGd|UKq-=jY@$%*eETi~QY#9T$BkRqhX_jPzh)Kn-qr=!`xlI%*i5Blsz$e77J?=fR?pvR zFDqvpw-CSUc-j8*zAy3gt~E(bCm%hQ`UR!q;|p6B#-IUnZ0E}p4ke3&^x;Y@%5bmy z3v%INI?h9&0d21qPSFQy8SYOpvEI`F20fr%&OlZ?l8(!Z6LcU{Rvz?$y(uP#T~E0R z4NQRF#8};IH_IVhYQA{;TZcg@&gJ1^(1lu2G`V$+;Zz z{Q!8t@rxRLX1x63)D@SWJsUb#DatVH4rzI|=5RifK^G!16RmI6`MLv~u@bPCG|C>~ zj~csBaS_5ZX~((-q%qR7jA2c@7;aaDE-J~8!;UTqcf{8p?V2a;* zUtGrqc@69;=btFrkYp=wG5n6ZG&>GRzyLP2+ZX_7)Ey(c^Tqj^o438=4yfgR?Uvuq z2U<}dLgZ_=2_-lQ%~Q6^x>x9YO}upB)^Rf?o}nGF9a#{`EdGc8tA?j#APQ|pO+Gket#%TjjgX0C4EvHI6=p}?0b&>YvM zS_#lniYwj)Df)8lrz||bmX@SvD@im$Z5_3uRhHtJYIP9FAr<8Sr2=8!VX0ssDPR`F zQp_m&S2+oY)dWfBr-cf~JsFkQdU+wFim30uO}<2JR?%klcRJo=Ewmr5np`bH3^X_) zBtY31IXJqsF$>FPg)xN)BA_8JktSqD-vYV$%CqlR0@>!|6n^KYp;Cc~$t{tGj}^A& zrAXzLhaO{WfO3@8*Ot`s-wut~dFT^Z6dUwu--lE7ug0C7?e)L><90aSADaP_LO9AwTu5vqN+3D@d-bdNu3T2>%dTN+=0$Wl%3!ageyyzb-h+u0PVC+MG zAM(JEIrnP5G*Qkx1x_jjeR(1tX}?evePz(CfdO@8uivxRvAiEQcTjyVybm;i@1!r~ zb57a0sV*HlT@3?*m3%Kd>(7F?`10tSJo%Zus|L*D!vLQv)-7a?zDwKDKG{Dm=NC#O zgsT=!ZaKg8uTkhT{KPD)>|AmYp8JG@d7LldW;}eFQ2ZVIcZ5XGzJ^EB%+nL6L*gkF zLDw=PJeuHhJUz>3sQzg%&4tUCD>&BPTFDFxVv=4=K6vYmdEjZ=h(Y=de5PHia3C#F zoF~Mgx1h=4WIN)`FDVQBt1|hOt&JaD*WYYLdHia>ed78tpyIpFe}+E7Ai$AMq@SCX z86oV9wtB&TNIDc%Z{JjD({Nru-iZ|;qJQUlIbZs_(VqQuXR@gF(~?K9<6QMBtZ)5@ z;C9_yaFF2d1ULJ2XRNbs=5u8qtQ`@y_rV>D?3K37Ew)*DoT#5Xd1o#nYQ}io%F|pL z)QTAFJw5E)^E10UAj8K}+=u6_B4{)nQ1Wjj)11YD@Uakm)(8l@F()0Oy+?4@DJ0Xs z*|c`OuOcQnS8(4?`%SKEx(IL&Mr}#3KH6(Gn_;c9p)0B{Xo&hO#-M5I|JS0)E9a|v z#J~x7k2iW6w;%Wo#wDsj zz*h=)G2`@*b4(@}8Y6ejl5KTs@hlF;7`FSKp(L)LWf4ycDDlgE*{#y{V%>43nuQ>6 z-49r(%YZ!6+ z6P`bsxytn)Zz6HJ9f_*$FTQc%KRzhtw9<8<+E;WsSzuv=4`R$M-iZIsV2ip+)Fd^oLdYJ*SeU%PhHBSz9Hy~udG;2h0wJ<6ihB1hAZe@MQ6 z_WE-?>ogiX_$E*J;#o~w+B&IcCGqviw;Nh&ydCT$(s$C%x47Dtfv^9nDo0;N`VV|@ z5GM;kkHTu#vvIyEx+PRLVCx?$cKjmOOK)&L#{LQYY#jDZCAE#>B9jnfZZA7}+w@ne z#e!FP@AWz2+!gd7tM+9o5@{2O40k@4L_H#C>vUe-QBrF9%qfQQ2Y-uL)?mP$A=%Pk zE&)wo(xKzQf=4Ebe06aZ*_y;nI`;Qz~S5k;RG(z@6O>Bhsfs8rk^+69zGrF(Oz+X>Hvq~SdFx*>4lTs zY)%TgyZ_?Z2w`)T`$Bhm_Loh2RdP6d)}9!yb^5oorFQ^(v%#`nzResCe)xUwPN8*t zL02U&M>-<-OSX2;^TaQNPb&B5pg8SrAxUnO<|M%yclV9NE-5QKrDv#0LH8u)?{7cx zW~>B&fuVT2S!}XJ6e?k&_|aeja9>jZ%BId-tpkd}EVnSxmen zC4;h9WaJ!WJSC5iJ@K0%!G;nTIXGx1?RDhWHS@Qe3z)%J#7cPGep@3A% zTM^p^X?c`$71N^zL}ip7ZuTt%&OB>7zWw(Ai)F|?89UR$OZf+20)EYjd;!LLGPR$cKfPb}0Q%)w)}C|HwuLZj z2Nk(0zBr}<;V@V3iB9rwy2GK3Zx6z{~Ivng*KU4Xw} zYyI$N_83kL;V*dSQ#Ip^TV}yD2|~d+7XqYv(Lapgk&*{>TcYY8N%S?c_R7Pr-7g>@ zd;!gz1WP0qqS+>G{^AA#^;K;~bAg$os7On%(l`9Etql&})$+)m<=^c);**R~c&}bS za+%43`2C@}7A75+xhj625)`mvZy6qCRIU_gTP3SGYA zS0Y<%59J8S{CcwWRe+ivh#kYoYvrx5fVp*j)7*}($;$UYP~#a#l4<@+|lIRRWj;1rAp1vu!FzF0;^Gc^kK~MSt+EJ z>YCF8W`y_2%P6A(Ic&y2a_%02Y25r8&P^TFhMehOQJA@tRNu8BS+^&i#rOTls}Xfr zmo*0f>4%x{BQzj!?`-&(SxHu|;rjtNnoD+?h1?VSJ>--!TPK8k$QZ(MVk)8zMxf50 zM+$xX_g$vh&UCHgnu~^G*6tyJyrzUF(<0uyYQbQ#mFlY(lX@gFQ*$)5cgeW18IU>N z%p2T)bLXmx&`|YTbbnSqn(golm7ZnJ%$!!-wVXcfAO?|@s!Em$o*!?P$#Q*adWPp` zdPN9}K-H5zv^P*kbCDJ#KV+g$mc=* zLD2p-ubTM{-p-GpNeRh^98BiI zIk<{+p0QzkB+simqOTTDiEtYp;`EToeuwU?t!Qp-CqZNos<99%?sf#U@6=@QqVPVH z`UXrwC#f2DJ?d=~?Je8SIseHsDZTQC(fjJllseJvSHRbC!RMx;8XMAp;?d9Ot4FH@ z8R$3)$r7<`*lqKwADnef`zPM@Nah>oI`NS>G%@i3vDj1_GD$I&O?$vX)O5t6-cUW^ z+?1#G>ubc^)2z1BmBadK-I!C-U!Mjc)^P!+B>u5l;eYNg_lN2WT-y|Hd4x1_#&N#G zW52uQ%CBmb#bimAZMbKEA|m`_VgTLOai)EY8>m(Lt?Q8ThrWaF8uO@RCE3Wf@?TL2 z*}h2;!@EsC+6}YQA%HwPd1yb@yeB=a4jM5eN9I7u`S{8GNKiK5aUs}f3`s!^3}q59 zwX_O3YY^HB5+kS)vcG@5BK;_A3Lzblfrwmu7{n?6xro`OeyM=KBZq^BU)AIm1&9(N zAdp*1Vm=cxO_J8Q$Oi}h#7R7)!rbg9y`W5+$?}`s6a*%*YYS!eO5Gz^aDd*>i+J7iv7IP;9+ zb5PdYMd0q+q95-W#mI7Nk7k>UUKh1ETfG9UVatE$V)J*f_k@6i*#tEO;FIn68Hl#x zQd%@$WLL;BjJa`m?ca0(Cr|fyvRfT3pU;i?^OzXJ>O{J?C>_~CXqPst&t#RYM?mKPWXgKa+*zgqY5=7 z{Co{CQ1ac^F(!%X2Ip{tk+HqKXLn;9mibre7^E^Vn@hWsi($$#UyC?8N%x;AvWxFI zvBq7Ria@-eI~RytB$XE6D}_EitpWQaEN!KVPboUGu>WYAgwTl!$Yaf%joVHegm+)} zo4--sn$IB|z5R#9v|hqy`Qc-hw57STZKfz9+k_t^9nQsn+UuAz<`sRe`N|KE(wvH4 z4?|i%nTz(k48^1o{X_nfp&;sYbYitx%6hp39jOy@V5~AvdkL4449qL+7B5GF>^^4O zy<6Vcg_xkL)=;l^5S-*vSLPGHQI$`cr6gx}-FKop(z+v>g~N`vC6ye9cnn=?j6Iyh zoaVhc(!G2$L3TYe&$hvhy8~E{L-Jpy?Ki%W3^U=Ftz3Ly@UhccjamU4DrrA$%Y|KcKVwl6%{NrLn?-A&*%z2!nkXcA53N% z{hD;G5bze8SR6M{bU2p!1*8e5eOwmr ziRpW;@bE3&O&-WwA$&p>7a2R*uQB=RsramyS2FvGD|Z?(zLYet-;eTzB$WQpNm@aI zL55DO?960ja4?((h@#$THEUh|MGp%!cZPkS)9C&Ew~o!&y0vUFSDh8+H6~osSG?mx z3O5&c@LHG>tZv>=-(tVkyt7u>lINE8-ppN>%|9C?XNZefOdQ+0>puLVJlBL=d9260 z_~FM=f)@MKT6N^SWb1d??g0{Ad8{!YjI96bgSTF4@$6peCIxR7(nq#sjwtGo>AifC z!A!epuoCNr*8M6HzM9G``<1p{0$Nx?tQ@qhZy>NI_V&P9^K}9afxJ*iy}Aa_sNZIipuz1%o+OdcdMa>F)QW?W|JcC5nyV=x$)W<<&W*GGEH<;x=R1DkF7?f zwn@OfWaDoBmVSv@Idu5C*qzj9-wWqhmiku`-y!^CNo6NUQgJ*GB#ZvQo`9WeI0kn_ z)2B=W5}fGB(LK?NU^7*v%rWay5Bc^6kL2+!crZ!4STTJ|91+ULm`x~}HT}ro($VRUKJbjp z_A6I|_b>&hjC?ADsZ$byQ1Or~yq_LkaoSd@F%#456XhqN)(&QO9Y{ooAED4_5HdMN8ug7WKX_?=>@%ZvsI; zTK|NgB|p?L-X}wU|6uNiJJE)7f9*YA6q8g%F@g!98ItilWNB3lA%>REhByGrllVh_ ziQ7CAqCno#E_npUpX=d(uV;Q-@m2i(MLE0FtlNi+$NMfAMR+w|h!%NQpLu=(-NtMN zG}~%?+J1pQO1#!!G%nDH_01(%|Bcq%e2GtKgCL`p-Dsqc|0t4{wR*6RY(+K&Tph4} z)#FI%S?Ye0_&jl)H{z?k^Rh%}6YSdCj+pQMt~5#12lNRR)TiA~ccQ075Z}&yJ^YC~ zLxP(B)$t*O!Tmjj6A6MALEyR{=m|kXT+xY5;K5+Nw-i|7vW?rBR!`iZuBsA9;rLnp zKbEsp#^!}fj3Eu)QcAHLsd*6Ypi5xO<^4{cwVPUuB)=Bj#Jw+HQC~EK!yoNuez++X zuza$at)vtDKHI-aEvXmIeMpl-)yuY0x$C-RWQp>nV#3IXF!oQB%IYh@!Y1qasf z7DnLAI1}8H;nG53Y{BM`pR%K9@;^hwx7SV@3POn)lA#(_m1*w@mRz5Tweg+FlUEXJ zIk}AX7jzx2uKXD@4hkjcs`x5dO<4H&_jS4sF3>?CjTt+Y%?Tjfq?g}SyeZ;^$cXD> zIY?09+az;mcOy#wmXTItFZ6g)L1F4xdw}HC>0dRn}MyKMBXMMDcU|TT}`ybCI>pHp$}$6 z>dc+}b2pUC9f04U$_LWe-oSP#!6bLkL=pM`XW_2f5q86q%`Y+~Me!RG7UX>R;PKAz+$MX{$E^4MV9!T4_2;o^V z)^C@TWHSNJFU?`n5Q(K2Qnfk|Fbl}({-I+`9PJM@fjAXQs>hx6IA(&A%&OG>WXI_h ziEMi9@ed3If}Fh+j?0WyP*m1U@Hh9!^UKso`p)`mvcUN$5-${Su}TsGV)}t(g$q_h zA|6R}mchN?wwwYfKaTSFV4<7H-)Hn z8Gw~+A2U;~Yyh|^5;hJJlAiDZQiip1q8Q+;SVUHPg>g z(Z|;}dwAP^`rmyHk%}hdKpe7lWng*nTn?5Hd3`~VVpAg0Pip9Zd~kay*xY%El=*U0 z`rHeMX>6$|AEVhHs_|)U*DE!v+mNXFu04OLS0}fUGxxOInvYcRhPle%*fVSg?cByy z`J;!%7Q)cz#>3v`9`PKwR;)`nSB?w=_rdKEOB%5S2r)kld+w&65 z<^O838LJz|ozvQn95F#}<3JFSg>Pex;aWZ^`pYrS2PWTX8N(JBtvZ0Jjajl1?_^j3 zBCoPGxzvxt5Xw&e`hoCFy@>So`XYa^R3562ZO(BJQ?i6@t+fxulw+^b#QXl2`iMibWK;mA3%SQX z>FB2Fk{!^+8tvX2IGpf~7xQ4x$*)f&O-gdiECj{pCS}`T^gnF#^v#=ha-84r7?jkW@hs$_K8c+NMh4H zUu*U<7VfLXgjV~qxn}^Bau4p0_iu7!b$~f4iQPQL(OiAWlx?~Eu)fT@m0zdW(*2*Z zz1Z$d)yT>xOMS!Gr+=K2Yd`DMHvD#2_PaOdq)DQP<;0fEf}}1lkIPruB=0K zujB5zXeb%?9ueLXw7&fW$wosudIuq?5&@NL?ptSrl=w}=*h2x<28gWgmhzv_MZA-q zd9Z4wQ2K0`$6w#L4?a|v9xb)i0g~MEv;MLN44jsTg|f6Lpxv=9F^92QrMyA{Et z5a{i7oT>hL_LW>@1q2;ZqHoP)wj|Mnzo^KG0EzFTZs99e5YU=SDFANCB}4g=ZEs>D zy1iyx>W0@?OA@Rbf7AN&r^L@+(l5f&>Lvu2Ks(ietbvuzb7yih>aiU2ta?JI^>Xd-<7eTt>YN z|2+xZAl`~7nY(J}Jx@)F4(4+1PRYCTb_sR$ioD{3&*$7B*@vUg+{ZeKx|MKEEd9;Ura5 zTM}bI*^cgeBfu*pjlG7!lXSx>>^+1CKn2Ybi^>vIO{&Ts5Q!bWUWDM43>HGd3jqR- z^0%x5gYKIR0dayBB(REoOaenvT3X^v>NeN0qICvyIq{98?!+f;rF4M@)b?xgBoXr* zCYDDe^|Z}-AIB`yL?j(YaZ#^^JwZZGcl}D52Fm1v!0wY%+HX{}BX`Y6OO6GS#(iANqn;9(b_Gl+ z|89(Uk#ytkw=`GkmEOEKRuRE}LJlNF-0nSM#i7#ZPi0lAq*Uj@VM(v7?K1Cw@k@+j zu;4FV@lGu#yJ?~l*HAPnrYU|Tv+_o>8mgp1d8WN~fLYg}=ZaWQ zzD;l&(YR#$#k=JR-tKsJ_lsQfTk6hEp}}-qMvCa;QO4A$<~nbqnun>kpUy{y^e5k> z>!*&bxG`wsob62ciS=^aaB_a|=d{0+%I(tJN^+)AfqPa3D-A}bsw3>g&lLeKQTbQToP_ucP4AI`*4o!KW54{ zw8Cq7DpbiJQ7dKUnC&CO@Z@vIzULIB!&` z7_+`mwZRRaUp8wNG5h8vg9wRc%KkR1SS|kPEK>WDwg0F2@r_w72tyMNhzKc6jgHgn zh6Co{pECfy@i*3_8y^wd_bNk_X847ToT@#&CCEaouy>qc+y7$`SbOGcDu_QY@}M9J zzWn|HHK$6(Q;ScHSzFi~u99v}xvvF3N>}--MvT#=LG_~CLd^l(B!0816U&u}*bp`?^uCSf}raPCf#tonOu^u|c2n9X3 z$s(c!O~zArWCkxBBYbA5bITb#NRH9=*RC6 z@LcFwsI_jot6w9vE6m_xeT@o%j%1r)*H8{P2UmCjoB>rx`Kt^Twy0sZEoCO&mYQ}g zd5wb%q!$FQPT&ifq3TiXksu;qZUw=N;kzvaGkDNc1U?G{HcEyW0-^vMya=`=PFdVn zvL~C&Y5!EWpitI#v!*sNbIlOa-QU}vn|j0)E>oyrCt};;u+Lz}h3erTab#z3dG@S3 zy>78Q_IJEGWIYY?DMpj=IPVl9%=R3z$njW8yJsuepmA-Ox69iEL#}t;68t3?f}bbX z(HKrrKZ%UM!V0Q}tP9wC`0QWhX|6E297nH527Pu&)TY-z@A`@Y)_{@D0~#S=BUe^| z*p9_TqerwIOU=!F`>U(S1JDL^r6+mM)`K$X+Jo$pN~{*;+iF#2N`hqZs_dtJfV7{h zn~ECw+~vT!gGAEkO15vlEpgGvqG;MExQ{z5;-SrIXwL6`9aj<1?bD!YVH|x0P`Nsd z3{;+M^_d!8RJ+CFq88;fV-r1^TN(eFp2ld{KI|~K&E~uaah{u^q)xHBl|AIqwCK7B z8c9g?e=C&?b3&S7gTgN3W2o?xGVoh0cj$uk->Sd`hDZGJf|yI=n--pYP-#*tqPB-bT29}+63Pu~NE-vak zHFnqDw5?kcNH_`zf9R(gTV*1%7%7N)Lz4HnW9^WM6&A}cL;21{I2=C@(#YyG2y$jP zi?S+h3lwB;vSV8D2lA&^I$FR6`LO~*=xr~rmes)4@cm;RC|j3IWyP+@HBXFdtY0S074p%vM)B|A}+yz#wE zUr(y&$=Y`lQiZItWENh^qv~ywOvk-Dknt}?(my%xL>PiWR_k9c>CTFsz;;2!L#9e$ zzYgg_ZZgQDjs(z8-;|~wx&F~?pg3~~l=#Vb6(`+vqUBvJt2NgSJrwKxPJT=+%&R_Z zIN1H=-qyvHyhfve{hl0u5v`Y{+8BL3=)B*mB>GXYYr>i=GTNlPKIA5^Tqt}&v*jL9s43pWuku`1!meP48?1U3l6~i5B}t2+_%+4 zU_HaXm85Cd~Gq&q1npb@0g_iCelen4JauJl$;UIPQML{AH8qhI*JHD+btfG-EvB@Eh09 z3A**o{8vx%k}G_j&A&lH`i&`}6Xg?)!Hhys8+oHr#3kbdg$)Qy*K`W6S)o-@8)QZ% z0VB}1>^HagPcBhwOT2hRVgwmaduK=V$sK*G2||*Dgjyl>qHhso*^sCAT;(NVh0hPE$__q?rovaXe)2ol_B!%w$XqxxNw}>3$4}?s zT z+%w8nV5|mDrd)qssZ`cBHj+e0I%NnO#Qx1)dBs8vZD+sY2H0gIl*?|AiXj{ z<$jo{Fw<8YMnz? z;>UzMB?^bE<>z}|yD4_AHS1~8tSA{$m_K@OU)hV3`)e+oF@K4-s=*hQtH{9Yw z2xp`cTX}9b4q>wl8k1oJt=@Ay2T7^fN9O(<2xO!(?Omy zhq7EBe}+nkr6u4Yy=3ypd}uhqu|B0=h>ETuw2{k+r>9xp6gHX&07g}uv!kn--41&l z{HOV5z}GDsKhz!-a$Pk1`6I=avV5iA8BzJ{vOeJMKufI|U+#3`lN-j68=u9P@V2s> z_fQr_ZP0Knf@m#$`v)U)rcUai$*kA5_OqL~9O2md9f}JAO~V{3@?a+%x_&t2Yvlk) zD!#Y-epjGNC_TB##rWNuZSN~hyZ+lGdJc>ikhSQ(_9^6RF1+#BUJ0G% zur&;&DUaN*$qs=xm!ZGCq)1K1SNtFDZOQ#?pS$`IGKZd&XhwR#lEnkdzdm_IqE*eR z+y1`1!2W@@Fi6-3|0!fQWL5X1y{BMm0uBIjL&jo)fyI6j#}$}I0qo&&DVbj8%zM6% zGmOqx7GZT(zF>109Me1Y2&-Mj@yu$D$_mUk_Cj#Wvr@WtpW3xdzrQS$kSHwL{JJF6 zK&})N+KOcYyj%EPlqTj8c^_7Yy z8b<+3H2mXW9Tn{ z+xj(w{k0z(#w6Jx%K?kvez3t>o{^lT^AJMu6W{Dm@0HGu{*-ClJwa9@EF~6Ep{G}H zG}UhF6{=~NDLUKp&8gvBCY$pd`o=Ra(GWd=MIH<)(I)t!G+Q|$HcI^`a1hu?d<_*p+hl1Yo-wfjLfC78G}1QRM1&^aAlw^-~w zs)rii?vrl|*Y@Gw#zkHPbE!v!ON19qVde|>$ju0D|3w}wzD4#8B-6i))Au`Fi*a+q zGhAmj81tuHJhIVaH$c~bi)u=}{6cy>c-Jm+zwYX`?7UlDI)*1|X+g~M+=U32mBK#n z?44&5g*LIs+0p$d!m+IY*LmQ{f7K#{5OfxPX9l4w!JgY*8%088w0{5s^dz_l6&ByKPIz**za94hHel5ym_2)pJnqKystfT-Xi_h^^I3xjSIM35L2vRL77)pk zomw5IS0LJo#b#Oqhi`qj8QTK1M4Y1%V4+XVi0d~0^LhE(2#$%|6Lg$+qES~|4dMk&NoBQ%wz*m5o4}VNMmN*$ z*~G?|yLjXUBJjp54~aQ#Zp|H_Uexia!@UVcx)FT&-~&t={C)qKMW zVQWO;8;94H#p}xHBcfCKhK~`7DR}R}qs+;(Ys`^@0kG*GtjgGmb3C49Hw)wbBY9(b zv&uWZJQ*@Dbk?nvOAQfLt2s}Rc;bAC$u)NnJKlkPohFRzqSJNXnIW6e5u18Ug#7P4 z?=%Z?+8KFj-MH#dyc$OfX`|th2~vA&0--R_0_ddkMI;wcz6b5(dnmHN64$236%K5D zEYh+$by%}Ti3jyNm;P`F#JXY>C{7D8Y9g3fm!lLVFF9}UZksUD(2T`#=`I@`6}a)v z@wbsm1Q)f;$BbLK2}!i$foj${FkthwCKaDi5wQ$Yub>x0$?MmE+ zmRBW^78BHWl70oyEg#+ZaarM(5UM}KeqngcbpTbi+qT37fLRsTt$^9qqP@ol4ZC?% z!zBGq`2=~x_kHjRad0y$Afwj85ffJI!j*68%b2N)PWmY8#?0B+0^Px~_K|j{yQD-s z#t+*H#^*Y&)kBx@WO>W~-El9f-$-G9YYk;AgQWN8>^jt=Xy#_mc9)^s=v;Gq`(<|= zGy0dh$hFJ~F+IFYi><5^GJ6PAm-gV?Ae0%-U>)dnTy8q}x<6T2tUA<#3FrrMdR^UL zm?KfS8U5=Jw5vJ6JK-Nw$f^KJoU`>j1Xv)f_jf4sq+hqCX*yOq(E|CM2&p5T4j+q6 zLB*x;uta?`6%H*3!DhiF?N<5^j{19|9{UF+7EDyb#l6cHD9vOEXF2V0ez}Eg5F(Xy z9X~y^7o1I5t+aet9@)O<{kWa3CvxfXwc)3h@#r=4s|TdhZoEYn_BiezD*kJffDRbY zOiL9oM<~TgLJpT1(RBglU?J$s^T|Ho3gbzCZrT`^7UFjC)8w_oAd$(+p`^u(p?)pn};-;+m2i_!~a%R-$e{wT72djeMHiWMU`d}_3kRw6zhjetNv zjn-FiGOBznX^*nc)ZWEceHewBW#H>F zJ`#16x>&1qJj%Li*p)68+5JdX3qp+cWc$1t0TKl7(htM*otwaX_RfHi*#frAVtZ}< zC*XkqQTzT`quipiu-dxEt}}Vh%i4>9Hx9eTFn4rTx(Ay$+7+jW2Gd#(Z4)9HSNmrL z`B3@Tp5LYt3qHdRS|>~U!iW}y(%<@@m2n>|lgcN+IN%g{p?ma8s{=lv0;LN(wy~W-wWq+@YAdRaVt6b z=svOE`wb*rn?^m5U*!q?-s*38%j)Nw3#CkEWLY9ZnvvV2;yq?k>AtEM`c{fJozv z69rzdD<=&{hkunAs}0Bssw0BYxVBiK9oDroSHaXIZlZ6(mrx$h1zu3tT;0@sNyLDl zSj=}TK=zIHq+$JJ@o+s(4J+bsY9MP$_1pxL*Nb@VauJy3;W66H_AGsV>NA@a-GjRj zkeXvGEZFP6zy^WbZ|L^iHK-vSN-wa${7#~3(Ax^`T#efTAhoWSLbt&TCdfufw<3}6I zeYe){d73(%a+6G2C0VJ5Jsey}+&{?w98mM19w0HzSgNOO z&Ka2-&!~D+{#?tAJP`)fUTFh2VVxDZxAg+$aoo zd%47}GNP5W1ScW5pt{np zT=K_*-$*nV4gXV${yMe*$xw#KtZF#MgZwBb;=x=GMcolXVrHNy3N%92S0#1{a`gP$GKEn^cr?KcJg^_V#%ytVtw$H34ltt``p!>Z*k}7X z{V-{4LOZ-eJd5m^sN@flM}8_#50*h4xE!tF*T1_IDM!BZ@~olqq4IHm=|1ST(VHv& zH@jep->C8XzfuAmgj2DvWPM6x6~D}y)C)5LfmYQz8`{Ggb}66d#c2&_+m0ZW8$+{d zW=T)43Bg$2(drXYFyp!eYZ<7gMPG<|582CthV4LTP0IbfY$d5+IU9rMiRoj#h@lzO zvs*-!X0*TOl4jX;qzAk{M9ifcen3!yfL-4M2kchM7|nbW9gXh7XQVwNm-|?`a<{s# z!mNIc&o}mO?nb@mJ&LAv{Km38{M}-17=W-fb7KC{P!Mivr}*-^zLS}(m_c#*#GtY0YYickD{t2!O=+<6tT4YlI;=s zY)eNZ$<-OH{4h|u${G~dlhxI^zBUlrk^CRQl!cFH(eN!JX)vhyBeKcY!+|_3)NeBGiSTU?#wKm_{0pp9Ua6beqjj zZjMDvlaz^DRujX5@l5S=75?GcDHpi`a?Q7A%5~lQ01NO`2fchWgpehk@_tGEkT5aU z^2LJv;W8bNEdbs`5XPIy(24IQ+U5i`>Qn^lvbOgm;F6Wm|^7i0BR zrj|a5bnSa4W7xET7+`i9uziFFLM({Vq1Ck_I|Po3*A%Gd}V z(QaK&*KQ06O0%JPzB*GF_jOp{3H?LS%+tBP##2<->?|rE!qIUaJxK;U?2$|)Lp^PB zI%I?_RNxG%*@K#(N*n#){(TG>1sR4wZa zfYRpKMt2A#;GaH8&;;bPq3T~KWx`Us9hS)$O6b(*z}+{*6pkONLK!FP2dC_PYL{{dU6G=x)y$BX3EC^TTro^|xG==$JQ=9n!>X+ry1) zgkvSKUmh(p!VvTN)IixSp39(!Nl%T>Qs4Q+f##?l&e$|*J&_Hp_zUiM>blry4%6f?oK1S zBy8gG4%p%w_07^#E~)*xnsjzq32p zH#A=1mw&-k?Acg7n};*m)+LysQ3zsQ_aE9p+Y4pyE=5QhuZ| z(yAD<$Sg-=5cBxmF?48X|>;a2nIzg-_suR8Bq&G$?sK2(zOL)J+C-l7Be^ye7ro5w&)(O>#brTjKqrA~$%3*$UV*lv z*?M&1u4dUkkXK%J`hBkbQ*Lge>fxnZGf{WMYh!TNjUv9-pU-S>+~8={xD(w% z;@&z`JM2U;_y9N#;OK7o-^Lp_S2*u>9>=ZZh+E^eYG|lr{OCEIjI_pEfe_eCXbwEHfY} zC?Q*nHAZBdg514*CMjPoce`GF{(-u|ovS2#r5YHNNVQEW+I=G*EfADHVQdH=pLzZu z04C0(1xiP7y;}+oNPp8tEC&Vr5fpgT1;oS5ei9O=Z98b7i0li(k!f~-*MYm@Xf?2B zsP%*BsKWh9hQyoad-qK`=6yNGjDHcQ*SFHm^Uuq0W`lm(()%XRoxbt>Y%|^8uii{` zDK@xLL0vk$x~6i@ru)3Rf<%62Y>)FpQZ)1;?hKxZgCBSB)2ZJ2EHTEabKI74=UcsW zRx`J%NJ(qaKS&k?*cqf)53F4N2o#20*|IV^c8%ajbxe9;VfT;QE9uY++na1SHpWK; z4AD`L>>&uN!7=VmVfgMXmkY4sW)dGdSs6?Q^o>7VzVzcBetDapcNURA-#{mtOR{vZ z!uAMK`NQ8t!)AWycZLw<_XVDNulOh%Jucpoa~9EZ2D=fx^`^<2#Qhszw*%&HO8nIX z`E3NyP#-UQ_AjD{5P3GE{g8QI#GXgt-yrvZ#hR4bigj)|1x3LUPUM$EDaP+S4N zj?O=`in*2Zq7|Vn+VSAegmd;g zcWfb^tv*48k9D?}fS*cJ-nU*|XGy6mC2Oj7oU$9g!5-Y+rJPlj3KEGkM}XmPIXA(d z8pERUZ?*mJhhO|}!vg&9g>FXdL6fRF0XFCl>JmQ~`;)k+wwgML`q9ZCp|{Q4r0N4i`S9`+~v(3E8Hbd*z8-OM`v+)E=$kFRuCkfORLL= zUh`p}ynSk*-@GowJ411oO&;lKEmFi={1f=WipgvBpK|I8fSTCy}aJMU;!&t`5hE&%kbm5kAqzHs2KUq#~=bsXk>q~EHN%vdbw{dCOb zi$^M19{^H;{@2W=K5@OqZk0|`e%VLNGCpK!k%1he%xBFlvvXPvN0$YE%Aft%Vgyaa z;bWqaOgd<9S2<1_K6|a^dtgp|MlT9O@u-F!mXyaJml2i=T?42FL)dsr|Hc2>ET9Pd z{L6IlFGOPe*>_gDZVzORVzr892fxgW^h0dUz6_)}!c&&e+V48BPHY2vJaKhj3NLQC z0GnBYbTd=&mp%@ipaHHezu3^x1yGg>7?nvvrQuf|EVtJg6i58F`f3s5H@(V30JkU} zrYTzO_7~$?IPyMwnj+kGrzgCwtGb>sdp&A&35_xk0UN)pLZXrXYJJG|WNtf3QWNq6 zQPm!PGqzDqHGw2&)vcf5>$hd40kP({tAKSX(cmK4Hqz$>DRGQ$_UpmEAoN3Pfyqnt zGhmS7)=V>mz#k~`FQ8vm{3^UF)H9R8RB#c^@6J($n|wu_CG|2V*sb} zU$=+~Ga}7zZ|2^Ka5P*9Gh9$trZ-8!*yI3Wgm8DS|JE-cLB8?7Oco133(BU=F3*{! zQe*Z&PbBAzSG5Yu=Hw%rHek+dV$_BlOS2%pIx6YJSJ`G98?*9DqseL|%B#?G?@G%Z z;1&Dz_!GtDfN<3qtD%4OS=Lx!d_SkK;56Hx_e*OFCvD+NRqR-|xMrBk=a+5l=3de! z;RKnbf%o&vtxn@{ZlNkqri6j738dgJ)=>Qi9JA{qyO#YkrsXIlHI+g35Ot5yz%!$~ zf0L=Zu3M_c!_IVCQ9#Px`;EcR*F(Uyt*7`{<%(^WLF+haI6}1QD7E}jM{{=8F{-5l$(22Pp9OSsY{{TUZgeaN?yNX;yNRCqpOCYJ6 z-KYZBQUqSGWGhRd8yv~J?ii%1Q8Q>St75JJQ0;M__T`*UVgt(%1!sJ*Dw|0k^8FF1 z49dSUW$-)YJSU4g?WX!@Qq>h)AQm(fmicBeWUU8-J)?rsrLkY^u#1qFt|Ete(xG*& zkyfSS^u2ip+%C( z#IZDa84-d)dpK~|y^QokT(FqSuZtjb@#0lI?Y!T!U{N?B!2k?J@A03^sNHIAYhrhIWq)&6h*gBvA<-G81QL#PC z)7d)J*d}Lxp=OX$L;APdhod5EZQEIl zkw5vDMb7?8=9s#gOgQNF)gW!(yzM-8*6I}^EXs@JY&oOfw(ROjCkv`v8W3;{ES&E@ zG?cjgiw4(qqH7vWGhv?{V{MD|xNhn9#QWXh$z3 zk|u)$YBP{)5dqT~-vJOVg^5&cFgX9x#(>?eDSQYKUiU+hZ3Gysf_8SbcV*5FF*Q!w z{uZpXR-T4MvO0tQc;=?2UZJvrn6;j$9*Ev!Q}G9~WDI0v4&wB`9(rH+0^i2eRKhDO ze;TW+cS%77r=B-Wb~+|MX6kNyj$dkRmdFw0ee*=npi-xP>?d=3T(-`&_unQaLr7x( z-Q2%`F?gE8xi|2USS@h&O}=hAzlPrTzFQv@fuQmIU&X5o2ixBlfl1&Q-N|p%f4TBR zK`KZOUyy6hMYcY^{ZtU3p6{AggkA(R0Cy{lNQ51H(V&t)4LF_jSF7oUQ5hG2$UJfK z?bMKk`T;f<1QkcK+V2kDTqy`sQ&!i~+h6Gue+}(4JDc2zGW4|X`Qzma^kfUc1#~eZ$a&h!qpoVa(WnlXOo?r(Ld2I~{H z`+R`aD)g;YYm~W_Z%zZ|5R;H5{(qs`q06S>8)#opu*5{*D;~xM95A&*$hfT-W#}>2 zJ)*w1BtoFm`@sU-tJGFpRUkjmwMbSn(bXG!#XRR$qgw~c+Xq43H$&t?JWuu?2L1$M z+~+>Vci+4u8xWOUGz5^I6Jn{i`mdm&RMjnMY+ZZ7SY~V|Uf9k@M^Gdd?WaO3MY2GC zHDR;6lw%sPm-$)+I*GXK5gHfDw_=b{bVK(n+kOlCXFXy>K0Jhfb(39u7KetA9PV4L z)Qrv#i;ba)U=H5Qgajg3qzbn@M+bl%$Qyqeez_zavsUFN}Tt*ziL}~OT$X^u~fzy&# zd=NMaNP{vySDK+@;L>}jokj1X7eZAZg!H~!^IlnkU{Hu5h-85E$#9IVmzwJe z{<2Q<<6C5@1z)Me?QA-Q`E_LhNeKn1;fBN|NWdg|^YrZC((P=BDVL+BpQwuGo3(_l zCcSr{T@TXmsu0DqpxB}Cv6F39(H+xbpitTirzfPeeuk@FacV9`dHJsngJ=C#qFwLlY~$5o287GEK7da)f4 z*tDKC5PnE5R^t1E)b4!tz`Xhd>4?pn?HIkA7{Pwp9v@s!gy?J?Y`6LhvQI4((5&9W zL*ul?4VJ6iN{Y0l6q$OEslL3So0E+%p7olGw|+j@a6xuHozEJ#e(_kanBBBh?;!&z zc^2i(x?rFXP~Vc!ilYJK1;ECK#P=Zfb*vl{4u&t35_?11U)aX%9oGZ~6$vwIYX6D% zTgI=Hwqp3BE7NEm67!Y;*%zNVWd3c&??I*a6Cctv-L#fkv$H#i z0{9hV-}rrnSG~(M$}DAL2>q2Em|QcFO~8ryPwJk$URUHuNZzPR_Kf`e*g=-d=GfTK zc)5k42E&^!yQ_jnKWvg#aNu}>${m~wBE#(+(bAU^7>ZzNf0_^NVk&K;PGirC`jU{+ zi)2zRl>wCu55{OVJtU7|Fz)NTtg2F~lCW*y7e`(4GXLPaE)X&EVbQVSTx8e|NG5j7 z;w3QDtw7JgWBBJ^ChsSAKQpCsKFh;@KWj~^wWH@+h?$B@EaH;GUv4Y6K^QEimC@-X zAg~QgU7wF~E);248AFV&9iAQd-*dEo81NQ$_gyHE@lXsup2CZZm@53ae5i=-2?~}K zJdwdJr6RS;)>`vPU?&P}$^?(0#h4;IGuy3$bB*D5)Co#XcRGCIIEfUhn4_AwZIiffdq5^ps(<7dogj8FIhmd9`9>hduUN_C;ZI55hOTpqkZ z@OizZ%l@93wnD#{&-@w!;TxIM8gJ5mBQEi&9|8Wy!>2k>$oY9=89T*|So7&?eBBr5 zaQcozk?RKq(Lt`roO#)1|C82X$%a_>S7y%({z@EC0;Uz~k1us=_6Gk9M&t;{=9N&Y z$O#Ws*un9bF1(#^K)J=batJ*;CPY0Ex=otsS7ll1o`7AG2H%onkM>0~58;-M5 z@py@MMUU?FMbKldUR=}~y0NAKzj9d(Bbt@Qec&m&jV?~9vRJ&kGHzk^yI^b_L6(MD z1x(ZqD)hlg@K@h|y&lLh4EJGIBHAu^VZJnM=zOTI9v5G#T22sN7mnPwU_pxuyD7g= zjfVXpHLzq@F?c9mHteH70`K!S8O=dTOWbdD(IR;|vH7HgFngl#m0HDzBDEB7vAH%l ztoL~yL&yjAwuE1fA|BW)9l?RG*9Mq=BjM?$22ekYgC$XZf7;#aVHN3n{A(XdIw z*E(HS1!;Jxs-0(*=) zzN6Lw(cg|2Hz)XhL29xUcc5x8=G|ySAELd3ja5TQw(`l}^xlzG+o$MN;dep)ExJ~# zx0{~1M373#m-iv~0Qx}YyNAAnkv%wzIp*weCgDMK$47aX;$N6#ovo4-Y8HO)4^vjt z0`m)Zz<|C8dsCfEVNu$;jH8d}94w9e`S&3zX{sz;wF`cfS>Ck2@2p%xT<(F#`;9HT zi?mY;E8SX0^o?xAjvXPp2C}k$Rm{3;x9m(>zWmUpb(106%E+PX4q$vyQu|Pj5RKqY zF(`PGWkwBkzBWN-f%v*-)1xT-d5SGh6JRhXN`N2hFE)Oct8vT;z0VD}*z7KHph(i?tFYpK(ftAoII$ifu7W-4V+ z#a{k9U3*`W0K_ya(c8&?FZJk8VeywkYH@)kpRtl`bPmlS(Z=kuYp#o&vf~w#_kB|a z`Yf;WkeTnsy+=ksSM$l22E3ruzyC-|qmHbZ;1Q8N32cm$)IV*ryMsqQd9=NXY^0Gn zByxF$rmHXjY5@Ctdn172js6qHKcqR*2@t#&!Dbia(mI6f^WQpl1nlBwjfpM6;#62M zl@sZlH!AU3i)S)aM?K~b+>8Fz*4@}k@+baMApt^aqcO)I?pF^E55Do2oeR?IA~rX# z@{7~isv9LB23;3itxTKHlziq4>5pl%&GxJH23M*7ja2`Rlbex{wheD&cJkfzMJ`CT z4w7w@>ckM7(>`HbFt$mlu0GToJ}I;j!KO&Xxi$)58gYwW>}MsSG$^in`(f|=xBUx_ z+oaq)uRi$Wk9p3JvG%)j^%#UcduteB3NChZ&>k?VOer%U%|NE~9+>V#KlWUhiw>C-0iu4ieLy%oK4 z&TzDK$Y1oC5BO8N$={r*NuAZhI3$#nicBK-DD2U9Y^RHCb`yG)^Fk`R{Y~ggI$aN6 z3Oo$aA$<%PtDZ@dR)qB3AdJS~8tA(x-!9wZ#Y&%=+3GtKlL!Y-#M;VNkacbtcm}VM zePqAEr4wBde$zlDRSRc?%&jxWyCp!QN3!09eA5(8-kNnpF+f%^&1m!h>y@1o#7ORz zJBB8DC;O$p$?ViMC6I!u5L$Mec@YChQE5-~vyLwat0^AO49(PrK^~|%ye{IS#(k8s zn(YLt*|r@&JWMV2KCf|p)@rS4d&0o#hpFZP?J(RW+rSM!Q@-RCURU@_XBV%CU%4CM z_9Dlytjz7<3gEP0mawLb5Sy!W07(U){BX<2^OC1`Byl-$&TewmAJ~UewePYhN%X$t z%IIYda{H(A1&|AsEE*sV5p=gYH%OP~9!6tm%GOKw{@MM@P#V5nIxP0ssLBJe;EViF z?Eg?rtOi8>9>6YNzqKe(7cw<+EqaoUfu%*_se=y#lj0?d*S#JO1Z~YgdJ*v8Yfu_l z|7Mbn8y+exO8`8^^XHF|9>wl_&gCM+uOem!NA~{u+j-EZ@1REca_%<1-q#vpUy#&) zg!WSVRS0F351d5@*=EU-DNbx2&#vD8t<{bTtTsM=-dNNTSXSABAjOZ97?xH%Xf9#& zyx#Mqud7S+LoxYMhs}O32sF;^ncKhv zLx_kaNj3a#H35o+@FIts$B(P@^t?VDIOZxeH&aJe!XD+8LJba1PYNd+^uN-WYuz#J zj;*Pgf?FW}o?@bmO{J%vzTi@m>I5wAT|w6FhN6IjHvXzj|HW@!fr1kSqqx55`?+Lf zBTRC<%EdZP4@BGM>D*`W;9eySA!0hMvW9;^gQ_6=F5MXxciOjyLYw+w-wd?|`D$A1 zU&O2iVA!oZq^lmzZmaZ~JvnllE>@hl+h5gcWzBr^$vUmc>g$7RRyHNm7l-M~4mu<+r%j6St=rxPuFGnIr zHRPTmcvy}>5VinCYj8U61i=WW#L2#GbpIl8!;6ycYkAV!BvUmGyyK37n z%ftV*7eL#_9;1OoBTxnvu*PZ3@m^Xh`X`weA9x+DgfH|(JNO~xmHf}tCZi~1t{5@h zj+|#704XW!KXC2j(xYSm+^Ds>44pAJtb^Q1>26GJdm}szX7r3^cpwPC*g&Y-0_-!! ztIIyF_KK5rAKpHF+B?1&`9(t@XhR#Ys`Xb!c0S0Q&=d9kun$>}fLb`iqPCtKZz|$R zGV%kmY>l6_3m{MqnRU5H!PnYcvaVY7Q7vyIKcuGd(V^sFE&IBwnm-IGwE?-;$Xx>z z9{P2-siKyz!=om43etTp$V%tSxb3TSz^_sCvoc8pZj0l|IJGV%r$Ivm11MK04Nq7_ zNL2QiZbSa67NWpT=jrX}r?rJ0CF=E1`P~52$}MTsLLdCfH(1S!e8wGBxs~fT#5+Hv z&VV~XzQ&>-d-ncUBd)9D38~X6z(B8}%86tIIrvX}V3PbwD*?M5VFtl7Ch#>Jn^lSP zRPAqbp63d(Ho8Y>9VT#B^tgA>#`Nj{NgmhV;4PBXtLtF^lPf)mlU@fy82Nhy&uUDN zpMg(#ulpWZo=a_GVa08{f0lgs9r}By#S7u6vagWN&lGomreh!ZF8{NcbLeVJ#q`G0Fx{(GC>%*BHA{q~_TM({ z8;U(jF#KsJnp=W@!zT6)h0D<7oXzZm>*PTWlVge`WuqfP|M58Z1Uasg!Da#n6#+|E zpKC#0bIVE#c;XA^hu43kR_@->iBqFeND?hxHfD8yI%X;&2VjiXW5=|n2CuR60neFB ziaiAmYI?pxgDT9WXKJF_6A^euQFg~&q~l13rX3NJz*6f!xa@+-xzma~S{qd`=#J$t zX7ULh2vS}g&KS1b9MnVmPGseCNoR2SH*CO7Rb_F2S?|nI&suc_reJ;bLk$H zR*M3PPRB5ezdl^a8Q17fOs?~Knjd}V8^?PGqiqbnaSuK-y0j!?sFn7OC8?NuHkYBu z!Fh8e*+Fz9je+>(+9;beQRSXQI%ln3q2Lh8Ivt@Y7%MX{p9LEg;jD59l3$U(EhjA0;XF) z(OJE6j(am@;k&}BSZ6x6b2Wk9AJKihfL>BEZGI~qtI{=+N0PuTKpj;iYkT9lWIJb4 z2ebFVSvIJGTP&sAjl&gi8ja>3!$Dat+y+cw&}i=?<6__SP?MjpMenVf&l@SR(XlY% z*#Ig8gHG2-M{&OPyCKqf8BGsoAUS-ScIXrchUKXs5ihE zS;|v)TIzLLSTc1}u%UMgXR!Nl;CQ=cV9#X=uBq%DsZVCKqTQA=xaSV2<_sm$qj1o? zy|k)k7>~=5QO)*;XY8tdvmT05!s~{ve4qJL-9%dzyE*lC9cmcjk|OWjwdkQ&Ad? z_*)yl?Gj((35wpoRxF&O-=??3(rO-m8@z%kX~*wjUb%@xIzyJLG!#)jZmVnzG=^383*v1DQs-iy1U-Y~PapYe z-iX{48zMMY1vIaxt-M?Tf<+QL1IHyaB~}^J?UWbqLT?zu!`+zNsKKk4D6(tq2usZ2 z=(3~`??^n;uRq#vqV3@difO(L!(H0>g z9$VIW;sj}3Zr*(WV63icQZh)`DWR3n27@ZG$a~4R`=9t6^`Awp{U%G6@{P;Ny2Iy~ zz4lR-IhI4Z=KE&8=`@wU%!TjC)C{Xkf%%U4yAT+qa3Grv#_tO&OaRwsvDjjAh&7z=N$b*Ypzh?NgUWoY;Go=|IlDFk`w>r0`~PZ z{fD@XlZ2uE>w&t9)04Zm;v+8by)r>jC8~@y^dKf8>#~agLIC`$&wKuV>qJ0ax8Eyp zg23=VGKx0$k78Rw!i>nPF9?kU@cGS~2RG<2yV!e01_eG~n4?-<@W$cJx+H?={N-Ds zHTV2a^04!lYQ%Q_j50DmiNyf!;TKwSbttrB2fOKkn!*^%+sUfqusdUrf1lD;pw@R=zHQlI|k}GhY-u5F#rsmaY75u78JfurHYD z?PGZ$#4mj{d+}{nZ3T%n#^}R+O-k-fA7}G#xNH{d!(X7kf8rF;8a$Lcm3i9wGtCp6&8|PYnB_oSP9mKH!V)^P)7^JckO;|ey!03pf$}@2NdoIap2g0`jSomL;O&9vz-AcbyCjD0|bp8|35DPxwKRwQqn>RNs7do4U zw3f&i;9+N|NjN2>nw~~8lU&?K0{=7uP`%l z8Pj4uP%ap+^B6va4!o+Cn!@MZT45AI>w?-Vo&)PTzQyC@?q3N|M)iBv+%L z6CwZbONxiGflSz*%rk;N>+vlNGPq7qQy2@H-rVbdKK7ePQs9ffpZCk#Pp930)jTy> zUPM~zcW-b1K5G$$?7ok2TAU7~hKVm!>ma388q!Ml9Pu~wFD3Sznrdf+JQUoBuKlUG zP&GHv409;O6$O|3FLt0uI{}DySr>upXMBOe1lzWG%NeS+Ah9G}W6O~q%|xQ^jqkf< z!2p2E(*%4JXu{f~+Nv_G#FB5!uF~uaP(0yB-|7?P8{hpVP=x}o_q(pGhGCkI6JTkl zXqZsd^fsToh=pj)-~pTwWApQ$G668&i2p7db%M)d-M@QWY17z$oR%u1`-vR7L(#@5 z`USu5#p{IEzG)cK2;9!FX%??AkG zVGON^439KjGKX=k57nkg1gNDCcaS*}W9-xitCwWJ9i~96zMb?zndA>)26eZGzU~F#sQFYx=;yY* z@?AGtsSKBC{=CGGfId{He}Gp3x=9EYs(V=;t5r@^h855wri-VEUY6RF=PbOj`C07% z!goyf5>#V0ur|L5+cW7GCttY|;fG_XkO=c5ux;aj%Lko|XLyE%T@Vc8n6%PD({A<0 z6*=h;0&`6VvO2w+Wt?Rj%3<-5bL>tp+82k{-PfzeGGs%XnN560cx-~!bl5%47iH|F!+=ls==k+igNek zbD>rqew@#@rLE&5!Xj3>oXHu4%~#189BGM%*8-Xh0*O`=aj;mg6LD{;>J8jN#zDqs zZ|Y&fjnF&)D+iP+Sz%S}W8e&W3QEY>X~z(K`ZAnCl{FzG|Z$^v45 z*@(p!*7pqkoZqX{3$JW2%AI?gSr)9bx=G)=7E@Zq&#F!8eA8AY%-X1=Pxa5cLZ3;$ z51nAynDXS8Ku^{nX?!2Pa^dtYElEKAagqda%+;t^I`lb-UQ;IzKM&_tO=K!Eco|ED>MAb%pzz<>o(Kx=-iA(8*88*$7~HHg zunw~$BhUZVR76f;{>9;NU(h;&F0BVh-~~J>;2+@%@2ZlFXg|I0Mfgkx>n4WwAL|*p z+y-DA79lPQwv!wWqkanoCy2O7TSjN#ZG{ICR`so(y?>lWG@Zj1T%6`U^^V*EC09eu z*|vI<==-cMQK=AcU{!Q-DP4P&wR8hGPLXmyJ)YdkMDR}UKFprkxqw99zIt28ML?RI3 z1_=)Hhh~R5MLz&T%=a=b-Q%>WG{WPvu0?1YQx>Ak)cU2lMT1gFw^9=K6@s~<{cf^b zp?*Tdg7JVEdUw9DNAh{DLPR_sZh|>+Ty~!2#U+>!$|>&k;eth3zH!vW*^thS6c2TZ zggINXF>gI-`FE_q73H%h<|7QC#_{zCZitUw8eaf(^?c=(FwzseLm zG))Zi*+z;Abfth|@Z_$+$_mcbY#X^X!N2`Iq}wGw7py^m;;&>6T;we^sgFTh7ZcyV zOTOBjs0Zo|9~~F#9rj}f%(|Bh;U4+(G}G#Zn3K~Zp8*iwX9iRs(#^^KsgjCoCwdPTaJWrH-aaOvklO4?RxxL^BPVqD<__!m{`B_RbdezVDs2aFok0FkxaRdN-#U{N`{& z^s&W!x19bE-jC0Jh(r3{{4Dqn)V&Ef89aA5L*u@hmNxsfJ~bVjpsbSRWCB6vsiBZT zjscQ^hw;2z$+XKv*8_G8r;L!D&s|f`pqPg}#40U!a?f?`9rZ+EX z+5Tev&Q|)(&eti#*hvUl)KWjhbPkn#dwSTY?y!Q1jIxqi!#$)sn~F}`mnBnVkHANO2ys zLmK*eQl-YfGC*$2r(ns1c-{*Bo`;9YdfB_g=4NZg z-hcx#0;155>a?F2k$v1n;_#5{va-yR7LmUJ8*tg6I}VGUBYuRgrE&q?b;7>DSdbgo---}IyQx^d^M&f2&e zUkpVfeMIi~{p%92f`Y^AoX$8kAoA<}I}qYq(|VHu5lQZB18ST2dbZmto)4^plr3h3 zf&dK#_!B%Nfi6u+Pu(6KLFK5h?BQi#339M>1t>_G!xPo0{(#-MrSp@KUTxnY65Ofx zzPt?nNwnLyMrlvnHyM0EApAKU6U`m#0 ze5Y9d0GR1C(p>JIut0QG#Fl5dx3=owl#6+Dmae!t#VaWOp1==Djea*D_&)Tz$X#3L zUrNu=4Vv#Mzd(gnPOXq{01Dyh9u0$T>$&=_>j&%qpq@CLLE)g7Y{u$0&6|*(Act!N z=lYP3p`X9s;t-k6dIO@o9l90qyf*e_?#P{jMtUM4)xqR|-6Q{*=ia+n1fBIy%Ir zLf6gT4!z0mfr$4RfY2?G_&}(IvGd04qsj?H|8)xzps4W;n9#VAYYOh#ozbYooU2+wu>oZ#Xbr)UD4`O1^I-w z>`{qxS!+^QY{3u_fp5^gE7`Z${XW6Y2tb48RZ?B&+y;L5hCce*(*+7fq9D~QpAaj+qBqj4V6=zw7D5ux8 z;n``vM;Gu`k4op4Q75GI^LlNqNKlSwU}Ke(BU198woaxKW>S> zqVTAQ9Y(zmY=ssjH`!S@ej77lP;8pnjiE~^(fyMgck}j}peQ{2+^$*iynTrOzB}<8 z;fSy-b5rFSf{x_=z(tUspUYnLbekSwPPJj>v5Zb7VG3Vu)W%a0Zt`9~1(jCfbomeh zj>Up8S2)gaGD!o>Za!r#m2XOz`5pst#))DuYE ze&gN}?9tdxtLVoSnBY!X)-{TVTJpzHYY`<|zHdHi5!hB9zuDv8BS(J72T@zH6)L{V zy>(}dsO*MJW|!2DgF55h^$IMM>NzV(bus2FCCpVx4b`aa?+vk{OOLfoS5`)~zzcTEXRQSIJ<0esF63}2|#7-M*5Zyuj{>w1}aPl5p z>*hMz{bp`%ZuVrUlZcK9zk(_@c@^+IooF4;ij_Io96f}P5#?Z;lGQR9BHxuWZd`Y< zuS@_#6!jGb56&+;y3tBb7}cl>TWl^|=l>=6BRbvA(2pwm%+!Tcue>W@$_F5{$T}|aF$=7Z z*{|b2C1P_{#akC-e6zpX+_aki2%MxXwqMS~aKOV*+;dO?p(|&C;&}>99yb zFxzcUNqvtD-Dv3MML>P?a|5mB_BTABTA3Ggq75tOZJPFT!mrNi!O{}%Of5*=>SDUY zW3KO(O&3}4nsc1C3P8v*Vj`^|9!J!^75|zJpzKE1KF0bTuP~zrq+bMjahLKzVb|hi zT>AZXk9Ss&F#a7KREjM!u6xPZRL&7>89Fxt^Sy&{G4f)?UnEGnUjJznN~E<^ouluz z#l_Rf!B}{NZ92)Axy(2e>NIOH9j47#ZzYI^kZUc^bCmxLTh1>^RFWez3)SZP(iweR zkjHka>G}&W`vKn+5AEi#uVeuoCd>`X0b9&VFY@gV(62=OGD_ASru{~mriOv-0|Z*H z#L9)I$0-AYT&k3);v|9fr-6kWkJj?neQM~ym8TqQBm@GXoX3Ea_-wVNMxjbI^TKhf zn)-k}p~ThtH9&zoC&--dma=^XU2#nRLa@b4w(F-DwX3$cf@ZPq8{>hr3Y^B{j9*B> zMJp@oI#cQayzdA~smLF{XoMpy-IsblOp&ehzNMKa1WsR~y`aAUX*5(O#R8E20b4ia zP;OP1(CgQkt=o1Ax5F9N9bW|Lp~@*gNN~1OA9ajZ`w_c~u{quiHH=Nl3i^DR$x;=n z8HF$1@>s4%geI2pJRv%tJ})nU(ABrec^+oNc^u?q7i38i%^5~quf$9d`c?>dk`qMH z3?KPo`{4Sm5xYbebyd-wdf(w+jwvjq0My}H-8NKt6oAD zZo+NFi!jJaJZ=28sd9MgMrd56q#NCBy;q&eYCzCpP0dg*AzsL7>wK{#<+-lnaf^Vz z`BD7!+SFaODy^$}pPkFGzHU>YSxl4Cb%VL;_iwFG^jslCXi+iE6i*dt%}%H`UcfK> zoi>jQ`p;7gzUq>E1fLSVX+X+#3#ikLgHaW0qW-Z7Fl}yq(n|eL8STy47Rexsx@~M zr_8~uxkN6VgRuq5=N?YQqoIKHBTp1P?sRXa|15W@sFwe0S*Pc#OcWEkirEK^)nPSl zS%lZ7?Gq{mH;aMdkpo)v7r^-cBpd z!h(N4z-z@3(0P4S6Y|NaWTjtHM1;>N ze_Ydvy5`IFGJbwtFs&dk@ufP^6%*S<(R#WAZC^eS-%5Fl&muZmb4&VNl|PvKN-ovv z@5AojQS-;Igq`+|p}Dn^gn}TQ5Fi;(iTV*o7N1McwKB>h*wjrKH;8{BO)CV2QZn^S z$oeQs#26^+=fNBbkS3V~xft{2>(SMBJ=P(50t}{>P^*n?7xY}}(Hx`^Wo1DBc%2Lz zZMrN)b^4@O(xl|v`iVS222AXN_bPglTeRD) zNttxQm7aKg*I(D9OsM5yGlM#D_H3s?$d!umzII!{6*u1@%{zvf2Q3oE+ z%J7Y_HUyi&RYS3O_#PmlnSmaWMitr}Tph56P~YwyLCSo*aE^W=C$f|LQ26a-<0hw$ zyE`T6h4{Lz_q-DfCh>_B*J`2*`Dtj5IwuF?+OLl1Zv*la7)Ci?GxujdU<3O>h=4C8 zU40qpziLw_)K}{%Wz0BOfr?BESWH3Io4H{+nNA+=;?e1Ux_`hy4f$jG#Pc8KtIMwu zIock~&*|I*-=L7t?D%wwhqiA7J?($3;+lA=lAzr(s&>YbF%{_NbE%1uA<5sum2b{(lbjyD>k6$Gl&S!A28+jg$SlvW0sGUv$ z79i98gN7Xez5vjlvqCr}fKL@&u&y0Jqx~1ISMx<0N`@ElWYQGvb{OU+f1Y;L^%Sqj z^hVl|r(O43Vp{C@r5?@SrUq8vLBJn~hwb6d<_P#Jw0`XG?EKy&CCHOyIp^RLN^lrw5oMNgO|d?7SLm3galN z6NVFoB8i>~^070LllI4Ofl((Z71K|WN0PtTg)2Wq--!32OqIpMtFeI#`OGHRj-yZY zR;xs-8D8}* z@({u0hFX5K6p=o^YtrDnaP^L`boCzB0}%B3NNyV#Jd=a#P06`Uuo_4EtcX4|{2Z#2 z_xJ3eBPC5TI@?vy! z_d?>GW=vE1Wj&V&N|DBWuX(O7rp$Ky7qq6n=KcG=cY8jEswEnknRoq{{|FZi<#kh! zSIq^V6PcRV_O{3xwZ1h-Y0;}bg2rA_w5cBczVd*(@$OH7fW>mSn+^aBG9dtbEdQZZm+T+-%hP~g77G!(4RzbO!{0)&#iGh zld~D1fBXw`RwX+H`Q`}zHo-8&GAInUG7)=VAZC+p)ssd$vR1mF?T#3uy-OWJpZ@Xa zKv%;0C2@I5F@^l_qk*eNuswoB!>PY;y9M)%>9PY28S-9;Cv2;(F?g3(wU)|7f9t4$m8Xj$2At93L zpu>SzSr9^V*+M}kZKrbk(Y`}DrH?&cedtqt{bPrY!whW+0wSC2K6+cIf*PQ^gRLd*{u4=Jt%uQUu=K;u+EPBA_Ci^bg*v!C}-YchU^r zKIvq5cSsCg9@W4lR_RO7EjUJqf!Zs7pEOrEg`AADEQ~2XKZ7rd^LFIoJG<}va6gxu zyG^Wt+{Fi7$tN1ne53*<5Vj=+;`y(~EAb|K5`^XAO;-?Ih;?;!2*!oesBH=^a7HAb z^?O2lhfu|{X(sio!a}q#&DJFQ(1R%|L{DEOZY4WT!_- z-N|eID02%^%#}x-Ji24W+h=6S!uEe?`VN1p-}nE+(c8$(=GZgImSeAERLB;gtc+~g z4w)sJ?Afrgv&XRy*_&(!N49LoIp_DP&-eEiIFH-?y6)?`o~vJo!abWJwIcjG+18tL zpjl8pESN?o6zPG+g4(AXv0b;9TM4N^-F*zI`1kL7q8E7&NQ7B49>y@$tiz-t58 ziWdMYYGVWc`CMu7Z%*aD-p$A|s%A507IU1U++Gs|s@yl`OEp##U5#idHH= zAxSekGjrVMlcf;^cG=oOkE!{(FGQ)(>UBo1G0j+aJw)@$j(1x2&HY8)*^@pY^!yF-E`k z=G2Vi&j#Cnl4Whn)E2e9+HavJ(x+3ruUD~kc*denDVA1bfYYV6Gp1rYpFtQ~K%0Vb zkX`=))d&g}ia(W!%M)VSpNJs7azJ|Nb#S0MCGM2=Kl3N+BKIEEam-rZ4;yr8ApEZb ztds+2MR_)kFa3_4%B80*r3z?U8dAyQb1%C4&gRxHr={H?F|A*v@4j8Kw|@V8n7txQ z{Mp;Bb=vfg0+a$kd=NqKZywgx&wATn;a-$D}IQJ!DRv2R_~x4 zVKhO>_o3e=J{6Dq9hXz%Tvt4!s&zC;%6(FL7 zYz6wDR)*sI55LcxDa{WtD5fwmv075E$qUE%<8-)^w>$%1c{YCEM{EeFvnA4zjM4f` zaYh@Ejk~`G+?c4}rUqQ%o*ch$FU2ZSmmD6qc_QRMT2`+ph@}aOYEsqg21NeLJvnJ& zV?X*0z_&X8aX}$it-|8KTZ2A-E{{M4A?~!KUzIan81ClHBJ!Sl-(AVfmHz>TU9zM- z3;OMH>u!Rzk&AiMG31Jz_LeOzZOD-@v&-9pZWyL8Is#~F<7)3FY>C~0&0;$M-JhBc zM^`a#vWCRE5}8wXcNBkj=Q<3O5O`TE7<_ScuTLVDE2N8r8%N=GTr`}pGli_0c_1dL zq7s~U1w(#F(!TByFgTtLDa4x%i_l?t%3{2hpdHWPiHED+EH^4QI3>qRRhxV;_73^s zhls%OX)p{VJ@c{~W+?ps+XM={yxG5d9ga;9^be9R1W!qj{b!dr>SDmB!uuR^qQ8P{ zZzxq_&QcAgx9`-9HuY^(pim4-3@AAI{Y8LWj%EbhnJUlG?K0*!WR-deWKLj!luha* z+vM5$wm`>b6l&+Pd^dO*JMSiIJ@#Pcv6RQdg+pM>=n zkt6wzk{ZGzNq9!0_bf*%!W|LpbDZtlP;XBwwZ9wkh)|C6M&B+O-(lL_()ahqf%BYn zSTaesU`_IZJ~QEcq+WGL%O|NLyv*S(&JXwi2+Cn?eRqKP2B&p+Fu1i`U zll_Y!o?=Ra;;->cV^)5VnK|X(&Z=!{`VYEHio@Vo0u!h0!MT)u{$~AsEVsc$D}eT@ z(0A^iu`r;sDicSt(WISY0(Kfu?X5vRUMy2s-n8jYp1nuDeu{{+W1J+SNjQQiFN22)5=3)H~ znK`qC5_`r{Be_12XEjsXp7H!%Z(KLY({w~&)MsnGkzZqOEU`hUjyBR8eGJ_~zv7N1 zQrvu=Gs55N$JLrQw;hf+2@A5k{O~53Zp88I^QCa>q0~KxE!MZt7KiIP=142ac28V6#Sbsg_KLA}J4-GWD*4bn zJKPES&xdYh!*Ef1ed_9}mJ(HwW&v)gL|K2?4&o_L08&fDkz(@6zX9!`Ah+(TyI;bC z7CXQ_R8&xYx<%IzAKLrjB$fBOZ6Imfh&@k59^U^-c{{HBY$J=;z59jF( zXBTPy)2hz_TXt$bYL)l+=v0XJo_v{du5-$3M8=*Smb;-0y+QIYN-RY*Jvo;e<8#PK zJ9_8G2?eC@f_Pwk`cS$5k+5tOV=Q$*?}3&CO}+*|o|KOjxVj0>O8a_IXggJ$A__nI zl6FncaV(p&FagO+$(52E>{jx~2VwZ-rS#Pg5D7}Vn^W1|Mb1x##}I(O4tgNpnru#8XVRL>U(5ga%9xm%kW!tU#L!KVdXEYy zFswxGByELW$oSye*rmfb-vMyZMN#d_XgrAl%aO^=Y{`mfSThq; z|9ph;wNCCAHPMy*Mu}*(P#z95IJ|*PVbQ{U_jxmF!Cm0BBG)R0VeJUX+VAPt^w$aq zLfTLWxYTq$y7l{;D5LgVZ;{_Dvw+I0f&U&q>743sKvlZ9c(W{&teDL_H}Tmu5BS0s z;r`r_j#XGVPe@L+UoMXF77cF2S|3o@cT>!2+WIw<1x$?j47@C#yag~Y0f_JnSA;2* z$le(O+GGmUUI?$}QPcyc|IGh@Vg+m}gA_5j7wBxafh+=WP}7ZZ+$*=dA8{Z%dvIY% zhw|uv3q_>fpC68JmdRiD{85owjU~#cfaGZfr;qE$J}%9kqPcgg(hnOjd=L4aV5>MX zA(AMhqvzUGb72{X7=_1F6iPfn11CrKpwaNiudX)Cv=h<`Mn|91GmsHhZ8Y<8|AY3Vqb|KVd^V4Bb;DQa54&m z#GT&9O35bUZsfcO@+#4tk{SLHa^Lyu#mc*@V&12gFTX3ci2b{T!{ZlZcc9`r#|AlS z%*H-0Kh|q&XlLR`A-quyWcAB^iiFKbm|7kLbYW6TH}y33N~pnuInoc!Y&3d$-mGmK z6rK_Q!(7~t!farey9u`4IkCWwb1#;afP&wW46dM8wbH4>cm^25k(j)l3(NAB@$F9x z+K!hA?A0%4lUYG{D1B@?0hgg+;A2TX4I)E1_pfz_XKK%B0TTv>8;%+!xeK;Z1&OvB zALtgY46V3#f$@ZVd!g?S|JBTj!%&3E4F=rR%;N2zeKp7Z&cP07ITBL~qg`N@Bh@JA z@wE9@cEVv_;|raH`HB4%gzP>-MmU!uOz|4MqxD0%mh36L2|ii!2chA%ax-Dya=+T* z8cAY6lD>~iJ1xr8zC?P|rD=sgyu?-D*5{@bT5ar}@n+z@U~-2%7y!}l<$Z5_`UsYq*l>_ED;8bmbh#-5Mb zJ3jFQP%I6mvEr?UJu8k;zKo!39d?CAKYzMG%=Nv0{2q3Bxj9qjY@?^yLIZqGt9(85 z&7l)YiDRp4z^Tza)F)N6VI4KHH!C%j*CjitIy^?+{_L9w5HER05-8#EsFRAr3jgD2 z2jiCFVzBko$3uak9!GW^{nRI;=kIV1xatGNHtWhkr;Nk3d4hHv9chiLR@f5L`{AKiX?L4k+p_oATncD<1 z%uIwFhdft)^0?Eq9>v|3|BuZIL`ONRzogx~33RMO#x9M0h{ii?F_*{Ly!`p`yCx&{ zf=`w;*~;(ZmbizM^L!)Z`f6hLldDI3d9eFo2AicCo}w(@wr}XE$@3S7f7OIgrDRe$ zME%jbi$!)YwiBnD>mGUwj)eUPfFZUhxcXBJb--`2jjkISzg8Mt_V@P}VgOlLGRMO& zbp+KS)dd5?{!8|$PHkNGE16~)NREWOXQsUazQN!#>fAw@$3282wVMYbP>al{EO~R< z_0q+p=@$=)%YiCER3-ySK`Kllo)0sYElu4Cfz6(E&a-?P5PSHOowyCYf%6Kbs!z4KDm;itzf^$aR1%ZEQ2Jf+rLuE)0Txft_3l^xP|44%rnH*jn9mJjW~8jnQhwxck;fK|D}*UDV#xVGS!TkwY}*>j zcRLN$P@1O`l*z#!PBrXTNg8oP=Y?i=KEoeJ=XaVZ_RZCR>X1GIR%5Vqm#F#tB*?pwSv^Emo*i^1*~sz7XU91xw%R3!AeRQ%mp79i)3 z2`_kJE5$5eK+p3pk_)(5ZiSs$FfUdK=6pR^N{ zd?o5fR&6O9O0z>2i;8I(Q8s;b+~I6G$W{C4J?G{I5uPkrhk$X`d^mGHM@Z9;iiQ9c z-BaC?EqvpQz&_g$0-=pSRoFt!^QR;v1D}ff;yOS8<j z4|+#xVw$p@NR4B8`^oiv+p8_j=T(rmWbC=V;||gUN5u0`V9Z*Txw<5jx)P+_!7c}> zvS*Mndtb&X^^|fGTTHXw$3D5dev!YjFZiPV$oS#E&Ndhd^R5YEU~q$PUC`QA)F zBNgpPP0=C-+q+ZzcTWXO9<~E+3;W!l*lq~tdj=FBx}V><;6>Lk^I5YT->De`!_5#y zI|&NJjh*JC&=oY>e3v5rLE*&kD^X~3a&JEp{jz@1;$g$8_ zYj<_6BSE$AS8LPme_iFN;C*d_$NwCetY__g+)6<|#?zr|L95C|w`WsE93(FC{p4EP z7V6u4j+y<`(+->FNJ7Qhov;nK$FzgGZtC7HV=ILO>I@72AjAjvz7lc4*x0@xhh26} zz0*Ydlqj&T@%lZ<7`7P0^=g|RH0#tw-dK5f9UM#Td4S(`ex0E66cUSdTaDAHNN4G@ zx&PGy_r7jdVAuRuZ9+2aTxfGyM_4gS^64#$Z0aurF`wfJ#n)PQve}>(BZ1kf@k0=A zrq=n^kzqIy{|%T?ck$Zdikg0>k`^7H zDc-;1?zl=n56I`rZ(pcaBw&ABNlI(iHMH~-0d28#?(e>P3)q#MOFAHp?|Qc&Jwn_{ zZ@e*;S1}WOmeLdf6QnprEN=V4+>tl-x!$dJ6F;LV@qWK$L{a2~k-YJ+^>uOGXbf%1%IW>hy!FA- z@6-4|Os)VsK+bB5b;QD4sV3W7{Ys4aqI(gq}SuZKojuu+~>M4_?^(rc0cYkWe_q?LjZ`iIzQwhhnm>ag; zx`2GMRO`xu@NA6T!o)@*7~B!m&p6-C{1BYH(1A^QAE@p>h&^sVdrVSJ0?r7}A4T)R zv#>EUfcoEPYg1T|)f2mW)8cvBHs#GH*(B#S96G_KXlX}=vGZYDJdKdCq|9p)E47qz z?YeuLNnfShwP`{wz`(#ZDxc!^n_G#{yUk~_4|R18iV4Dd|LeHi&>PUEp**Ci*u)pP zpCRX>UH7kv{`<5k=R(vZp1|?ae&qRViV(*!)%9^{U5+%}aa${n*A{#lxmej3!a`3^ zuTe7@B(Ax^6SJx$!B%Pd*Pbd_U2zAGUzigd&T&VB-SfCRa&y61L3&a496O9vcXO3f zfc8M+Nug*+FmK_bA3Y=D{#AmFC1l(8MPjrL^jfW5Of-Q|CTC}R8Or_{+*4!H+4wg* zUs6H{A%rCC7cc4pJxZ}&uNcSE7Rf(*IjMeHe!zU*I8ix*YIyR7;I{68ocK(0J5nI? zE~Sxo492x1#d|rau4#X6oD|uQ&&&bB12D;llZM;bi^)>FS{oKXOafd=N+>A3SvqiV zL*3|zVS{cx$8Z#_J6@^kDXfFA)FVHLZ7hf;h-3NU zMoHZ)KQZZ~J)g8gn55c}%MV1hzT4XL1eOk5dD^ebfV$>$oVn-Bru*|HQxLUY^9Rq_ zbEwCEpQ75N1y-LNulckPP_5v&UPF@*)WFkO-}WM9 z&swqn{uYy7va#qpO}owB!`{z_eY*ZkcgSKPiL)%z1@X_;uvL|a1 z$-MR86K8gEsDEYGM{hySK;ICM3ADLV+o8wb{oH>Y0OMtwk$Oo@%RUXBO*s{C^Q7uFab|}_= z;}|}H#3>48C0zB-G*GtEf}pn&lzvJG{0(Z`bN2F_MP4TPpz@>=%0{yC+@((zSI4ig zjn|fmzvJ-HHD%G1{6S^*P6e$-CAh9qP%awRv>9O00d~?~_JkFbdbx48#U{l1RqmcH zThjWK_YAhqKBXK4fjFv&K!D_i2KA-s7Ckc9K;z{S-B4OYom5`Kjb}vJ=qsz(d5qC2Qq?>E^v7h*?LB`FMRLhI1_R>6G7aFgYE)K{pRHn z-kw*8!j}wB$5d%`<5bu?TRR6raxP3(*QL4c5)O2 zU)n^Z^{*k~ya(5g$xM#|E6v{IR%nxCB8VrbKB38wWWHpYzjR2*wrD9^hhA z__qGno7n(o@D1WayP+{iJMGG*pS5M)$f z3R{KNfQUct`kL(kbfGx*7KwjrgH&O~VB77_5mJpUSCQ``R7LkqiY-~f2dehqdX%2A zyy1Aj$@1kpVGI)T3iczOowN${Ig0z<4r!PUl;@}L;S+V5-^4MEl|InwbJ#ILtS zgZH1C3XnVYVM6v7eA~S~6Qxz0s7nZ;xlfy=Ik;- zxD@o)cJ=58W|z|Xd`G``fB>GY8PiwNM*#tt#+B^K$_8p4p`;MD^P5AoH7rXS)g26@ zUh9sV*0|~6I3y9XPKL-ww2l!U1RK1k1L@G2L~h6D%l8z36MG?yle8gBA9gz>yX|Vj^V>4k-+46gmE)#n z9}o&=KXYoV>2h}KFQ%n{E$rgN+qk@jh~@Rc=P&4E{@&@ol0G+^*YDx3H(ISPoH%qp zfRG6XXHgJ`|LVT4&mfAshki^Wp6ZV3p0oKX4ad=VoMm`E$x9aClYe5;)w7qX6-rbE z=rimE?jG)ApeS|bWX_6&e26=n5MDwY!y%0@;%3_C? zQO5d`H0y-q5!2FG;+HSeJ{LXtN3jLk2y(n!c{LnfY(r>3?jlPk5E6S|sEhg9@~@5L z$OaU)j)%aL+bPL^`gW<>ljl(ELEp*DmJ74$xaH#qLxfyoYtq)VQmH$Sa{pMdz&y6c zEqmIFl&<=KTYX!r4Y`X(2b?x(u8>X}eFcIEaM3U{t}be|JBmZQmAX>rr+Qv=%Me9T zgu~HQ6kC)aA0xRUyW6s4n+qPTce&;*viJ9xrkgNWh2RQQpyyY6z(^LGt&JG~Fy)(_z|Od&Qg!S4Y0_@5*KK1N~{`*w5hOPhMxB!#%0 z0|e6(bem-|cI>AO+YM~vOqnT$-<~~Ws)6@`C&T!6-#20X2l_Gg3D0zBF+YaQP&hx@pnJvSqZ z7jk(_Wt+7{@73H3oa(zDWhd+X>KU9QwQ$9ZTekc-rOeIvz+`XHXYa{U?FB=0Erahwe}A)@r5~{tyb=3VrhGFO6gFD3g2iAQ7)-nQN90la}#$%%Y$=3;+Bs7%o-59mYs{?W)mAVcL$_Wwi0rr*6*BHi2} zCOlvL6d6=YXF2AH2GcgbbJ*m_bvfEaH>GUd|8=A>@cf1%KsO^Y>=@W?GsT7KFz+ALez644<0y@S z(-si{7g4eYtAvA6jvduLjlC{4$P>_+gK8<;`S}H5Lq2#RFixgWz!)hP^eEsqdXD>r zq=iX-_ppXW_9FjawC>teF~D>s>nBXpns&&zC;67rMVCd z6%Ad$!|7|nfza#%!eNU*p}dx(VJJu(+wU&!_3A&_i%z1|mrD)l6cgtF0U|?1Z3e^} zB68$D)3gz_qu_ z(Z|;b8Y3q6=FnSq@CxPKmj%3TrK%?@7q{?-iIA9S`OH$h+vJf)RB0DK_BFSB z+9K70MlI`Y8vDO~(Bt@vjs2a$T?(21GFQmk)VOYxpsV~}o+-iKZNyWMcDP4Tf`~w@ zb1&npfj?sXQ5U^qWD+)WR%2wSyxSD`a=T^7{%5;ELJYo4{}2D!V^-o$Nt^8N%;YADR0UPIznzyycUOiAVEQ%u51j0|DAV!P5O5C zeTx&T_?3$P8mqkYxDJVj0I=X7OEl1+Qq-wDyWWymEDJAPKLNRSAB$jhX38r|1AW~B z%}=5T*+LBW-0gzq#b(cC2cs2AWrAP|ts}t(t)-Mkk_ERgFt!GZ3zYxhQpLw65kiQ9 zEXDZ^iq$Ww`^0F4&pKu^y=JbgFSD#!Ph6X@H)Do>otdZ!l<()9DLC}+kpvnJyj)8) z9Kq{#hiN^pea}@qZb8A{^e?c%J8Kp)zUQsR%*|u_0j_l+iW%lTL!l+Bc(*5EduQYV z>Nwi3X1&USa894Cn#ab1t^5w!OHs}Zkfcztzn%_E5d7G{8EnJz_o=`FINPm3SIpfeeWTbG%10_m_|3u}; z=bWXd7y6Nb?a0CH#L-xBAJqX$y-V^s(hWpKwyMO=%Mg4D#>R@>IUSY>4j}@uiyL+$ zyQ6p(-J1GF*kUcp5%G7$GN*+gh*A2IkLE-sv>)CV)HD1a7YCs7V=WY2ONqh)N1SsDea_eEy}5AlbgTuhbB8n@zx`T}T=irx7}a{X_jkjQ zk)Rde55@_jz1I-z;20;7;|&Lip`j=|vIyqhm!q4Fky(`UN~=#uUY)$l1M{)Sx6u~D z%--FAL~q-)f51cWm;T{8Mxv0pWWGBtXS*ZM!Cl+1UAjD)weHW_=1uO3+NvWJmEOz{ zalgz+L}W^z-V5+iCa+v3rIz+!I$nQoD80>dfFg6**WkeabqIvaM@CQtAqX`_XDhx;B0|si9;OFEOwo%&6krd(U+z7z$*LflaWKFt0^ygm>BK|+~q6jV%9K>VuZN;$03eBp`)Y3ZI+M= zV{vB!bxO+5Mssc^Nku#!ee016H@WlRfwc56`gkM**lbC?#&miqR_H*n@)MZ{#Hu9dSQT>@-f^6+g zyfOv|8g-F_7&F_%qH5KH`t%r?{Vbw1YIyNWq8ypmFCC>Inx578%N;^U zZ=r{u-uWGKnNa1Eha#K&|2t?K<;;hFK`=Z!fCjKTz(fA{X*=?x_Lgb3k^U@2tPffI z0tcaKgtM&ua}~$3m~n=OKjX^ZmKQzLVExu7|D=!kzZZ@it4|DK*KJzcd@dsz4KXWc zZ_TdLYbCaG2A&p1;2?uzcmOEuK+AP}I6PT#Hk6X#XcJ4hg7n}>z}q}LT)~FG7N#s$ zcJTbX?--2q++h)-?q^oV@0wuy@rv>8qwKDaU++BDSLXwV0N+AT`jPSOhpj7>{?dgrBY{0k@Km5 zUCU8jDquaQd47(OZ`t*?{VCO}^#K?$Svbf-6e-YHpW|G`ZjrAVuk*XiuqhMps(ea8 zzbQL`?x0cM`yRVGf8i9=jrCn6hY zz4e};F(E{qVZGEUfI^I271Idjz)f7}E9%&`#!Rvj|3QAHq!V zUhLVx{^eHuRU|umcz9wC8i(JcJ$v?$0%LEFyucmC(Z|vEi>wV8b$J$_ZD03%_ropD z70A699%{E%JXkC`({QNqxv3X!I|k^KR7R5TsVXN=rX@0XZrR_7r5w2l=BUxQ)fE*- z7@kzA3iMHeu0suAE610=EyTcO(f14lITBvE?KvBe zb4t>FnOefv`tTuhx+7vlv~>)E4UP`ELnnDxR0D;@olhGyMLK%kmz*PPPgjkj4(E$f zP#3=ChfX*tOQWB_(b^~K5TfrDU%b`RqoaG*iyY7Y2NvUz1IMcJnpEWqzT1<9vo zwh+VxLxbr=JScn|UqgD25#$uvc9pB1227b+!`2!KF2M= zHTiP6ofzNd34hh`7MQgrT7mTgzE+WG^n?3_#FgyY>5BXH!=|-bgWr~2`OE1ClQ+A! z%37A|ePLvFS$&4IT_|W~fvy{JFlvZ9pw)7)g9yzz4nM|Rm)2#N8yQR(hp=7`Qea@m zGT5v@sI+@vpdW982ph8tmbh%nh9LrnR$iX1)L)K@DNrhs^H{wdv2CCPQ}-+{sTdAz{8z(lH&%i`Yg(K@Mo ze#!EfKK9-QWiodz$8DQepI1&V(dNC&zbcwa>yDuifySLm+mn^IN)M%Oztnj{ABwY` z(#~|e48{M_yV|?cK*Bt%aC>yTs{xX^*wx!Gx9(!rMcqj$3i&*5)D)I51HGC40Y8`iJ)HMCn#j(SECV@yv@=RA zK1iS|6r0HCuTUzNZ}X;p&CW2JvjrWYE;X9v?+BFQr->G7KFE{n>-EM;bN7*~>Bj@W z-YM8koOLcyp=i}tG1W5uh>e0i>wd?k!{V!Aza#ROhzLKE-FXkj50-4^kZINT@zvGC zqpqVUeW9JJiR)uMryyN&8GJkM8bDb(7sl;avi1!Ej)ylQuyEIi(RR`y3h-< zU$6Y~gwx$$v*#tPHpB{bJEylD&q9(XlWZN8ERgVb>^+Q&)C2Wuhu} zkc6!%;Ay~?=Ngc08OLkZrg=}-*VytmHZI5VMTVDccb9w6Vq9RvGRJVo=Afvw+@kse z&LC~iJm&-PZKXjLIUj%bsG9q1MQpQ356NdO8EIb!@eumh&a0e?!h*La8^~_svV-B7 zV@3uxLG$JHnzil?ighRs0Z#Z)iEswPTe0;#W$CS3m zHV{@x?%gf3#hm8#NtzHNYXFYR7-l-imiE|05PJP=XmdFBXw<#ZQq5z(hqGy`(F6K3`yY2lg8V7vR zZm6z&_onH%rw@O9j*{ZzgVISwcr39!G2d2rDFSEp`0@yCqovqzh`HYM@h$N){8EgE zV7>+s^s5cT0N#eVUHv;?jm;JT>Jlqj7A~AEszPDo^XNm#`$}$SvftaDdFB6uy8&|z zVnWou@?BntCw&qKnRKZiDfQmH!)?0b_AJlqM3CPj0@7I=Vd>eZE1b$xZ}pD}Z#uMk z_Sk>=J_6Re9pe~YN2C@?_*$JP2X)Hh=xUDk5zQYXmnO&nKsq@$@fUTg)V1bq0lhS% z*Z~rxvgzV;)%L@7ZN!LJA7?nDL17nlOhy+|e!k@)O@Cg9K>`F zpCEhXn%vd~)8yvel%JuYmM`*=@?#$4QQ$#t;FdN%Sv)+0%l&6Mg|EHrq@nw zfdRw61#DD)fa5regpm>H91+fh!f6IYB#3_7H=z)r#OQ*2bI(R5BHiYDmeAOmXH`{Y z+<+~hie%dPEL>AeD|RsTI|%vihZ0NWuN&yDPWM)z`Q@|j{+y~8F4-5IHL&Xg`-JWd zJgpz69rwGkQ46q;NO~9|a3MfIDaoCeQHQEsh+IQonLO*V;1ujBQHYg(dHNjPS4fR+GyI{Vb<5_gL$0L#~oLI zm5MLqlG0y$kuwXz6fkhx6jTKSknh`>D(?FNKPF*g{p_eu=gvL-4#J3_*7yd6lflcI zU~QA>Z)en!o~DDzYl8`E8Is}T`y`DtG+#7yDc`d^wK{Iv4*uk=)9!eoHu>^; zWu}7G+)cH+Th>;)WCYe!vh=Ot4T?HspziUh`mZmTiHLl!(l(8MmlQ~A#mfx?))syb z^uvre&2G`@uUqlQN_Ck5y~2HxBpLYLWF_o7%0fYQT5H;{1LAG&4(Nt|Y!lCeMWX`7 z1g!aJM;u0zo~7YEFQNyJyt2g&j8cJrq)1QR?*Ib@L3MtD@A&ZS?2Qe*D64rl4bGTdJk9(Zc<@Km_WbI6N%gY+&|Mv>R zbq2q+VH?qY$EcPPhawPk$2S$naH!{p>g*H1lmdw%$NZifPPz&vNkU#sgyg}QPAD~w zDRP`oB(Ad82#9vZ_Ww}S32yT)MIy)s+Qkj|!ExUci5Nz>6NSnTaB|0U>y^pzkUlP= z;#mEdH1;jz5as*E(7TVB30`n^Oo=`^ARkKd&3lk4?8ES=3WO{!3}LjT>bbj=u4Hr1oP5Xg4V^1NSIM~Q#TY0mG>fZtm;CcyM9B(`M+ciyF z=&k3I%d8xc+EeGk)nv~0CWW&1-w`>ksNPTV-6y z`SFY|dUU6Ggtg9R^ppMWG5tsgJd3p_zaAJV9q7BIC~55AXoD4Ym_b5Ob7WV;IGib!e0D%t`K@SC~!ajSDCg>@<_p2gi%!r3olz?Y>cUL8*%Xnsg|~ZmSk>&5a@5*DH|I1%jU8I zVrNP5U;UPc2kHSukO?Vkvy1r$8MRuL8Z>_p{x+}jDx|3r^nNC1; zxA1)*-}7u+rQFi;Ako-YGJW44!oR)p%kk*1d+L;18;WKdptOQ>?5=#-96*GtN4@X& zcxXV89!^W^@2@^rApY1hLgc$aLh6#w+_Pcd0U!vk{1yRrDMXDBavbzS^ zeSqNk7_P3Cy_M$H4?cUSWn~U+VC*P3XRr;uzV@5YrOq%{;>}eqIj#zEsE`5zm*>2~ znRkHhgpf;1-GF2{9SAHyQ)6RScK3pV**Z3l*E2?iD6$Rg9NNvLtG^3X-0pEDC1lI0 z-^mDJk8ac_6Zxz10Rair%9#-oUplFAps`eM*V%m~`4*ck_%cA4%S(~E~JGqtnBh`NF8k96Cn<=c*&m2FuPmm{A6987sfU!y@ z%5-zaMN+TAn7J~9b>E;ieQF9E7i@-Mn{T2=2UTKv0kV>t$Yc)OjU&7A%+<=lP3eY0 zx1YSiP$T2fJIBZ}9)+L136bR!JJ4d6~*Hc%F z@>ts;5|;L=Tie!3{J1yYj$L&|PXO5=%NwHO+vjKyhybMJu;URa9s$-jRg26{OxCx6 z&*HXl&NIaCgC8lU5Wq1$c=S|bj*YzE|Faf7-s113uMou9m?X?z2VO004wXG=uAg}w zsY&2YwhB5#yZ2CWfVFWp?_HKmdhF9!4GW(vamGQk%~62{=5Od7LdYO-?lYckCuly@ zjteUN-)5O#kbjVGpU1m*s4T-qO@YADUHj9<{_%g>EQ6QOBN#p2<_ISn*ZU(+59B~s zWy?Ld3&;H7cqbpDT`qd8bB$rOTiTbh0V}8z^ zcRH@aMFDUz{a0>0l5{VgMidd6z5E%fXCs~e=fg?-V&h}IRFz2Wp(L13=BTZH?68fn z;TGuTR9^7NNpw7g1kmU>D;{37J!QO*_q~1VQ|c_($Xk?+>K8~2!r{AhZOWfd9;lxC zBpUEvt$)Dr5FNbuW`AgaSMPH8Y!sLl#2#{pQP-hOH#P=n3BBmjjGv~xm`6`mW&Z*h zSFh_m6nWb@k){n2*1J6MspafHZ1}A-(~(r-3yyonOL^RZleoB*nc@Ux!)_;q!Es@C zyY7bTU%>ZD1JG>9uQ&Qr>5Ju64B5L|NBauXEALGHovO%R7-UqD25D&6Yxp6@XRE`h z(Osv{VOBnDj@M3&0*$L$Gi*D2{<$!Q+sm;`JS<0i-sqbueQf+#npt{29Q@)S=<0jS9w2aY64Sf^^k^VLNMaYbQit`pf5feyTXzz-&4C-l ztwRcv-plwzPQ%d3ucs-CD;Q){eye0~Hr&-Lpqt;j*EWAAej(T8(`-ztH+f5{67g3# zewmw#p2-?4;tD=mj2j|1S-D!i6MTr?pt2aKR^$%x9T|DYtF;{>Ms)b1#J77*l5y_u zc>+bmR5`NaOvOqhtGZbGta%UDx|cR;w|4 zdY|qjz=e&2^j;}gfcilPbQX`eQ@Z=ZwuWBCyIi*-dr*$bm3P0$;M(6DS!Y{4T1+fc zNxPJ!YV_q8DM7m`v;Pg>!fhE-S^Oh^S=whGx=NMbckx{y&<|!;$L$`{UP$?3D=FA$uere1HD|FE8hPUgtcYkEiVH+>caR z#oan3;O~34v|nV&$3ybL@UG&{DR>g6o(sMnU_Sw8!Ox^7< zsp=fgOLNmYLz*Ygo{8O+NZxDKS=I*zK=~B>fEat$O~aPyB$J>Jw`4Gtap4~w-VFt$ zZ*{oeV?~;SdXM>_I%zFo+eF26H%fa2UZ+!fP{`E~O%Ve8BPE_^0)26cG$w(Pa*&D? zQJp@w<`l~(ltpjJpT?ak2(z5G`mM2|Yu-rZxm@RZr?{!(FOW}EFOqSAG?NkT&m4Q+ zeBtir-L-KZ_Y&6r*a!#$8MkR+D#(ZaB~ZyziTMxRncB z(+D(JD@?Q0V(YLef#Jmy`G8DLXCJZJAMKHp5#KDuI#pS->umFA2`u1f$Qe{!FbS7( zYGaGOPF*M)Y4c=d%Rc&G%=hc*!6|czPkC` z`n&mqF`#SP-01NZGZqY=lN6UF9|GMiyBuEv;pb1E?Cr+>+5n;=32K=Z>fP^p?wSU) zuTVWbmp=LBa~3YFR-#3%MX{0Ah zO~a3KV0}k=Gt^5kU}Km|e@b_zr;Ykng_br`@WWuPyS3HvSz_7M1EDVXLLXETr$q!o zY}Pj(t@lnD^H#X!o9B}6;EUPI&a-bt30%*BKq4U8Wab2f;)3pwL(VmEgw#rC3e7z- zy>9K@!@MV>*^mEYEPw`DG^37^S?}~ zxE~FOGH?#KlhDi+P53Ra*SRt&u6lV`24rv;i}x9?BTH^DP%EwfN6w9X62C=8|$gjt7Y3E z4Q?d-2PQc9yDA*V+NF02u3ju(U{nJa2^Q#i7Ta%4;YaTpbq$Y~LzJKJ6v4s68Mfil zri~H7SI_ab%0oTaEk8Ted`w{Pm$w}+a>F>)PZbsGY=yk(@@3=s;%(2`bCeJrmlm^y z0%q~?fFE%JSs5}RV?8ic1M<7JkJ5d`8@LB9@e4T1ue;{>fh7=|p@g7z)EQMbX ze`Fs3Jwf+EZwdgRCFr4Kt>IA{wZ|!saS+@I#1$ub?Tfe`YJZF~R9ra?e`j-o^A6^Z z+3#e$b8mk*nuE_Yl#S6s)V(Q9^>3HFj1SUwc;zH1Ei5S`EG8-rM01%N%&b>z$uIlJ z9kw@%6!8SszMSCR`S@}q2QSOZP9z%nho#NiXI(~r=kOX`(G$F5)ZIJ6HwK^VZqQ~h zOCJTmF)}xd=xkbZU?E;*HZxkNe7u*6v>jo$-af&wz(z0|qmige!uP!0YEv)Qlt^TI zWubw!b99cT-|-#O8oU-^D8$&^ykt&pR1caJW5D8G7=eWD(O$o19*4QDe20HfxJKlPx^wwuYckCVB1ilWSmA%RFbP z{f27%YJrpDck!6w$v4e=#1mXngl}-Y6wbZ0`Y*Q)jZa>F*E8$5k)X)ho$y~H#v&-F zMJR!}aJ{aFBqEapD?ya7K}BpPjMZ!%IaD~Xv9?c~w6Zb2vAMdx?k6R~?Ytsj!|3o{ zn4B`A;S1Le5h-OU&0n6}#3#OFw1~B9bJtSY!H+*T+a|0Z(bSM7izQ^(x@2WGuP=1C zW&b?HLk8=bUO&+{Yq^A93HRDR@n4Cj8~2c$=V=1cY!o;Fde)urPD?n%@JU%=yIGNBzv{#*CM}bC}~nPhe!Tc?lK=0y=~74fJ9I8{{{^8m9ge ziSg|$UN;jx+EcalPgcNL-Nv7*A$qm3^Waq)V4>o3AT_jrg~0CkSKGQt+J*1raJ+xI z^4LB?d0DaXbnoCvWC>?XPXw_7L3HlN%~Q*j{ix0p|DwnR9~lv9CkD*vg-E}3nJLp5Noqf(Jevd6a;zu)xp%yI4&GF#`)2#^MgBmL zgH*+-ty?4!<(B9(K9qUm{8bOSU2~nqOX)~RzLib%{LO2a&eR9T67~7wtX;9W$N|9^ z!AvD!4yXOAsA`4_xxjz5aWK}|)+J$NdS_9->0$?V>&=V`Yi~Fvc;GA*&%|G5A8}w> zYJGlS;;8ezB76PD$2xR$Fnk_l*k=Fb{X=!mT2fnHCZuY6x4ac1_z0h_v`tpQ`6ja~ zD%>fv9!L#fxu&EPrWogpQ+QcpIuAVy^p``J9*1xF3%L2X2cb1DA+w^A=dfT?CrFNp2XOH1vg2ZUF=<&bDfDL zGpc{*1MH~`g`mgbwm#-Dnfx1kH(EuM)@dZq-Zc4CHwwx(Q zb>zd;l1S6GFGYvWLhd;;(VjZ;36#H2aP*;<*RSRKAUYaC8uHu9JR+~s0hbSeZ>7GW z9Rv0n`dzm^2QxJdclj1;e?%ThvWxV37BS8x?Lh~jhGgE}L=3O{$oOoGWOr~F(|?U0 z6BT`q&lj(%8$~nVfL+99bJxi{;9sj?S;D$?N0GFE=*`^an11%$x)oby$w%?YXysUR z0Jn9$-n41R`=d#E^Tr~Kg3tbDl{smg=VP~7Kol2V#=;Q>4)_0-JEuCTDM;K61QE)< zmPIx^%cB)>mX0P2={n~FSNvuj|Ni8V!0+vcu7pk-sJb+e)2Q--KuP^`j>@N$>lTPx z9e=T*vCSX0-Yc4-g?vi?@>3sKsc>+?ov9u1ui4P_{tbrDYuD|E^Q+kZKhWMjJ-ig4dsw<5^yghDvV86 zh}-*6v(+gOx)8qV*nLASa%R9~#BLU?X_JM#Y%1&BW>;RUc2AVB4|;y_UV5|$&5i7q+T zxEUEW!CM$|GT0B-|8k%3c@05%c$ISiR?HSjgZIaq!UTUZNdc7r|inN_dj7?1TJ@;hfGQv{y z;$^-Mdrtj`sjkknPYCIwsw4LQkyEhGdm=qWtvr1-va zC3Uz}nnvG4ZOta>WpF5+m%_{L=Q%k*Gi9liNk8BlExW*m(>vKO%oU$9$b8|8NXUVS z+iuLo!JH~zQI%%JlG;C27IlkblPK_2;N=ou9objlmEa=e{bBHchtCtTR!gNVQ%zCwrD!X2Cav|@iH*hO1)3=)x$tHXG9S2E)@nxL|8%! zJ~dTl1t@n-3`=!TT8wvOjvc4&`T6;^O=Nu9_#@vXt_0}<>(S{2P1!2Qa@;j*c;|)a z4*Q-HJ8VJ?9jX(Dh3(XpxFv@GJexcX&|{}GmDEX3&5O#)jlpWZZ*N`6wz{jmiakUv zc*QyVysf-HCr{J$_tyPjy~g3Kcva+}^8nx9?T4~r{d-ro^@%Z+DJ`CV#BzkwoZX4p zu_hiY!L=O87UlfZO|J){5>q>AHM@QmX@m-pC{d8{hm^eDW$Q&nYFHZR>PLb=H_D@w zKr|qN@5d{ru@v7xTtuZ2^_T+L)Ew8hgQI)6^GlPDNCD}BRvI@0v^>Xc>=AyVSiEF$`5zDyhg&R=68yf^^2{36(_Emdd4q-bH3=-F@YSbGkTq1LZ zw3I&HwSE?L2y#8-ig=VS^>R6odF;?*!&64s=jTgu~FO_!L zsM==@ga*($yfc%OtL;7n`t(ql=#iq^5o@eR@&&#TWLy#iPwrT)719e>?~i-Vp28Qx zZUmi24|*#G>>h)oe`_or*96_~*+D3?&2eH<+gV0xGdJ*1*ETXI8Lj{7@4rF|WGEFv zYS!wG(Q_?*DaRtVol%7>8O$#uHyBJJxcOtpC80n0_;6pI1-^a=p-*D| z3P-I@un`CxYJLF-Qyov07%r|QV3V_z2wj(gNF|SA}S6vzR zGr~X#bx6^CUF-Eqqa>vDp>etXk1%mugYM}3kFw5uzANu22x}t=hM|XiE$^o6dHmE8hiU)JBBIydYdum` ze~L!~3t@efm2Y}Ba-X{v{dhOL>T}>7TVx0BDf%Go4EGx+9d=~H{)yq)#Nx(H ztEVsGSl$PM?{)oG6BGnFC}Wfd=BvGG9W|Km-3XaiOqCaJ@M@=2;PZwDckY??c7%wR zrniKG1wy@b)`Ne{6j}P-=Z#l-xt&Cmdh=bEaS6ablT`QPVjKF_6>c>iiuw(>^>5(n zNIaa!$)Dx0lx6i`W0jZ1h2ox-(S3R#d=l2W6s!M}f*>cME%i=pf^kxD zV>=B%-8z7!$j$}r{AgY`iS~OCf>@=z8(LP#{M%i=uE6{l2M;G>71f9C0p^?S= zyMDE6q35_>?*+A!Sp0PZqr!pNahrTTNG*f`*BbumxMTA&3yZ@8VQrf$B%fDm|4&O# zj#o&kh9l>ym}SE&1b|+A?#{}0Pq5hE6D@;ho$pFQL$7>gwkNt)Ps>jq`KX%+H5sH5 zWO3W5n8juW{azh#$bcDs_(l{O0cz#?VIz9HngG<{`(&WX;d=CLs-m3&)ON(+!EdUT z6ZC;oejMi|I^SDibX=BLmUy2gL3aX7v;6KWg-~Dae8Hn9t@Fca(>R&__KnYgjr;5Z z&H|On-PunMfip9s<7{KmlkQqxLRa`#wN**r_A}80&~oG;dP2Nc&vAL!t79VU5xw~n z_iQ^=Rw+Ifp#FX^&IP9;es|6tH7#IXEd9`A3V9O+c>WebeVqLrYuw*CA+K;+n`o=1GHaNSUo`xcNz;@P2RTTL z()?!)73U^ikR%cwMpY-Fi3@JwJ%%jbQ6|IV$PfPzU#b7E1phtN#P5%faHALJ%K;+c zHYVv>#I}m^W|-?QJy+19gSGwOD^?W%01fy#69!A`Suyw#S4-1s(5||kGUC$H^;OhR z4WRpO(5HwY-~zRgP;J*g!sVlOhtfcDIEy56xzpF+b7hPt1nTX9VlZSL+a%M=n6_qGC{G3pk;!Ei9cwE%2yb#?&;BWVy)7T zES**J)AACX?q=LzC*mgZ^mPb)I!MbJcN=gqkvTcj!q2WFmUOfQt1;-1B{KqC zA@+qZ_G(c>R;W7e256jhaTmh=eLFQDTY*&rRo)uoQY}syyJqrfbn7ZfFG?yu|4s3tLc&b3UzzY=`&a znL#PNf1L56;|N9z;enlx6T|O!w)IKrB`>pQcxe1VF&Q~FKsZDxQw9xgby1`xhj+Ct zu5EW0+Xg|{Y?&xSYF6Y~KKF%mc^4~Fe>mUS^6<&($FWqYTmL73X8hm zbaK|m9^}t8eplDB#%un&4P5qk@X+4suPYMhrGd;dN?>2Ah~H_z8@f=K_i3Lb#>WsR zm|I8AU0%t|4|H7LB3g+T zwj+OHfsxDW-F3w!Y?B`LATu1YS;TdnipDuz9!EJxEOynu$zf^hMNG++i{Ao8?JJr4 z+-$g4I$Mr(w|rzt{J+xz&cs zyRIor{N@{B3q8G~RN9D}O)m7^6_)kj?x#=r4T(J@@_#vavT+R^c&~5V?dCFw-stUF z?ee;_l^o&dWF~pb%d*>#Ij2ku+E3Ss3`ssz-!AWW?8z*-#}a%s*HvXFu5D|hseGH6 zA>jhoD=zW}T-PA|^PN_Sn*@d$r&k9KG_R_18D>uiapd`^&71Q0p6?Vp$9pOGpW)X= zm)Hmo>-*_2$?_KdxVzHk4rnPoFoRcQ(u;||xj{gd;h4(5lgo7jf=Hh46U@TMDY5iM3U zh4#Q7!rP_7kQi4i-w+v2wg3A@-g0*eJ-fWlSV*NXt~Gg@wTrGz3B^4>3fUW=&{57{ zaMlcqKq<0?m$r=)ufKXk7FV>Dzjfy%5V}tHTeDa~)wFo&V5hU$Z|K`o3uJ**m;Ust z&R>IF8tlefH)exu;e|low zu_w8)QIo3W5*&hxV>;4Kmu#zI0$&bpgRdwrQX?HwOibe3AGXvEW3DM4LP{RZ&$bKE zOKP9b*BzrnRIQ&L zxY~XezYfDG=>5>MqCyS2znJ`4dN2{Soh;=5`}J?CLk%z(4g|2^!ric&nf}$LZS5@n zz3lQ4O)PsZ<9Xdet=2m}Om2l^R^6fHyUF`Lt3pf9OZz$}U&OwB95!)Bm6AB{d2SJ^X)VOV5L?4T;q}*Fqv(^fH#{k@Kz)nIc|CKwij_8I7Lp z?lX7V(XEU5A+r>+WIXR)KRD4Gyr+2I_W(rY%JyB_jrlGMuHx*dyJIcE(A9S*2!wTe z8YXuq*+idZ@-eG}r-T(N_zzCe@`GgqmnevpQhl4~^_z9&o5Y{fJ8WFP+2?&C|M%(z z47qhGvc=f?DRi#-aeCN}A=UgULy6b)5Jp$JOirj{8bk4WdhFK^BUr|J?4sJAnK)LT zw~jfHqBmn_LSBG?@7cG9D$STK#A;mZ- z_K$8CdsiIG7MbhBMP@Ad`ve8+bY13w1v4-h_*Bj%pu(*8~9P zV>xE<A%89MA&^M9@8FHxy;24Kym;YD~wdm*ahIkY^4Y{s)=2FHd{ z3Wknbkp~>lN$!!n{UV`{Zh2q#h2xz{O%K~i)Of%X$ww<03>gqnG1@XBI{o&51{l$NTBjTs0M{k^9^F{V&16jY-T!r{?w-70?lcifwp zVKs~M6tIC~D59TNo?LKzl3SVguv&zey+w2d@!U*%UXOI0> z){xLbykuKm`!hG1k1-_Dkp!DKeyWd`Qd6zoJsG2GSPfI8b7<-RD$2}|z$_w2X zy&Ep>hj^m!JZoX&s=(C`Lq{X|izOlg@B21cJL3C|=gl_@d_n3!Rv=UH?Go0m^2)lP z(E;lL}#b;LCR^o0k!=@N2fnn2sRwLC)~qRN*zJRY%N7gFgQk} z!L{b=9nx6(lG4+|Lh2Pn#A<bnDP&{jEVZ6DxNpZ3M_Y(U9!Ng|=eRe~bI?uZ( ztL~2XGPqwb7JrhbudgTM!@@d?p$u0V#C_?*1J4igQVUZP@~fqZha^m1eDF+Rc}+vc zYfq>DCK_3XZ92HvJVPh{KnZ&pi(=bmy9p>BPERbqy3U`6B@AFT7v-E4fjmnH~5ZbN#^wBucMhsp~bB2+8^_PhoT1ynaxxgj*6A6?SlCc82GCM4R zM_=PIhr>QqkU?ImX8=J1XWyNh+$z+eIJP=u&r z1nN`}w7=YiD(VB%#=Izyt(QW_B^r5U2l-}mia0nt=s^%{(71~Uq!-aMD6P~7cf_^lBRBU`8a1MU=w!Z4B#C?m*ot%%nrf?pG zlj<&^cMGlOCb}0Pn?}x2=kauTk1*rkhYsi(+?|54;Sv1Z;JZP2M}b3+f{$Tb*YJW} zyFurQXH}Xf3vo+FPyL)dag7~1R9Cz?sNGW+ z+tzGJ^loVTi&(EK01O16m&(JtIqFS%_N~4-N54TH`n9XiVLln}&bN569|l9<9XfyX z_K&*)s_!c<=LLo8reej6YwvJhosXMbT+E?EoWCwW=X-Lua;nBeqJjI%ry~?l2*sLosi_J|Jd((6$m$?XIQ@5vHR#jMNULhBJZyrcKQ>3PafK%aPqr-uOIx^ z<^m0byL0FXvamc9d)#q)k_lxw&pS|()}QxNny%!p8octdxn& zL41$Tiq78Vu7-jbKRzjDRMknw#w9@>*6)-;(n(dJ7lH^LrLeHU!DUz4Dqsh zTi(YSD8b$W9h0_MlUpX^d?mi}8WLk_R9{Vt zcN%DH*y!<}s?luMHtT%?iDBrY>hpx&oYN0QxIbXbO}^_5FQ=%$r-G@hEi&HSC;db< zT?<*JOqx<)tGH6nQ1rtXR{)a?KOy9|UG!7v9>;ezkmR=_j3SJ>9h+4VsonWt<+P`_ z{sU!^1D^fiGF;zkI`97W&$8s59(9g4x8&vJwQ{;#177{Fi4aiE)dxr{2?;oSqlusN z`Rw$GY65$Sso#~i&-@T0FGFvc(ggtA}*LW;~+-|0j+Hg~dqFGl6rUnUpBw@^! zb*+S!*2qo0tLD9bIK%R;_kftMTD0IX(?m({{wFRWVGlzPLr1~~fS|?mH`HskwO;+DE8Nn*3KLIrC#xyy-Tc$P$Ben@u=tB&Xkvly?=BT>kv-)dj{*wenWsXQ-iQ?fG0_OaEObE&1(os8*+kUo453;sVF$?%lfeW8prnb zo{@vp=QAaTZ*v-8|1mh8%Vi+V+E#2`pIcN&K3kmF9anHC?s!c-_U(hN`b(cGWTlZ% z&UDzTo2>vgqZ4UzV8&J!@6G-EFsN&Zz6<=1zAOkzxhDnT>*OzrfFXuoY8O!LdZFkMD z9+8Q18MA;s?F}{lo(cp4ht1{WqYCm-R;`3~W3C#-1$d#*4yRn9UoIQW)?Z;_RnX@@ z4`m1h_Y?bB)|32l4tP(oo+~}Y{ZF#ovD?3xT2qd9c^YS26ui3lH`t?x)GYSp6<;I! zH~J90bmS%01E!_HzmPCcU%VV-yAA7okXiC^h2)C>JMhkUlbn()3h~0A$}TXqUuKsr~KNbB?tn~g zhj0k|qIoD)QrS$v!pL4R|Ki{re)23m#1@ow{yKU`uvE(VV`bfoU+^n7dIi0kFI#s4Pjz>w#yv+P~uS$Ey@Dl>WYv$FM@kZ5#7MoP%eY>z~z;KW%jQ|j7` z5cFLcH2hsO8+8fDr+k3^CZ>H@k!4xpmx{~^ZUMr@;y1yVp>g%m{wrlc< z!J`eQHuD?{9bIhuUJdngzMF2!vVIcFd1<{ifXTh$mjCY!fDv8GrT9m^6%H#uGt3H# zPI>T1G9_s69)wr-IzrgGaI`)(D|@iii%ZNeWO!}Ok1Car0jzoMe(j^})6!Q%Zx>V- z3A7RVY9*})n|iDPV{nr$Th$;+Tz=Ocy2BaWv%k@cz~}~Dos~3Fr4nmJulvee;jSdJ z2k^#2koRYoTv4If6Eq#a2znly&(&|^6b}aAVEQgUcNnWAEMXXc%W>1&O0!C{J#X7z z?Zf!3-f`;jGK%ZVD3p+CXDNA7iwu@--OpWI+a=3e9IQ-dr&9cXSMyE0LN5&*=#If( z#!|``tqXbYIX408W8;{Sbz&B?E(qe$(PUre!9(8`_aFnIg@-@A=mWlHb%1Y+@zGdS zY&BTPwFVDXDnca4-xmJa2C{(NlkmAN<=SAi0n62IH`lAnBDtxdf#*o3{mC&zJ7NSg zr;!Re_x41K@gaNKm4Frf`noqEb#93K@6_+O`1!Ol01@E_U)U|%X&!r5$*;1AiY1qgqPFQxHi*(s@)9pn9{yZIBq_`k3qNH##O&=4vh`8pZ z@0MM9DzlUA!mfDM^r*Sw%K9u=#wVz=G2tz0ee?OVjXv%9anw-yTaW6}!Gf%iJaKEi z;ib5s^v1@?Se|Cg?)+xU+|KD|HFsS>q1$Z5$-{TNMoGU?+B1gM`Ri5B>genJIR=Nr zAh$>eg*~<%d`1_`$ssmX5&HiI4gi_(4F_E}WOvXOoyy{sTvrqd;#7&~I1gG=dHEpY z?SdbMP2M$asiMdI{vxqRb#+^zTzxtp%3mOW9T4{lFsVK{9y;ht2|~<$O>`7IB)Bd{ zhod3t+JQhLql&?`d4F#w0a)5rF7^%g*II3zk9AGB|59OJy}{XoK#xJ*jjj3t*Vofu zsm??+8Gq)zNIG2ax=_?IAMfmxueHjvx|@XTOQb+@Sj}vgACP`A75Y102v1J?%vp`t zwah<1Ii1ziPAUUUO8ZY^Aq(+VN>-*8#_Rg@1_^EM{l;x!#Ke@=ANsv`Ddo?u)t~$! zF(=FQww_edXJ?dUPsa@*;OIs4IA-t&UFBLC)n=Fa>*eeGpM6A_YWC}e=oa)vR3v2y zjt=_M)yL4LpJ+}Yzl#jLmY{SiIi0#^Qs8bE+~oM8uSbkTkb zFe-)D0fGQixk0D-l7PM_u1Vl(xZ&jT;=!48?})$Ct4$+w-N#!!guLN?b)}#p74@-q z&JoLgc8Xn>D2QMNSMrg(P&wt?Rm8}jrpi0Qgpj=@n$Ym>A0pRmRQy@ToWJ5h^QVL6qbC8?G;k|lN-y7Ca)YC#ZdZM&W~aKoL~P^zN(ld#;>sGOVs6bIO=;Rimv-)Ws1)0!qW?F@ z+)>W;1+?wvg`fqMx0(5y`~Ej4oxEagNUQ&1(n*c2t~2c4gESANSBJ+Ht#7ozal7KA zF9}ITM`0MdYO%PLt6&Eawi}k8L5kO%)h{J^!55i|*<16jMyq`s4^fPyl!=u2l5s@fE6|rco=@MDRFoHiBcRozCbJsx0Hb*?_xZx!7}-0}z2hA) zZiyFSe=QLuItPG%;91No?}0hyt7F5%bz((qFzT{?J?2d^(+d~)*$$?lF4NJ=WQP#> zeI5@jCvsJ|`RFTnDvwX-!@@1$jb;_<=}alV&ioCrESY0~EL*tO)${Kf|8>s2aVvz@ zcj+8F3Wn3?WYd(#Kf6+({!f|8e1laYJD?VF5efzC$E%~ro^zHx zVNQPUvH&xW#NO-~+k<2scJg;szymq9v1(H9(r8=Wr!M4p%6RlW0M~d%{Embb>BGzlNSfAzwXMVwqod94M=wK8 zPky|sXV4|S#$7E7xAD~JxOnLepCf2;JjwHVF(28atTaI+oT|h46l7yF9*B!^LKmP^ zP$Z`$89=-gTbT0CyGFFzv`*`QAnh$C&?}o(KQc@aLE`9OG^x$4+3gw9ukaJm=db+$ zalMu+``7!uJ-#de!h}8I3)frdHf;OPyRI&Nnl8I8e`!=-qJ4JUbyZdGB0`;Gj(v%K zFTV-e=J59N{pz;P+%cQoj%l({VBtPw0M~KI67=jpdD&k}MJCg|!oKa=ovYwIl!bqL z8ZlP(&mDx7+7XdI$T{;*5yRRa_w(q{ESjf47Ildkft~Q_%eM|4ubXBXxbe7SU>TP8>y0SYQCk>TW==<$E%b4{)recr8~yh1XgBgEoKio zE3qA|i`rd-kIvwRN##Rj#3Pn@v9pn~YU>rK2+*$Q7RpF95+S^DLh+ zim-W}u%KSaQK(D~8y1I&ArXzkp|9&KmTP~>`@I()y}^pqeO)dx+x?x6QTYQkCwum& zTs=W|3$6_F>1}Dl|15Ptr;z@)Q_u$({z95aEPuT;vi#WEU5FpVQ|45ZXU;@s{s{kB zO=A-5i}lB#Lthzh>uPrit%=_(=RO<5K4V%&GWS6y&zpnd`ELG&a3tHRgQx4+YV9Pf z5Z&UE;xl+d_%rofkG!E710#LWed(JoH9mT8Y#u6J`|2y`KJ2)66l}GTQt^!;bU#>M zQQpx#@rW_uGQ47lIp^dGC5<*@4Ksv<&qI&xK0)xTL`F^Tio(;V! zPw>D0HGrEui^EDE98~={fwTNL*d<#6hQv9tPuZIU632OYdDAg-i8V+|Kgrxmx5voS zLHaVrz+K?r#2Zv+*6IuVL|x#G>FSOMh1+q#M{qu?nK);?U})wfStNL|@4ZDCV> z>xdt6PV$t#8HF^#(qWW#im9}tbFYVIhLqeS<1E{MIupshxk1aJ3bG){>@=_7mXYEN*;C6sPz|$Mhoh=|p+8 z_*K(mZhPri=sKa@LQHF2B>9Ub$D-3odD1I&qlM{xl3>LTWwPo+cJ;>YhH_Y~C{>+g z%Xsk6-Qyuv9C_FF_>xs%5sSZYu}j%?6{2sT9Wm_K|j$(Ph zjn@Gm(Abvp>6==04$)5lmYtIm-J;pSu0VG^k2&3Kf{quF?%@eTAU`DmW0rSGD<;;t zJ5tO+M~=9T@~`92vRu)?d^E!rtYy9hwg@E<<{TVQBPlGr#Lf4kYXkke_b}Q(iS(#Q zTQ$iy|4F`k@%i{YvEb?1A|73zW#5D|(=3!h=Gxp%_fPAU?Dv}giAE@tbKf%>h$(_* z>-K~rK|28I@_}gj-}>GUaPZM3Z#ARwYt{-U5;7i66(H7ut0@I%YyG?fVIsc$ZtUoU?rZRF`PUg$chj?F1DZHSZKB1-p{b&Y<{Mq%rqh&lsW6A4= zLE8Ea^@Rn791N^^>B|i@{Z`XKww24X$nf*66s^;}U2;m5NE@-6IfM;xU8u~u>@HCQ zw(zAgR$K*a6D@w+p>0|B#hW0d7d5Sag?S?JoMb>sFPN|giYrDutXmXky&y<=Q(F>BSjj7b7|rO*lHHWwG{j)MLXy24&b&YWQZRdu;8g&vt<03& z7^5sjj2w^%Fmo`CU}onoY7n{a-@|^_r|gXb}-X3sscs&!e=P- z0$Css=4soex5~y%j!dg4DcxTqFDpL`D+tOl zl+}Ih^jGWf7tQt3>;j9Et+H){g=wn0mOXeZP#_)K1EThswZi+2%SMM5ffrAmzxj~% z&Er(aIP!=#GKHlv4`D?fjkMdRV6VDEYl0Q0o4GNm==!fJ~l&?+gJs@6a$d6H`DU`Q`vIrgNUBA9q27VF8eey zi=J3I%rv?XZr>tw*RjOh`*+BpH z@?DKFp7Bh&i!aFPc7ft1Tp=OOyQpN7?gc1QyJW=I((CtjX{+Xs(e{MNQi&?_sJ2hE zs=>siH}cHGWX^p2R!5`Lz_+<#Jipcw7k)TdmN5F*$?Dh~E;sDhNY|uf$aDHkEz?jF zyihll+pxKq*_Zb`Mch_2_|sF|2t*(gHA%64RdDzL3`l1xaPpFS3@Ue?1Qq z7Y5NP{X=*0OZP*qvCl&;OrBTOBm&)na0f0J4L=y)q$QOphtAJyDjEy&*u3__R?bRu z{Qmm$=ES^nU14pdHo)CU6z#Fl^g>K)RA!GlKKh$MLtngt zvsFU8LkCm?0kg$J_)4O#~o3XKtBlL3s$i|9Iw@WU+hc zT;8%toKF7saN>_BKjl<4t>QiiFUS@I$has5eH7RT^K^OMq#Vsr2uz>+2|hp^Qmsl! zw+{@K5@*Hv_jX8;&Q&|N zJ*&o7oXa2NIpy;~seG9nvmrzZs=33hKx%KzZPud2<^J!@w5;Qd6c3B`-%d7lxS9=b zq38d)1s>pJ)vV@RSj_6(Mv)07yz*&AQqR>C@N#3ro3~^3T7Dq=7~nt5j|U$N{6o2aMb2UpK!9rDS#=D z_g;uk5jys~{!%j!My(Isq4lfElqiVwnFfe?(rei~+FhenVRTX?KyhE`(^>6ehimYL;I~#Ni)ra4QdUZX8Qf%?lc5D&9!G#k_`%il7IpL-+ zZ4i1p$6Z?{e{K|q>76V$Fx^Y8^S?`dSHH!F0C#qgkG3aK`VYVlY%HrEMlA2TW5&@& zsZ6!FRK&p{D9Z3j4C2!^<>Lp!Jc^1U{1Hp%(^*6Bywud=XgURbul!sSzSK>GQMi?d z>=XC;`H|r@024ARAW`uE35!^qz4W_Z5vC2q!hxqndvSef3$8wcSD#z>zfQRb)Aob~ zv9P!2P-WqXGkAubF^)=t^7jPQK7G!AD&yqzdBlC$??9KwjXSdzi9jl`Wye%}Vnqra zc-&sTJ1Xcv0WCP8ZZ|I zN{lKa{=787;=1en`g>uVrmi_0fOzFGH>I>Z6HZ8PKUPh7Oz+j>NOpGB z^w;cx_AJ)~k%xiJ0Ota?{%xWwxyJY3ZVf2DbjrD{e?5rY*(5I!ACX13VQflxw*k-$ zLJk{Pb21SClSNkt?VO_kBC3)3J-)@w9SQvE*Ahu>#hh$8ezPqPFUP|b#@~;c{25b>*QB?c5=zY zkn|rSj8PhUk@IdDg|zqh2*~D68R0UdJy|6}Y|Y~bp|BPqxK-`*>nkkodHnNh5H*|y zzx%SvyL$g#v|?&Tp&UBw*i0_R5fD#6O#<(jWEXQH7-bIc3&h->Ija1A5Xt=)K{uFc zkhTEZF7FBu#f+vHw)`DH1We?A1JAOZKn9GiJ3ZH``E{r@33**Xakpaol#0FmRu6m+ zA`>5e6hMgAy$1L!2Y^n_sNhy+)u*nFEuWtBo3DDvRLQ1tk=(dzsP3e&_ia1yRT1Za z<&TXJ-4op`Ufem38r9Kdoip6_D*35exPcC*GINHQ7QAtGpx5;j)_h z3VKo$!~f*DMtw3Gov9!POAZf$Yt?Tsn#ARuMGyLilox-)tzjcKphs z`?7OW?Z-wEngi#`EPBHc*MbPW3NJ)tg}~Un5-`W;gGj~seOl)4{<~Al-ytqB zZW__iAA^AJjuaCsTRbnLeK2`*B-QlUR+p87Tg=$6Y*OTQ`L*2kE-J;#q7Y04*}c4u z6v^$;Tl;apQ>b(QTVhp!w09=)1UaS5FPTA45jO&TH=2P+=~5wNNP|refHnzLNoNG2 z^ojrs&rBV??FkOu!GTncj(X)t^|nAULsX`~ah?vdmlm6Cox?|9n^5{}o!$}vIdtr4 zszm=EQ(qkw)%$%tLkNhpN;8y#fOOXol46sBgh)tt!%)&Ch;)gH(ufigLk~!cbPO;s zLwCc>yca&-^;<9h!@4~8-1D5X&pvxIKIa~wmJ}~(z#Z-Hcj#vJjrsefnM*GMcL9rs zybBk+9tVP)W7YW#af?iVG~#zP>7oGF52KdkOpWl$t(BHoDOcTz)c;jy*Ol|e)M(`4 zAO>n+7l{2&wX~=E$l><(=|7*NTIsUIHXySDb_CF+^hmVy*;nCq7Z=N3~{k} z7-M3p;6L|blvEePwXi61tK@fKVtbNUddJW{kIr0+kH-UvZ~5|SH}4D;xt9wbAD8*9 zj~giX%j|D&P8h^|88zg}_-YYKjwamhq|9g+UUHUwULvRP zKA;?XsUo8t&w!5)zLXiMx)%0GxTkcqY>77R4jySs#XPH?zrTw^Rt|}h8%yI4(#flY zE6u4dAXBuDRI3TC#i@~x2WZB`Z8M1W#}CQ^@o+OXzC{~`veQaP{}i@Z6VSkKOrn~` z?G1Y3D{)``X6+O-agP_ic&E0OQam9$PJ<3>A>%ivj?>I-pN=1|K4&$Xon;K>5 z7fJK_8=$EIrF+@HGl^{iRu-P7b8OPJ@bPUdJa6D9WZWMcsL_b<>X$2-?m9A-+9c{A z`#W*%n1x>zL^Xy7`I;G#K)l^T7=-H>e!dES-QxG7gmill&Y$tJG}qTU&X9yPqm6!F zoa$WE`n`V&8q*V>9c0s+F}O^clR{U<;v*-DODu&w^e#H<{h0qQ@v)!NgIn(zcwy66 z=o{3Wzq26YZi49qC|xx_((dTnw&@am$Ib6KiGkRvcM_D~gR(s4t-?_F6QVd7vd5(V z&4X10*zS5^YY*Uq>*KMf=f81)xAe#%R#8%@chtagX(1bX7eVKQQ2xbR)qWIiVT3W9 zYzUd*N95zDYSsTi-v158{{_mkU-4_Zoj&J-gSoX8wXeK$%mTefK{!8Y_6hHdQC3QC zJ@jM3v9G>~LfL|)zK;`}ZEweF?o;bNr4p3kmy4dERR8N0QOzNP#X1j)p7rdb_obV0 z@^)cOxxQ5GQ}wRzg)DFU($Xmr|D=7_7_p_GomgXrck$L5rBS__3<2%2B}j}_bf-*!k_4Sn%f7{o%G zaQR7{O$n%3;-@DJ;&vqsABmF0|G#eS5byv?|o^L-s()#V_+Vs_s=6Uvuz=U)HP-#1MInCz|*nN;U&g}zmbuy$namT1%;v96t};h(@}hy)I@ z94`m&nq0U2T;sEOU(|nTHa?-;2kVVw9<-l5S)5%v-ydWkXblqF$-Q0oskpMH#LeTG z`%?OBq9Vb_S1!Ux5TC~yiwynunWVe=Nx^k9QJo=-mU_t<6(c1TrXw8uc?y0g!6}Xp zP>`zqj;t#t$Gr$Gi9mv((|Gdxq~?t3wX*QxjnL(AyCI^xjb&lDcq4;T9e+sSkL5*2!$v!Q%SwVNfTT$=(B`lhS-=h60A z;MQqwv&2Ak{D2J`o@KWZZyCmhnmyW{Tg(y)n#GF2T5X#GC3ESmF48d;H~grv5%jfbkiAp~jFSVkhbjoQ-k!@v%*JM3GjLDZQ68 z*1x3XTW!>o;g=^kuMnM~Y>_sxqP2ukCE#0W4%!o)wFmi6z3@-xiX zhyk9J>QZ1sDQoxiw|=D2WUqM}x`9lv&Rj|Gi3?h5Dt9!njfGl1TC@59${75b*?@mC zUHjFU=TTr&cC-5XN~hUs>*bbZsYFPW97cxYzPb9#*DQ|?ch282;{K$#UpO87sc-mz z{B_dgFnpt&bded7tfYzIq>hjU5=(usDP#p7;JIzAzo|CV_TUff(4=E>$EC4YuH|&; zq!i@G-#YfW_zH@=0&$Mj@F0dtFi7A&6$xORMraJu!ECIbSj1%jM&uxl7dp_lAC|;Q zeB|*~KT8Ga_%LYqH^mtgGdX3!n$wL=M7mY!*MU=}w^Yr_8s%iX^?k!7tSf+}#;b)c^Gf z#cIuRI4N|aD@HWHe;4)QZY==8KS*%Fdaoa6ER5EY#96jHw&3{w%Y-X1Z+c+u1wn@? z-+-fak&-Z}{rSIFH*agR?7!8edGKUJ0qC&sXebN|m%{;Mrc4TPKYM|q?~DZ~!N(?v zG>CGK-e80Pls7U#0a;;dSjiXG-IbcABF$vZ6 zB9(@7=UxeiKwsM@QvT=J39xy(Lu>_yes39Yd1!=xB5Q1(1kmkVP#U>2l(|e+{>3LA;+T( z$VqGpa#CU2A#+P`x{8-nxOF&UTr1*SVNc{>_>tfak%qq(#Xl9;B7kIpL6HgVi{SAK z?nQeToWT2Ie$DpC_(TD6Oa7c$Ww*0bp@us6b{EBYJ@YhkZB6#al)lVD2Jx#f1qA2%}f>+>Bpg4D&GcPMJY_&iqAJi9_~z%#K`2@3M)^VjmcJYE*V!xup#WA8r@ zdWf_zL~6QYJO(OD^Y5zAm_L{R$W^ETcZAdhwh`NiwqXo#Mgb=(u{c5!t-Bi}x{&7` z^W&d>1uc4+Tv0WVM=ZSwaLy59A|U;gQr|dC0fHK#g#eXJ(0z7l0!d!iVOqnQ0oC-j zYU*np7h#Jud49~t5Xm#0zhyV&+LzR&D+F3sWXd;aEZ}eJe}rmyU(#h}%6q%lCvWkFFk@h8FTBrjuhw>pOL{@5#81lO%>@dqs{fW^0``N-ChOw!KT46~NI)yc_XvsD-E3AkGpijm2}Xt2nBG=cMC&A9jA!GWNPj1< z>${;ilFMi6t{GLj)qNK$UF2OPzC}DUng!XUtma1?0ci=epxV}s`iu(S$R?0#gex?i zw_M9ugRx1)%Ye}e70%OBrKcdw>fxZ>gKh;UnCw)8M#CIFob`e60${&YYPTnXLdcs< z503Mt$gC!Vq!(avr-Mf|C$9T0%Q=ccluQG5BKnM*DMr~=er(q{7W>0@7Byf6fyx%D zu=H~|Bi3);0FnjtcKNM1vx$YeuCzp&!h*r>rR}Lb`HPD#Y7@&utz+6J z$=WxW6c_sl^&I=gzupW(9z%DI{NpZXYycJSzB(Geh{nUF&GxZHfiH4@Z0Fwl#K_