snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
import type { ReactNode } from 'react';
import { ChatContext } from '../contexts/ChatContext';
import { useRoom } from '../contexts/RoomContext';
import { useChatMessagesInstance } from './hooks/useChatMessagesInstance';
export type ChatProviderProps = {
children: ReactNode;
tmid?: string;
};
const ChatProvider = ({ children, tmid }: ChatProviderProps) => {
const { _id: rid, encrypted } = useRoom();
const value = useChatMessagesInstance({ rid, tmid, encrypted });
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
};
export default ChatProvider;

View File

@@ -0,0 +1,432 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { useLocalStorage } from '@rocket.chat/fuselage-hooks';
import { escapeRegExp, isTruthy } from '@rocket.chat/tools';
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
import { useEndpoint, useSetting, useUserId, useUserPreference } from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { normalizeUsername } from '../../../../lib/utils/normalizeUsername';
import { hasAtLeastOnePermission } from '../../../lib/authorization';
import { pipe } from '../../../lib/cachedStores/pipe';
import { emoji } from '../../../lib/emoji';
import { cannedResponsesQueryKeys } from '../../../lib/queryKeys';
import { slashCommands } from '../../../lib/slashCommand';
import { Messages, Subscriptions } from '../../../stores';
import ComposerBoxPopupCannedResponse from '../composer/ComposerBoxPopupCannedResponse';
import type { ComposerBoxPopupEmojiProps } from '../composer/ComposerBoxPopupEmoji';
import ComposerBoxPopupEmoji from '../composer/ComposerBoxPopupEmoji';
import ComposerBoxPopupRoom from '../composer/ComposerBoxPopupRoom';
import type { ComposerBoxPopupRoomProps } from '../composer/ComposerBoxPopupRoom';
import type { ComposerBoxPopupSlashCommandProps } from '../composer/ComposerBoxPopupSlashCommand';
import ComposerBoxPopupSlashCommand from '../composer/ComposerBoxPopupSlashCommand';
import ComposerBoxPopupUser from '../composer/ComposerBoxPopupUser';
import type { ComposerBoxPopupUserProps } from '../composer/ComposerBoxPopupUser';
import type { ComposerPopupContextValue, ComposerPopupOption } from '../contexts/ComposerPopupContext';
import { ComposerPopupContext, createMessageBoxPopupConfig } from '../contexts/ComposerPopupContext';
import useCannedResponsesQuery from './hooks/useCannedResponsesQuery';
const getToneRegexes = () => {
const exactFinalTone = new RegExp('^tone[1-5](?:-[1-5])?:*$');
const colorBlind = new RegExp('tone[1-5](?:-[1-5])?:*$');
const seeColor = new RegExp('_t(?:o(?:n(?:e(?:[1-5](?:-[1-5]?)?)?)?)?)?:?$');
return [exactFinalTone, colorBlind, seeColor];
};
const matchesEmojiSuggestion = (id: string, key: string, filterRegex: RegExp, [exactFinalTone, colorBlind, seeColor]: RegExp[]) => {
if (!filterRegex.test(id)) {
return false;
}
if (emoji.list[id]?.emojiPackage === 'emojiCustom') {
return true;
}
return exactFinalTone.test(id.substring(key.length)) || seeColor.test(key) || !colorBlind.test(id);
};
export type CannedResponse = { _id: string; shortcut: string; text: string };
export type ComposerPopupProviderProps = {
children: ReactNode;
room: IRoom;
};
const getLastRecentUsers = (rid: string, uid: string) => {
const uniqueUsers = new Map<
string,
{
_id: string;
username: string;
name?: string;
ts: Date;
suggestion?: boolean;
}
>();
Messages.state
.filter((record) => record.rid === rid && record.u && record.u._id !== uid && !record.t && !!record.ts)
.sort((a, b) => b.ts.getTime() - a.ts.getTime())
.forEach(({ u: { username, name, _id }, ts }) => {
if (!uniqueUsers.has(username)) {
uniqueUsers.set(username, {
_id,
username,
name,
ts,
});
}
});
return Array.from(uniqueUsers.values());
};
const ComposerPopupProvider = ({ children, room }: ComposerPopupProviderProps) => {
const { _id: rid, encrypted: isRoomEncrypted } = room;
// TODO: this is awful because we are just triggering the query to get the data
// and we are not using the data itself, we should find a better way to do this
useCannedResponsesQuery(room);
const userSpotlight = useEndpoint('GET', '/v1/spotlight');
const suggestionsCount = useSetting('Number_of_users_autocomplete_suggestions', 5);
const cannedResponseEnabled = useSetting('Canned_Responses_Enable', true);
const [recentEmojis] = useLocalStorage<string[]>('emoji.recent', []);
const [previewTitle, setPreviewTitle] = useState('');
const isOmnichannel = isOmnichannelRoom(room);
const useEmoji = useUserPreference('useEmojis');
const { t, i18n } = useTranslation();
const e2eEnabled = useSetting('E2E_Enable', false);
const unencryptedMessagesAllowed = useSetting('E2E_Allow_Unencrypted_Messages', false);
const encrypted = isRoomEncrypted && e2eEnabled && !unencryptedMessagesAllowed;
const queryClient = useQueryClient();
const uid = useUserId();
const call = useEndpoint('GET', '/v1/commands.preview');
const value: ComposerPopupContextValue = useMemo(() => {
return [
createMessageBoxPopupConfig({
trigger: '@',
title: t('People'),
getItemsFromLocal: async (filter: string) => {
const filterRegex = filter && new RegExp(escapeRegExp(filter), 'i');
const items: ComposerBoxPopupUserProps[] = [];
const roomMessageUsers = getLastRecentUsers(rid, uid!)
.filter((u) => {
if (!filterRegex) return true;
return filterRegex.test(u.username) || (u.name && filterRegex.test(u.name));
})
.sort((a, b) => b.ts.getTime() - a.ts.getTime())
.slice(0, suggestionsCount ?? 5)
.map((u) => ({
...u,
suggestion: true,
}));
if (!filterRegex || filterRegex.test('all')) {
items.push({
_id: 'all',
username: 'all',
system: true,
name: t('Notify_all_in_this_room'),
sort: 4,
});
}
if (!filterRegex || filterRegex.test('here')) {
items.push({
_id: 'here',
username: 'here',
system: true,
name: t('Notify_active_in_this_room'),
sort: 4,
});
}
return [...roomMessageUsers, ...items];
},
getItemsFromServer: async (filter: string) => {
const filterRegex = filter && new RegExp(escapeRegExp(filter), 'i');
const usernames = getLastRecentUsers(rid, uid!)
.filter((u) => {
if (!filterRegex) return true;
return filterRegex.test(u.username) || (u.name && filterRegex.test(u.name));
})
.sort((a, b) => b.ts.getTime() - a.ts.getTime())
.slice(0, suggestionsCount ?? 5)
.map((u) => u.username);
const { users = [] } = await userSpotlight({
query: filter,
usernames: usernames.join(','),
type: JSON.stringify({ users: true, mentions: true }),
rid,
});
return users.map(({ _id, username, nickname, name, status, avatarETag, outside }) => {
return {
_id,
username,
nickname,
name,
status,
avatarETag,
outside,
sort: 3,
};
});
},
getValue: (item) => normalizeUsername(item.username),
renderItem: ({ item }) => <ComposerBoxPopupUser {...item} />,
}),
createMessageBoxPopupConfig<ComposerBoxPopupRoomProps>({
trigger: '#',
title: t('Channels'),
getItemsFromLocal: async (filter: string) => {
const filterRegex = new RegExp(escapeRegExp(filter), 'i');
const { apply: transform } = pipe<SubscriptionWithRoom>()
.sortByField('ls', -1)
.slice(0, suggestionsCount ?? 5);
const predicate = (record: SubscriptionWithRoom): boolean =>
Boolean(
(record.fname?.match(filterRegex) || record.name?.match(filterRegex)) &&
!record.federated &&
(record.t === 'c' || record.t === 'p'),
);
const records = transform(Subscriptions.state.filter(predicate));
return records;
},
getItemsFromServer: async (filter: string) => {
const { rooms = [] } = await userSpotlight({
query: filter,
type: JSON.stringify({ rooms: true, mentions: true }),
rid,
});
return rooms as unknown as ComposerBoxPopupRoomProps[];
},
getValue: (item) => `${item.name || item.fname}`,
renderItem: ({ item }) => <ComposerBoxPopupRoom {...item} />,
}) as any,
useEmoji &&
createMessageBoxPopupConfig<ComposerBoxPopupEmojiProps>({
trigger: ':',
prefix: '',
title: t('Emoji'),
triggerLength: 2,
getItemsFromLocal: async (filter: string) => {
const toneRegexes = getToneRegexes();
const emojiSort = (recents: string[]) => (a: { _id: string }, b: { _id: string }) => {
const aExact = a._id === key ? 2 : 0;
const bExact = b._id === key ? 2 : 0;
const aPartial = a._id.startsWith(key) ? 1 : 0;
const bPartial = b._id.startsWith(key) ? 1 : 0;
let aScore = aExact + aPartial;
let bScore = bExact + bPartial;
if (recents.includes(a._id)) {
aScore += recents.indexOf(a._id) + 1;
}
if (recents.includes(b._id)) {
bScore += recents.indexOf(b._id) + 1;
}
if (aScore > bScore) {
return -1;
}
if (aScore < bScore) {
return 1;
}
return 0;
};
const filterRegex = new RegExp(escapeRegExp(filter), 'i');
const key = `:${filter}`;
const recents = recentEmojis.map((item) => `:${item}:`);
const collection = emoji.list;
return Object.keys(collection)
.map((_id) => {
const data = collection[key];
return { _id, data };
})
.filter(({ _id }) => matchesEmojiSuggestion(_id, key, filterRegex, toneRegexes))
.sort(emojiSort(recents))
.slice(0, 10);
},
getItemsFromServer: async () => {
return [];
},
getValue: (item) => {
const emojiEntry = emoji.list[item._id];
return emojiEntry && 'unicode' in emojiEntry ? (emojiEntry.unicode ?? item._id) : item._id;
},
renderItem: ({ item }) => <ComposerBoxPopupEmoji {...item} />,
}),
createMessageBoxPopupConfig<ComposerBoxPopupEmojiProps>({
title: t('Emoji'),
trigger: '\\+:',
prefix: '+',
suffix: ' ',
triggerAnywhere: false,
getItemsFromLocal: async (filter: string) => {
const toneRegexes = getToneRegexes();
const emojiSort = (recents: string[]) => (a: { _id: string }, b: { _id: string }) => {
let idA = a._id;
let idB = a._id;
if (recents.includes(a._id)) {
idA = recents.indexOf(a._id) + idA;
}
if (recents.includes(b._id)) {
idB = recents.indexOf(b._id) + idB;
}
if (idA < idB) {
return -1;
}
if (idA > idB) {
return 1;
}
return 0;
};
const filterRegex = new RegExp(escapeRegExp(filter), 'i');
const key = `:${filter}`;
const recents = recentEmojis.map((item) => `:${item}:`);
const collection = emoji.list;
return Object.keys(collection)
.map((_id) => {
const data = collection[key];
return { _id, data };
})
.filter(({ _id }) => matchesEmojiSuggestion(_id, key, filterRegex, toneRegexes))
.sort(emojiSort(recents))
.slice(0, 10);
},
getItemsFromServer: async () => {
return [];
},
getValue: (item) => `${item._id}`,
renderItem: ({ item }) => <ComposerBoxPopupEmoji {...item} />,
}),
createMessageBoxPopupConfig<ComposerBoxPopupSlashCommandProps>({
title: t('Commands'),
trigger: '/',
suffix: ' ',
triggerAnywhere: false,
disabled: encrypted,
renderItem: ({ item }) => <ComposerBoxPopupSlashCommand {...item} />,
getItemsFromLocal: async (filter: string) => {
return Object.keys(slashCommands.commands)
.map((command) => {
const item = slashCommands.commands[command];
return {
_id: command,
params: item.params && i18n.exists(item.params) ? t(item.params) : (item.params ?? ''),
description: item.description && i18n.exists(item.description) ? t(item.description) : item.description,
permission: item.permission,
...(encrypted && { disabled: encrypted }),
};
})
.filter((command) => {
const isMatch = command._id.indexOf(filter) > -1;
if (!isMatch) {
return false;
}
if (!command.permission) {
return true;
}
return hasAtLeastOnePermission(command.permission, rid);
})
.sort((a, b) => a._id.localeCompare(b._id))
.slice(0, 11);
},
getItemsFromServer: async () => [],
}),
cannedResponseEnabled &&
isOmnichannel &&
createMessageBoxPopupConfig<{
_id: string;
text: string;
shortcut: string;
}>({
title: t('Canned_Responses'),
trigger: '!',
prefix: '',
triggerAnywhere: true,
renderItem: ({ item }) => <ComposerBoxPopupCannedResponse {...item} />,
getItemsFromLocal: async (filter: string) => {
const exp = new RegExp(filter, 'i');
// TODO: this is bad, but can only be fixed by refactoring the whole thing
const cannedResponses = queryClient.getQueryData<CannedResponse[]>(cannedResponsesQueryKeys.all) ?? [];
return cannedResponses
.filter((record) => record.shortcut.match(exp))
.sort((a, b) => a.shortcut.localeCompare(b.shortcut))
.slice(0, 11)
.map((record) => ({
_id: record._id,
text: record.text,
shortcut: record.shortcut,
}));
},
getItemsFromServer: async () => [],
getValue: (item) => item.text,
}),
createMessageBoxPopupConfig({
title: previewTitle,
matchSelectorRegex: /(?:^)(\/[\w\d\S]+ )[^]*$/,
preview: true,
getItemsFromLocal: async ({ cmd, params }: { cmd: string; params: string; tmid: string }) => {
const { preview } = await call({ command: cmd, params, roomId: rid });
setPreviewTitle(t(preview?.i18nTitle ?? ''));
return (
preview?.items.map((item) => ({
_id: item.id,
value: item.value,
type: item.type,
})) ?? []
);
},
}),
].filter<ComposerPopupOption>(isTruthy);
}, [
call,
cannedResponseEnabled,
encrypted,
i18n,
isOmnichannel,
previewTitle,
queryClient,
recentEmojis,
rid,
suggestionsCount,
t,
uid,
useEmoji,
userSpotlight,
]);
return <ComposerPopupContext.Provider value={value}>{children}</ComposerPopupContext.Provider>;
};
export default ComposerPopupProvider;

