stream-chat-react-native-core
Version:
The official React Native and Expo components for Stream Chat, a service for building chat applications
424 lines (378 loc) • 13.1 kB
text/typescript
import type React from 'react';
import dayjs from 'dayjs';
import EmojiRegex from 'emoji-regex';
import type {
AttachmentLoadingState,
ChannelState,
LocalMessage,
MessageResponse,
} from 'stream-chat';
import { IconProps } from '../../src/icons/utils/base';
import { ValueOf } from '../types/types';
export type ReactionData = {
Icon: React.ComponentType<IconProps>;
type: string;
/**
* Whether the reaction should appear inside of the reaction picker in the
* context menu or not. If `true`, it will consequently not appear in the
* extra reactions bottom sheet.
*/
isMain?: boolean;
};
export const FileState = Object.freeze({
BLOCKED: 'blocked',
FAILED: 'failed',
FINISHED: 'finished',
PENDING: 'pending',
UPLOADING: 'uploading',
});
export const ProgressIndicatorTypes: {
IN_PROGRESS: 'in_progress';
INACTIVE: 'inactive';
NOT_SUPPORTED: 'not_supported';
PENDING: 'pending';
RETRY: 'retry';
} = Object.freeze({
IN_PROGRESS: 'in_progress',
INACTIVE: 'inactive',
NOT_SUPPORTED: 'not_supported',
PENDING: 'pending',
RETRY: 'retry',
});
export const MessageStatusTypes = {
DELIVERED: 'delivered',
FAILED: 'failed',
RECEIVED: 'received',
SENDING: 'sending',
};
export type Progress = ValueOf<typeof ProgressIndicatorTypes>;
type IndicatorStatesMap = Record<AttachmentLoadingState, Progress | undefined>;
export const getIndicatorTypeForFileState = (
fileState: AttachmentLoadingState,
allowSendBeforeAttachmentsUpload: boolean,
): Progress | undefined => {
const indicatorMap: IndicatorStatesMap = {
[FileState.UPLOADING]: allowSendBeforeAttachmentsUpload
? ProgressIndicatorTypes.INACTIVE
: ProgressIndicatorTypes.IN_PROGRESS,
[FileState.BLOCKED]: ProgressIndicatorTypes.NOT_SUPPORTED,
[FileState.FAILED]: allowSendBeforeAttachmentsUpload
? ProgressIndicatorTypes.INACTIVE
: ProgressIndicatorTypes.RETRY,
[FileState.PENDING]: ProgressIndicatorTypes.PENDING,
[FileState.FINISHED]: ProgressIndicatorTypes.INACTIVE,
};
return indicatorMap[fileState];
};
/**
* Utility to check if the message is a Blocked message.
* @param message
* @returns boolean
*/
export const isBlockedMessage = (message: LocalMessage) => {
return (
message.type === 'error' &&
(message.moderation_details?.action === 'MESSAGE_RESPONSE_ACTION_REMOVE' ||
message.moderation?.action === 'remove')
);
};
/**
* Utility to check if the message is a Bounced message.
* @param message
* @returns boolean
*/
export const isBouncedMessage = (message: LocalMessage) =>
message.type === 'error' &&
(message?.moderation_details?.action === 'MESSAGE_RESPONSE_ACTION_BOUNCE' ||
message?.moderation?.action === 'bounce');
/**
* Utility to check if the message is a edited message.
* @param message
* @returns boolean
*/
export const isEditedMessage = (message: LocalMessage) => !!message.message_text_updated_at;
export const makeImageCompatibleUrl = (url: string) =>
(url.indexOf('//') === 0 ? `https:${url}` : url).trim();
export const getUrlWithoutParams = (url?: string) => {
if (!url) {
return url;
}
const indexOfQuestion = url.indexOf('?');
if (indexOfQuestion === -1) {
return url;
}
return url.substring(0, url.indexOf('?'));
};
export const isLocalUrl = (url: string) => !url.includes('http');
export const generateRandomId = (a = ''): string =>
a
? /* eslint-disable no-bitwise */
((Number(a) ^ (Math.random() * 16)) >> (Number(a) / 4)).toString(16)
: `${1e7}-${1e3}-${4e3}-${8e3}-${1e11}`.replace(/[018]/g, generateRandomId);
/*
* Returns true if the message text only contains emojis
*/
export const hasOnlyEmojis = (text: string) => {
try {
// get all emojis in the string
const emojiOnlyString = [...text.matchAll(EmojiRegex())].join('');
// remove all spaces from original text
const originalTextWithNoSpaces = text.replaceAll(/\s/g, '');
// check if both are the same
return (
emojiOnlyString.length !== 0 && emojiOnlyString.length === originalTextWithNoSpaces.length
);
} catch (e) {
return false;
}
};
/**
* Stringifies a message object
* @param {LocalMessage} message - the message object to be stringified
* @returns {string} The stringified message
*/
export const stringifyMessage = ({
message,
includeReactions = true,
}: {
message: MessageResponse | LocalMessage;
includeReactions?: boolean;
}): string => {
const {
attachments,
deleted_at,
i18n,
latest_reactions,
reaction_groups,
reply_count,
status,
text,
type,
updated_at,
} = message;
const baseFieldsString = `${type}${deleted_at}${text}${reply_count}${status}${updated_at}${JSON.stringify(i18n)}${attachments?.length}`;
if (!includeReactions) {
return baseFieldsString;
}
return `${
latest_reactions ? latest_reactions.map(({ type, user }) => `${type}${user?.id}`).join() : ''
}${
reaction_groups
? Object.entries(reaction_groups)
.flatMap(
([type, { count, first_reaction_at, last_reaction_at }]) =>
`${type}${count}${first_reaction_at}${last_reaction_at}`,
)
.join()
: ''
}${baseFieldsString}`;
};
/**
* Reduces a list of messages to strings that are used in useEffect & useMemo
* @param {messages} messages - the array of messages to be compared
* @returns {string} The mapped message string
*/
export const reduceMessagesToString = (messages: LocalMessage[]): string =>
messages
.map((message) =>
message?.quoted_message
? `${stringifyMessage({ message })}_${message.quoted_message.type}_${message.quoted_message.deleted_at}_${message.quoted_message.text}_${message.quoted_message.updated_at}`
: stringifyMessage({ message }),
)
.join();
/**
* Utility to get the file name from the path using regex.
* `[^/]+` matches one or more characters that are not a slash (/), ensuring we capture the filename part.
* `\.` matches the period before the file extension.
* `[^/]+$` matches one or more characters that are not a slash (/) until the end of the string, capturing the file extension.
* @param path string
* @returns string
*/
export const getFileNameFromPath = (path: string) => {
const pattern = /[^/]+\.[^/]+$/;
const match = path.match(pattern);
return match ? match[0] : '';
};
/**
* Utility to get the duration label from the duration in seconds.
* @param duration number
* @returns string
*/
export const getDurationLabelFromDuration = (duration: number) => {
if (!duration) {
return '00:00';
}
const ONE_HOUR_IN_SECONDS = 3600;
const ONE_HOUR_IN_MILLISECONDS = ONE_HOUR_IN_SECONDS * 1000;
let durationLabel = '00:00';
const isDurationLongerThanHour = duration / ONE_HOUR_IN_MILLISECONDS >= 1;
const formattedDurationParam = isDurationLongerThanHour ? 'HH:mm:ss' : 'm:ss';
const formattedVideoDuration = dayjs
.duration(duration, 'milliseconds')
.format(formattedDurationParam);
durationLabel = formattedVideoDuration;
return durationLabel;
};
export const formatMsToMinSec = (ms: number) => {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const totalMinutes = Math.floor(totalSeconds / 60);
const totalHours = Math.floor(totalMinutes / 60);
if (totalHours >= 1) {
return `${totalHours}h`;
}
if (totalMinutes >= 1) {
return `${totalMinutes}m`;
}
return `${totalSeconds}s`;
};
/**
* Utility to escape special characters in a string.
* @param text
* @returns string
*/
export function escapeRegExp(text: string) {
return text.replace(/[-[\]{}()*+?.,/\\^$|#]/g, '\\$&');
}
/**
* Utility to find the index of a message in the messages array by id.
* @param messages
* @param targetId
* @returns number
*/
export const findInMessagesById = (messages: ChannelState['messages'], targetId: string) => {
const idx = messages.findIndex((message) => message.id === targetId);
return idx;
};
/**
* Utility to find the index of a message in the messages array by date.
* @param messages
* @param targetDate
* @returns an object with the index and the message object
*/
export const findInMessagesByDate = (
messages: MessageResponse[] | ChannelState['messages'],
targetDate: Date,
) => {
// Binary search
const targetTimestamp = targetDate.getTime();
let left = 0;
let right = messages.length - 1;
let middle = 0;
while (left <= right) {
middle = Math.floor(left + (right - left) / 2);
const middleTimestamp = new Date(messages[middle].created_at as string | Date).getTime();
const middleLeftTimestamp =
messages[middle - 1]?.created_at &&
new Date(messages[middle - 1].created_at as string | Date).getTime();
const middleRightTimestamp =
messages[middle + 1]?.created_at &&
new Date(messages[middle + 1].created_at as string | Date).getTime();
if (
middleTimestamp === targetTimestamp ||
(middleLeftTimestamp &&
middleRightTimestamp &&
middleLeftTimestamp < targetTimestamp &&
middleRightTimestamp > targetTimestamp)
) {
return { index: middle, message: messages[middle] };
} else if (middleTimestamp < targetTimestamp) {
left = middle + 1;
} else {
right = middle - 1;
}
}
return { index: -1 };
};
/**
* The purpose of this function is to compare two messages and determine if they are equal.
* It checks various properties of the messages, such as status, type, text, pinned state, updated_at timestamp, i18n data, and reply count.
* If all these properties match, it returns true, indicating that the messages are considered equal.
* If any of the properties differ, it returns false, indicating that the messages are not equal.
* Useful for the `areEqual` logic in the React.memo of the Message component/sub-components.
*/
export const checkMessageEquality = (
prevMessage?: LocalMessage | null,
nextMessage?: LocalMessage | null,
): boolean => {
const prevMessageExists = !!prevMessage;
const nextMessageExists = !!nextMessage;
if (!prevMessageExists && !nextMessageExists) {
return true;
}
if (prevMessageExists !== nextMessageExists) {
return false;
}
const messageEqual =
prevMessage?.status === nextMessage?.status &&
prevMessage?.type === nextMessage?.type &&
prevMessage?.text === nextMessage?.text &&
prevMessage?.pinned === nextMessage?.pinned &&
prevMessage?.i18n === nextMessage?.i18n &&
prevMessage?.reply_count === nextMessage?.reply_count &&
prevMessage?.updated_at?.getTime?.() === nextMessage?.updated_at?.getTime?.() &&
prevMessage?.deleted_at?.getTime?.() === nextMessage?.deleted_at?.getTime?.();
return messageEqual;
};
/**
* The purpose of this function is to compare two quoted messages and determine if they are equal.
* It checks various properties of the messages, such as status, type, text, updated_at timestamp, and deleted_at.
* If all these properties match, it returns true, indicating that the messages are considered equal.
* If any of the properties differ, it returns false, indicating that the messages are not equal.
* Useful for the `areEqual` logic in the React.memo of the Message component/sub-components.
*/
export const checkQuotedMessageEquality = (
prevQuotedMessage?: LocalMessage | null,
nextQuotedMessage?: LocalMessage | null,
): boolean => {
const prevQuotedMessageExists = !!prevQuotedMessage;
const nextQuotedMessageExists = !!nextQuotedMessage;
if (!prevQuotedMessageExists && !nextQuotedMessageExists) {
return true;
}
if (prevQuotedMessageExists !== nextQuotedMessageExists) {
return false;
}
const quotedMessageEqual =
prevQuotedMessage?.type === nextQuotedMessage?.type &&
prevQuotedMessage?.text === nextQuotedMessage?.text &&
prevQuotedMessage?.updated_at?.getTime?.() === nextQuotedMessage?.updated_at?.getTime?.() &&
prevQuotedMessage?.deleted_at?.getTime?.() === nextQuotedMessage?.deleted_at?.getTime?.();
return quotedMessageEqual;
};
/**
* Utility to get initials from name.
* @param name string
* @param numberOfInitials number - optional, default is 2
* @returns string
*/
export const getInitialsFromName = (name: string, numberOfInitials: number = 2) => {
if (!name) return '';
const trimmed = name.trim();
if (!trimmed) return '';
return trimmed
.split(/\s+/)
.slice(0, numberOfInitials)
.map((n) => n.charAt(0).toUpperCase())
.join('');
};
// Utility to hash a string to a number
export const hashStringToNumber = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0; // Convert to 32bit integer
}
return Math.abs(hash) ?? 0;
};
export const getFileSizeDisplayText = (size?: number | string) => {
if (!size) {
return;
}
if (typeof size === 'string') {
size = parseFloat(size);
}
if (size < 1000 * 1000) {
return `${Math.floor(Math.floor(size / 10) / 100)} KB`;
}
return `${Math.floor(Math.floor(size / 10000) / 100)} MB`;
};