stream-chat
Version:
JS SDK for the Stream Chat API
1,344 lines (1,178 loc) • 40.2 kB
text/typescript
import FormData from 'form-data';
import type {
AscDesc,
ChannelFilters,
ChannelQueryOptions,
ChannelSort,
ChannelSortBase,
LocalMessage,
LocalMessageBase,
Logger,
Message,
MessagePaginationOptions,
MessageResponse,
MessageResponseBase,
MessageSet,
OwnUserBase,
OwnUserResponse,
PromoteChannelParams,
QueryChannelAPIResponse,
ReactionGroupResponse,
UpdatedMessage,
UserResponse,
} from './types';
import type { StreamChat } from './client';
import type { Channel } from './channel';
import type { AxiosRequestConfig } from 'axios';
import { LOCAL_MESSAGE_FIELDS, RESERVED_UPDATED_MESSAGE_FIELDS } from './constants';
/**
* logChatPromiseExecution - utility function for logging the execution of a promise..
* use this when you want to run the promise and handle errors by logging a warning
*
* @param {Promise<T>} promise The promise you want to run and log
* @param {string} name A descriptive name of what the promise does for log output
*
*/
export function logChatPromiseExecution<T>(promise: Promise<T>, name: string) {
promise.then().catch((error) => {
console.warn(`failed to do ${name}, ran into error: `, error);
});
}
export const sleep = (m: number): Promise<void> => new Promise((r) => setTimeout(r, m));
export function isFunction(value: unknown): value is (...args: unknown[]) => unknown {
return (
typeof value === 'function' ||
value instanceof Function ||
Object.prototype.toString.call(value) === '[object Function]'
);
}
export const chatCodes = {
TOKEN_EXPIRED: 40,
WS_CLOSED_SUCCESS: 1000,
};
function isReadableStream(obj: unknown): obj is NodeJS.ReadStream {
return (
obj !== null &&
typeof obj === 'object' &&
((obj as NodeJS.ReadStream).readable ||
typeof (obj as NodeJS.ReadStream)._read === 'function')
);
}
function isBuffer(obj: unknown): obj is Buffer {
return (
obj != null &&
(obj as Buffer).constructor != null &&
// @ts-expect-error expected
typeof obj.constructor.isBuffer === 'function' &&
// @ts-expect-error expected
obj.constructor.isBuffer(obj)
);
}
function isFileWebAPI(uri: unknown): uri is File {
return typeof window !== 'undefined' && 'File' in window && uri instanceof File;
}
export function isOwnUser(
user?: OwnUserResponse | UserResponse,
): user is OwnUserResponse {
return (user as OwnUserResponse)?.total_unread_count !== undefined;
}
function isBlobWebAPI(uri: unknown): uri is Blob {
return typeof window !== 'undefined' && 'Blob' in window && uri instanceof Blob;
}
export function isOwnUserBaseProperty(property: string) {
const ownUserBaseProperties: {
[Property in keyof Required<OwnUserBase>]: boolean;
} = {
channel_mutes: true,
devices: true,
mutes: true,
total_unread_count: true,
unread_channels: true,
unread_count: true,
unread_threads: true,
invisible: true,
privacy_settings: true,
roles: true,
push_preferences: true,
};
return ownUserBaseProperties[property as keyof OwnUserBase];
}
export function addFileToFormData(
uri: string | NodeJS.ReadableStream | Buffer | File,
name?: string,
contentType?: string,
) {
const data = new FormData();
if (isReadableStream(uri) || isBuffer(uri) || isFileWebAPI(uri) || isBlobWebAPI(uri)) {
if (name) data.append('file', uri, name);
else data.append('file', uri);
} else {
data.append('file', {
uri,
name: name || (uri as string).split('/').reverse()[0],
contentType: contentType || undefined,
type: contentType || undefined,
});
}
return data;
}
export function normalizeQuerySort<T extends Record<string, AscDesc | undefined>>(
sort: T | T[],
) {
const sortFields: Array<{ direction: AscDesc; field: keyof T }> = [];
const sortArr = Array.isArray(sort) ? sort : [sort];
for (const item of sortArr) {
const entries = Object.entries(item) as [keyof T, AscDesc][];
if (entries.length > 1) {
console.warn(
"client._buildSort() - multiple fields in a single sort object detected. Object's field order is not guaranteed",
);
}
for (const [field, direction] of entries) {
sortFields.push({ field, direction });
}
}
return sortFields;
}
/**
* retryInterval - A retry interval which increases acc to number of failures
*
* @return {number} Duration to wait in milliseconds
*/
export function retryInterval(numberOfFailures: number) {
// try to reconnect in 0.25-25 seconds (random to spread out the load from failures)
const max = Math.min(500 + numberOfFailures * 2000, 25000);
const min = Math.min(Math.max(250, (numberOfFailures - 1) * 2000), 25000);
return Math.floor(Math.random() * (max - min) + min);
}
export function randomId() {
return generateUUIDv4();
}
function hex(bytes: Uint8Array): string {
let s = '';
for (let i = 0; i < bytes.length; i++) {
s += bytes[i].toString(16).padStart(2, '0');
}
return s;
}
// https://tools.ietf.org/html/rfc4122
export function generateUUIDv4() {
const bytes = getRandomBytes(16);
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version
bytes[8] = (bytes[8] & 0xbf) | 0x80; // variant
return (
hex(bytes.subarray(0, 4)) +
'-' +
hex(bytes.subarray(4, 6)) +
'-' +
hex(bytes.subarray(6, 8)) +
'-' +
hex(bytes.subarray(8, 10)) +
'-' +
hex(bytes.subarray(10, 16))
);
}
function getRandomValuesWithMathRandom(bytes: Uint8Array): void {
const max = Math.pow(2, (8 * bytes.byteLength) / bytes.length);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = Math.random() * max;
}
}
declare const msCrypto: Crypto;
const getRandomValues = (() => {
if (typeof crypto !== 'undefined' && typeof crypto?.getRandomValues !== 'undefined') {
return crypto.getRandomValues.bind(crypto);
} else if (typeof msCrypto !== 'undefined') {
return msCrypto.getRandomValues.bind(msCrypto);
} else {
return getRandomValuesWithMathRandom;
}
})();
function getRandomBytes(length: number): Uint8Array {
const bytes = new Uint8Array(length);
getRandomValues(bytes);
return bytes;
}
export function convertErrorToJson(err: Error) {
const jsonObj = {} as Record<string, unknown>;
if (!err) return jsonObj;
try {
Object.getOwnPropertyNames(err).forEach((key) => {
jsonObj[key] = Object.getOwnPropertyDescriptor(err, key);
});
} catch (_) {
return {
error: 'failed to serialize the error',
};
}
return jsonObj;
}
/**
* isOnline safely return the navigator.online value for browser env
* if navigator is not in global object, it always return true
*/
export function isOnline() {
const nav =
typeof navigator !== 'undefined'
? navigator
: typeof window !== 'undefined' && window.navigator
? window.navigator
: undefined;
if (!nav) {
console.warn(
'isOnline failed to access window.navigator and assume browser is online',
);
return true;
}
// RN navigator has undefined for onLine
if (typeof nav.onLine !== 'boolean') {
return true;
}
return nav.onLine;
}
/**
* listenForConnectionChanges - Adds an event listener fired on browser going online or offline
*/
export function addConnectionEventListeners(cb: (e: Event) => void) {
if (typeof window !== 'undefined' && window.addEventListener) {
window.addEventListener('offline', cb);
window.addEventListener('online', cb);
}
}
export function removeConnectionEventListeners(cb: (e: Event) => void) {
if (typeof window !== 'undefined' && window.removeEventListener) {
window.removeEventListener('offline', cb);
window.removeEventListener('online', cb);
}
}
export const axiosParamsSerializer: AxiosRequestConfig['paramsSerializer'] = (params) => {
const newParams = [];
for (const k in params) {
// Stream backend doesn't treat "undefined" value same as value not being present.
// So, we need to skip the undefined values.
if (params[k] === undefined) continue;
if (Array.isArray(params[k]) || typeof params[k] === 'object') {
newParams.push(`${k}=${encodeURIComponent(JSON.stringify(params[k]))}`);
} else {
newParams.push(`${k}=${encodeURIComponent(params[k])}`);
}
}
return newParams.join('&');
};
/**
* Takes the message object, parses the dates, sets `__html`
* and sets the status to `received` if missing; returns a new LocalMessage object.
*
* @param {LocalMessage} message `LocalMessage` object
*/
export function formatMessage(
message: MessageResponse | MessageResponseBase | LocalMessage,
): LocalMessage {
const toLocalMessageBase = (
msg: MessageResponse | MessageResponseBase | LocalMessage | null | undefined,
): LocalMessageBase | null => {
if (!msg) return null;
return {
...msg,
created_at: msg.created_at ? new Date(msg.created_at) : new Date(),
deleted_at: msg.deleted_at ? new Date(msg.deleted_at) : null,
pinned_at: msg.pinned_at ? new Date(msg.pinned_at) : null,
reaction_groups: maybeGetReactionGroupsFallback(
msg.reaction_groups,
msg.reaction_counts,
msg.reaction_scores,
),
status: msg.status || 'received',
updated_at: msg.updated_at ? new Date(msg.updated_at) : new Date(),
};
};
return {
...toLocalMessageBase(message),
error: (message as LocalMessage).error ?? null,
quoted_message: toLocalMessageBase((message as MessageResponse).quoted_message),
} as LocalMessage;
}
/**
* @private
*
* Takes a LocalMessage, parses the dates back to strings,
* and converts the message back to a MessageResponse.
*
* @param {MessageResponse} message `MessageResponse` object
*/
export function unformatMessage(message: LocalMessage): MessageResponse {
const toMessageResponseBase = (
msg: LocalMessage | null | undefined,
): MessageResponseBase | null => {
if (!msg) return null;
const newDateString = new Date().toISOString();
return {
...msg,
created_at: message.created_at ? message.created_at.toISOString() : newDateString,
deleted_at: message.deleted_at ? message.deleted_at.toISOString() : undefined,
pinned_at: message.pinned_at ? message.pinned_at.toISOString() : undefined,
updated_at: message.updated_at ? message.updated_at.toISOString() : newDateString,
};
};
return {
...toMessageResponseBase(message),
quoted_message: toMessageResponseBase((message as LocalMessage).quoted_message),
} as MessageResponse;
}
export const localMessageToNewMessagePayload = (localMessage: LocalMessage): Message => {
/* eslint-disable @typescript-eslint/no-unused-vars */
const {
// Remove all timestamp fields and client-specific fields.
// Field pinned_at can therefore be earlier than created_at as new message payload can hold it.
created_at,
updated_at,
deleted_at,
// Client-specific fields
error,
status,
// Reaction related fields
latest_reactions,
own_reactions,
reaction_counts,
reaction_scores,
reply_count,
// Message text related fields that shouldn't be in update
command,
html,
i18n,
quoted_message,
mentioned_users,
// Message content related fields
...messageFields
} = localMessage;
return {
...messageFields,
pinned_at: messageFields.pinned_at?.toISOString(),
mentioned_users: mentioned_users?.map((user) => user.id),
};
};
export const toUpdatedMessagePayload = (
message: LocalMessage | Partial<MessageResponse>,
): UpdatedMessage => {
const reservedKeys = {
...RESERVED_UPDATED_MESSAGE_FIELDS,
...LOCAL_MESSAGE_FIELDS,
} as const;
const messageFields = Object.fromEntries(
Object.entries(message).filter(
([key]) => !reservedKeys[key as keyof typeof reservedKeys],
),
) as UpdatedMessage;
return {
...messageFields,
pinned: !!message.pinned_at,
mentioned_users: message.mentioned_users?.map((user) =>
typeof user === 'string' ? user : user.id,
),
};
};
export const toDeletedMessage = ({
message,
deletedAt,
hardDelete = false,
}: {
message: LocalMessage | LocalMessageBase;
deletedAt: LocalMessage['deleted_at'];
hardDelete: boolean;
}) => {
if (hardDelete) {
/**
* In case of hard delete, we need to strip down all text, html, attachments and all the custom properties on message
* The hard-deleted message is kept in the UI until the messages are re-queried
* FIXME: we are returning an object that does not match LocalMessage | LocalMessageBase
*/
return {
attachments: [],
cid: message.cid,
created_at: message.created_at,
deleted_at: deletedAt,
id: message.id,
latest_reactions: [],
mentioned_users: [],
own_reactions: [],
parent_id: message.parent_id,
reply_count: message.reply_count,
status: message.status,
thread_participants: message.thread_participants,
type: 'deleted' as const,
updated_at: message.updated_at,
user: message.user,
};
} else {
return {
...message,
attachments: [],
type: 'deleted',
deleted_at: deletedAt,
};
}
};
export const deleteUserMessages = ({
messages,
user,
hardDelete = false,
deletedAt,
}: {
messages: Array<LocalMessage>;
user: UserResponse;
hardDelete: boolean;
deletedAt: LocalMessage['deleted_at'];
}) => {
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
if (message.user?.id === user.id) {
messages[i] =
message.type === 'deleted'
? message
: (toDeletedMessage({ message, hardDelete, deletedAt }) as LocalMessage);
}
if (message.quoted_message?.user?.id === user.id) {
messages[i].quoted_message =
message.quoted_message.type === 'deleted'
? message.quoted_message
: (toDeletedMessage({
message: messages[i].quoted_message as LocalMessageBase,
hardDelete,
deletedAt,
}) as LocalMessage);
}
}
};
export const findIndexInSortedArray = <T, L>({
needle,
sortedArray,
selectKey,
selectValueToCompare = (e) => e,
sortDirection = 'ascending',
}: {
needle: T;
sortedArray: readonly T[];
/**
* In an array of objects (like messages), pick a unique property identifying
* an element. It will be used to find a direct match for the needle element
* in case compare values are not unique.
*
* @example
* ```ts
* selectKey: (message) => message.id
* ```
*/
selectKey?: (arrayElement: T) => string;
/**
* In an array of objects (like messages), pick a specific
* property to compare the needle value to.
*
* @example
* ```ts
* selectValueToCompare: (message) => message.created_at.getTime()
* ```
*/
selectValueToCompare?: (arrayElement: T) => L | T;
/**
* @default ascending
* @description
* ```md
* ascending - [1,2,3,4,5...]
* descending - [...5,4,3,2,1]
* ```
*/
sortDirection?: 'ascending' | 'descending';
}) => {
if (!sortedArray.length) return 0;
let left = 0;
let right = sortedArray.length - 1;
let middle = 0;
const recalculateMiddle = () => {
middle = Math.round((left + right) / 2);
};
const comparableNeedle = selectValueToCompare(needle);
while (left <= right) {
recalculateMiddle();
const comparableMiddle = selectValueToCompare(sortedArray[middle]);
if (
(sortDirection === 'ascending' && comparableNeedle < comparableMiddle) ||
(sortDirection === 'descending' && comparableNeedle >= comparableMiddle)
) {
right = middle - 1;
} else {
left = middle + 1;
}
}
// In case there are several array elements with the same comparable value, search around the insertion
// point to possibly find an element with the same key. If found, prefer it.
// This, for example, prevents duplication of messages with the same creation date.
if (selectKey) {
const needleKey = selectKey(needle);
const step = sortDirection === 'ascending' ? -1 : +1;
for (
let i = left + step;
0 <= i &&
i < sortedArray.length &&
selectValueToCompare(sortedArray[i]) === comparableNeedle;
i += step
) {
if (selectKey(sortedArray[i]) === needleKey) {
return i;
}
}
}
return left;
};
export function addToMessageList<T extends LocalMessage>(
messages: readonly T[],
newMessage: T,
timestampChanged = false,
sortBy: 'pinned_at' | 'created_at' = 'created_at',
addIfDoesNotExist = true,
) {
const addMessageToList = addIfDoesNotExist || timestampChanged;
let newMessages = [...messages];
// if created_at has changed, message should be filtered and re-inserted in correct order
// slow op but usually this only happens for a message inserted to state before actual response with correct timestamp
if (timestampChanged) {
newMessages = newMessages.filter(
(message) => !(message.id && newMessage.id === message.id),
);
}
// for empty list just concat and return unless it's an update or deletion
if (newMessages.length === 0 && addMessageToList) {
return newMessages.concat(newMessage);
} else if (newMessages.length === 0) {
return newMessages;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const messageTime = newMessage[sortBy]!.getTime();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const messageIsNewest = newMessages.at(-1)![sortBy]!.getTime() < messageTime;
// if message is newer than last item in the list concat and return unless it's an update or deletion
if (messageIsNewest && addMessageToList) {
return newMessages.concat(newMessage);
} else if (messageIsNewest) {
return newMessages;
}
// find the closest index to push the new message
const insertionIndex = findIndexInSortedArray({
needle: newMessage,
sortedArray: newMessages,
sortDirection: 'ascending',
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
selectValueToCompare: (m) => m[sortBy]!.getTime(),
selectKey: (m) => m.id,
});
// message already exists and not filtered with timestampChanged, update and return
if (
!timestampChanged &&
newMessage.id &&
newMessages[insertionIndex] &&
newMessage.id === newMessages[insertionIndex].id
) {
newMessages[insertionIndex] = newMessage;
return newMessages;
}
// do not add updated or deleted messages to the list if they already exist or come with a timestamp change
if (addMessageToList) {
newMessages.splice(insertionIndex, 0, newMessage);
}
return newMessages;
}
function maybeGetReactionGroupsFallback(
groups: { [key: string]: ReactionGroupResponse } | null | undefined,
counts: { [key: string]: number } | null | undefined,
scores: { [key: string]: number } | null | undefined,
): { [key: string]: ReactionGroupResponse } | null {
if (groups) {
return groups;
}
if (counts && scores) {
const fallback: { [key: string]: ReactionGroupResponse } = {};
for (const type of Object.keys(counts)) {
fallback[type] = {
count: counts[type],
sum_scores: scores[type],
};
}
return fallback;
}
return null;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface DebouncedFunc<T extends (...args: any[]) => any> {
/**
* Call the original function, but applying the debounce rules.
*
* If the debounced function can be run immediately, this calls it and returns its return
* value.
*
* Otherwise, it returns the return value of the last invocation, or undefined if the debounced
* function was not invoked yet.
*/
(...args: Parameters<T>): ReturnType<T> | undefined;
/**
* Throw away any pending invocation of the debounced function.
*/
cancel(): void;
/**
* If there is a pending invocation of the debounced function, invoke it immediately and return
* its return value.
*
* Otherwise, return the value from the last invocation, or undefined if the debounced function
* was never invoked.
*/
flush(): ReturnType<T> | undefined;
}
// works exactly the same as lodash.debounce
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const debounce = <T extends (...args: any[]) => any>(
fn: T,
timeout = 0,
{ leading = false, trailing = true }: { leading?: boolean; trailing?: boolean } = {},
): DebouncedFunc<T> => {
let runningTimeout: null | NodeJS.Timeout = null;
let argsForTrailingExecution: Parameters<T> | null = null;
let lastResult: ReturnType<T> | undefined;
const debouncedFn = (...args: Parameters<T>) => {
if (runningTimeout) {
clearTimeout(runningTimeout);
} else if (leading) {
lastResult = fn(...args);
}
if (trailing) argsForTrailingExecution = args;
const timeoutHandler = () => {
if (argsForTrailingExecution) {
lastResult = fn(...argsForTrailingExecution);
argsForTrailingExecution = null;
}
runningTimeout = null;
};
runningTimeout = setTimeout(timeoutHandler, timeout);
return lastResult;
};
debouncedFn.cancel = () => {
if (runningTimeout) clearTimeout(runningTimeout);
};
debouncedFn.flush = () => {
if (runningTimeout) {
clearTimeout(runningTimeout);
runningTimeout = null;
if (argsForTrailingExecution) {
lastResult = fn(...argsForTrailingExecution);
}
}
return lastResult;
};
return debouncedFn;
};
// works exactly the same as lodash.throttle
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const throttle = <T extends (...args: any[]) => any>(
fn: T,
timeout = 200,
{ leading = true, trailing = false }: { leading?: boolean; trailing?: boolean } = {},
) => {
let runningTimeout: null | NodeJS.Timeout = null;
let storedArgs: Parameters<T> | null = null;
return (...args: Parameters<T>) => {
if (runningTimeout) {
if (trailing) storedArgs = args;
return;
}
if (leading) fn(...args);
const timeoutHandler = () => {
if (storedArgs) {
fn(...storedArgs);
storedArgs = null;
runningTimeout = setTimeout(timeoutHandler, timeout);
return;
}
runningTimeout = null;
};
runningTimeout = setTimeout(timeoutHandler, timeout);
};
};
const get = <T>(obj: T, path: string): unknown =>
path.split('.').reduce<unknown>((acc, key) => {
if (acc && typeof acc === 'object' && key in acc) {
return (acc as Record<string, unknown>)[key];
}
return undefined;
}, obj);
// works exactly the same as lodash.uniqBy
export const uniqBy = <T>(
array: T[] | unknown,
iteratee: ((item: T) => unknown) | keyof T,
): T[] => {
if (!Array.isArray(array)) return [];
const seen = new Set<unknown>();
return array.filter((item) => {
const key =
typeof iteratee === 'function' ? iteratee(item) : get(item, iteratee as string);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
type MessagePaginationUpdatedParams = {
parentSet: MessageSet;
requestedPageSize: number;
returnedPage: MessageResponse[];
logger?: Logger;
messagePaginationOptions?: MessagePaginationOptions;
};
export function binarySearchByDateEqualOrNearestGreater(
array: {
created_at?: string;
}[],
targetDate: Date,
): number {
let left = 0;
let right = array.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const midCreatedAt = array[mid].created_at;
if (!midCreatedAt) {
left += 1;
continue;
}
const midDate = new Date(midCreatedAt);
if (midDate.getTime() === targetDate.getTime()) {
return mid;
} else if (midDate.getTime() < targetDate.getTime()) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return left;
}
const messagePaginationCreatedAtAround = ({
parentSet,
requestedPageSize,
returnedPage,
messagePaginationOptions,
}: MessagePaginationUpdatedParams) => {
const newPagination = { ...parentSet.pagination };
if (!messagePaginationOptions?.created_at_around) return newPagination;
let hasPrev;
let hasNext;
let updateHasPrev;
let updateHasNext;
const createdAtAroundDate = new Date(messagePaginationOptions.created_at_around);
const [firstPageMsg, lastPageMsg] = [returnedPage[0], returnedPage.slice(-1)[0]];
// expect ASC order (from oldest to newest)
const wholePageHasNewerMessages =
!!firstPageMsg?.created_at && new Date(firstPageMsg.created_at) > createdAtAroundDate;
const wholePageHasOlderMessages =
!!lastPageMsg?.created_at && new Date(lastPageMsg.created_at) < createdAtAroundDate;
const requestedPageSizeNotMet =
requestedPageSize > parentSet.messages.length &&
requestedPageSize > returnedPage.length;
const noMoreMessages =
(requestedPageSize > parentSet.messages.length ||
parentSet.messages.length >= returnedPage.length) &&
requestedPageSize > returnedPage.length;
if (wholePageHasNewerMessages) {
hasPrev = false;
updateHasPrev = true;
if (requestedPageSizeNotMet) {
hasNext = false;
updateHasNext = true;
}
} else if (wholePageHasOlderMessages) {
hasNext = false;
updateHasNext = true;
if (requestedPageSizeNotMet) {
hasPrev = false;
updateHasPrev = true;
}
} else if (noMoreMessages) {
hasNext = hasPrev = false;
updateHasPrev = updateHasNext = true;
} else {
const [firstPageMsgIsFirstInSet, lastPageMsgIsLastInSet] = [
firstPageMsg?.id && firstPageMsg.id === parentSet.messages[0]?.id,
lastPageMsg?.id && lastPageMsg.id === parentSet.messages.slice(-1)[0]?.id,
];
updateHasPrev = firstPageMsgIsFirstInSet;
updateHasNext = lastPageMsgIsLastInSet;
const midPointByCount = Math.floor(returnedPage.length / 2);
const midPointByCreationDate = binarySearchByDateEqualOrNearestGreater(
returnedPage,
createdAtAroundDate,
);
if (midPointByCreationDate !== -1) {
hasPrev = midPointByCount <= midPointByCreationDate;
hasNext = midPointByCount >= midPointByCreationDate;
}
}
if (updateHasPrev && typeof hasPrev !== 'undefined') newPagination.hasPrev = hasPrev;
if (updateHasNext && typeof hasNext !== 'undefined') newPagination.hasNext = hasNext;
return newPagination;
};
const messagePaginationIdAround = ({
parentSet,
requestedPageSize,
returnedPage,
messagePaginationOptions,
}: MessagePaginationUpdatedParams) => {
const newPagination = { ...parentSet.pagination };
const { id_around } = messagePaginationOptions || {};
if (!id_around) return newPagination;
let hasPrev;
let hasNext;
const [firstPageMsg, lastPageMsg] = [returnedPage[0], returnedPage.slice(-1)[0]];
const [firstPageMsgIsFirstInSet, lastPageMsgIsLastInSet] = [
firstPageMsg?.id === parentSet.messages[0]?.id,
lastPageMsg?.id === parentSet.messages.slice(-1)[0]?.id,
];
let updateHasPrev = firstPageMsgIsFirstInSet;
let updateHasNext = lastPageMsgIsLastInSet;
const midPoint = Math.floor(returnedPage.length / 2);
const noMoreMessages =
(requestedPageSize > parentSet.messages.length ||
parentSet.messages.length >= returnedPage.length) &&
requestedPageSize > returnedPage.length;
if (noMoreMessages) {
hasNext = hasPrev = false;
updateHasPrev = updateHasNext = true;
} else if (!returnedPage[midPoint]) {
return newPagination;
} else if (returnedPage[midPoint].id === id_around) {
hasPrev = hasNext = true;
} else {
let targetMsg;
const halves = [returnedPage.slice(0, midPoint), returnedPage.slice(midPoint)];
hasPrev = hasNext = true;
for (let i = 0; i < halves.length; i++) {
targetMsg = halves[i].find((message) => message.id === id_around);
if (targetMsg && i === 0) {
hasPrev = false;
}
if (targetMsg && i === 1) {
hasNext = false;
}
}
}
if (updateHasPrev && typeof hasPrev !== 'undefined') newPagination.hasPrev = hasPrev;
if (updateHasNext && typeof hasNext !== 'undefined') newPagination.hasNext = hasNext;
return newPagination;
};
const messagePaginationLinear = ({
parentSet,
requestedPageSize,
returnedPage,
messagePaginationOptions,
}: MessagePaginationUpdatedParams) => {
const newPagination = { ...parentSet.pagination };
let hasPrev;
let hasNext;
const [firstPageMsg, lastPageMsg] = [returnedPage[0], returnedPage.slice(-1)[0]];
const [firstPageMsgIsFirstInSet, lastPageMsgIsLastInSet] = [
firstPageMsg?.id && firstPageMsg.id === parentSet.messages[0]?.id,
lastPageMsg?.id && lastPageMsg.id === parentSet.messages.slice(-1)[0]?.id,
];
const queriedNextMessages =
messagePaginationOptions &&
(messagePaginationOptions.created_at_after_or_equal ||
messagePaginationOptions.created_at_after ||
messagePaginationOptions.id_gt ||
messagePaginationOptions.id_gte);
const queriedPrevMessages =
typeof messagePaginationOptions === 'undefined'
? true
: messagePaginationOptions.created_at_before_or_equal ||
messagePaginationOptions.created_at_before ||
messagePaginationOptions.id_lt ||
messagePaginationOptions.id_lte ||
messagePaginationOptions.offset;
const containsUnrecognizedOptionsOnly =
!queriedNextMessages &&
!queriedPrevMessages &&
!messagePaginationOptions?.id_around &&
!messagePaginationOptions?.created_at_around;
const hasMore = returnedPage.length >= requestedPageSize;
if (typeof queriedPrevMessages !== 'undefined' || containsUnrecognizedOptionsOnly) {
hasPrev = hasMore;
}
if (typeof queriedNextMessages !== 'undefined') {
hasNext = hasMore;
}
const returnedPageIsEmpty = returnedPage.length === 0;
if ((firstPageMsgIsFirstInSet || returnedPageIsEmpty) && typeof hasPrev !== 'undefined')
newPagination.hasPrev = hasPrev;
if ((lastPageMsgIsLastInSet || returnedPageIsEmpty) && typeof hasNext !== 'undefined')
newPagination.hasNext = hasNext;
return newPagination;
};
export const messageSetPagination = (params: MessagePaginationUpdatedParams) => {
const messagesFilteredLocally = params.returnedPage.filter(({ shadowed }) => shadowed);
if (
params.parentSet.messages.length + messagesFilteredLocally.length <
params.returnedPage.length
) {
params.logger?.(
'error',
'Corrupted message set state: parent set size < returned page size',
);
return params.parentSet.pagination;
}
if (params.messagePaginationOptions?.created_at_around) {
return messagePaginationCreatedAtAround(params);
} else if (params.messagePaginationOptions?.id_around) {
return messagePaginationIdAround(params);
} else {
return messagePaginationLinear(params);
}
};
/**
* A utility object used to prevent duplicate invocation of channel.watch() to be triggered when
* 'notification.message_new' and 'notification.added_to_channel' events arrive at the same time.
*/
const WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL: Record<
string,
Promise<QueryChannelAPIResponse> | undefined
> = {};
type GetChannelParams = {
client: StreamChat;
channel?: Channel;
id?: string;
members?: string[];
options?: ChannelQueryOptions;
type?: string;
};
/**
* Calls channel.watch() if it was not already recently called. Waits for watch promise to resolve even if it was invoked previously.
* If the channel is not passed as a property, it will get it either by its channel.cid or by its members list and do the same.
* @param client
* @param members
* @param options
* @param type
* @param id
* @param channel
*/
export const getAndWatchChannel = async ({
channel,
client,
id,
members,
options,
type,
}: GetChannelParams) => {
if (!channel && !type) {
throw new Error('Channel or channel type have to be provided to query a channel.');
}
// unfortunately typescript is not able to infer that if (!channel && !type) === false, then channel or type has to be truthy
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const channelToWatch = channel || client.channel(type!, id, { members });
// need to keep as with call to channel.watch the id can be changed from undefined to an actual ID generated server-side
const originalCid = channelToWatch.id
? channelToWatch.cid
: members && members.length
? generateChannelTempCid(channelToWatch.type, members)
: undefined;
if (!originalCid) {
throw new Error(
'Channel ID or channel members array have to be provided to query a channel.',
);
}
const queryPromise = WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[originalCid];
if (queryPromise) {
await queryPromise;
} else {
try {
WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[originalCid] = channelToWatch.watch(options);
await WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[originalCid];
} finally {
delete WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[originalCid];
}
}
return channelToWatch;
};
/**
* Generates a temporary channel.cid for channels created without ID, as they need to be referenced
* by an identifier until the back-end generates the final ID. The cid is generated by its member IDs
* which are sorted and can be recreated the same every time given the same arguments.
* @param channelType
* @param members
*/
export const generateChannelTempCid = (channelType: string, members: string[]) => {
if (!members) return;
const membersStr = [...members].sort().join(',');
if (!membersStr) return;
return `${channelType}:!members-${membersStr}`;
};
/**
* Checks if a channel is pinned or not. Will return true only if channel.state.membership.pinned_at exists.
* @param channel
*/
export const isChannelPinned = (channel: Channel) => {
if (!channel) return false;
const member = channel.state.membership;
return !!member?.pinned_at;
};
/**
* Checks if a channel is archived or not. Will return true only if channel.state.membership.archived_at exists.
* @param channel
*/
export const isChannelArchived = (channel: Channel) => {
if (!channel) return false;
const member = channel.state.membership;
return !!member?.archived_at;
};
/**
* A utility that tells us whether we should consider archived channels or not based
* on filters. Will return true only if filters.archived exists and is a boolean value.
* @param filters
*/
export const shouldConsiderArchivedChannels = (filters: ChannelFilters) => {
if (!filters) return false;
return typeof filters.archived === 'boolean';
};
/**
* Extracts the value of the sort parameter at a given index, for a targeted key. Can
* handle both array and object versions of sort. Will return null if the index/key
* combination does not exist.
* @param atIndex - the index at which we'll examine the sort value, if it's an array one
* @param sort - the sort value - both array and object notations are accepted
* @param targetKey - the target key which needs to exist for the sort at a certain index
*/
export const extractSortValue = ({
atIndex,
sort,
targetKey,
}: {
atIndex: number;
targetKey: keyof ChannelSortBase;
sort?: ChannelSort;
}) => {
if (!sort) return null;
let option: null | ChannelSortBase = null;
if (Array.isArray(sort)) {
option = sort[atIndex] ?? null;
} else {
let index = 0;
for (const key in sort) {
if (index !== atIndex) {
index++;
continue;
}
if (key !== targetKey) {
return null;
}
option = sort;
break;
}
}
return option?.[targetKey] ?? null;
};
/**
* Returns true only if `{ pinned_at: -1 }` or `{ pinned_at: 1 }` option is first within the `sort` array.
*/
export const shouldConsiderPinnedChannels = (sort: ChannelSort) => {
const value = findPinnedAtSortOrder({ sort });
if (typeof value !== 'number') return false;
return Math.abs(value) === 1;
};
/**
* Checks whether the sort value of type object contains a pinned_at value or if
* an array sort value type has the first value be an object containing pinned_at.
* @param sort
*/
export const findPinnedAtSortOrder = ({ sort }: { sort: ChannelSort }) =>
extractSortValue({
atIndex: 0,
sort,
targetKey: 'pinned_at',
});
/**
* Finds the index of the last consecutively pinned channel, starting from the start of the
* array. Will not consider any pinned channels after the contiguous subsequence at the
* start of the array.
* @param channels
*/
export const findLastPinnedChannelIndex = ({ channels }: { channels: Channel[] }) => {
let lastPinnedChannelIndex: number | null = null;
for (const channel of channels) {
if (!isChannelPinned(channel)) break;
if (typeof lastPinnedChannelIndex === 'number') {
lastPinnedChannelIndex++;
} else {
lastPinnedChannelIndex = 0;
}
}
return lastPinnedChannelIndex;
};
/**
* A utility used to move a channel towards the beginning of a list of channels (promote it to a higher position). It
* considers pinned channels in the process if needed and makes sure to only update the list reference if the list
* should actually change. It will try to move the channel as high as it can within the list.
* @param channels - the list of channels we want to modify
* @param channelToMove - the channel we want to promote
* @param channelToMoveIndexWithinChannels - optionally, the index of the channel we want to move if we know it (will skip a manual check)
* @param sort - the sort value used to check for pinned channels
*/
export const promoteChannel = ({
channels,
channelToMove,
channelToMoveIndexWithinChannels,
sort,
}: PromoteChannelParams) => {
// get index of channel to move up
const targetChannelIndex =
channelToMoveIndexWithinChannels ??
channels.findIndex((channel) => channel.cid === channelToMove.cid);
const targetChannelExistsWithinList = targetChannelIndex >= 0;
const targetChannelAlreadyAtTheTop = targetChannelIndex === 0;
// pinned channels should not move within the list based on recent activity, channels which
// receive messages and are not pinned should move upwards but only under the last pinned channel
// in the list
const considerPinnedChannels = shouldConsiderPinnedChannels(sort);
const isTargetChannelPinned = isChannelPinned(channelToMove);
if (targetChannelAlreadyAtTheTop || (considerPinnedChannels && isTargetChannelPinned)) {
return channels;
}
const newChannels = [...channels];
// target channel index is known, remove it from the list
if (targetChannelExistsWithinList) {
newChannels.splice(targetChannelIndex, 1);
}
// as position of pinned channels has to stay unchanged, we need to
// find last pinned channel in the list to move the target channel after
let lastPinnedChannelIndex: number | null = null;
if (considerPinnedChannels) {
lastPinnedChannelIndex = findLastPinnedChannelIndex({ channels: newChannels });
}
// re-insert it at the new place (to specific index if pinned channels are considered)
newChannels.splice(
typeof lastPinnedChannelIndex === 'number' ? lastPinnedChannelIndex + 1 : 0,
0,
channelToMove,
);
return newChannels;
};
export const isDate = (value: unknown): value is Date => !!(value as Date).getTime;
export const isLocalMessage = (message: unknown): message is LocalMessage =>
isDate((message as LocalMessage).created_at);
export const runDetached = <T>(
callback: Promise<void | T>,
options?: {
context?: string;
onSuccessCallback?: (res: T | void) => void | Promise<void>;
onErrorCallback?: (error: Error) => void | Promise<void>;
},
) => {
const { context, onSuccessCallback = () => undefined, onErrorCallback } = options ?? {};
const defaultOnError = (error: Error) => {
console.log(`An error has occurred in context ${context}: ${error}`);
};
const onError = onErrorCallback ?? defaultOnError;
let promise = callback;
if (onSuccessCallback) {
promise = promise.then(onSuccessCallback);
}
promise.catch(onError);
};