View File

@@ -0,0 +1,49 @@
import type { ReactNode } from 'react';
import { createContext, useContext, useMemo, useState } from 'react';
type DateListContextValue = {
list: Set<HTMLElement>;
dateRef: () => (ref: HTMLElement | null) => void;
};
const DateListContext = createContext<DateListContextValue | undefined>(undefined);
const useDateRef = () => {
const context = useDateListController();
return useMemo(() => context.dateRef(), [context]);
};
export type DateListProviderProps = { children: ReactNode };
const DateListProvider = ({ children }: DateListProviderProps) => {
const [list] = useState<Set<HTMLElement>>(new Set<HTMLElement>());
const addToList = (value: HTMLElement) => {
list.add(value);
return () => {
list.delete(value);
};
};
const dateRef = () => {
let remove: () => void;
return (ref: HTMLElement | null) => {
if (remove) remove();
if (!ref) return;
remove = addToList(ref);
};
};
return <DateListContext.Provider value={{ list, dateRef }}>{children}</DateListContext.Provider>;
};
const useDateListController = () => {
const context = useContext(DateListContext);
if (!context) {
throw new Error('useDateController must be used within an DateScrollProvider');
}
return context;
};
export { DateListProvider, useDateListController, useDateRef };

View File

@@ -0,0 +1,127 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { useSearchParameter } from '@rocket.chat/ui-contexts';
import type { ReactNode, ContextType } from 'react';
import { useMemo, memo, useEffect } from 'react';
import ComposerPopupProvider from './ComposerPopupProvider';
import RoomToolboxProvider from './RoomToolboxProvider';
import UserCardProvider from './UserCardProvider';
import { useRedirectOnSettingsChanged } from './hooks/useRedirectOnSettingsChanged';
import { useUsersNameChanged } from './hooks/useUsersNameChanged';
import { omit } from '../../../../lib/utils/omit';
import { useFireGlobalEvent } from '../../../hooks/useFireGlobalEvent';
import { useRoomRolesQuery } from '../../../hooks/useRoomRolesQuery';
import { RoomHistoryManager, useRoomHistoryState } from '../../../lib/RoomHistoryManager';
import { RoomManager } from '../../../lib/RoomManager';
import { UserAction } from '../../../lib/UserAction';
import { roomCoordinator } from '../../../lib/rooms/roomCoordinator';
import ImageGalleryProvider from '../../../providers/ImageGalleryProvider';
import { Rooms, Subscriptions } from '../../../stores';
import RoomNotFound from '../RoomNotFound';
import RoomSkeleton from '../RoomSkeleton';
import type { IRoomWithFederationOriginalName } from '../contexts/RoomContext';
import { RoomContext } from '../contexts/RoomContext';
export type RoomProviderProps = {
children: ReactNode;
rid: IRoom['_id'];
};
const RoomProvider = ({ rid, children }: RoomProviderProps) => {
const room = Rooms.use((state) => state.get(rid));
const messageJumpParam = useSearchParameter('msg');
const subscritionFromLocal = Subscriptions.use((state) => state.find((record) => record.rid === rid));
useRedirectOnSettingsChanged(subscritionFromLocal);
useUsersNameChanged();
const pseudoRoom: IRoomWithFederationOriginalName | null = useMemo(() => {
if (!room) {
return null;
}
return {
...subscritionFromLocal,
...room,
name: roomCoordinator.getRoomName(room.t, room),
federationOriginalName: room.name,
};
}, [room, subscritionFromLocal]);
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<typeof RoomContext> => {
if (!pseudoRoom) {
return null;
}
return {
rid,
room: pseudoRoom,
subscription: subscritionFromLocal ?? undefined,
hasMorePreviousMessages,
hasMoreNextMessages,
isLoadingMoreMessages,
};
}, [hasMoreNextMessages, hasMorePreviousMessages, isLoadingMoreMessages, pseudoRoom, rid, subscritionFromLocal]);
const { mutate: fireRoomOpenedEvent } = useFireGlobalEvent('room-opened', rid);
useEffect(() => {
if (room) {
fireRoomOpenedEvent(omit(room, 'usernames'));
}
}, [rid, room, fireRoomOpenedEvent]);
useEffect(() => {
RoomManager.open(rid);
return (): void => {
RoomManager.back(rid);
};
}, [rid]);
// Prefetch first batch of history in parallel with room metadata fetches, instead of waiting
// for RoomBody's scroll/resize observer in useGetMore to fire.
useEffect(() => {
if (!room || messageJumpParam || RoomHistoryManager.isLoaded(rid) || RoomHistoryManager.isLoading(rid)) {
return;
}
void RoomHistoryManager.getMore(rid);
}, [rid, room, messageJumpParam]);
// Prefetch room roles alongside history so message rendering doesn't trigger a late fetch.
useRoomRolesQuery(rid, { enabled: !!room });
const subscribed = !!subscritionFromLocal;
useEffect(() => {
if (!subscribed) {
return;
}
return UserAction.addStream(rid);
}, [rid, subscribed]);
if (!pseudoRoom) {
return !room && !subscritionFromLocal ? <RoomNotFound /> : <RoomSkeleton />;
}
return (
<RoomContext.Provider value={context}>
<RoomToolboxProvider>
<ImageGalleryProvider>
<UserCardProvider>
<ComposerPopupProvider room={pseudoRoom}>{children}</ComposerPopupProvider>
</UserCardProvider>
</ImageGalleryProvider>
</RoomToolboxProvider>
</RoomContext.Provider>
);
};
export default memo(RoomProvider);

