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
433 lines
14 KiB
TypeScript
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;
|