diff --git a/.changeset/cute-humans-follow.md b/.changeset/cute-humans-follow.md new file mode 100644 index 0000000000000..3788df75f8d2d --- /dev/null +++ b/.changeset/cute-humans-follow.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/model-typings': patch +'@rocket.chat/models': patch +'@rocket.chat/meteor': patch +--- + +Fixes auto-translate not activating for users who set their language preference after joining rooms diff --git a/.changeset/short-mice-cover.md b/.changeset/short-mice-cover.md new file mode 100644 index 0000000000000..a1d0b0ad967b2 --- /dev/null +++ b/.changeset/short-mice-cover.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes rooms failing to open on transient network errors, and prevents a "Room Not Found" flash when opening a room with a local subscription diff --git a/.changeset/true-cooks-lick.md b/.changeset/true-cooks-lick.md new file mode 100644 index 0000000000000..265b130bedfe2 --- /dev/null +++ b/.changeset/true-cooks-lick.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/apps-engine': minor +'@rocket.chat/meteor': minor +--- + +Exposes the `isFederated` and `federation` fields for room and user objects in apps diff --git a/apps/meteor/app/apps/server/bridges/rooms.ts b/apps/meteor/app/apps/server/bridges/rooms.ts index 11b2b405fb2e6..1a2963a005676 100644 --- a/apps/meteor/app/apps/server/bridges/rooms.ts +++ b/apps/meteor/app/apps/server/bridges/rooms.ts @@ -37,6 +37,8 @@ const rawRoomProjection: FindOptions['projection'] = { prid: 1, teamId: 1, teamMain: 1, + federated: 1, + federation: 1, livechatData: 1, waitingResponse: 1, open: 1, diff --git a/apps/meteor/app/apps/server/converters/rooms.js b/apps/meteor/app/apps/server/converters/rooms.js index 2c699399f1b5e..38c4f25678e24 100644 --- a/apps/meteor/app/apps/server/converters/rooms.js +++ b/apps/meteor/app/apps/server/converters/rooms.js @@ -59,6 +59,8 @@ export class AppRoomsConverter { contactId: 'contactId', departmentId: 'departmentId', parentRoomId: 'prid', + isFederated: 'federated', + federation: 'federation', visitor: (data) => { const { v } = data; if (!v) { @@ -261,6 +263,8 @@ export class AppRoomsConverter { ...(room.lastModifiedAt && { lm: room.lastModifiedAt }), ...(room.customFields && { customFields: room.customFields }), ...(room.livechatData && { livechatData: room.livechatData }), + ...(typeof room.isFederated !== 'undefined' && { federated: room.isFederated }), + ...(typeof room.federation !== 'undefined' && { federation: room.federation }), ...(typeof room.parentRoom !== 'undefined' && { prid: room.parentRoom.id }), ...(contactId && { contactId }), ...(room._USERNAMES && { _USERNAMES: room._USERNAMES }), @@ -304,6 +308,8 @@ export class AppRoomsConverter { closer: 'closer', teamId: 'teamId', isTeamMain: 'teamMain', + isFederated: 'federated', + federation: 'federation', isDefault: (room) => { const result = !!room.default; delete room.default; diff --git a/apps/meteor/app/apps/server/converters/users.js b/apps/meteor/app/apps/server/converters/users.js index 7ce44b28f8355..4bca06ab168ed 100644 --- a/apps/meteor/app/apps/server/converters/users.js +++ b/apps/meteor/app/apps/server/converters/users.js @@ -45,6 +45,8 @@ export class AppUsersConverter { lastLoginAt: user.lastLogin, appId: user.appId, customFields: user.customFields, + isFederated: user.federated, + federation: user.federation, sipExtension: user.freeSwitchExtension, settings: { preferences: { @@ -75,6 +77,8 @@ export class AppUsersConverter { _updatedAt: user.updatedAt, lastLogin: user.lastLoginAt, appId: user.appId, + federated: user.isFederated, + federation: user.federation, freeSwitchExtension: user.sipExtension, }); } diff --git a/apps/meteor/client/views/room/hooks/useOpenRoom.ts b/apps/meteor/client/views/room/hooks/useOpenRoom.ts index 76dd6d9cc2d18..21e4d27a2d240 100644 --- a/apps/meteor/client/views/room/hooks/useOpenRoom.ts +++ b/apps/meteor/client/views/room/hooks/useOpenRoom.ts @@ -78,6 +78,12 @@ export function useOpenRoom({ type, reference }: { type: RoomType; reference: st try { roomData = await getRoomByTypeAndName(type, reference); } catch (error) { + const isDefinitivelyNotFound = error && typeof error === 'object' && 'error' in error && error.error === 'error-invalid-room'; + + if (!isDefinitivelyNotFound) { + throw error; + } + if (type !== 'd') { throw new RoomNotFoundError(undefined, { type, reference }); } @@ -137,7 +143,16 @@ export function useOpenRoom({ type, reference }: { type: RoomType; reference: st return { rid: room._id }; }, - retry: 0, + retry: (failureCount, error) => { + const unrecoverableErrors = [RoomNotFoundError, OldUrlRoomError, NotAuthorizedError, NotSubscribedToRoomError]; + + if (unrecoverableErrors.some((e) => error instanceof e)) { + return false; + } + + return failureCount < 4; + }, + retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 5000), }); const queryClient = useQueryClient(); diff --git a/apps/meteor/client/views/room/providers/RoomProvider.tsx b/apps/meteor/client/views/room/providers/RoomProvider.tsx index 4422e023f2c81..854e83fa04e7e 100644 --- a/apps/meteor/client/views/room/providers/RoomProvider.tsx +++ b/apps/meteor/client/views/room/providers/RoomProvider.tsx @@ -108,7 +108,7 @@ const RoomProvider = ({ rid, children }: RoomProviderProps) => { }, [rid, subscribed]); if (!pseudoRoom) { - return !room ? : ; + return !room && !subscritionFromLocal ? : ; } return ( diff --git a/apps/meteor/server/methods/saveUserPreferences.ts b/apps/meteor/server/methods/saveUserPreferences.ts index 2645e35dd47a7..af95dd26d765c 100644 --- a/apps/meteor/server/methods/saveUserPreferences.ts +++ b/apps/meteor/server/methods/saveUserPreferences.ts @@ -220,7 +220,10 @@ export const saveUserPreferences = async (settings: Partial, us } if (language && oldLanguage !== language && rcSettings.get('AutoTranslate_AutoEnableOnJoinRoom')) { - const response = await Subscriptions.updateAllAutoTranslateLanguagesByUserId(user._id, language); + const workspaceLanguage = rcSettings.get('Language'); + const targetLanguage = language === 'default' || language === workspaceLanguage ? null : language; + + const response = await Subscriptions.setAutoTranslateByUserId(user._id, targetLanguage); if (response.modifiedCount) { void notifyOnSubscriptionChangedByAutoTranslateAndUserId(user._id); } diff --git a/apps/meteor/tests/end-to-end/api/autotranslate.ts b/apps/meteor/tests/end-to-end/api/autotranslate.ts index 4d6f65c67de80..9ece0a53e9463 100644 --- a/apps/meteor/tests/end-to-end/api/autotranslate.ts +++ b/apps/meteor/tests/end-to-end/api/autotranslate.ts @@ -669,6 +669,96 @@ describe('AutoTranslate', () => { expect(subscription).to.have.property('autoTranslateLanguage', 'es'); channelsToRemove.push(newChannel); }); + + it('should enable autotranslate on existing subscriptions when user changes from workspace language to a different one', async () => { + await setLanguagePref('pt-BR', credA); + const newChannel = await createChannel(undefined, credA); + channelsToRemove.push(newChannel); + + const subBefore = await getSub(newChannel._id, credA); + expect(subBefore).to.not.have.property('autoTranslate'); + expect(subBefore).to.not.have.property('autoTranslateLanguage'); + + await setLanguagePref('en', credA); + + const subAfter = await getSub(newChannel._id, credA); + expect(subAfter).to.have.property('autoTranslate', true); + expect(subAfter).to.have.property('autoTranslateLanguage', 'en'); + }); + + it("should enable autotranslate on existing subscriptions when user changes from 'default' language to a non-workspace language", async () => { + await setLanguagePref('default', credA); + const newChannel = await createChannel(undefined, credA); + channelsToRemove.push(newChannel); + + const subBefore = await getSub(newChannel._id, credA); + expect(subBefore).to.not.have.property('autoTranslate'); + expect(subBefore).to.not.have.property('autoTranslateLanguage'); + + await setLanguagePref('en', credA); + + const subAfter = await getSub(newChannel._id, credA); + expect(subAfter).to.have.property('autoTranslate', true); + expect(subAfter).to.have.property('autoTranslateLanguage', 'en'); + }); + + it('should disable autotranslate on existing subscriptions when user changes language to default', async () => { + await setLanguagePref('en', credA); + const newChannel = await createChannel(undefined, credA); + channelsToRemove.push(newChannel); + + const subBefore = await getSub(newChannel._id, credA); + expect(subBefore).to.have.property('autoTranslate', true); + + await setLanguagePref('default', credA); + + const subAfter = await getSub(newChannel._id, credA); + expect(subAfter).to.not.have.property('autoTranslate'); + expect(subAfter).to.not.have.property('autoTranslateLanguage'); + }); + + it('should disable autotranslate on existing subscriptions when user changes language to the workspace default', async () => { + await setLanguagePref('en', credA); + const newChannel = await createChannel(undefined, credA); + channelsToRemove.push(newChannel); + + const subBefore = await getSub(newChannel._id, credA); + expect(subBefore).to.have.property('autoTranslate', true); + + await setLanguagePref('pt-BR', credA); + + const subAfter = await getSub(newChannel._id, credA); + expect(subAfter).to.not.have.property('autoTranslate'); + expect(subAfter).to.not.have.property('autoTranslateLanguage'); + }); + + it('should not re-enable autotranslate for rooms where the user has explicitly opted out', async () => { + await updatePermission('auto-translate', ['admin', 'user']); + + await setLanguagePref('en', credA); + const newChannel = await createChannel(undefined, credA); + channelsToRemove.push(newChannel); + + const subEnabled = await getSub(newChannel._id, credA); + expect(subEnabled).to.have.property('autoTranslate', true); + + await request + .post(api('autotranslate.saveSettings')) + .set(credA) + .send({ roomId: newChannel._id, field: 'autoTranslate', value: false, defaultLanguage: 'en' }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + const subOptedOut = await getSub(newChannel._id, credA); + expect(subOptedOut).to.have.property('autoTranslate', false); + + await setLanguagePref('es', credA); + + const subAfterLangChange = await getSub(newChannel._id, credA); + expect(subAfterLangChange).to.have.property('autoTranslate', false); + }); }); }); }); diff --git a/packages/apps-engine/src/definition/federation/FederationLookup.ts b/packages/apps-engine/src/definition/federation/FederationLookup.ts new file mode 100644 index 0000000000000..385df1518473f --- /dev/null +++ b/packages/apps-engine/src/definition/federation/FederationLookup.ts @@ -0,0 +1,5 @@ +export type FederationLookup = { + version: number; + mrid: string; + origin: string; +}; diff --git a/packages/apps-engine/src/definition/federation/index.ts b/packages/apps-engine/src/definition/federation/index.ts new file mode 100644 index 0000000000000..2c61cb7db5b20 --- /dev/null +++ b/packages/apps-engine/src/definition/federation/index.ts @@ -0,0 +1 @@ +export type * from './FederationLookup'; diff --git a/packages/apps-engine/src/definition/rooms/IRoom.ts b/packages/apps-engine/src/definition/rooms/IRoom.ts index 7fd382e0929f3..aa1a7b02f6670 100644 --- a/packages/apps-engine/src/definition/rooms/IRoom.ts +++ b/packages/apps-engine/src/definition/rooms/IRoom.ts @@ -1,4 +1,5 @@ import type { IAbacAttributeDefinition } from '../abac/AbacAttributes'; +import type { FederationLookup } from '../federation'; import type { IUser } from '../users'; import type { RoomType } from './RoomType'; @@ -26,6 +27,8 @@ export interface IRoom { customFields?: { [key: string]: any }; parentRoom?: IRoom; livechatData?: { [key: string]: any }; + isFederated?: boolean; + federation?: FederationLookup; abacAttributes?: IAbacAttributeDefinition[]; } diff --git a/packages/apps-engine/src/definition/rooms/IRoomRaw.ts b/packages/apps-engine/src/definition/rooms/IRoomRaw.ts index 1ffed4b9ada82..e57ae69b96e2a 100644 --- a/packages/apps-engine/src/definition/rooms/IRoomRaw.ts +++ b/packages/apps-engine/src/definition/rooms/IRoomRaw.ts @@ -1,7 +1,8 @@ import type { IVisitor } from '../livechat'; +import type { RoomType } from './RoomType'; +import type { FederationLookup } from '../federation'; import type { IOmnichannelSource, IVisitorChannelInfo } from '../livechat/ILivechatRoom'; import type { IUserLookup } from '../users'; -import type { RoomType } from './RoomType'; /** * A lightweight representation of a room without resolving relational data. @@ -40,4 +41,6 @@ export interface IRoomRaw { visitor?: Pick & IVisitorChannelInfo; departmentId?: string; contactId?: string; + isFederated?: boolean; + federation?: FederationLookup; } diff --git a/packages/apps-engine/src/definition/users/IUser.ts b/packages/apps-engine/src/definition/users/IUser.ts index 73f3a41f0a68d..7bc5b09550da4 100644 --- a/packages/apps-engine/src/definition/users/IUser.ts +++ b/packages/apps-engine/src/definition/users/IUser.ts @@ -1,3 +1,4 @@ +import type { FederationLookup } from '../federation'; import type { IUserEmail } from './IUserEmail'; import type { IUserSettings } from './IUserSettings'; import type { UserStatusConnection } from './UserStatusConnection'; @@ -25,5 +26,7 @@ export interface IUser { settings?: IUserSettings; appId?: string; sipExtension?: string; + isFederated?: boolean; + federation?: FederationLookup; customFields?: { [key: string]: any }; } diff --git a/packages/model-typings/src/models/ISubscriptionsModel.ts b/packages/model-typings/src/models/ISubscriptionsModel.ts index 8273feec9f517..73d2a2892aef9 100644 --- a/packages/model-typings/src/models/ISubscriptionsModel.ts +++ b/packages/model-typings/src/models/ISubscriptionsModel.ts @@ -176,7 +176,7 @@ export interface ISubscriptionsModel extends IBaseModel { cachedFindByUserId(userId: string, options?: FindOptions): FindCursor; updateAutoTranslateById(_id: string, autoTranslate: boolean): Promise; - updateAllAutoTranslateLanguagesByUserId(userId: IUser['_id'], language: string): Promise; + setAutoTranslateByUserId(userId: IUser['_id'], language: string | null): Promise; findByAutoTranslateAndUserId( userId: ISubscription['u']['_id'], autoTranslate?: ISubscription['autoTranslate'], diff --git a/packages/models/src/models/Subscriptions.ts b/packages/models/src/models/Subscriptions.ts index 679080739d3ba..66207a6eada3c 100644 --- a/packages/models/src/models/Subscriptions.ts +++ b/packages/models/src/models/Subscriptions.ts @@ -707,8 +707,8 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri }; } else { update = { - $unset: { - autoTranslate: 1, + $set: { + autoTranslate: false, }, }; } @@ -723,19 +723,21 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.findOneAndUpdate(query, update, { returnDocument: 'after' }); } - updateAllAutoTranslateLanguagesByUserId(userId: IUser['_id'], language: string): Promise { - const query = { - 'u._id': userId, - 'autoTranslate': true, - }; - - const update: UpdateFilter = { - $set: { - autoTranslateLanguage: language, - }, - }; + setAutoTranslateByUserId(userId: IUser['_id'], language: string | null): Promise { + if (language) { + return Promise.all([ + this.updateMany({ 'u._id': userId, 'autoTranslate': true }, { $set: { autoTranslateLanguage: language } }), + this.updateMany( + { 'u._id': userId, 'autoTranslate': { $exists: false } }, + { $set: { autoTranslate: true, autoTranslateLanguage: language } }, + ), + ]).then(([updateResult, enableResult]) => ({ + ...updateResult, + modifiedCount: updateResult.modifiedCount + enableResult.modifiedCount, + })); + } - return this.updateMany(query, update); + return this.updateMany({ 'u._id': userId, 'autoTranslate': true }, { $unset: { autoTranslate: 1, autoTranslateLanguage: 1 } }); } findByAutoTranslateAndUserId(