View File

@@ -0,0 +1,118 @@
import { useStableCallback, useStableArray } from '@rocket.chat/fuselage-hooks';
import {
useUserId,
useSetting,
useRouter,
useRouteParameter,
useLayoutHiddenActions,
RoomToolboxContext,
type RoomToolboxContextValue,
} from '@rocket.chat/ui-contexts';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
import { useRoom } from '../contexts/RoomContext';
import { getRoomGroup } from '../lib/getRoomGroup';
import { useAppsRoomActions } from './hooks/useAppsRoomActions';
import { useCoreRoomActions } from './hooks/useCoreRoomActions';
import { useCoreRoomRoutes } from './hooks/useCoreRoomRoutes';
export type RoomToolboxProviderProps = { children: ReactNode };
const RoomToolboxProvider = ({ children }: RoomToolboxProviderProps) => {
const room = useRoom();
const router = useRouter();
const openTab = useStableCallback((actionId: string, context?: string) => {
if (actionId === tab?.id && context === undefined) {
return closeTab();
}
const routeName = router.getRouteName();
if (!routeName) {
throw new Error('Route name is not defined');
}
const { layout } = router.getSearchParameters();
router.navigate({
name: routeName,
params: {
...router.getRouteParameters(),
tab: actionId,
context: context ?? '',
},
search: layout ? { layout } : undefined,
});
});
const closeTab = useStableCallback(() => {
const routeName = router.getRouteName();
if (!routeName) {
throw new Error('Route name is not defined');
}
router.navigate({
name: routeName,
params: {
...router.getRouteParameters(),
tab: '',
context: '',
},
search: (({ msg: _, ...rest }) => rest)(router.getSearchParameters()),
});
});
const context = useRouteParameter('context');
const coreRoomActions = useCoreRoomActions();
const appsRoomActions = useAppsRoomActions();
// core routes open the contextual bar, but have no button on the header
const coreRoomRoutes = useCoreRoomRoutes();
const allowAnonymousRead = useSetting<boolean>('Accounts_AllowAnonymousRead', false);
const uid = useUserId();
const { roomToolbox: hiddenActions } = useLayoutHiddenActions();
const actions = useStableArray(
[...coreRoomActions, ...appsRoomActions]
.filter((action) => uid || (allowAnonymousRead && 'anonymous' in action && action.anonymous))
.filter((action) => !action.groups || action.groups.includes(getRoomGroup(room)))
.filter((action) => !hiddenActions.includes(action.id))
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
);
const tabActionId = useRouteParameter('tab');
const tab = useMemo(() => {
if (!tabActionId) {
return undefined;
}
const coreRouteTab = coreRoomRoutes.find((route) => route.id === tabActionId);
if (coreRouteTab) {
return coreRouteTab;
}
return actions.find((action) => action.id === tabActionId);
}, [coreRoomRoutes, actions, tabActionId]);
const contextValue = useMemo(
(): RoomToolboxContextValue => ({
actions,
tab,
context,
openTab,
closeTab,
}),
[actions, tab, context, openTab, closeTab],
);
return <RoomToolboxContext.Provider value={contextValue}>{children}</RoomToolboxContext.Provider>;
};
export default RoomToolboxProvider;

