snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
This commit is contained in:
484
react/features/chat/functions.ts
Normal file
484
react/features/chat/functions.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
// @ts-expect-error
|
||||
import aliases from 'react-emoji-render/data/aliases';
|
||||
// eslint-disable-next-line lines-around-comment
|
||||
// @ts-expect-error
|
||||
import emojiAsciiAliases from 'react-emoji-render/data/asciiAliases';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { IReduxState } from '../app/types';
|
||||
import { getLocalizedDateFormatter } from '../base/i18n/dateUtil';
|
||||
import i18next from '../base/i18n/i18next';
|
||||
import { MEET_FEATURES } from '../base/jwt/constants';
|
||||
import { isJwtFeatureEnabled } from '../base/jwt/functions';
|
||||
import { getParticipantById, isPrivateChatEnabled } from '../base/participants/functions';
|
||||
import { IParticipant } from '../base/participants/types';
|
||||
import { escapeRegexp } from '../base/util/helpers';
|
||||
import { arePollsDisabled } from '../conference/functions.any';
|
||||
import { getCustomPanelWidth } from '../custom-panel/functions';
|
||||
import { isFileSharingEnabled } from '../file-sharing/functions.any';
|
||||
import { getParticipantsPaneWidth } from '../participants-pane/functions';
|
||||
import { isCCTabEnabled } from '../subtitles/functions.any';
|
||||
import { VIDEO_SPACE_MIN_SIZE } from '../video-layout/constants';
|
||||
import { IVisitorChatParticipant } from '../visitors/types';
|
||||
|
||||
import { ChatTabs, MAX_PENDING_EDITS, MESSAGE_TYPE_ERROR, MESSAGE_TYPE_LOCAL, TIMESTAMP_FORMAT } from './constants';
|
||||
import { IMessage, IPendingEdit, IPendingEditsMap } from './types';
|
||||
|
||||
/**
|
||||
* An ASCII emoticon regexp array to find and replace old-style ASCII
|
||||
* emoticons (such as :O) with the new Unicode representation, so that
|
||||
* devices and browsers that support them can render these natively
|
||||
* without a 3rd party component.
|
||||
*
|
||||
* NOTE: this is currently only used on mobile, but it can be used
|
||||
* on web too once we drop support for browsers that don't support
|
||||
* unicode emoji rendering.
|
||||
*/
|
||||
const ASCII_EMOTICON_REGEXP_ARRAY: Array<[RegExp, string]> = [];
|
||||
|
||||
/**
|
||||
* An emoji regexp array to find and replace alias emoticons
|
||||
* (such as :smiley:) with the new Unicode representation, so that
|
||||
* devices and browsers that support them can render these natively
|
||||
* without a 3rd party component.
|
||||
*
|
||||
* NOTE: this is currently only used on mobile, but it can be used
|
||||
* on web too once we drop support for browsers that don't support
|
||||
* unicode emoji rendering.
|
||||
*/
|
||||
const SLACK_EMOJI_REGEXP_ARRAY: Array<[RegExp, string]> = [];
|
||||
|
||||
(function() {
|
||||
for (const [ key, value ] of Object.entries(aliases)) {
|
||||
|
||||
// Add ASCII emoticons
|
||||
const asciiEmoticons = emojiAsciiAliases[key];
|
||||
|
||||
if (asciiEmoticons) {
|
||||
const asciiEscapedValues = asciiEmoticons.map((v: string) => escapeRegexp(v));
|
||||
|
||||
const asciiRegexp = `(${asciiEscapedValues.join('|')})`;
|
||||
|
||||
// Escape urls
|
||||
const formattedAsciiRegexp = key === 'confused'
|
||||
? `(?=(${asciiRegexp}))(:(?!//).)`
|
||||
: asciiRegexp;
|
||||
|
||||
ASCII_EMOTICON_REGEXP_ARRAY.push([ new RegExp(formattedAsciiRegexp, 'g'), value as string ]);
|
||||
}
|
||||
|
||||
// Add slack-type emojis
|
||||
const emojiRegexp = `\\B(${escapeRegexp(`:${key}:`)})\\B`;
|
||||
|
||||
SLACK_EMOJI_REGEXP_ARRAY.push([ new RegExp(emojiRegexp, 'g'), value as string ]);
|
||||
}
|
||||
})();
|
||||
|
||||
/**
|
||||
* Replaces ASCII and other non-unicode emoticons with unicode emojis to let the emojis be rendered
|
||||
* by the platform native renderer.
|
||||
*
|
||||
* @param {string} message - The message to parse and replace.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function replaceNonUnicodeEmojis(message: string): string {
|
||||
let replacedMessage = message;
|
||||
|
||||
for (const [ regexp, replaceValue ] of SLACK_EMOJI_REGEXP_ARRAY) {
|
||||
replacedMessage = replacedMessage.replace(regexp, replaceValue);
|
||||
}
|
||||
|
||||
for (const [ regexp, replaceValue ] of ASCII_EMOTICON_REGEXP_ARRAY) {
|
||||
replacedMessage = replacedMessage.replace(regexp, replaceValue);
|
||||
}
|
||||
|
||||
return replacedMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selector for calculating the number of unread chat messages.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {number} The number of unread messages.
|
||||
*/
|
||||
export function getUnreadCount(state: IReduxState): number {
|
||||
const { lastReadMessage, messages } = state['features/chat'];
|
||||
const messagesCount = messages.length;
|
||||
|
||||
if (!messagesCount) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let reactionMessages = 0;
|
||||
let lastReadIndex: number;
|
||||
|
||||
if (navigator.product === 'ReactNative') {
|
||||
// React native stores the messages in a reversed order.
|
||||
lastReadIndex = messages.indexOf(<IMessage>lastReadMessage);
|
||||
|
||||
for (let i = 0; i < lastReadIndex; i++) {
|
||||
if (messages[i].isReaction) {
|
||||
reactionMessages++;
|
||||
}
|
||||
}
|
||||
|
||||
return lastReadIndex - reactionMessages;
|
||||
}
|
||||
|
||||
lastReadIndex = messages.lastIndexOf(<IMessage>lastReadMessage);
|
||||
|
||||
for (let i = lastReadIndex + 1; i < messagesCount; i++) {
|
||||
if (messages[i].isReaction) {
|
||||
reactionMessages++;
|
||||
}
|
||||
}
|
||||
|
||||
return messagesCount - (lastReadIndex + 1) - reactionMessages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the unread files count.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {number} The number of unread files.
|
||||
*/
|
||||
export function getUnreadFilesCount(state: IReduxState): number {
|
||||
return state['features/chat']?.unreadFilesCount || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether the chat smileys are disabled or not.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {boolean} The disabled flag.
|
||||
*/
|
||||
export function areSmileysDisabled(state: IReduxState): boolean {
|
||||
const disableChatSmileys = state['features/base/config']?.disableChatSmileys === true;
|
||||
|
||||
return disableChatSmileys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the chat feature is disabled.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {boolean} True if chat is disabled, false otherwise.
|
||||
*/
|
||||
export function isChatDisabled(state: IReduxState): boolean {
|
||||
return Boolean(state['features/base/config']?.disableChat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the default focused tab based on what features are enabled.
|
||||
* Returns the first available tab in priority order: CHAT -> POLLS -> FILE_SHARING -> CLOSED_CAPTIONS.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {ChatTabs | undefined} The default focused tab.
|
||||
*/
|
||||
export function getDefaultFocusedTab(state: IReduxState): ChatTabs | undefined {
|
||||
if (!isChatDisabled(state)) {
|
||||
return ChatTabs.CHAT;
|
||||
}
|
||||
|
||||
if (!arePollsDisabled(state)) {
|
||||
return ChatTabs.POLLS;
|
||||
}
|
||||
|
||||
if (isFileSharingEnabled(state)) {
|
||||
return ChatTabs.FILE_SHARING;
|
||||
}
|
||||
|
||||
if (isCCTabEnabled(state)) {
|
||||
return ChatTabs.CLOSED_CAPTIONS;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently focused tab or the default focused tab if none is set.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {ChatTabs | undefined} The focused tab or undefined if no tabs are available.
|
||||
*/
|
||||
export function getFocusedTab(state: IReduxState): ChatTabs | undefined {
|
||||
return state['features/chat'].focusedTab || getDefaultFocusedTab(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the timestamp to display for the message.
|
||||
*
|
||||
* @param {IMessage} message - The message from which to get the timestamp.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getFormattedTimestamp(message: IMessage): string {
|
||||
return getLocalizedDateFormatter(new Date(message.timestamp))
|
||||
.format(TIMESTAMP_FORMAT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the message text to be rendered in the component.
|
||||
*
|
||||
* @param {IMessage} message - The message from which to get the text.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getMessageText(message: IMessage): string {
|
||||
return message.messageType === MESSAGE_TYPE_ERROR
|
||||
? i18next.t('chat.error', {
|
||||
error: message.message
|
||||
})
|
||||
: message.message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes regexp special characters in a string.
|
||||
*
|
||||
* @param {string} value - The string to escape.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function escapeSearchRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits message text into segments, flagging which ones match the search
|
||||
* query (case-insensitive), so callers can render matches with a highlight
|
||||
* style. Shared between web and native so both wrap matches identically.
|
||||
*
|
||||
* @param {string} text - The message text.
|
||||
* @param {string} query - The search query.
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
export function getHighlightSegments(text: string, query: string): Array<{ match: boolean; text: string; }> {
|
||||
if (!query) {
|
||||
return [ { match: false,
|
||||
text } ];
|
||||
}
|
||||
|
||||
const regexp = new RegExp(`(${escapeSearchRegExp(query)})`, 'gi');
|
||||
|
||||
return text
|
||||
.split(regexp)
|
||||
.filter(part => part.length > 0)
|
||||
.map(part => ({
|
||||
match: part.toLowerCase() === query.toLowerCase(),
|
||||
text: part
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a message can be replied to.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {IMessage} message - The message to be checked.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function getCanReplyToMessage(state: IReduxState, message: IMessage): boolean {
|
||||
const { knocking } = state['features/lobby'];
|
||||
const participant = getParticipantById(state, message.participantId);
|
||||
|
||||
// Check if basic reply conditions are met
|
||||
const basicCanReply = (Boolean(participant) || message.isFromVisitor)
|
||||
&& (message.privateMessage || (message.lobbyChat && !knocking))
|
||||
&& message.messageType !== MESSAGE_TYPE_LOCAL;
|
||||
|
||||
if (!basicCanReply) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check private chat configuration for visitor messages
|
||||
if (message.isFromVisitor) {
|
||||
const visitorParticipant = { id: message.participantId, name: message.displayName, isVisitor: true as const };
|
||||
|
||||
return isPrivateChatEnabled(visitorParticipant, state);
|
||||
}
|
||||
|
||||
// For non-visitor messages, use the regular participant
|
||||
return isPrivateChatEnabled(participant, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the message that is displayed as a notice for private messages.
|
||||
*
|
||||
* @param {IMessage} message - The message to be checked.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getPrivateNoticeMessage(message: IMessage): string {
|
||||
let recipient;
|
||||
|
||||
if (message.messageType === MESSAGE_TYPE_LOCAL) {
|
||||
// For messages sent by local user, show the recipient name
|
||||
// For visitor messages, use the visitor's display name with indicator
|
||||
recipient = message.sentToVisitor ? `${message.recipient} ${i18next.t('visitors.chatIndicator')}` : message.recipient;
|
||||
} else {
|
||||
// For messages received from others, show "you"
|
||||
recipient = i18next.t('chat.you');
|
||||
}
|
||||
|
||||
return i18next.t('chat.privateNotice', {
|
||||
recipient
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if participant is not allowed to send group messages.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {boolean} - Returns true if the participant is not allowed to send group messages.
|
||||
*/
|
||||
export function isSendGroupChatDisabled(state: IReduxState): boolean {
|
||||
const { groupChatRequiresPermission } = state['features/dynamic-branding'];
|
||||
|
||||
if (!groupChatRequiresPermission) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !isJwtFeatureEnabled(state, MEET_FEATURES.SEND_GROUPCHAT, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the maximum width available for the chat panel based on the current window size
|
||||
* and other UI elements.
|
||||
*
|
||||
* @param {IReduxState} state - The Redux state containing the application's current state.
|
||||
* @returns {number} The maximum width in pixels available for the chat panel. Returns 0 if there
|
||||
* is no space available.
|
||||
*/
|
||||
export function getChatMaxSize(state: IReduxState): number {
|
||||
const { clientWidth } = state['features/base/responsive-ui'];
|
||||
|
||||
return Math.max(
|
||||
clientWidth - getParticipantsPaneWidth(state) - getCustomPanelWidth(state) - VIDEO_SPACE_MIN_SIZE,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a participant is a visitor chat participant.
|
||||
*
|
||||
* @param {IParticipant | IVisitorChatParticipant | undefined} participant - The participant to check.
|
||||
* @returns {boolean} - True if the participant is a visitor chat participant.
|
||||
*/
|
||||
export function isVisitorChatParticipant(
|
||||
participant?: IParticipant | IVisitorChatParticipant
|
||||
): participant is IVisitorChatParticipant {
|
||||
return Boolean(participant && 'isVisitor' in participant && participant.isVisitor === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a suffix to be appended to the display name based on the message origin.
|
||||
*
|
||||
* @param {IMessage} message - The message.
|
||||
* @returns {string} The suffix, if any.
|
||||
*/
|
||||
export function getDisplayNameSuffix(message: IMessage): string {
|
||||
let suffix = '';
|
||||
|
||||
if (message.isFromVisitor) {
|
||||
suffix = ` ${i18next.t('visitors.chatIndicator')}`;
|
||||
} else if (message.isFromGuest) {
|
||||
suffix = ` ${i18next.t('chat.guestsChatIndicator')}`;
|
||||
}
|
||||
|
||||
return suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a message is a file message by verifying the presence of file metadata.
|
||||
*
|
||||
* @param {IMessage} message - The message to check.
|
||||
* @returns {boolean} True if the message contains file metadata, false otherwise.
|
||||
*/
|
||||
export function isFileMessage(message: IMessage): boolean {
|
||||
return Boolean(message?.fileMetadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a pending edit into the map, evicting the oldest entry if the cap is exceeded.
|
||||
*
|
||||
* @param {IPendingEditsMap} pendingEdits - The current pending edits map.
|
||||
* @param {string} messageId - The id of the message the edit targets.
|
||||
* @param {IPendingEdit} edit - The pending edit to insert.
|
||||
* @returns {IPendingEditsMap} The updated pending edits map.
|
||||
*/
|
||||
export function addPendingEdit(
|
||||
pendingEdits: IPendingEditsMap,
|
||||
messageId: string,
|
||||
edit: IPendingEdit
|
||||
): IPendingEditsMap {
|
||||
const next = { ...pendingEdits, [messageId]: edit };
|
||||
const keys = Object.keys(next);
|
||||
|
||||
if (keys.length > MAX_PENDING_EDITS) {
|
||||
// Object key order is insertion order for string keys in JS, so the first key is the oldest.
|
||||
delete next[keys[0]];
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns the current chat search query.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getChatSearchQuery(state: IReduxState): string {
|
||||
return state['features/chat'].searchQuery || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable empty-array reference returned when there is no active search query, so repeated
|
||||
* "no query" calls return the same reference instead of a new empty array each time.
|
||||
*/
|
||||
const NO_SEARCH_MATCHES: IMessage[] = [];
|
||||
|
||||
const _getMessages = (state: IReduxState) => state['features/chat'].messages;
|
||||
|
||||
/**
|
||||
* Returns all messages matching the current search query, in display order.
|
||||
* Reaction and file messages are excluded since they have no searchable text.
|
||||
* Memoized: only recomputes when messages or the search query change.
|
||||
*
|
||||
* @returns {IMessage[]}
|
||||
*/
|
||||
export const getChatSearchMatches = createSelector(
|
||||
_getMessages,
|
||||
getChatSearchQuery,
|
||||
(messages, searchQuery): IMessage[] => {
|
||||
const query = searchQuery?.trim().toLowerCase();
|
||||
|
||||
if (!query) {
|
||||
return NO_SEARCH_MATCHES;
|
||||
}
|
||||
|
||||
return messages.filter(message =>
|
||||
!message.isReaction
|
||||
&& !isFileMessage(message)
|
||||
&& getMessageText(message).toLowerCase().includes(query));
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns the index of the currently focused search match.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getChatSearchMatchIndex(state: IReduxState): number {
|
||||
return state['features/chat'].searchMatchIndex || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the message that is the currently focused search match, if any.
|
||||
* Memoized via getChatSearchMatches — this is called from every ChatMessage's
|
||||
* mapStateToProps, so keeping it cheap matters.
|
||||
*
|
||||
* @returns {IMessage | undefined}
|
||||
*/
|
||||
export const getActiveChatSearchMatch = createSelector(
|
||||
getChatSearchMatches,
|
||||
getChatSearchMatchIndex,
|
||||
(matches, index): IMessage | undefined => matches[index]
|
||||
);
|
||||
Reference in New Issue
Block a user