diff --git a/apps/meteor/app/autotranslate/client/lib/actionButton.ts b/apps/meteor/app/autotranslate/client/lib/actionButton.ts index 742ea4c9a5b79..4b12d7ec0017e 100644 --- a/apps/meteor/app/autotranslate/client/lib/actionButton.ts +++ b/apps/meteor/app/autotranslate/client/lib/actionButton.ts @@ -1,7 +1,3 @@ -import { Meteor } from 'meteor/meteor'; - import { AutoTranslate } from './autotranslate'; -Meteor.startup(() => { - AutoTranslate.init(); -}); +AutoTranslate.init(); diff --git a/apps/meteor/app/autotranslate/client/lib/autotranslate.ts b/apps/meteor/app/autotranslate/client/lib/autotranslate.ts index 1c294c2023137..9ab66e6086efc 100644 --- a/apps/meteor/app/autotranslate/client/lib/autotranslate.ts +++ b/apps/meteor/app/autotranslate/client/lib/autotranslate.ts @@ -1,13 +1,11 @@ import type { IRoom, ISubscription, ISupportedLanguage, ITranslatedMessage, MessageAttachmentDefault } from '@rocket.chat/core-typings'; import { isTranslatedMessageAttachment } from '@rocket.chat/core-typings'; import mem from 'mem'; -import { Meteor } from 'meteor/meteor'; -import { Tracker } from 'meteor/tracker'; +import { PermissionsCachedStore } from '../../../../client/cachedStores'; import { settings } from '../../../../client/lib/settings'; -import { getUserId } from '../../../../client/lib/user'; -import { watchUser, watchUserId } from '../../../../client/meteor/user'; -import { Messages, Subscriptions } from '../../../../client/stores'; +import { getUserId, userIdStore } from '../../../../client/lib/user'; +import { Messages, Subscriptions, Users } from '../../../../client/stores'; import { hasTranslationLanguageInAttachments, hasTranslationLanguageInMessage, @@ -18,15 +16,16 @@ import { sdk } from '../../../utils/client/lib/SDKClient'; let userLanguage = 'en'; let username = ''; -Meteor.startup(() => { - Tracker.autorun(() => { - const user = watchUser(); - if (!user) return; - - userLanguage = user.language || 'en'; - username = user.username || ''; - }); -}); +const refreshUserCache = () => { + const uid = userIdStore.getState(); + const user = uid ? Users.use.getState().get(uid) : undefined; + if (!user) return; + userLanguage = user.language || 'en'; + username = user.username || ''; +}; +refreshUserCache(); +userIdStore.subscribe(refreshUserCache); +Users.use.subscribe(refreshUserCache); export const AutoTranslate = { initialized: false, @@ -84,14 +83,7 @@ export const AutoTranslate = { return; } - Tracker.autorun(async (c) => { - const uid = watchUserId(); - if (!settings.watch('AutoTranslate_Enabled') || !uid || !hasPermission('auto-translate')) { - return; - } - - c.stop(); - + const loadProviders = async () => { try { [this.providersMetadata, this.supportedLanguages] = await Promise.all([ sdk.call('autoTranslate.getProviderUiMetadata'), @@ -101,7 +93,25 @@ export const AutoTranslate = { // Avoid unwanted error message on UI when autotranslate is disabled while fetching data console.error((e as Error).message); } - }); + }; + + let loaded = false; + const unsubs: Array<() => void> = []; + const tryLoad = async () => { + if (loaded) return; + if (!settings.peek('AutoTranslate_Enabled') || !userIdStore.getState() || !hasPermission('auto-translate')) { + return; + } + loaded = true; + unsubs.splice(0).forEach((unsubscribe) => unsubscribe()); + await loadProviders(); + }; + + unsubs.push(userIdStore.subscribe(() => void tryLoad())); + unsubs.push(settings.observe('AutoTranslate_Enabled', () => void tryLoad())); + unsubs.push(PermissionsCachedStore.useReady.subscribe(() => void tryLoad())); + + void tryLoad(); Subscriptions.use.subscribe(() => { mem.clear(this.findSubscriptionByRid); diff --git a/apps/meteor/app/lib/client/index.ts b/apps/meteor/app/lib/client/index.ts index 2769be7fe5764..2d07fbe1ac5b4 100644 --- a/apps/meteor/app/lib/client/index.ts +++ b/apps/meteor/app/lib/client/index.ts @@ -1,3 +1,2 @@ import '../lib/MessageTypes'; import './OAuthProxy'; -import './methods/sendMessage'; diff --git a/apps/meteor/app/lib/client/methods/sendMessage.ts b/apps/meteor/app/lib/client/methods/sendMessage.ts index 0969ea8d9cf36..8644191c21542 100644 --- a/apps/meteor/app/lib/client/methods/sendMessage.ts +++ b/apps/meteor/app/lib/client/methods/sendMessage.ts @@ -1,7 +1,5 @@ import type { IMessage } from '@rocket.chat/core-typings'; -import type { ServerMethods } from '@rocket.chat/ddp-client'; import { clientCallbacks } from '@rocket.chat/ui-client'; -import { Meteor } from 'meteor/meteor'; import { onClientMessageReceived } from '../../../../client/lib/onClientMessageReceived'; import { settings } from '../../../../client/lib/settings'; @@ -11,40 +9,41 @@ import { Messages, Rooms } from '../../../../client/stores'; import { trim } from '../../../../lib/utils/stringUtils'; import { t } from '../../../utils/lib/i18n'; -Meteor.methods({ - async sendMessage(message) { - const uid = getUserId(); - if (!uid || trim(message.msg) === '') { - return false; - } - const messageAlreadyExists = message._id && Messages.state.get(message._id); - if (messageAlreadyExists) { - return dispatchToastMessage({ type: 'error', message: t('Message_Already_Sent') }); - } - const user = getUser(); - if (!user?.username) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'sendMessage' }); - } - message.ts = new Date(); - message.u = { +export const runOptimisticSendMessage = async ( + message: Partial & { rid: IMessage['rid']; msg: IMessage['msg'] }, +): Promise => { + const uid = getUserId(); + if (!uid || trim(message.msg) === '') { + return; + } + const messageAlreadyExists = message._id && Messages.state.get(message._id); + if (messageAlreadyExists) { + dispatchToastMessage({ type: 'error', message: t('Message_Already_Sent') }); + return; + } + const user = getUser(); + if (!user?.username) { + return; + } + + const room = Rooms.state.get(message.rid); + if (room?.federated) { + return; + } + + const optimistic: IMessage = { + ...(message as IMessage), + ts: new Date(), + u: { _id: uid, username: user.username, name: user.name || '', - }; - message.temp = true; - if (settings.peek('Message_Read_Receipt_Enabled')) { - message.unread = true; - } - - // If the room is federated, send the message to matrix only - const room = Rooms.state.get(message.rid); - if (room?.federated) { - return; - } + }, + temp: true, + ...(settings.peek('Message_Read_Receipt_Enabled') ? { unread: true } : {}), + }; - await onClientMessageReceived(message as IMessage).then((message) => { - Messages.state.store(message); - return clientCallbacks.run('afterSaveMessage', message, { room, user }); - }); - }, -}); + const processed = await onClientMessageReceived(optimistic); + Messages.state.store(processed); + await clientCallbacks.run('afterSaveMessage', processed, { room, user }); +}; diff --git a/apps/meteor/app/livechat/client/lib/stream/queueManager.ts b/apps/meteor/app/livechat/client/lib/stream/queueManager.ts index 5fc9be444ec98..ed48999575202 100644 --- a/apps/meteor/app/livechat/client/lib/stream/queueManager.ts +++ b/apps/meteor/app/livechat/client/lib/stream/queueManager.ts @@ -5,7 +5,6 @@ import { type IOmnichannelAgent, type Serialized, } from '@rocket.chat/core-typings'; -import { Tracker } from 'meteor/tracker'; import { useLivechatInquiryStore } from '../../../../../client/hooks/useLivechatInquiryStore'; import { queryClient } from '../../../../../client/lib/queryClient'; @@ -64,8 +63,10 @@ const removeInquiry = async (inquiry: ILivechatInquiryRecord) => { return queryClient.invalidateQueries({ queryKey: ['rooms', { reference: inquiry.rid, type: 'l' }] }); }; +const INQUIRY_COUNT_SETTING = 'Livechat_guest_pool_max_number_incoming_livechats_displayed'; + const getInquiriesFromAPI = async () => { - const count = settings.peek('Livechat_guest_pool_max_number_incoming_livechats_displayed') ?? 0; + const count = settings.peek(INQUIRY_COUNT_SETTING) ?? 0; const { inquiries } = await sdk.rest.get('/v1/livechat/inquiries.queuedForUser', { count }); return inquiries; }; @@ -140,10 +141,12 @@ const subscribe = async (userId: IOmnichannelAgent['_id']) => { const cleanDepartmentListeners = addListenerForeachDepartment(agentDepartments); const globalCleanup = addGlobalListener(); - const computation = Tracker.autorun(async () => { - const inquiriesFromAPI = await getInquiriesFromAPI(); + const refetchInquiries = async () => updateInquiries(await getInquiriesFromAPI()); + + await refetchInquiries(); - await updateInquiries(inquiriesFromAPI); + const unobserveInquiryCount = settings.observe(INQUIRY_COUNT_SETTING, () => { + void refetchInquiries(); }); return () => { @@ -152,8 +155,8 @@ const subscribe = async (userId: IOmnichannelAgent['_id']) => { cleanAgentListener?.(); cleanDepartmentListeners?.(); globalCleanup?.(); + unobserveInquiryCount(); departments.clear(); - computation.stop(); }; } catch (error) { dispatchToastMessage({ type: 'error', message: error }); diff --git a/apps/meteor/app/reactions/client/index.ts b/apps/meteor/app/reactions/client/index.ts deleted file mode 100644 index 935ab48e93ad6..0000000000000 --- a/apps/meteor/app/reactions/client/index.ts +++ /dev/null @@ -1 +0,0 @@ -import './methods/setReaction'; diff --git a/apps/meteor/app/reactions/client/methods/setReaction.ts b/apps/meteor/app/reactions/client/methods/setReaction.ts index 96cd4bcb95910..093fdd9ea2b0c 100644 --- a/apps/meteor/app/reactions/client/methods/setReaction.ts +++ b/apps/meteor/app/reactions/client/methods/setReaction.ts @@ -1,84 +1,81 @@ import type { IMessage } from '@rocket.chat/core-typings'; -import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Meteor } from 'meteor/meteor'; import { roomCoordinator } from '../../../../client/lib/rooms/roomCoordinator'; import { getUser, getUserId } from '../../../../client/lib/user'; import { Rooms, Subscriptions, Messages } from '../../../../client/stores'; import { emoji } from '../../../emoji/client'; -Meteor.methods({ - async setReaction(reaction, messageId) { - if (!getUserId()) { - throw new Meteor.Error(203, 'User_logged_out'); - } - - const user = getUser(); +export const runOptimisticSetReaction = (reaction: string, messageId: IMessage['_id']): void => { + if (!getUserId()) { + return; + } - if (!user?.username) { - return false; - } + const user = getUser(); + if (!user?.username) { + return; + } - const message: IMessage | undefined = Messages.state.get(messageId); - if (!message) { - return false; - } + const message: IMessage | undefined = Messages.state.get(messageId); + if (!message) { + return; + } - const room = Rooms.state.get(message.rid); - if (!room) { - return false; - } - - if (message.private) { - return false; - } + const room = Rooms.state.get(message.rid); + if (!room) { + return; + } - if (!emoji.list[reaction]) { - return false; - } + if (message.private) { + return; + } - if (roomCoordinator.readOnly(room, user)) { - return false; - } + if (!emoji.list[reaction]) { + return; + } - if (!Subscriptions.state.find(({ rid }) => rid === message.rid)) { - return false; - } + if (roomCoordinator.readOnly(room, user)) { + return; + } - if (message.reactions?.[reaction] && message.reactions[reaction].usernames.indexOf(user.username) !== -1) { - message.reactions[reaction].usernames.splice(message.reactions[reaction].usernames.indexOf(user.username), 1); + if (!Subscriptions.state.find(({ rid }) => rid === message.rid)) { + return; + } - if (message.reactions[reaction].usernames.length === 0) { - delete message.reactions[reaction]; - } + if (message.reactions?.[reaction] && message.reactions[reaction].usernames.indexOf(user.username) !== -1) { + message.reactions[reaction].usernames.splice(message.reactions[reaction].usernames.indexOf(user.username), 1); - if (!message.reactions || typeof message.reactions !== 'object' || Object.keys(message.reactions).length === 0) { - delete message.reactions; - Messages.state.update( - (record) => record._id === messageId, - ({ reactions: _, ...record }) => record, - ); - } else { - Messages.state.update( - (record) => record._id === messageId, - (record) => ({ ...record, reactions: message.reactions }), - ); - } - } else { - if (!message.reactions) { - message.reactions = {}; - } - if (!message.reactions[reaction]) { - message.reactions[reaction] = { - usernames: [], - }; - } - message.reactions[reaction].usernames.push(user.username); + if (message.reactions[reaction].usernames.length === 0) { + delete message.reactions[reaction]; + } + if (!message.reactions || typeof message.reactions !== 'object' || Object.keys(message.reactions).length === 0) { + delete message.reactions; Messages.state.update( (record) => record._id === messageId, - (record) => ({ ...record, reactions: message.reactions }), + ({ reactions: _, ...record }) => record, ); + return; } - }, -}); + + Messages.state.update( + (record) => record._id === messageId, + (record) => ({ ...record, reactions: message.reactions }), + ); + return; + } + + if (!message.reactions) { + message.reactions = {}; + } + if (!message.reactions[reaction]) { + message.reactions[reaction] = { + usernames: [], + }; + } + message.reactions[reaction].usernames.push(user.username); + + Messages.state.update( + (record) => record._id === messageId, + (record) => ({ ...record, reactions: message.reactions }), + ); +}; diff --git a/apps/meteor/app/slashcommands-join/client/client.ts b/apps/meteor/app/slashcommands-join/client/client.ts index bc8d589f51ac4..878981a11951c 100644 --- a/apps/meteor/app/slashcommands-join/client/client.ts +++ b/apps/meteor/app/slashcommands-join/client/client.ts @@ -1,5 +1,3 @@ -import type { Meteor } from 'meteor/meteor'; - import { slashCommands } from '../../utils/client/slashCommand'; slashCommands.add({ @@ -10,7 +8,7 @@ slashCommands.add({ permission: 'view-c-room', }, result(err, _result: unknown, params: Record) { - if ((err as Meteor.Error).error === 'error-user-already-in-room') { + if ((err as { error?: string } | undefined)?.error === 'error-user-already-in-room') { params.cmd = 'open'; params.msg.msg = params.msg.msg.replace('join', 'open'); return void slashCommands.run({ command: 'open', params: params.params, message: params.msg, triggerId: '', userId: params.userId }); diff --git a/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts b/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts index 14b1d291ef016..c6ce002b70c12 100644 --- a/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts +++ b/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts @@ -1,8 +1,7 @@ import type { IMessage, IRoom, ISubscription } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { differenceInMilliseconds } from 'date-fns'; -import { ReactiveVar } from 'meteor/reactive-var'; -import { Tracker } from 'meteor/tracker'; +import { useCallback, useSyncExternalStore } from 'react'; import type { MutableRefObject } from 'react'; import { onClientMessageReceived } from '../../../../client/lib/onClientMessageReceived'; @@ -13,8 +12,6 @@ import { waitForElement } from '../../../../client/lib/utils/waitForElement'; import { Messages, Subscriptions } from '../../../../client/stores'; import { getUserPreference } from '../../../utils/client'; -const waitAfterFlush = () => new Promise((resolve) => Tracker.afterFlush(() => resolve(void 0))); - const processMessage = async (msg: IMessage & { ignored?: boolean }, { subscription }: { subscription?: ISubscription }) => { const userId = msg.u?._id; @@ -46,36 +43,37 @@ export async function upsertMessageBulk({ const defaultLimit = parseInt(getConfig('roomListLimit') ?? '50') || 50; +export type RoomHistoryState = { + hasMore: boolean; + hasMoreNext: boolean; + isLoading: boolean; + unreadNotLoaded: number; + firstUnread: IMessage | undefined; + loaded: number | undefined; + oldestTs?: Date; + scroll?: { + scrollHeight: number; + scrollTop: number; + }; +}; + +const roomStateEvent = (rid: IRoom['_id']) => `state:${rid}` as const; + class RoomHistoryManagerClass extends Emitter { private lastRequest?: Date; - private histories: Record< - IRoom['_id'], - { - hasMore: ReactiveVar; - hasMoreNext: ReactiveVar; - isLoading: ReactiveVar; - unreadNotLoaded: ReactiveVar; - firstUnread: ReactiveVar; - loaded: number | undefined; - oldestTs?: Date; - scroll?: { - scrollHeight: number; - scrollTop: number; - }; - } - > = {}; + private histories: Record = {}; private requestsList: string[] = []; - public getRoom(rid: IRoom['_id']) { + public getRoom(rid: IRoom['_id']): RoomHistoryState { if (!this.histories[rid]) { this.histories[rid] = { - hasMore: new ReactiveVar(true), - hasMoreNext: new ReactiveVar(false), - isLoading: new ReactiveVar(false), - unreadNotLoaded: new ReactiveVar(0), - firstUnread: new ReactiveVar(undefined), + hasMore: true, + hasMoreNext: false, + isLoading: false, + unreadNotLoaded: 0, + firstUnread: undefined, loaded: undefined, }; } @@ -83,6 +81,16 @@ class RoomHistoryManagerClass extends Emitter { return this.histories[rid]; } + public updateRoom(rid: IRoom['_id'], patch: Partial): void { + const room = this.getRoom(rid); + Object.assign(room, patch); + this.emit(roomStateEvent(rid), room); + } + + public subscribeToRoom(rid: IRoom['_id'], cb: (state: RoomHistoryState) => void): () => void { + return this.on(roomStateEvent(rid), cb); + } + private async queue(): Promise { return new Promise((resolve) => { const requestId = crypto.randomUUID(); @@ -122,12 +130,12 @@ class RoomHistoryManagerClass extends Emitter { public async getMore(rid: IRoom['_id'], { limit = defaultLimit }: { limit?: number } = {}): Promise { const room = this.getRoom(rid); - if (Tracker.nonreactive(() => room.hasMore.get()) !== true) { + if (room.hasMore !== true) { return; } try { - room.isLoading.set(true); + this.updateRoom(rid, { isLoading: true }); await this.queue(); @@ -155,8 +163,10 @@ class RoomHistoryManagerClass extends Emitter { this.unqueue(); const { messages = [] } = result; - room.unreadNotLoaded.set(result.unreadNotLoaded); - room.firstUnread.set(result.firstUnread); + this.updateRoom(rid, { + unreadNotLoaded: result.unreadNotLoaded, + firstUnread: result.firstUnread, + }); if (messages.length > 0) { room.oldestTs = messages[messages.length - 1].ts; @@ -185,17 +195,16 @@ class RoomHistoryManagerClass extends Emitter { room.loaded += visibleMessages.length; if (messages.length < limit) { - room.hasMore.set(false); + this.updateRoom(rid, { hasMore: false }); } - if (room.hasMore.get() && (visibleMessages.length === 0 || room.loaded < limit)) { + if (room.hasMore && (visibleMessages.length === 0 || room.loaded < limit)) { return this.getMore(rid); } this.emit('loaded-messages'); } finally { - room.isLoading.set(false); - await waitAfterFlush(); + this.updateRoom(rid, { isLoading: false }); } } @@ -218,14 +227,14 @@ class RoomHistoryManagerClass extends Emitter { public async getMoreNext(rid: IRoom['_id'], atBottomRef: MutableRefObject) { const room = this.getRoom(rid); - if (Tracker.nonreactive(() => room.hasMoreNext.get()) !== true) { + if (room.hasMoreNext !== true) { return; } await this.queue(); atBottomRef.current = false; - room.isLoading.set(true); + this.updateRoom(rid, { isLoading: true }); const lastMessage = Messages.state.findFirst( (record) => record.rid === rid && record._hidden !== true, @@ -244,27 +253,25 @@ class RoomHistoryManagerClass extends Emitter { this.emit('loaded-messages'); - room.isLoading.set(false); + this.updateRoom(rid, { isLoading: false }); if (!room.loaded) { room.loaded = 0; } room.loaded += result.messages.length; if (result.messages.length < defaultLimit) { - room.hasMoreNext.set(false); + this.updateRoom(rid, { hasMoreNext: false }); } } this.unqueue(); } public hasMore(rid: IRoom['_id']) { - const room = this.getRoom(rid); - return room.hasMore.get(); + return this.getRoom(rid).hasMore; } public hasMoreNext(rid: IRoom['_id']) { - const room = this.getRoom(rid); - return room.hasMoreNext.get(); + return this.getRoom(rid).hasMoreNext; } public getMoreIfIsEmpty(rid: IRoom['_id']) { @@ -276,8 +283,7 @@ class RoomHistoryManagerClass extends Emitter { } public isLoading(rid: IRoom['_id']) { - const room = this.getRoom(rid); - return room.isLoading.get(); + return this.getRoom(rid).isLoading; } public close(rid: IRoom['_id']) { @@ -288,11 +294,13 @@ class RoomHistoryManagerClass extends Emitter { public clear(rid: IRoom['_id']) { const room = this.getRoom(rid); Messages.state.remove((record) => record.rid === rid); - room.isLoading.set(false); - room.hasMore.set(true); - room.hasMoreNext.set(false); room.oldestTs = undefined; room.loaded = undefined; + this.updateRoom(rid, { + isLoading: false, + hasMore: true, + hasMoreNext: false, + }); } public async getSurroundingMessages(message?: Pick & { ts?: Date }) { @@ -332,18 +340,24 @@ class RoomHistoryManagerClass extends Emitter { await upsertMessageBulk({ msgs: Array.from(result.messages).filter((msg) => msg.t !== 'command'), subscription }); - Tracker.afterFlush(async () => { - this.emit('loaded-messages'); - room.isLoading.set(false); - }); + this.emit('loaded-messages'); + this.updateRoom(message.rid, { isLoading: false }); if (!room.loaded) { room.loaded = 0; } room.loaded += result.messages.length; - room.hasMore.set(result.moreBefore); - room.hasMoreNext.set(result.moreAfter); + this.updateRoom(message.rid, { + hasMore: result.moreBefore, + hasMoreNext: result.moreAfter, + }); } } export const RoomHistoryManager = new RoomHistoryManagerClass(); + +export const useRoomHistoryState = (rid: IRoom['_id'], selector: (state: RoomHistoryState) => T): T => + useSyncExternalStore( + useCallback((onStoreChange) => RoomHistoryManager.subscribeToRoom(rid, onStoreChange), [rid]), + () => selector(RoomHistoryManager.getRoom(rid)), + ); diff --git a/apps/meteor/app/ui/client/lib/recorderjs/AudioEncoder.ts b/apps/meteor/app/ui/client/lib/recorderjs/AudioEncoder.ts index 2360c69e354aa..68665ecede78a 100644 --- a/apps/meteor/app/ui/client/lib/recorderjs/AudioEncoder.ts +++ b/apps/meteor/app/ui/client/lib/recorderjs/AudioEncoder.ts @@ -1,5 +1,4 @@ import { Emitter } from '@rocket.chat/emitter'; -import { Meteor } from 'meteor/meteor'; export class AudioEncoder extends Emitter { private worker: Worker; @@ -9,7 +8,7 @@ export class AudioEncoder extends Emitter { constructor(source: MediaStreamAudioSourceNode, { bufferLen = 4096, numChannels = 1, bitRate = 32 } = {}) { super(); - const workerPath = Meteor.absoluteUrl('workers/mp3-encoder/index.js'); + const workerPath = new URL('workers/mp3-encoder/index.js', __meteor_runtime_config__.ROOT_URL).toString(); this.worker = new Worker(workerPath); this.worker.onmessage = this.handleWorkerMessage; diff --git a/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.spec.ts b/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.spec.ts index f0a4e58f8a6f8..3ef71d274e547 100644 --- a/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.spec.ts +++ b/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.spec.ts @@ -1,18 +1,6 @@ import { VideoRecorder } from './videoRecorder'; import { createDeferredPromise } from '../../../../../tests/mocks/utils/createDeferredMockFn'; -jest.mock('meteor/reactive-var', () => ({ - ReactiveVar: jest.fn().mockImplementation((initialValue) => { - let value = initialValue; - return { - get: jest.fn(() => value), - set: jest.fn((newValue) => { - value = newValue; - }), - }; - }), -})); - describe('VideoRecorder', () => { let mockStream: MediaStream; let mockVideoTrack: MediaStreamTrack; @@ -93,7 +81,7 @@ describe('VideoRecorder', () => { streamDeferred.resolve(mockStream); await jest.runAllTimersAsync(); - expect(VideoRecorder.cameraStarted.get()).toBe(false); + expect(VideoRecorder.cameraStarted).toBe(false); }); it('should handle multiple start/stop cycles', async () => { @@ -115,7 +103,7 @@ describe('VideoRecorder', () => { await jest.runAllTimersAsync(); expect(cb).toHaveBeenCalledWith(true); - expect(VideoRecorder.cameraStarted.get()).toBe(true); + expect(VideoRecorder.cameraStarted).toBe(true); }); it('should invalidate pending callbacks from previous start when new start is called', async () => { @@ -155,19 +143,19 @@ describe('VideoRecorder', () => { await jest.runAllTimersAsync(); expect(cb).toHaveBeenCalledWith(true); - expect(VideoRecorder.cameraStarted.get()).toBe(true); + expect(VideoRecorder.cameraStarted).toBe(true); }); it('should stop camera tracks', () => { (VideoRecorder as any).stream = mockStream; (VideoRecorder as any).started = true; - VideoRecorder.cameraStarted.set(true); + (VideoRecorder as any)._cameraStarted = true; VideoRecorder.stop(); expect(mockVideoTrack.stop).toHaveBeenCalled(); expect(mockAudioTrack.stop).toHaveBeenCalled(); - expect(VideoRecorder.cameraStarted.get()).toBe(false); + expect(VideoRecorder.cameraStarted).toBe(false); }); it('should return supported mime types', () => { diff --git a/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.ts b/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.ts index 0557f4706cd8b..53cd04eaa5be6 100644 --- a/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.ts +++ b/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.ts @@ -1,13 +1,16 @@ -import { ReactiveVar } from 'meteor/reactive-var'; +import { Emitter } from '@rocket.chat/emitter'; +import { useCallback, useSyncExternalStore } from 'react'; -class VideoRecorder { - public cameraStarted = new ReactiveVar(false); +type VideoRecorderEvents = { + cameraStartedChange: boolean; +}; - private started = false; +class VideoRecorder extends Emitter { + private _cameraStarted = false; - private recording = new ReactiveVar(false); + private started = false; - private recordingAvailable = new ReactiveVar(false); + private recordingAvailable = false; private videoel: HTMLVideoElement | undefined; @@ -19,6 +22,18 @@ class VideoRecorder { private sessionId = 0; + public get cameraStarted(): boolean { + return this._cameraStarted; + } + + private setCameraStarted(value: boolean) { + if (this._cameraStarted === value) { + return; + } + this._cameraStarted = value; + this.emit('cameraStartedChange', value); + } + public getSupportedMimeTypes() { if (window.MediaRecorder.isTypeSupported('video/webm')) { return 'video/webm; codecs=vp8,opus'; @@ -71,12 +86,11 @@ class VideoRecorder { this.mediaRecorder = new MediaRecorder(this.stream, { mimeType: this.getSupportedMimeTypes() }); this.mediaRecorder.ondataavailable = (blobev) => { this.chunks.push(blobev.data); - if (!this.recordingAvailable.get()) { - return this.recordingAvailable.set(true); + if (!this.recordingAvailable) { + this.recordingAvailable = true; } }; this.mediaRecorder.start(); - return this.recording.set(true); } private stopStreamTracks(stream: MediaStream) { @@ -109,7 +123,7 @@ class VideoRecorder { } this.started = true; - return this.cameraStarted.set(true); + this.setCameraStarted(true); } public stop(cb?: (blob: Blob) => void) { @@ -128,8 +142,8 @@ class VideoRecorder { const wasStarted = this.started; this.started = false; - this.cameraStarted.set(false); - this.recordingAvailable.set(false); + this.setCameraStarted(false); + this.recordingAvailable = false; if (cb && this.chunks && wasStarted) { const blob = new Blob(this.chunks); @@ -141,12 +155,11 @@ class VideoRecorder { } public stopRecording() { - if (!this.started || !this.recording || !this.mediaRecorder) { + if (!this.started || !this.mediaRecorder) { return; } this.mediaRecorder.stop(); - this.recording.set(false); delete this.mediaRecorder; } } @@ -154,3 +167,9 @@ class VideoRecorder { const instance = new VideoRecorder(); export { instance as VideoRecorder }; + +export const useVideoRecorderCameraStarted = (): boolean => + useSyncExternalStore( + useCallback((onStoreChange) => instance.on('cameraStartedChange', onStoreChange), []), + () => instance.cameraStarted, + ); diff --git a/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx b/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx index e40afaf35f438..e3a6f13271d01 100644 --- a/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx +++ b/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx @@ -1,7 +1,7 @@ import type { IUser } from '@rocket.chat/core-typings'; import { Box } from '@rocket.chat/fuselage'; +import { Random } from '@rocket.chat/random'; import { GenericModal } from '@rocket.chat/ui-client'; -import { Tracker } from 'meteor/tracker'; import type { ReactElement } from 'react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -34,19 +34,13 @@ const GameCenterInvitePlayersModal = ({ game, onClose }: IGameCenterInvitePlayer roomCoordinator.openRouteLink(result.t, result); - Tracker.autorun((c) => { - if (openedRoom !== result.rid) { - return; - } - + if (openedRoom === result.rid) { callWithErrorHandling('sendMessage', { _id: Random.id(), rid: result.rid, msg: t('Apps_Game_Center_Play_Game_Together', { name }), }); - - c.stop(); - }); + } onClose(); } catch (err) { console.warn(err); diff --git a/apps/meteor/client/components/message/toolbar/items/actions/ReactionMessageAction.tsx b/apps/meteor/client/components/message/toolbar/items/actions/ReactionMessageAction.tsx index 6ce3dc1be51d6..f206df360e76a 100644 --- a/apps/meteor/client/components/message/toolbar/items/actions/ReactionMessageAction.tsx +++ b/apps/meteor/client/components/message/toolbar/items/actions/ReactionMessageAction.tsx @@ -6,12 +6,11 @@ import { type IRoom, type ISubscription, } from '@rocket.chat/core-typings'; -import { useUser, useEndpoint } from '@rocket.chat/ui-contexts'; -import { useCallback } from 'react'; +import { useUser, useEndpoint, usePermission } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useEmojiPickerData } from '../../../../../contexts/EmojiPickerContext'; -import { useReactiveValue } from '../../../../../hooks/useReactiveValue'; import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator'; import EmojiElement from '../../../../../views/composer/EmojiPicker/EmojiElement'; import { useChat } from '../../../../../views/room/contexts/ChatContext'; @@ -33,8 +32,10 @@ const ReactionMessageAction = ({ message, room, subscription }: ReactionMessageA const isFederated = room && isRoomFederated(room); const isFederationBlocked = isFederated && !isRoomNativeFederated(room); - const enabled = useReactiveValue( - useCallback(() => { + // depend on post-readonly so readOnly re-evaluates when the permission toggles at runtime. + const postReadOnly = usePermission('post-readonly', room._id); + const enabled = useMemo( + () => { if (isFederationBlocked) { return false; } @@ -48,7 +49,9 @@ const ReactionMessageAction = ({ message, room, subscription }: ReactionMessageA } return true; - }, [chat, room, subscription, message.private, user, isFederationBlocked]), + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [chat, room, subscription, message.private, user, isFederationBlocked, postReadOnly], ); if (!enabled) { diff --git a/apps/meteor/client/definitions/IOAuthProvider.ts b/apps/meteor/client/definitions/IOAuthProvider.ts index 00bc3be2b0408..291f2b1ab68cd 100644 --- a/apps/meteor/client/definitions/IOAuthProvider.ts +++ b/apps/meteor/client/definitions/IOAuthProvider.ts @@ -1,9 +1,22 @@ -import type { Meteor } from 'meteor/meteor'; +// Shape mirrors @types/meteor's Meteor.LoginWithExternalServiceOptions — +// the boxed Boolean type is intentional so this stays structurally +// compatible with existing oauth.ts call sites still typed against +// Meteor.LoginWithExternalServiceOptions. +/* eslint-disable @typescript-eslint/no-wrapper-object-types */ +export type LoginWithExternalServiceOptions = { + requestPermissions?: readonly string[] | undefined; + requestOfflineToken?: Boolean | undefined; + forceApprovalPrompt?: Boolean | undefined; + redirectUrl?: string | undefined; + loginHint?: string | undefined; + loginStyle?: string | undefined; +}; +/* eslint-enable @typescript-eslint/no-wrapper-object-types */ export interface IOAuthProvider { readonly name: string; requestCredential( - options: Meteor.LoginWithExternalServiceOptions | undefined, + options: LoginWithExternalServiceOptions | undefined, credentialRequestCompleteCallback: (credentialTokenOrError?: string | Error) => void, ): void; } diff --git a/apps/meteor/client/importPackages.ts b/apps/meteor/client/importPackages.ts index 6ec3976093bc5..80901839f7ed5 100644 --- a/apps/meteor/client/importPackages.ts +++ b/apps/meteor/client/importPackages.ts @@ -23,4 +23,3 @@ import '../app/slashcommands-unarchiveroom/client'; import '../app/wordpress/client'; import '../app/utils/client'; import '../app/ui-utils/client'; -import '../app/reactions/client'; diff --git a/apps/meteor/client/lib/chats/flows/processSetReaction.ts b/apps/meteor/client/lib/chats/flows/processSetReaction.ts index 1478320ef3e0b..74e28efa937d7 100644 --- a/apps/meteor/client/lib/chats/flows/processSetReaction.ts +++ b/apps/meteor/client/lib/chats/flows/processSetReaction.ts @@ -1,6 +1,7 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { emoji } from '../../../../app/emoji/client'; +import { runOptimisticSetReaction } from '../../../../app/reactions/client/methods/setReaction'; import { callWithErrorHandling } from '../../utils/callWithErrorHandling'; import type { ChatAPI } from '../ChatAPI'; @@ -22,6 +23,7 @@ export const processSetReaction = async (chat: ChatAPI, { msg }: Pick 0) { + if (RoomHistoryManager.getRoom(this.rid).unreadNotLoaded > 0) { return; } @@ -160,7 +160,7 @@ export class ReadStateManager extends Emitter { } return sdk.rest.post('/v1/subscriptions.read', { rid: this.rid }).then(() => { - RoomHistoryManager.getRoom(this.rid).unreadNotLoaded.set(0); + RoomHistoryManager.updateRoom(this.rid, { unreadNotLoaded: 0 }); }); } } diff --git a/apps/meteor/client/lib/rooms/roomCoordinator.tsx b/apps/meteor/client/lib/rooms/roomCoordinator.tsx index 54b0cf2145f5a..64a68752eef8e 100644 --- a/apps/meteor/client/lib/rooms/roomCoordinator.tsx +++ b/apps/meteor/client/lib/rooms/roomCoordinator.tsx @@ -1,6 +1,5 @@ import type { IRoom, RoomType, IUser, AtLeast, ValueOf, ISubscription } from '@rocket.chat/core-typings'; import type { RouteName } from '@rocket.chat/ui-contexts'; -import { Meteor } from 'meteor/meteor'; import { hasPermission } from '../../../app/authorization/client'; import type { @@ -203,12 +202,13 @@ class RoomCoordinatorClient extends RoomCoordinator { return false; } - return Meteor.absoluteUrl( + return new URL( router.buildRoutePath({ name: config.route.name, params: routeData, }), - ); + __meteor_runtime_config__.ROOT_URL, + ).toString(); } public isRouteNameKnown(routeName: string): boolean { diff --git a/apps/meteor/client/lib/utils/getConfig.ts b/apps/meteor/client/lib/utils/getConfig.ts index 0c78cf2555a19..1c4c1ff6dc194 100644 --- a/apps/meteor/client/lib/utils/getConfig.ts +++ b/apps/meteor/client/lib/utils/getConfig.ts @@ -1,9 +1,15 @@ -import { Meteor } from 'meteor/meteor'; +const readLocalStorage = (key: string): string | null => { + try { + return window.localStorage.getItem(key); + } catch { + return null; + } +}; export const getConfig = (key: string, defaultValue?: T): string | null | T => { const searchParams = new URLSearchParams(window.location.search); - const storedItem = searchParams.get(key) || Meteor._localStorage.getItem(`rc-config-${key}`); + const storedItem = searchParams.get(key) || readLocalStorage(`rc-config-${key}`); return storedItem ?? defaultValue ?? null; }; diff --git a/apps/meteor/client/lib/utils/timeAgo.ts b/apps/meteor/client/lib/utils/timeAgo.ts index 2826a218d98f8..0889a3e6f0439 100644 --- a/apps/meteor/client/lib/utils/timeAgo.ts +++ b/apps/meteor/client/lib/utils/timeAgo.ts @@ -1,5 +1,3 @@ -import { Tracker } from 'meteor/tracker'; - import { getUserPreference } from '../../../app/utils/client'; import { t } from '../../../app/utils/lib/i18n'; import { settings } from '../settings'; @@ -9,7 +7,7 @@ import { formatTimeAgo } from './dateFormat'; const dayFormat = ['h:mm A', 'H:mm']; export const timeAgo = (date: string | Date | number) => { - const clockMode = Tracker.nonreactive(() => getUserPreference(getUserId(), 'clockMode', false) as number | boolean); + const clockMode = getUserPreference(getUserId(), 'clockMode', false) as number | boolean; const messageTimeFormat = settings.peek('Message_TimeFormat'); const sameDay = (typeof clockMode === 'number' ? dayFormat[clockMode - 1] : undefined) || messageTimeFormat; diff --git a/apps/meteor/client/meteor/overrides/oauthRedirectUri.ts b/apps/meteor/client/meteor/overrides/oauthRedirectUri.ts index 23f53acfe1d77..c9900ff69a417 100644 --- a/apps/meteor/client/meteor/overrides/oauthRedirectUri.ts +++ b/apps/meteor/client/meteor/overrides/oauthRedirectUri.ts @@ -1,3 +1,6 @@ +// TODO: remove this override together with the Meteor auth/DDP layer — it +// monkey-patches meteor/oauth for backwards compatibility with pre-2.3 +// clients. Once Meteor's OAuth stack is gone, this file goes with it. import { OAuth } from 'meteor/oauth'; declare module 'meteor/oauth' { diff --git a/apps/meteor/client/meteor/overrides/userAndUsers.ts b/apps/meteor/client/meteor/overrides/userAndUsers.ts index aa240aeb00042..ddcaef32a34c3 100644 --- a/apps/meteor/client/meteor/overrides/userAndUsers.ts +++ b/apps/meteor/client/meteor/overrides/userAndUsers.ts @@ -1,3 +1,10 @@ +// TODO: remove this override together with the Meteor webapp/DDP/Accounts layer — +// it bridges Meteor's Accounts.connection userId reactive source to the Zustand +// userIdStore and keeps Meteor.userId / Meteor.user / Meteor.users pointing at +// the local Zustand collection. Naively replacing Tracker.autorun with +// Accounts.onLogin/onLogout breaks callers (e.g. UserProvider's logoutCleanUp) +// that read userIdStore from within other onLogout callbacks and depend on the +// async Tracker.flush ordering to still see a truthy uid. import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; diff --git a/apps/meteor/client/meteor/startup/absoluteUrl.ts b/apps/meteor/client/meteor/startup/absoluteUrl.ts index 8e2578cad8b7b..3566e8f41a443 100644 --- a/apps/meteor/client/meteor/startup/absoluteUrl.ts +++ b/apps/meteor/client/meteor/startup/absoluteUrl.ts @@ -1,3 +1,6 @@ +// TODO: remove this file together with the Meteor webapp/DDP layer — it only +// exists to patch Meteor.absoluteUrl's rootUrl, which no longer has a caller +// once DDP is gone. import { Meteor } from 'meteor/meteor'; import { baseURI } from '../../lib/baseURI'; diff --git a/apps/meteor/client/providers/SessionProvider.tsx b/apps/meteor/client/providers/SessionProvider.tsx index 00d33729bad9a..0508d172fd0c9 100644 --- a/apps/meteor/client/providers/SessionProvider.tsx +++ b/apps/meteor/client/providers/SessionProvider.tsx @@ -1,13 +1,21 @@ +import { Emitter } from '@rocket.chat/emitter'; import { SessionContext } from '@rocket.chat/ui-contexts'; -import { Session } from 'meteor/session'; import type { ReactNode } from 'react'; -import { createReactiveSubscriptionFactory } from '../lib/createReactiveSubscriptionFactory'; +const store = new Map(); +const emitter = new Emitter(); const contextValue = { - query: createReactiveSubscriptionFactory((name) => Session.get(name)), + query: (name: string): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => unknown] => [ + (onStoreChange) => emitter.on(name, onStoreChange), + () => store.get(name), + ], dispatch: (name: string, value: unknown): void => { - Session.set(name, value); + if (store.has(name) && store.get(name) === value) { + return; + } + store.set(name, value); + emitter.emit(name); }, }; diff --git a/apps/meteor/client/startup/incomingMessages.ts b/apps/meteor/client/startup/incomingMessages.ts index ac840725e9bf4..1301dee578672 100644 --- a/apps/meteor/client/startup/incomingMessages.ts +++ b/apps/meteor/client/startup/incomingMessages.ts @@ -1,35 +1,32 @@ import type { IMessage } from '@rocket.chat/core-typings'; -import { Meteor } from 'meteor/meteor'; import { sdk } from '../../app/utils/client/lib/SDKClient'; import { onLoggedIn } from '../lib/loggedIn'; import { getUserId } from '../lib/user'; import { Messages } from '../stores'; -Meteor.startup(() => { - onLoggedIn(() => { - // Only event I found triggers this is from ephemeral messages - // Other types of messages come from another stream - return sdk.stream('notify-user', [`${getUserId()}/message`], (msg: IMessage) => { - msg.u = msg.u || { username: 'rocket.cat' }; - msg.private = true; +onLoggedIn(() => { + // Only event I found triggers this is from ephemeral messages + // Other types of messages come from another stream + return sdk.stream('notify-user', [`${getUserId()}/message`], (msg: IMessage) => { + msg.u = msg.u || { username: 'rocket.cat' }; + msg.private = true; - return Messages.state.store(msg); - }); + return Messages.state.store(msg); }); +}); - onLoggedIn(() => { - return sdk.stream('notify-user', [`${getUserId()}/subscriptions-changed`], (_action, sub) => { +onLoggedIn(() => { + return sdk.stream('notify-user', [`${getUserId()}/subscriptions-changed`], (_action, sub) => { + Messages.state.update( + (record) => record.rid === sub.rid && ('ignored' in sub && sub.ignored ? !sub.ignored.includes(record.u._id) : 'ignored' in record), + ({ ignored: _, ...record }) => record, + ); + if ('ignored' in sub && sub.ignored) { Messages.state.update( - (record) => record.rid === sub.rid && ('ignored' in sub && sub.ignored ? !sub.ignored.includes(record.u._id) : 'ignored' in record), - ({ ignored: _, ...record }) => record, + (record) => record.rid === sub.rid && record.t !== 'command' && (sub.ignored?.includes(record.u._id) ?? false), + (record) => ({ ...record, ignored: true }), ); - if ('ignored' in sub && sub.ignored) { - Messages.state.update( - (record) => record.rid === sub.rid && record.t !== 'command' && (sub.ignored?.includes(record.u._id) ?? false), - (record) => ({ ...record, ignored: true }), - ); - } - }); + } }); }); diff --git a/apps/meteor/client/startup/roles.ts b/apps/meteor/client/startup/roles.ts index de1121d25d69d..8120ef06a518c 100644 --- a/apps/meteor/client/startup/roles.ts +++ b/apps/meteor/client/startup/roles.ts @@ -1,40 +1,40 @@ import type { IRole } from '@rocket.chat/core-typings'; -import { Meteor } from 'meteor/meteor'; -import { Tracker } from 'meteor/tracker'; import { sdk } from '../../app/utils/client/lib/SDKClient'; import { onLoggedIn } from '../lib/loggedIn'; -import { watchUserId } from '../meteor/user'; +import { userIdStore } from '../lib/user'; import { Roles } from '../stores'; -Meteor.startup(() => { - onLoggedIn(async () => { - const { roles } = await sdk.rest.get('/v1/roles.list'); - // if a role is checked before this collection is populated, it will return undefined - Roles.state.replaceAll(roles); - }); - - type ClientAction = 'inserted' | 'updated' | 'removed' | 'changed'; +onLoggedIn(async () => { + const { roles } = await sdk.rest.get('/v1/roles.list'); + // if a role is checked before this collection is populated, it will return undefined + Roles.state.replaceAll(roles); +}); - const events: Record void) | undefined> = { - changed: (role) => { - delete role.type; - Roles.state.store(role); - }, - removed: (role) => { - Roles.state.delete(role._id); - }, - }; +type ClientAction = 'inserted' | 'updated' | 'removed' | 'changed'; - Tracker.autorun((c) => { - if (!watchUserId()) return; +const events: Record void) | undefined> = { + changed: (role) => { + delete role.type; + Roles.state.store(role); + }, + removed: (role) => { + Roles.state.delete(role._id); + }, +}; - Tracker.afterFlush(() => { - sdk.stream('roles', ['roles'], (role) => { - events[role.type]?.(role); - }); - }); +const subscribeToRolesStream = () => { + sdk.stream('roles', ['roles'], (role) => { + events[role.type]?.(role); + }); +}; - c.stop(); +if (userIdStore.getState()) { + subscribeToRolesStream(); +} else { + const unsubscribe = userIdStore.subscribe((uid) => { + if (!uid) return; + unsubscribe(); + subscribeToRolesStream(); }); -}); +} diff --git a/apps/meteor/client/startup/startup.ts b/apps/meteor/client/startup/startup.ts index e9568ae7e2dea..10bcbd8171cf9 100644 --- a/apps/meteor/client/startup/startup.ts +++ b/apps/meteor/client/startup/startup.ts @@ -1,43 +1,56 @@ import type { UserStatus } from '@rocket.chat/core-typings'; -import { Meteor } from 'meteor/meteor'; -import { Tracker } from 'meteor/tracker'; +import { Accounts } from 'meteor/accounts-base'; import 'highlight.js/styles/github.css'; import { sdk } from '../../app/utils/client/lib/SDKClient'; -import { synchronizeUserData, removeLocalUserData } from '../lib/userData'; +import { onLoggedIn } from '../lib/loggedIn'; +import { userIdStore } from '../lib/user'; +import { removeLocalUserData, synchronizeUserData } from '../lib/userData'; import { fireGlobalEvent } from '../lib/utils/fireGlobalEvent'; -import { watchUserId } from '../meteor/user'; - -Meteor.startup(() => { - let status: UserStatus | undefined = undefined; - Tracker.autorun(async () => { - const uid = watchUserId(); - if (!uid) { - removeLocalUserData(); - return; - } - - if (!Meteor.status().connected) { - return; - } - - if (Meteor.loggingIn()) { - return; - } - - const user = await synchronizeUserData(uid); - if (!user) { - return; - } - - const utcOffset = -new Date().getTimezoneOffset() / 60; - if (user.utcOffset !== utcOffset) { - sdk.call('userSetUtcOffset', utcOffset); - } - - if (user.status !== status) { - status = user.status; - fireGlobalEvent('status-changed', status); - } - }); +import { Users } from '../stores'; + +let status: UserStatus | undefined = undefined; + +const emitStatusChange = (next: UserStatus | undefined) => { + if (next === status) return; + status = next; + fireGlobalEvent('status-changed', status); +}; + +onLoggedIn(async () => { + const uid = userIdStore.getState(); + if (!uid) return; + + const user = await synchronizeUserData(uid); + if (!user) return; + + const utcOffset = -new Date().getTimezoneOffset() / 60; + if (user.utcOffset !== utcOffset) { + sdk.call('userSetUtcOffset', utcOffset); + } + + emitStatusChange(user.status); }); + +Users.use.subscribe(() => { + const uid = userIdStore.getState(); + if (!uid) return; + const user = Users.use.getState().get(uid); + if (!user) return; + emitStatusChange(user.status); +}); + +Accounts.onLogout(() => { + removeLocalUserData(); + status = undefined; +}); + +// Session-resume failure (expired stored token on page load): Meteor has already +// cleared Meteor.loginToken before this module runs, userId stays null, and no +// Accounts.onLogout callback fires for this scenario. Detect via the combination +// of missing token and missing uid at module init and clean up residual keys +// (e.g. E2EE public_key / private_key). Do NOT subscribe to userIdStore for this — +// the valid-session resume path is async and would clobber a valid token mid-flight. +if (!userIdStore.getState() && localStorage.getItem('Meteor.loginToken') === null) { + removeLocalUserData(); +} diff --git a/apps/meteor/client/startup/streamMessage/autotranslate.ts b/apps/meteor/client/startup/streamMessage/autotranslate.ts index c24b4e6dc310e..98913a16191db 100644 --- a/apps/meteor/client/startup/streamMessage/autotranslate.ts +++ b/apps/meteor/client/startup/streamMessage/autotranslate.ts @@ -1,23 +1,28 @@ import { clientCallbacks } from '@rocket.chat/ui-client'; -import { Meteor } from 'meteor/meteor'; -import { Tracker } from 'meteor/tracker'; import { hasPermission } from '../../../app/authorization/client'; +import { PermissionsCachedStore } from '../../cachedStores'; import { settings } from '../../lib/settings'; +import { Users } from '../../stores'; -Meteor.startup(() => { - Tracker.autorun(() => { - const isEnabled = settings.watch('AutoTranslate_Enabled') && hasPermission('auto-translate'); +const STREAM_HANDLER_ID = 'autotranslate-stream'; - if (!isEnabled) { - clientCallbacks.remove('streamMessage', 'autotranslate-stream'); - return; - } +const applyAutoTranslateStreamHandler = () => { + const isEnabled = settings.peek('AutoTranslate_Enabled') && hasPermission('auto-translate'); - import('../../../app/autotranslate/client').then(({ createAutoTranslateMessageStreamHandler }) => { - const streamMessage = createAutoTranslateMessageStreamHandler(); - clientCallbacks.remove('streamMessage', 'autotranslate-stream'); - clientCallbacks.add('streamMessage', streamMessage, clientCallbacks.priority.HIGH - 3, 'autotranslate-stream'); - }); + if (!isEnabled) { + clientCallbacks.remove('streamMessage', STREAM_HANDLER_ID); + return; + } + + void import('../../../app/autotranslate/client').then(({ createAutoTranslateMessageStreamHandler }) => { + const streamMessage = createAutoTranslateMessageStreamHandler(); + clientCallbacks.remove('streamMessage', STREAM_HANDLER_ID); + clientCallbacks.add('streamMessage', streamMessage, clientCallbacks.priority.HIGH - 3, STREAM_HANDLER_ID); }); -}); +}; + +applyAutoTranslateStreamHandler(); +settings.observe('AutoTranslate_Enabled', applyAutoTranslateStreamHandler); +PermissionsCachedStore.useReady.subscribe(applyAutoTranslateStreamHandler); +Users.use.subscribe(applyAutoTranslateStreamHandler); diff --git a/apps/meteor/client/views/composer/VideoMessageRecorder/VideoMessageRecorder.tsx b/apps/meteor/client/views/composer/VideoMessageRecorder/VideoMessageRecorder.tsx index 5952c35da15e3..6d59550e0ec0d 100644 --- a/apps/meteor/client/views/composer/VideoMessageRecorder/VideoMessageRecorder.tsx +++ b/apps/meteor/client/views/composer/VideoMessageRecorder/VideoMessageRecorder.tsx @@ -7,7 +7,7 @@ import type { AllHTMLAttributes, RefObject } from 'react'; import { useRef, useEffect, useState } from 'react'; import { UserAction, USER_ACTIVITIES } from '../../../../app/ui/client/lib/UserAction'; -import { VideoRecorder } from '../../../../app/ui/client/lib/recorderjs/videoRecorder'; +import { VideoRecorder, useVideoRecorderCameraStarted } from '../../../../app/ui/client/lib/recorderjs/videoRecorder'; import { useChat } from '../../room/contexts/ChatContext'; type VideoMessageRecorderProps = { @@ -45,7 +45,8 @@ const VideoMessageRecorder = ({ rid, tmid, reference }: VideoMessageRecorderProp const [recordingState, setRecordingState] = useState<'idle' | 'loading' | 'recording'>('idle'); const [recordingInterval, setRecordingInterval] = useState | null>(null); const isRecording = recordingState === 'recording'; - const sendButtonDisabled = !(VideoRecorder.cameraStarted.get() && !(recordingState === 'recording')); + const cameraStarted = useVideoRecorderCameraStarted(); + const sendButtonDisabled = !(cameraStarted && !isRecording); const chat = useChat(); diff --git a/apps/meteor/client/views/omnichannel/businessHours/useIsSingleBusinessHours.ts b/apps/meteor/client/views/omnichannel/businessHours/useIsSingleBusinessHours.ts index 67c7877ce34df..29bc09cbeca6d 100644 --- a/apps/meteor/client/views/omnichannel/businessHours/useIsSingleBusinessHours.ts +++ b/apps/meteor/client/views/omnichannel/businessHours/useIsSingleBusinessHours.ts @@ -1,7 +1,5 @@ -import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; - import { businessHourManager } from '../../../../app/livechat/client/views/app/business-hours/BusinessHours'; -import { useReactiveValue } from '../../../hooks/useReactiveValue'; -export const useIsSingleBusinessHours = () => - useReactiveValue(useEffectEvent(() => businessHourManager.getTemplate())) === 'livechatBusinessHoursForm'; +// businessHourManager holds a single behavior instance with no reactive store — the previous +// useReactiveValue wrapper never actually re-ran, so this is a plain read. +export const useIsSingleBusinessHours = () => businessHourManager.getTemplate() === 'livechatBusinessHoursForm'; diff --git a/apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx b/apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx index 68635721f2f52..cd95b23156a44 100644 --- a/apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx +++ b/apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx @@ -1,4 +1,3 @@ -import { Accounts } from 'meteor/accounts-base'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -16,7 +15,7 @@ const RoomE2EESetup = () => { const e2eRoomState = useE2EERoomState(room._id); const { t } = useTranslation(); - const randomPassword = Accounts.storageLocation.getItem('e2e.randomPassword'); + const randomPassword = localStorage.getItem('e2e.randomPassword'); const onSavePassword = useCallback(() => { if (!randomPassword) { diff --git a/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts b/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts index 0a5ab4c11ab91..c4aedcadb940f 100644 --- a/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts +++ b/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts @@ -1,10 +1,9 @@ import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; -import { useSetting, useTranslation, useUser } from '@rocket.chat/ui-contexts'; +import { usePermission, useSetting, useTranslation, useUser } from '@rocket.chat/ui-contexts'; import type { DragEvent, ReactNode } from 'react'; -import { useCallback, useMemo, useSyncExternalStore } from 'react'; +import { useMemo, useSyncExternalStore } from 'react'; import { useDropTarget } from './useDropTarget'; -import { useReactiveValue } from '../../../../hooks/useReactiveValue'; import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator'; import { useIsRoomOverMacLimit } from '../../../omnichannel/hooks/useIsRoomOverMacLimit'; import { useChat } from '../../contexts/ChatContext'; @@ -30,8 +29,11 @@ export const useFileUploadDropTarget = (): readonly [ const fileUploadEnabled = useSetting('FileUpload_Enabled', true); const user = useUser(); - const fileUploadAllowedForUser = useReactiveValue( - useCallback(() => !roomCoordinator.readOnly(room, { username: user?.username }), [room, user?.username]), + const postReadOnly = usePermission('post-readonly', room._id); + const fileUploadAllowedForUser = useMemo( + () => !roomCoordinator.readOnly(room, { username: user?.username }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [room, user?.username, postReadOnly], ); const chat = useChat(); diff --git a/apps/meteor/client/views/room/body/hooks/useUnreadMessages.ts b/apps/meteor/client/views/room/body/hooks/useUnreadMessages.ts index b30a78e3f9994..d818104ccbc47 100644 --- a/apps/meteor/client/views/room/body/hooks/useUnreadMessages.ts +++ b/apps/meteor/client/views/room/body/hooks/useUnreadMessages.ts @@ -1,12 +1,10 @@ import type { IRoom, ISubscription } from '@rocket.chat/core-typings'; import { useRouter } from '@rocket.chat/ui-contexts'; -import { Tracker } from 'meteor/tracker'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { RoomHistoryManager } from '../../../../../app/ui-utils/client'; +import { RoomHistoryManager, useRoomHistoryState } from '../../../../../app/ui-utils/client/lib/RoomHistoryManager'; import { withDebouncing, withThrottling } from '../../../../../lib/utils/highOrderFunctions'; -import { useReactiveValue } from '../../../../hooks/useReactiveValue'; import { useOpenedRoomUnreadSince } from '../../../../lib/RoomManager'; import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator'; import { setMessageJumpQueryStringParameter } from '../../../../lib/utils/setMessageJumpQueryStringParameter'; @@ -19,7 +17,7 @@ interface IUnreadMessages { } const useUnreadMessages = (room: IRoom): readonly [data: IUnreadMessages | undefined, setUnreadCount: Dispatch>] => { - const notLoadedCount = useReactiveValue(useCallback(() => RoomHistoryManager.getRoom(room._id).unreadNotLoaded.get(), [room._id])); + const notLoadedCount = useRoomHistoryState(room._id, (state) => state.unreadNotLoaded); const [loadedCount, setLoadedCount] = useState(0); const count = useMemo(() => notLoadedCount + loadedCount, [notLoadedCount, loadedCount]); @@ -64,7 +62,7 @@ export const useHandleUnread = ( const handleUnreadBarJumpToButtonClick = useCallback(() => { const rid = room._id; const { firstUnread } = RoomHistoryManager.getRoom(rid); - let message = firstUnread?.get(); + let message = firstUnread; if (!message) { message = findFirstMessage( (record) => record.rid === rid && record.ts.getTime() > (unread?.since.getTime() ?? -Infinity), @@ -169,7 +167,7 @@ export const useHandleUnread = ( wrapper.addEventListener( 'scroll', withThrottling({ wait: 300 })(() => { - Tracker.afterFlush(() => { + queueMicrotask(() => { const lastInvisibleMessageOnScreen = getElementFromPoint(0) || getElementFromPoint(20) || getElementFromPoint(40); if (!lastInvisibleMessageOnScreen) { diff --git a/apps/meteor/client/views/room/composer/hooks/useMessageComposerIsReadOnly.ts b/apps/meteor/client/views/room/composer/hooks/useMessageComposerIsReadOnly.ts index 974b5b16f539a..9625f8bc389c6 100644 --- a/apps/meteor/client/views/room/composer/hooks/useMessageComposerIsReadOnly.ts +++ b/apps/meteor/client/views/room/composer/hooks/useMessageComposerIsReadOnly.ts @@ -1,11 +1,14 @@ import type { IRoom } from '@rocket.chat/core-typings'; -import { useUser } from '@rocket.chat/ui-contexts'; -import { useCallback } from 'react'; +import { usePermission, useUser } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; -import { useReactiveValue } from '../../../../hooks/useReactiveValue'; import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator'; export const useMessageComposerIsReadOnly = (room: IRoom): boolean => { const user = useUser(); - return useReactiveValue(useCallback(() => roomCoordinator.readOnly(room, user), [room, user])); + // depend on post-readonly so this re-runs when the permission is granted/revoked at runtime; + // roomCoordinator.readOnly calls hasPermission internally and returns the up-to-date value. + const postReadOnly = usePermission('post-readonly', room._id); + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(() => roomCoordinator.readOnly(room, user), [room, user, postReadOnly]); }; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx index c653b0a132e55..20b6f94ebcc4e 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx @@ -28,10 +28,10 @@ import { formattingButtons } from '../../../../../app/ui-message/client/messageB import { getImageExtensionFromMime } from '../../../../../lib/getImageExtensionFromMime'; import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime'; import { useIsFederationEnabled } from '../../../../hooks/useIsFederationEnabled'; -import { useReactiveValue } from '../../../../hooks/useReactiveValue'; import type { ComposerAPI } from '../../../../lib/chats/ChatAPI'; import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator'; import { keyCodes } from '../../../../lib/utils/keyCodes'; +import { Subscriptions } from '../../../../stores'; import AudioMessageRecorder from '../../../composer/AudioMessageRecorder'; import VideoMessageRecorder from '../../../composer/VideoMessageRecorder'; import { useFileUpload } from '../../body/hooks/useFileUpload'; @@ -293,27 +293,28 @@ const MessageBox = ({ const federationMatrixEnabled = useIsFederationEnabled(); - const canSend = useReactiveValue( - useCallback(() => { - if (!room.t) { - return false; - } + // canSendMessage directives read from the Subscriptions store, so subscribe to it to re-run on changes + // (e.g. user joins/leaves the room). room and federationMatrixEnabled are already React-reactive. + const subscribeSubscriptions = useCallback((onStoreChange: () => void) => Subscriptions.use.subscribe(onStoreChange), []); + const canSend = useSyncExternalStore(subscribeSubscriptions, () => { + if (!room.t) { + return false; + } + + if (!roomCoordinator.getRoomDirectives(room.t).canSendMessage(room)) { + return false; + } - if (!roomCoordinator.getRoomDirectives(room.t).canSendMessage(room)) { + if (isRoomFederated(room)) { + // we are dropping the non native federation for now + if (!isRoomNativeFederated(room)) { return false; } - if (isRoomFederated(room)) { - // we are dropping the non native federation for now - if (!isRoomNativeFederated(room)) { - return false; - } - - return federationMatrixEnabled; - } - return true; - }, [room, federationMatrixEnabled]), - ); + return federationMatrixEnabled; + } + return true; + }); const sizes = useContentBoxSize(textareaRef); diff --git a/apps/meteor/client/views/room/providers/RoomProvider.tsx b/apps/meteor/client/views/room/providers/RoomProvider.tsx index 78c76e553779a..d4fb329c54885 100644 --- a/apps/meteor/client/views/room/providers/RoomProvider.tsx +++ b/apps/meteor/client/views/room/providers/RoomProvider.tsx @@ -1,6 +1,6 @@ import type { IRoom } from '@rocket.chat/core-typings'; import type { ReactNode, ContextType, ReactElement } from 'react'; -import { useMemo, memo, useEffect, useCallback } from 'react'; +import { useMemo, memo, useEffect } from 'react'; import ComposerPopupProvider from './ComposerPopupProvider'; import RoomToolboxProvider from './RoomToolboxProvider'; @@ -8,10 +8,9 @@ import UserCardProvider from './UserCardProvider'; import { useRedirectOnSettingsChanged } from './hooks/useRedirectOnSettingsChanged'; import { useUsersNameChanged } from './hooks/useUsersNameChanged'; import { UserAction } from '../../../../app/ui/client/lib/UserAction'; -import { RoomHistoryManager } from '../../../../app/ui-utils/client'; +import { useRoomHistoryState } from '../../../../app/ui-utils/client/lib/RoomHistoryManager'; import { omit } from '../../../../lib/utils/omit'; import { useFireGlobalEvent } from '../../../hooks/useFireGlobalEvent'; -import { useReactiveValue } from '../../../hooks/useReactiveValue'; import { RoomManager } from '../../../lib/RoomManager'; import { roomCoordinator } from '../../../lib/rooms/roomCoordinator'; import ImageGalleryProvider from '../../../providers/ImageGalleryProvider'; @@ -48,17 +47,9 @@ const RoomProvider = ({ rid, children }: RoomProviderProps): ReactElement => { }; }, [room, subscritionFromLocal]); - const { hasMorePreviousMessages, hasMoreNextMessages, isLoadingMoreMessages } = useReactiveValue( - useCallback(() => { - const { hasMore, hasMoreNext, isLoading } = RoomHistoryManager.getRoom(rid); - - return { - hasMorePreviousMessages: hasMore.get(), - hasMoreNextMessages: hasMoreNext.get(), - isLoadingMoreMessages: isLoading.get(), - }; - }, [rid]), - ); + const hasMorePreviousMessages = useRoomHistoryState(rid, (state) => state.hasMore); + const hasMoreNextMessages = useRoomHistoryState(rid, (state) => state.hasMoreNext); + const isLoadingMoreMessages = useRoomHistoryState(rid, (state) => state.isLoading); const context = useMemo((): ContextType => { if (!pseudoRoom) { diff --git a/apps/meteor/client/views/root/IndexRoute.tsx b/apps/meteor/client/views/root/IndexRoute.tsx index 90773af044916..32f1526f63ae1 100644 --- a/apps/meteor/client/views/root/IndexRoute.tsx +++ b/apps/meteor/client/views/root/IndexRoute.tsx @@ -1,6 +1,5 @@ import type { RouteName } from '@rocket.chat/ui-contexts'; import { useRouter, useUser, useUserId } from '@rocket.chat/ui-contexts'; -import { Tracker } from 'meteor/tracker'; import { useEffect } from 'react'; import PageLoading from './PageLoading'; @@ -16,24 +15,21 @@ const IndexRoute = () => { return; } - const computation = Tracker.autorun((c) => { - setTimeout(async () => { - if (user?.defaultRoom) { - const room = user.defaultRoom.split('/') as [routeName: RouteName, routeParam: string]; - router.navigate({ - name: room[0], - params: { name: room[1] }, - search: router.getSearchParameters(), - }); - } else { - router.navigate('/home'); - } - }, 0); - c.stop(); - }); + const timerId = setTimeout(() => { + if (user?.defaultRoom) { + const room = user.defaultRoom.split('/') as [routeName: RouteName, routeParam: string]; + router.navigate({ + name: room[0], + params: { name: room[1] }, + search: router.getSearchParameters(), + }); + } else { + router.navigate('/home'); + } + }, 0); return () => { - computation.stop(); + clearTimeout(timerId); }; }, [router, uid, user?.defaultRoom]); diff --git a/apps/meteor/client/views/root/hooks/useCorsSSLConfig.ts b/apps/meteor/client/views/root/hooks/useCorsSSLConfig.ts index c26cb62b7f480..ba3ea5c4ded6f 100644 --- a/apps/meteor/client/views/root/hooks/useCorsSSLConfig.ts +++ b/apps/meteor/client/views/root/hooks/useCorsSSLConfig.ts @@ -1,3 +1,6 @@ +// TODO: remove this hook together with the Meteor webapp/DDP layer — it only +// patches Meteor.absoluteUrl's `secure` default, which has no consumers once +// Meteor.absoluteUrl is gone. import { useSetting } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import { useEffect } from 'react'; diff --git a/apps/meteor/client/views/root/hooks/useIframe.ts b/apps/meteor/client/views/root/hooks/useIframe.ts index c0cfd4cbd678c..b66b9f247fc36 100644 --- a/apps/meteor/client/views/root/hooks/useIframe.ts +++ b/apps/meteor/client/views/root/hooks/useIframe.ts @@ -1,8 +1,9 @@ import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; import { useLoginWithIframe, useLoginWithToken, useSetting } from '@rocket.chat/ui-contexts'; -import type { Meteor } from 'meteor/meteor'; import { useCallback, useEffect, useState } from 'react'; +type CallbackError = Error & { error?: string | number; reason?: string; details?: unknown }; + export const useIframe = () => { const [iframeLoginUrl, setIframeLoginUrl] = useState(undefined); @@ -62,7 +63,7 @@ export const useIframe = () => { } const body = await result.json(); - loginWithToken(body, async (error: Meteor.Error | Meteor.TypedError | Error | null | undefined) => { + loginWithToken(body, async (error: CallbackError | Error | null | undefined) => { if (error) { setIframeLoginUrl(url); } else {