View File

@@ -0,0 +1,98 @@
import { Emitter } from '@rocket.chat/emitter';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
import { SelectedMessageContext } from '../MessageList/contexts/SelectedMessagesContext';
export const selectedMessageStore = new (class SelectMessageStore extends Emitter<
{
change: undefined;
toggleIsSelecting: boolean;
} & { [mid: string]: boolean }
> {
store = new Set<string>();
availableMessages = new Set<string>();
isSelecting = false;
addAvailableMessage(mid: string): void {
this.availableMessages.add(mid);
this.emit('change');
}
removeAvailableMessage(mid: string): void {
this.availableMessages.delete(mid);
this.emit('change');
}
setIsSelecting(isSelecting: boolean): void {
this.isSelecting = isSelecting;
this.emit('toggleIsSelecting', isSelecting);
}
getIsSelecting(): boolean {
return this.isSelecting;
}
isSelected(mid: string): boolean {
return Boolean(this.store.has(mid));
}
getSelectedMessages(): string[] {
return Array.from(this.store);
}
toggle(mid: string): void {
if (this.store.has(mid)) {
this.store.delete(mid);
this.emit(mid, false);
this.emit('change');
return;
}
this.store.add(mid);
this.emit(mid, true);
this.emit('change');
}
count(): number {
return this.store.size;
}
availableMessagesCount(): number {
return this.availableMessages.size;
}
clearStore(): void {
const selectedMessages = this.getSelectedMessages();
this.store.clear();
selectedMessages.forEach((mid) => this.emit(mid, false));
this.emit('change');
}
reset(): void {
this.clearStore();
this.isSelecting = false;
this.emit('toggleIsSelecting', false);
}
toggleAll(mids: string[]): void {
this.store = new Set([...this.store, ...mids]);
this.emit('change');
}
})();
export type SelectedMessagesProviderProps = {
children?: ReactNode;
};
export const SelectedMessagesProvider = ({ children }: SelectedMessagesProviderProps) => {
const value = useMemo(
() => ({
selectedMessageStore,
}),
[],
);
return <SelectedMessageContext.Provider value={value}>{children}</SelectedMessageContext.Provider>;
};

