Files
upstream-rocketchat/apps/meteor/client/views/room/providers/ComposerPopupProvider.tsx
upstream-mirror 30810eecd7
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
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

433 lines
14 KiB
TypeScript

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;