stream-chat
Version:
JS SDK for the Stream Chat API
754 lines (662 loc) • 23.5 kB
text/typescript
import type { StreamChat } from './client';
import type {
ChannelFilters,
ChannelOptions,
ChannelSort,
ChannelStateOptions,
Event,
} from './types';
import type { ValueOrPatch } from './store';
import { isPatch, StateStore } from './store';
import type { Channel } from './channel';
import {
extractSortValue,
findLastPinnedChannelIndex,
getAndWatchChannel,
isChannelArchived,
isChannelPinned,
promoteChannel,
shouldConsiderArchivedChannels,
shouldConsiderPinnedChannels,
sleep,
uniqBy,
} from './utils';
import { generateUUIDv4 } from './utils';
import {
DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES,
DEFAULT_QUERY_CHANNELS_RETRY_COUNT,
} from './constants';
import { WithSubscriptions } from './utils/WithSubscriptions';
export type ChannelManagerPagination = {
filters: ChannelFilters;
hasNext: boolean;
isLoading: boolean;
isLoadingNext: boolean;
options: ChannelOptions;
sort: ChannelSort;
};
export type ChannelManagerState = {
channels: Channel[];
/**
* This value will become true the first time queryChannels is successfully executed and
* will remain false otherwise. It's used as a control property regarding whether the list
* has been initialized yet (i.e a query has already been done at least once) or not. We do
* this to prevent state.channels from being forced to be nullable.
*/
initialized: boolean;
pagination: ChannelManagerPagination;
error: Error | undefined;
};
export type ChannelSetterParameterType = ValueOrPatch<ChannelManagerState['channels']>;
export type ChannelSetterType = (arg: ChannelSetterParameterType) => void;
export type GenericEventHandlerType<T extends unknown[]> = (
...args: T
) => void | (() => void) | ((...args: T) => Promise<void>) | Promise<void>;
export type EventHandlerType = GenericEventHandlerType<[Event]>;
export type EventHandlerOverrideType = GenericEventHandlerType<
[ChannelSetterType, Event]
>;
export type ChannelManagerEventTypes =
| 'notification.added_to_channel'
| 'notification.message_new'
| 'notification.removed_from_channel'
| 'message.new'
| 'member.updated'
| 'channel.deleted'
| 'channel.hidden'
| 'channel.truncated'
| 'channel.visible'
| 'channel.updated';
export type ChannelManagerEventHandlerNames =
| 'channelDeletedHandler'
| 'channelHiddenHandler'
| 'channelTruncatedHandler'
| 'channelUpdatedHandler'
| 'channelVisibleHandler'
| 'newMessageHandler'
| 'memberUpdatedHandler'
| 'notificationAddedToChannelHandler'
| 'notificationNewMessageHandler'
| 'notificationRemovedFromChannelHandler';
export type ChannelManagerEventHandlerOverrides = Partial<
Record<ChannelManagerEventHandlerNames, EventHandlerOverrideType>
>;
export type ExecuteChannelsQueryPayload = Pick<
ChannelManagerPagination,
'filters' | 'sort' | 'options'
> & { stateOptions: ChannelStateOptions };
export const channelManagerEventToHandlerMapping: {
[key in ChannelManagerEventTypes]: ChannelManagerEventHandlerNames;
} = {
'channel.deleted': 'channelDeletedHandler',
'channel.hidden': 'channelHiddenHandler',
'channel.truncated': 'channelTruncatedHandler',
'channel.updated': 'channelUpdatedHandler',
'channel.visible': 'channelVisibleHandler',
'message.new': 'newMessageHandler',
'member.updated': 'memberUpdatedHandler',
'notification.added_to_channel': 'notificationAddedToChannelHandler',
'notification.message_new': 'notificationNewMessageHandler',
'notification.removed_from_channel': 'notificationRemovedFromChannelHandler',
};
export type ChannelManagerOptions = {
/**
* Aborts a channels query that is already in progress and runs the new one.
*/
abortInFlightQuery?: boolean;
/**
* Allows channel promotion to be applied where applicable for channels that are
* currently not part of the channel list within the state. A good example of
* this would be a channel that is being watched and it receives a new message,
* but is not part of the list initially.
*/
allowNotLoadedChannelPromotionForEvent?: {
'channel.visible': boolean;
'message.new': boolean;
'notification.added_to_channel': boolean;
'notification.message_new': boolean;
};
/**
* Allows us to lock the order of channels within the list. Any event that would
* change the order of channels within the list will do nothing.
*/
lockChannelOrder?: boolean;
};
export type QueryChannelsRequestType = (
...params: Parameters<StreamChat['queryChannels']>
) => Promise<Channel[]>;
export const DEFAULT_CHANNEL_MANAGER_OPTIONS = {
abortInFlightQuery: false,
allowNotLoadedChannelPromotionForEvent: {
'channel.visible': true,
'message.new': true,
'notification.added_to_channel': true,
'notification.message_new': true,
},
lockChannelOrder: false,
};
export const DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS = {
limit: 10,
offset: 0,
};
/**
* A class that manages a list of channels and changes it based on configuration and WS events. The
* list of channels is reactive as well as the pagination and it can be subscribed to for state updates.
*
* @internal
*/
export class ChannelManager extends WithSubscriptions {
public readonly state: StateStore<ChannelManagerState>;
private client: StreamChat;
private eventHandlers: Map<string, EventHandlerType> = new Map();
private eventHandlerOverrides: Map<string, EventHandlerOverrideType> = new Map();
private queryChannelsRequest: QueryChannelsRequestType;
private options: ChannelManagerOptions = {};
private stateOptions: ChannelStateOptions = {};
private id: string;
constructor({
client,
eventHandlerOverrides = {},
options = {},
queryChannelsOverride,
}: {
client: StreamChat;
eventHandlerOverrides?: ChannelManagerEventHandlerOverrides;
options?: ChannelManagerOptions;
queryChannelsOverride?: QueryChannelsRequestType;
}) {
super();
this.id = `channel-manager-${generateUUIDv4()}`;
this.client = client;
this.state = new StateStore<ChannelManagerState>({
channels: [],
pagination: {
isLoading: false,
isLoadingNext: false,
hasNext: false,
filters: {},
sort: {},
options: DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS,
},
initialized: false,
error: undefined,
});
this.setEventHandlerOverrides(eventHandlerOverrides);
this.setOptions(options);
this.queryChannelsRequest =
queryChannelsOverride ?? ((...params) => this.client.queryChannels(...params));
this.eventHandlers = new Map(
Object.entries<EventHandlerType>({
channelDeletedHandler: this.channelDeletedHandler,
channelHiddenHandler: this.channelHiddenHandler,
channelVisibleHandler: this.channelVisibleHandler,
memberUpdatedHandler: this.memberUpdatedHandler,
newMessageHandler: this.newMessageHandler,
notificationAddedToChannelHandler: this.notificationAddedToChannelHandler,
notificationNewMessageHandler: this.notificationNewMessageHandler,
notificationRemovedFromChannelHandler: this.notificationRemovedFromChannelHandler,
}),
);
}
public setChannels = (valueOrFactory: ChannelSetterParameterType) => {
this.state.next((current) => {
const { channels: currentChannels } = current;
const newChannels = isPatch(valueOrFactory)
? valueOrFactory(currentChannels)
: valueOrFactory;
// If the references between the two values are the same, just return the
// current state; otherwise trigger a state change.
if (currentChannels === newChannels) {
return current;
}
return { ...current, channels: newChannels };
});
const {
channels,
pagination: { filters, sort },
} = this.state.getLatestValue();
this.client.offlineDb?.executeQuerySafely(
(db) =>
db.upsertCidsForQuery({
cids: channels.map((channel) => channel.cid),
filters,
sort,
}),
{ method: 'upsertCidsForQuery' },
);
};
public setEventHandlerOverrides = (
eventHandlerOverrides: ChannelManagerEventHandlerOverrides = {},
) => {
const truthyEventHandlerOverrides = Object.entries(eventHandlerOverrides).reduce<
Partial<ChannelManagerEventHandlerOverrides>
>((acc, [key, value]) => {
if (value) {
acc[key as keyof ChannelManagerEventHandlerOverrides] = value;
}
return acc;
}, {});
this.eventHandlerOverrides = new Map(
Object.entries<EventHandlerOverrideType>(truthyEventHandlerOverrides),
);
};
public setQueryChannelsRequest = (queryChannelsRequest: QueryChannelsRequestType) => {
this.queryChannelsRequest = queryChannelsRequest;
};
public setOptions = (options: ChannelManagerOptions = {}) => {
this.options = { ...DEFAULT_CHANNEL_MANAGER_OPTIONS, ...options };
};
private executeChannelsQuery = async (
payload: ExecuteChannelsQueryPayload,
retryCount = 0,
): Promise<void> => {
const { filters, sort, options, stateOptions } = payload;
const { offset, limit } = {
...DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS,
...options,
};
try {
const channels = await this.queryChannelsRequest(
filters,
sort,
options,
stateOptions,
);
const newOffset = offset + (channels?.length ?? 0);
const newOptions = { ...options, offset: newOffset };
const { pagination } = this.state.getLatestValue();
this.state.partialNext({
channels,
pagination: {
...pagination,
hasNext: (channels?.length ?? 0) >= limit,
isLoading: false,
options: newOptions,
},
initialized: true,
error: undefined,
});
this.client.offlineDb?.executeQuerySafely(
(db) =>
db.upsertCidsForQuery({
cids: channels.map((channel) => channel.cid),
filters: pagination.filters,
sort: pagination.sort,
}),
{ method: 'upsertCidsForQuery' },
);
} catch (err) {
if (retryCount >= DEFAULT_QUERY_CHANNELS_RETRY_COUNT) {
console.warn(err);
const wrappedError = new Error(
`Maximum number of retries reached in queryChannels. Last error message is: ${err}`,
);
const state = this.state.getLatestValue();
// If the offline support is enabled, and there are channels in the DB, we should not error out.
const isOfflineSupportEnabledWithChannels =
this.client.offlineDb && state.channels.length > 0;
this.state.partialNext({
error: isOfflineSupportEnabledWithChannels ? undefined : wrappedError,
pagination: {
...state.pagination,
isLoading: false,
isLoadingNext: false,
},
});
return;
}
await sleep(DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES);
return this.executeChannelsQuery(payload, retryCount + 1);
}
};
public queryChannels = async (
filters: ChannelFilters,
sort: ChannelSort = [],
options: ChannelOptions = {},
stateOptions: ChannelStateOptions = {},
) => {
const {
pagination: { isLoading, filters: filtersFromState },
initialized,
} = this.state.getLatestValue();
if (
isLoading &&
!this.options.abortInFlightQuery &&
// TODO: Figure a proper way to either deeply compare these or
// create hashes from each.
JSON.stringify(filtersFromState) === JSON.stringify(filters)
) {
return;
}
const executeChannelsQueryPayload = { filters, sort, options, stateOptions };
try {
this.stateOptions = stateOptions;
this.state.next((currentState) => ({
...currentState,
pagination: {
...currentState.pagination,
isLoading: true,
isLoadingNext: false,
filters,
sort,
options,
},
error: undefined,
}));
if (this.client.offlineDb?.getChannelsForQuery && this.client.user?.id) {
if (!initialized) {
const channelsFromDB = await this.client.offlineDb.getChannelsForQuery({
userId: this.client.user.id,
filters,
sort,
});
if (channelsFromDB) {
const offlineChannels = this.client.hydrateActiveChannels(channelsFromDB, {
offlineMode: true,
skipInitialization: [], // passing empty array will clear out the existing messages from channel state, this removes the possibility of duplicate messages
});
this.state.partialNext({ channels: offlineChannels });
}
}
if (!this.client.offlineDb.syncManager.syncStatus) {
this.client.offlineDb.syncManager.scheduleSyncStatusChangeCallback(
this.id,
async () => {
await this.executeChannelsQuery(executeChannelsQueryPayload);
},
);
return;
}
}
await this.executeChannelsQuery(executeChannelsQueryPayload);
} catch (error) {
this.client.logger('error', (error as Error).message);
this.state.next((currentState) => ({
...currentState,
pagination: { ...currentState.pagination, isLoading: false },
}));
throw error;
}
};
public loadNext = async () => {
const { pagination, initialized } = this.state.getLatestValue();
const { filters, sort, options, isLoadingNext, hasNext } = pagination;
if (!initialized || isLoadingNext || !hasNext) {
return;
}
try {
const { offset, limit } = {
...DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS,
...options,
};
this.state.partialNext({
pagination: { ...pagination, isLoading: false, isLoadingNext: true },
});
const nextChannels = await this.queryChannelsRequest(
filters,
sort,
options,
this.stateOptions,
);
const { channels } = this.state.getLatestValue();
const newOffset = offset + (nextChannels?.length ?? 0);
const newOptions = { ...options, offset: newOffset };
this.state.partialNext({
channels: uniqBy<Channel>([...(channels || []), ...nextChannels], 'cid'),
pagination: {
...pagination,
hasNext: (nextChannels?.length ?? 0) >= limit,
isLoading: false,
isLoadingNext: false,
options: newOptions,
},
});
} catch (error) {
this.client.logger('error', (error as Error).message);
this.state.next((currentState) => ({
...currentState,
pagination: {
...currentState.pagination,
isLoadingNext: false,
isLoading: false,
},
}));
throw error;
}
};
private notificationAddedToChannelHandler = async (event: Event) => {
const { id, type, members } = event?.channel ?? {};
if (
!type ||
!this.options.allowNotLoadedChannelPromotionForEvent?.[
'notification.added_to_channel'
]
) {
return;
}
const channel = await getAndWatchChannel({
client: this.client,
id,
members: members?.reduce<string[]>((acc, { user, user_id }) => {
const userId = user_id || user?.id;
if (userId) {
acc.push(userId);
}
return acc;
}, []),
type,
});
const { pagination, channels } = this.state.getLatestValue();
if (!channels) {
return;
}
const { sort } = pagination ?? {};
this.setChannels(
promoteChannel({
channels,
channelToMove: channel,
sort,
}),
);
};
private channelDeletedHandler = (event: Event) => {
const { channels } = this.state.getLatestValue();
if (!channels) {
return;
}
const newChannels = [...channels];
const channelIndex = newChannels.findIndex(
(channel) => channel.cid === (event.cid || event.channel?.cid),
);
if (channelIndex < 0) {
return;
}
newChannels.splice(channelIndex, 1);
this.setChannels(newChannels);
};
private channelHiddenHandler = this.channelDeletedHandler;
private newMessageHandler = (event: Event) => {
const { pagination, channels } = this.state.getLatestValue();
if (!channels) {
return;
}
const { filters, sort } = pagination ?? {};
const channelType = event.channel_type;
const channelId = event.channel_id;
if (!channelType || !channelId) {
return;
}
const targetChannel = this.client.channel(channelType, channelId);
const targetChannelIndex = channels.indexOf(targetChannel);
const targetChannelExistsWithinList = targetChannelIndex >= 0;
const isTargetChannelPinned = isChannelPinned(targetChannel);
const isTargetChannelArchived = isChannelArchived(targetChannel);
const considerArchivedChannels = shouldConsiderArchivedChannels(filters);
const considerPinnedChannels = shouldConsiderPinnedChannels(sort);
if (
// filter is defined, target channel is archived and filter option is set to false
(considerArchivedChannels && isTargetChannelArchived && !filters.archived) ||
// filter is defined, target channel isn't archived and filter option is set to true
(considerArchivedChannels && !isTargetChannelArchived && filters.archived) ||
// sort option is defined, target channel is pinned
(considerPinnedChannels && isTargetChannelPinned) ||
// list order is locked
this.options.lockChannelOrder ||
// target channel is not within the loaded list and loading from cache is disallowed
(!targetChannelExistsWithinList &&
!this.options.allowNotLoadedChannelPromotionForEvent?.['message.new'])
) {
return;
}
this.setChannels(
promoteChannel({
channels,
channelToMove: targetChannel,
channelToMoveIndexWithinChannels: targetChannelIndex,
sort,
}),
);
};
private notificationNewMessageHandler = async (event: Event) => {
const { id, type } = event?.channel ?? {};
if (!id || !type) {
return;
}
const channel = await getAndWatchChannel({
client: this.client,
id,
type,
});
const { channels, pagination } = this.state.getLatestValue();
const { filters, sort } = pagination ?? {};
const considerArchivedChannels = shouldConsiderArchivedChannels(filters);
const isTargetChannelArchived = isChannelArchived(channel);
if (
!channels ||
(considerArchivedChannels && isTargetChannelArchived && !filters.archived) ||
(considerArchivedChannels && !isTargetChannelArchived && filters.archived) ||
!this.options.allowNotLoadedChannelPromotionForEvent?.['notification.message_new']
) {
return;
}
this.setChannels(
promoteChannel({
channels,
channelToMove: channel,
sort,
}),
);
};
private channelVisibleHandler = async (event: Event) => {
const { channel_type: channelType, channel_id: channelId } = event;
if (!channelType || !channelId) {
return;
}
const channel = await getAndWatchChannel({
client: this.client,
id: event.channel_id,
type: event.channel_type,
});
const { channels, pagination } = this.state.getLatestValue();
const { sort, filters } = pagination ?? {};
const considerArchivedChannels = shouldConsiderArchivedChannels(filters);
const isTargetChannelArchived = isChannelArchived(channel);
if (
!channels ||
(considerArchivedChannels && isTargetChannelArchived && !filters.archived) ||
(considerArchivedChannels && !isTargetChannelArchived && filters.archived) ||
!this.options.allowNotLoadedChannelPromotionForEvent?.['channel.visible']
) {
return;
}
this.setChannels(
promoteChannel({
channels,
channelToMove: channel,
sort,
}),
);
};
private notificationRemovedFromChannelHandler = this.channelDeletedHandler;
private memberUpdatedHandler = (event: Event) => {
const { pagination, channels } = this.state.getLatestValue();
const { filters, sort } = pagination;
if (
!event.member?.user ||
event.member.user.id !== this.client.userID ||
!event.channel_type ||
!event.channel_id
) {
return;
}
const channelType = event.channel_type;
const channelId = event.channel_id;
const considerPinnedChannels = shouldConsiderPinnedChannels(sort);
const considerArchivedChannels = shouldConsiderArchivedChannels(filters);
const pinnedAtSort = extractSortValue({ atIndex: 0, sort, targetKey: 'pinned_at' });
if (
!channels ||
(!considerPinnedChannels && !considerArchivedChannels) ||
this.options.lockChannelOrder
) {
return;
}
const targetChannel = this.client.channel(channelType, channelId);
// assumes that channel instances are not changing
const targetChannelIndex = channels.indexOf(targetChannel);
const targetChannelExistsWithinList = targetChannelIndex >= 0;
const isTargetChannelPinned = isChannelPinned(targetChannel);
const isTargetChannelArchived = isChannelArchived(targetChannel);
const newChannels = [...channels];
if (targetChannelExistsWithinList) {
newChannels.splice(targetChannelIndex, 1);
}
// handle archiving (remove channel)
if (
// When archived filter true, and channel is unarchived
(considerArchivedChannels && !isTargetChannelArchived && filters?.archived) ||
// When archived filter false, and channel is archived
(considerArchivedChannels && isTargetChannelArchived && !filters?.archived)
) {
this.setChannels(newChannels);
return;
}
// handle pinning
let lastPinnedChannelIndex: number | null = null;
if (pinnedAtSort === 1 || (pinnedAtSort === -1 && !isTargetChannelPinned)) {
lastPinnedChannelIndex = findLastPinnedChannelIndex({ channels: newChannels });
}
const newTargetChannelIndex =
typeof lastPinnedChannelIndex === 'number' ? lastPinnedChannelIndex + 1 : 0;
// skip state update if the position of the channel does not change
if (channels[newTargetChannelIndex] === targetChannel) {
return;
}
newChannels.splice(newTargetChannelIndex, 0, targetChannel);
this.setChannels(newChannels);
};
private subscriptionOrOverride = (event: Event) => {
const handlerName =
channelManagerEventToHandlerMapping[event.type as ChannelManagerEventTypes];
const defaultEventHandler = this.eventHandlers.get(handlerName);
const eventHandlerOverride = this.eventHandlerOverrides.get(handlerName);
if (eventHandlerOverride && typeof eventHandlerOverride === 'function') {
eventHandlerOverride(this.setChannels, event);
return;
}
if (defaultEventHandler && typeof defaultEventHandler === 'function') {
defaultEventHandler(event);
}
};
public registerSubscriptions = () => {
if (this.hasSubscriptions) {
// Already listening for events and changes
return;
}
for (const eventType of Object.keys(channelManagerEventToHandlerMapping)) {
this.addUnsubscribeFunction(
this.client.on(eventType, this.subscriptionOrOverride).unsubscribe,
);
}
};
}