View File

@@ -0,0 +1,81 @@
import { useOverlayTrigger } from '@react-aria/overlays';
import { useOverlayTriggerState } from '@react-stately/overlays';
import { Popover } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useRoomToolbox, UserCardContext } from '@rocket.chat/ui-contexts';
import type { ComponentProps, ReactNode, UIEvent } from 'react';
import { Suspense, lazy, useCallback, useMemo, useRef, useState } from 'react';
import { useRoom } from '../contexts/RoomContext';
const UserCard = lazy(() => import('../UserCard'));
export type UserCardProviderProps = { children: ReactNode };
const UserCardProvider = ({ children }: UserCardProviderProps) => {
const room = useRoom();
const [userCardData, setUserCardData] = useState<ComponentProps<typeof UserCard> | null>(null);
const triggerRef = useRef<Element | null>(null);
const state = useOverlayTriggerState({});
const { triggerProps, overlayProps } = useOverlayTrigger({ type: 'dialog' }, state, triggerRef);
delete triggerProps.onPress;
const { openTab } = useRoomToolbox();
const openUserInfo = useStableCallback((username?: string) => {
switch (room.t) {
case 'l':
openTab('room-info', username);
break;
case 'd':
(room.uids?.length ?? 0) > 2 ? openTab('user-info-group', username) : openTab('user-info', username);
break;
default:
openTab('members-list', username);
break;
}
});
const handleSetUserCard = useCallback(
(e: UIEvent, username: string) => {
triggerRef.current = e.target as Element | null;
state.open();
setUserCardData({
username,
rid: room._id,
onOpenUserInfo: () => openUserInfo(username),
onClose: () => setUserCardData(null),
});
},
[openUserInfo, room._id, state],
);
const contextValue = useMemo(
() => ({
openUserCard: handleSetUserCard,
closeUserCard: () => setUserCardData(null),
triggerProps,
triggerRef,
state,
}),
[handleSetUserCard, state, triggerProps],
);
return (
<UserCardContext.Provider value={contextValue}>
{children}
{state.isOpen && userCardData && (
<Suspense fallback={null}>
<Popover placement='top left' triggerRef={triggerRef} state={state}>
<UserCard {...userCardData} {...overlayProps} />
</Popover>
</Suspense>
)}
</UserCardContext.Provider>
);
};
export default UserCardProvider;

View File

@@ -0,0 +1,56 @@
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Utilities } from '../../../../../ee/lib/misc/Utilities';
import { useAppActionButtons } from '../../../../hooks/useAppActionButtons';
import { useApplyButtonFilters } from '../../../../hooks/useApplyButtonFilters';
import { UiKitTriggerTimeoutError } from '../../../../lib/errors/UiKitTriggerTimeoutError';
import { useUiKitActionManager } from '../../../../uikit/hooks/useUiKitActionManager';
import { useRoom } from '../../contexts/RoomContext';
export const useAppsRoomActions = () => {
const result = useAppActionButtons('roomAction');
const actionManager = useUiKitActionManager();
const applyButtonFilters = useApplyButtonFilters();
const room = useRoom();
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
return useMemo(
() =>
result.data?.filter(applyButtonFilters).map((action): RoomToolboxActionConfig => ({
id: action.actionId,
variant: action.variant,
icon: undefined as unknown as RoomToolboxActionConfig['icon'],
order: 300,
title: Utilities.getI18nKeyForApp(action.labelI18n, action.appId),
groups: ['group', 'channel', 'live', 'team', 'direct', 'direct_multiple'],
// Filters were applied in the applyButtonFilters function
// if the code made it this far, the button should be shown
action: () => {
void actionManager
.emitInteraction(action.appId, {
type: 'actionButton',
actionId: action.actionId,
rid: room._id,
payload: { context: action.context },
})
.catch(async (reason) => {
if (reason instanceof UiKitTriggerTimeoutError) {
dispatchToastMessage({
type: 'error',
message: t('UIKit_Interaction_Timeout'),
});
return;
}
return reason;
});
},
type: 'apps',
})) ?? [],
[actionManager, applyButtonFilters, dispatchToastMessage, result.data, room._id, t],
);
};

View File

@@ -0,0 +1,64 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { useEndpoint, usePermission, useSetting, useStream, useUserId } from '@rocket.chat/ui-contexts';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { cannedResponsesQueryKeys } from '../../../../lib/queryKeys';
type CannedResponse = { _id: string; shortcut: string; text: string };
const useCannedResponsesQuery = (room: IRoom) => {
const isOmnichannel = isOmnichannelRoom(room);
const uid = useUserId();
const isCannedResponsesEnabled = useSetting('Canned_Responses_Enable', true);
const canViewCannedResponses = usePermission('view-canned-responses');
const subscribeToCannedResponses = useStream('canned-responses');
const enabled = isOmnichannel && !!uid && isCannedResponsesEnabled && canViewCannedResponses;
const getCannedResponses = useEndpoint('GET', '/v1/canned-responses.get');
const queryClient = useQueryClient();
useEffect(() => {
if (!enabled) return;
return subscribeToCannedResponses('canned-responses', (...[response, options]) => {
const { agentsId } = options || {};
if (Array.isArray(agentsId) && !agentsId.includes(uid)) {
return;
}
switch (response.type) {
case 'changed': {
const { _id, shortcut, text } = response;
queryClient.setQueryData<CannedResponse[]>(cannedResponsesQueryKeys.all, (responses) =>
responses?.filter((response) => response._id !== _id).concat([{ _id, shortcut, text }]),
);
break;
}
case 'removed': {
const { _id } = response;
queryClient.setQueryData<CannedResponse[]>(cannedResponsesQueryKeys.all, (responses) =>
responses?.filter((response) => response._id !== _id),
);
break;
}
}
});
}, [enabled, getCannedResponses, queryClient, subscribeToCannedResponses, uid]);
return useQuery<CannedResponse[]>({
queryKey: cannedResponsesQueryKeys.all,
queryFn: async () => {
const { responses } = await getCannedResponses();
return responses.map(({ _id, shortcut, text }) => ({ _id, shortcut, text }));
},
enabled,
staleTime: Infinity,
});
};
export default useCannedResponsesQuery;

View File

@@ -0,0 +1,219 @@
import type { ISubscription } from '@rocket.chat/core-typings';
import type { IActionManager } from '@rocket.chat/ui-contexts';
import { useUserId } from '@rocket.chat/ui-contexts';
import { renderHook } from '@testing-library/react';
import { useChatMessagesInstance } from './useChatMessagesInstance';
import { useEmojiPicker } from '../../../../contexts/EmojiPickerContext';
import { ChatMessages } from '../../../../lib/ChatMessages';
import type { E2ERoomState } from '../../../../lib/e2ee/E2ERoomState';
import { useUiKitActionManager } from '../../../../uikit/hooks/useUiKitActionManager';
import { useRoomSubscription } from '../../contexts/RoomContext';
import { useE2EERoomState } from '../../hooks/useE2EERoomState';
jest.mock('@rocket.chat/ui-contexts', () => ({
useUserId: jest.fn(),
}));
jest.mock('../../contexts/RoomContext', () => ({
useRoomSubscription: jest.fn(),
}));
jest.mock('../../../../uikit/hooks/useUiKitActionManager', () => ({
useUiKitActionManager: jest.fn(),
}));
jest.mock('../../hooks/useE2EERoomState', () => ({
useE2EERoomState: jest.fn(),
}));
jest.mock('../../../../contexts/EmojiPickerContext', () => ({
useEmojiPicker: jest.fn(),
}));
const updateSubscriptionMock = jest.fn();
jest.mock('../../../../lib/ChatMessages', () => {
return {
ChatMessages: jest.fn().mockImplementation(() => {
return {
release: jest.fn(),
readStateManager: {
updateSubscription: updateSubscriptionMock,
subscribeToMessages: jest.fn(),
},
};
}),
};
});
describe('useChatMessagesInstance', () => {
let mockUid: string;
let mockSubscription: Pick<ISubscription, 'u' | 't' | 'rid'>;
let mockActionManager: IActionManager | undefined;
let mockE2EERoomState: E2ERoomState;
let mockEmojiPicker: {
open: jest.Mock;
isOpen: boolean;
close: jest.Mock;
};
beforeEach(() => {
jest.clearAllMocks();
mockUid = 'mockUid';
mockSubscription = {
u: {
_id: mockUid,
username: 'usernameMock',
name: 'nameMock',
},
t: 'p',
rid: 'roomId',
};
mockActionManager = undefined;
mockE2EERoomState = 'READY';
mockEmojiPicker = {
open: jest.fn(),
isOpen: false,
close: jest.fn(),
};
(useUserId as jest.Mock).mockReturnValue(mockUid);
(useRoomSubscription as jest.Mock).mockReturnValue(mockSubscription);
(useUiKitActionManager as jest.Mock).mockReturnValue(mockActionManager);
(useE2EERoomState as jest.Mock).mockReturnValue(mockE2EERoomState);
(useEmojiPicker as jest.Mock).mockReturnValue(mockEmojiPicker);
});
it('should initialize ChatMessages instance with correct arguments', () => {
const { result } = renderHook(() =>
useChatMessagesInstance({
rid: mockSubscription.rid,
tmid: 'threadId',
encrypted: false,
}),
);
expect(ChatMessages).toHaveBeenCalledWith({
rid: mockSubscription.rid,
tmid: 'threadId',
uid: mockUid,
actionManager: mockActionManager,
});
expect(result.current.emojiPicker).toBe(mockEmojiPicker);
expect(ChatMessages).toHaveBeenCalledTimes(1);
expect(updateSubscriptionMock).toHaveBeenCalledTimes(1);
});
it('should update ChatMessages subscription', () => {
const { result, rerender } = renderHook(() =>
useChatMessagesInstance({
rid: mockSubscription.rid,
tmid: 'threadId',
encrypted: false,
}),
);
expect(ChatMessages).toHaveBeenCalledWith({
rid: mockSubscription.rid,
tmid: 'threadId',
uid: mockUid,
actionManager: mockActionManager,
});
expect(result.current.emojiPicker).toBe(mockEmojiPicker);
expect(ChatMessages).toHaveBeenCalledTimes(1);
expect(updateSubscriptionMock).toHaveBeenCalledTimes(1);
(useRoomSubscription as jest.Mock).mockReturnValue({ ...mockSubscription, rid: 'newRoomId' });
rerender();
expect(ChatMessages).toHaveBeenCalledWith({
rid: mockSubscription.rid,
tmid: 'threadId',
uid: mockUid,
actionManager: mockActionManager,
});
expect(result.current.emojiPicker).toBe(mockEmojiPicker);
expect(ChatMessages).toHaveBeenCalledTimes(1);
expect(updateSubscriptionMock).toHaveBeenCalledTimes(2);
});
it('should update ChatMessages instance when dependencies changes', () => {
const { result, rerender } = renderHook(() =>
useChatMessagesInstance({
rid: mockSubscription.rid,
tmid: 'threadId',
encrypted: false,
}),
);
expect(ChatMessages).toHaveBeenCalledWith({
rid: mockSubscription.rid,
tmid: 'threadId',
uid: mockUid,
actionManager: mockActionManager,
});
expect(result.current.emojiPicker).toBe(mockEmojiPicker);
expect(ChatMessages).toHaveBeenCalledTimes(1);
expect(updateSubscriptionMock).toHaveBeenCalledTimes(1);
(useE2EERoomState as jest.Mock).mockReturnValue('WAITING_KEYS');
rerender();
expect(ChatMessages).toHaveBeenCalledWith({
rid: mockSubscription.rid,
tmid: 'threadId',
uid: mockUid,
actionManager: mockActionManager,
});
expect(result.current.emojiPicker).toBe(mockEmojiPicker);
expect(updateSubscriptionMock).toHaveBeenCalledTimes(2);
expect(ChatMessages).toHaveBeenCalledTimes(2);
});
it('should update ChatMessages instance when hook props changes', () => {
const initialProps = {
rid: mockSubscription.rid,
tmid: 'threadId',
encrypted: false,
};
const { result, rerender } = renderHook((props = initialProps) => useChatMessagesInstance(props as any));
expect(ChatMessages).toHaveBeenCalledWith({
rid: mockSubscription.rid,
tmid: 'threadId',
uid: mockUid,
actionManager: mockActionManager,
});
expect(result.current.emojiPicker).toBe(mockEmojiPicker);
expect(ChatMessages).toHaveBeenCalledTimes(1);
expect(updateSubscriptionMock).toHaveBeenCalledTimes(1);
rerender({
rid: mockSubscription.rid,
tmid: 'threadId',
encrypted: true,
});
expect(ChatMessages).toHaveBeenCalledWith({
rid: mockSubscription.rid,
tmid: 'threadId',
uid: mockUid,
actionManager: mockActionManager,
});
expect(result.current.emojiPicker).toBe(mockEmojiPicker);
expect(updateSubscriptionMock).toHaveBeenCalledTimes(2);
expect(ChatMessages).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,48 @@
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import { useUserId } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { useInstance } from './useInstance';
import { useEmojiPicker } from '../../../../contexts/EmojiPickerContext';
import { ChatMessages } from '../../../../lib/ChatMessages';
import type { ChatAPI } from '../../../../lib/chats/ChatAPI';
import { useUiKitActionManager } from '../../../../uikit/hooks/useUiKitActionManager';
import { useRoomSubscription } from '../../contexts/RoomContext';
import { useE2EERoomState } from '../../hooks/useE2EERoomState';
export function useChatMessagesInstance({
rid,
tmid,
encrypted,
}: {
rid: IRoom['_id'];
tmid?: IMessage['_id'];
encrypted: IRoom['encrypted'];
}): ChatAPI {
const uid = useUserId();
const subscription = useRoomSubscription();
const actionManager = useUiKitActionManager();
const e2eRoomState = useE2EERoomState(rid);
const chatMessages = useInstance(() => {
const instance = new ChatMessages({ rid, tmid, uid, actionManager });
return [instance, () => instance.release()];
}, [rid, tmid, uid, encrypted, e2eRoomState]);
useEffect(() => {
if (subscription?.rid) {
return chatMessages?.readStateManager.subscribeToMessages();
}
}, [subscription?.rid, chatMessages?.readStateManager]);
useEffect(() => {
if (subscription) {
chatMessages?.readStateManager.updateSubscription(subscription);
}
}, [subscription, chatMessages?.readStateManager]);
chatMessages.emojiPicker = useEmojiPicker();
return chatMessages;
}

View File

@@ -0,0 +1,10 @@
import { useStableArray } from '@rocket.chat/fuselage-hooks';
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { roomActionHooks } from '../../../../ui';
export const useCoreRoomActions = () => {
return useStableArray(
roomActionHooks.map((roomActionHook) => roomActionHook()).filter((roomAction): roomAction is RoomToolboxActionConfig => !!roomAction),
);
};

View File

@@ -0,0 +1,19 @@
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import MediaCallHistoryContextualbarRoom from '../../../mediaCallHistory/MediaCallHistoryContextualbarRoom';
const mediaCallHistoryRoute: RoomToolboxActionConfig = {
id: 'media-call-history',
title: 'Call_Information',
tabComponent: MediaCallHistoryContextualbarRoom,
icon: 'info-circled',
groups: ['direct'],
};
const coreRoomRoutes = [mediaCallHistoryRoute];
// This isn't really a proper hook, but it could be extended in the future
// So we're maitaning the same pattern as `useCoreRoomActions`
export const useCoreRoomRoutes = (): Array<RoomToolboxActionConfig> => {
return coreRoomRoutes;
};

View File

@@ -0,0 +1,41 @@
import { renderHook } from '@testing-library/react';
import { useDepsMatch } from './useDepsMatch';
describe('useDepsMatch', () => {
it('should return true when dependencies match', () => {
const { result, rerender } = renderHook(({ deps }) => useDepsMatch(deps), {
initialProps: { deps: ['dep1', 'dep2'] },
});
expect(result.current).toBe(true);
rerender({ deps: ['dep1', 'dep2'] });
expect(result.current).toBe(true);
});
it('should return false when dependencies do not match', () => {
const { result, rerender } = renderHook(({ deps }) => useDepsMatch(deps), {
initialProps: { deps: ['dep1', 'dep2'] },
});
expect(result.current).toBe(true);
rerender({ deps: ['dep1', 'dep3'] });
expect(result.current).toBe(false);
});
it('should return false when dependencies length changes', () => {
const { result, rerender } = renderHook(({ deps }) => useDepsMatch(deps), {
initialProps: { deps: ['dep1', 'dep2'] },
});
expect(result.current).toBe(true);
rerender({ deps: ['dep1'] });
expect(result.current).toBe(false);
});
});

View File

@@ -0,0 +1,14 @@
import { useRef } from 'react';
const depsMatch = (a: unknown[], b: unknown[]): boolean => a.every((value, index) => Object.is(value, b[index]));
export const useDepsMatch = (deps: unknown[]): boolean => {
const prevDepsRef = useRef(deps);
const { current: prevDeps } = prevDepsRef;
const match = depsMatch(prevDeps, deps);
prevDepsRef.current = deps;
return match;
};

View File

@@ -0,0 +1,61 @@
import { renderHook } from '@testing-library/react';
import { useInstance } from './useInstance';
class MockChatMessages {
release() {
return 'released';
}
}
describe('useInstance', () => {
let factory: jest.Mock;
let release: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
release = jest.fn();
factory = jest.fn().mockReturnValue([{ instance: new MockChatMessages() }, release]);
});
it('should create a new instance when dependencies change', () => {
const { result, rerender } = renderHook(({ deps }) => useInstance(factory, deps), {
initialProps: { deps: ['initial-dep'] },
});
expect(result.current).toEqual({ instance: new MockChatMessages() });
expect(factory).toHaveBeenCalledTimes(1);
expect(release).toHaveBeenCalledTimes(0);
rerender({ deps: ['new-dep'] });
expect(result.current).toEqual({ instance: new MockChatMessages() });
expect(factory).toHaveBeenCalledTimes(2);
expect(release).toHaveBeenCalledTimes(1);
});
it('should not create a new instance when dependencies do not change', () => {
const { result, rerender } = renderHook(({ deps }) => useInstance(factory, deps), {
initialProps: { deps: ['initial-dep'] },
});
expect(result.current).toEqual({ instance: new MockChatMessages() });
expect(factory).toHaveBeenCalledTimes(1);
expect(release).toHaveBeenCalledTimes(0);
rerender({ deps: ['initial-dep'] });
expect(result.current).toEqual({ instance: new MockChatMessages() });
expect(factory).toHaveBeenCalledTimes(1);
expect(release).toHaveBeenCalledTimes(0);
});
it('should call release function when component unmounts', () => {
const { unmount } = renderHook(() => useInstance(factory, ['initial-dep']));
unmount();
expect(release).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,23 @@
import { useRef, useEffect } from 'react';
import { useDepsMatch } from './useDepsMatch';
export function useInstance<T>(factory: () => [instance: T, release?: () => void], deps: unknown[]): T {
const ref = useRef<[instance: T, release?: () => void]>(undefined);
useEffect(
() => () => {
ref.current?.[1]?.();
},
[],
);
const depsMatch = useDepsMatch(deps);
if (!ref.current || !depsMatch) {
ref.current?.[1]?.();
ref.current = factory();
}
return ref.current[0];
}

View File

@@ -0,0 +1,52 @@
import type { ISubscription } from '@rocket.chat/core-typings';
import { useRouter } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { LegacyRoomManager } from '../../../../lib/LegacyRoomManager';
import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator';
const routeNameToRoomTypeMap: Record<string, string> = {
channel: 'c',
group: 'p',
direct: 'd',
live: 'l',
};
export const useRedirectOnSettingsChanged = (subscription?: ISubscription | null) => {
const router = useRouter();
const subExists = !!subscription;
useEffect(() => {
if (!subExists) {
return;
}
const redirect = async () => {
const routeConfig = roomCoordinator.getRoomDirectives(subscription.t).config.route;
const channelName = router.getRouteParameters().name;
const routeName = router.getRouteName() as string;
if (!routeConfig?.path || !routeName || !channelName) {
return;
}
if (routeConfig.name === routeName && channelName === subscription.name) {
return;
}
const routeRoomType = routeNameToRoomTypeMap[routeName];
if (routeRoomType) {
await LegacyRoomManager.close(routeRoomType + routeName);
}
router.navigate({
pattern: routeConfig.path,
params: { ...router.getRouteParameters(), name: subscription.name },
search: router.getSearchParameters(),
});
};
redirect();
}, [subscription?.t, subscription?.name, router, subExists]);
};

View File

@@ -0,0 +1,50 @@
import type { IEditedMessage } from '@rocket.chat/core-typings';
import { isEditedMessage } from '@rocket.chat/core-typings';
import { useStream } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { Messages, Subscriptions } from '../../../../stores';
export const useUsersNameChanged = () => {
const notify = useStream('notify-logged');
const updateMessages = Messages.use((state) => state.update);
useEffect(() => {
return notify('Users:NameChanged', ({ _id, name, username }) => {
updateMessages(
(record) => record.u._id === _id,
(record) => ({
...record,
u: {
...record.u,
username: username ?? record.u.username,
name,
},
}),
);
updateMessages(
(record): record is IEditedMessage => isEditedMessage(record) && record.editedBy?._id === _id,
(record) => ({
...record,
editedBy: {
...record.editedBy,
username,
},
}),
);
updateMessages(
(record) => record.mentions?.some((mention) => mention._id === _id) ?? false,
(record) => ({
...record,
mentions: record.mentions?.map((mention) => (mention._id === _id ? { ...mention, username, name } : mention)),
}),
);
Subscriptions.state.update(
(record) => record.name === username && record.t === 'd',
(record) => ({ ...record, fname: name }),
);
});
}, [notify, updateMessages]);
};