stream-chat
Version:
JS SDK for the Stream Chat API
593 lines (515 loc) • 19.7 kB
text/typescript
import type { StreamChat } from './client';
import type {
DefaultGenerics,
ExtendableGenerics,
Event,
ChannelOptions,
ChannelStateOptions,
ChannelFilters,
ChannelSort,
} from './types';
import { StateStore, ValueOrPatch, isPatch } from './store';
import { Channel } from './channel';
import {
extractSortValue,
findLastPinnedChannelIndex,
getAndWatchChannel,
isChannelArchived,
isChannelPinned,
promoteChannel,
shouldConsiderArchivedChannels,
shouldConsiderPinnedChannels,
uniqBy,
} from './utils';
export type ChannelManagerPagination<SCG extends ExtendableGenerics = DefaultGenerics> = {
filters: ChannelFilters<SCG>;
hasNext: boolean;
isLoading: boolean;
isLoadingNext: boolean;
options: ChannelOptions;
sort: ChannelSort<SCG>;
};
export type ChannelManagerState<SCG extends ExtendableGenerics = DefaultGenerics> = {
channels: Channel<SCG>[];
/**
* 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<SCG>;
};
export type ChannelSetterParameterType<SCG extends ExtendableGenerics = DefaultGenerics> = ValueOrPatch<
ChannelManagerState<SCG>['channels']
>;
export type ChannelSetterType<SCG extends ExtendableGenerics = DefaultGenerics> = (
arg: ChannelSetterParameterType<SCG>,
) => void;
export type GenericEventHandlerType<T extends unknown[]> = (
...args: T
) => void | (() => void) | ((...args: T) => Promise<void>) | Promise<void>;
export type EventHandlerType<SCG extends ExtendableGenerics = DefaultGenerics> = GenericEventHandlerType<[Event<SCG>]>;
export type EventHandlerOverrideType<SCG extends ExtendableGenerics = DefaultGenerics> = GenericEventHandlerType<
[ChannelSetterType<SCG>, Event<SCG>]
>;
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<SCG extends ExtendableGenerics = DefaultGenerics> = Partial<
Record<ChannelManagerEventHandlerNames, EventHandlerOverrideType<SCG>>
>;
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 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<SCG extends ExtendableGenerics = DefaultGenerics> {
public readonly state: StateStore<ChannelManagerState<SCG>>;
private client: StreamChat<SCG>;
private unsubscribeFunctions: Set<() => void> = new Set();
private eventHandlers: Map<string, EventHandlerType<SCG>> = new Map();
private eventHandlerOverrides: Map<string, EventHandlerOverrideType<SCG>> = new Map();
private options: ChannelManagerOptions = {};
private stateOptions: ChannelStateOptions = {};
constructor({
client,
eventHandlerOverrides = {},
options = {},
}: {
client: StreamChat<SCG>;
eventHandlerOverrides?: ChannelManagerEventHandlerOverrides<SCG>;
options?: ChannelManagerOptions;
}) {
this.client = client;
this.state = new StateStore<ChannelManagerState<SCG>>({
channels: [],
pagination: {
isLoading: false,
isLoadingNext: false,
hasNext: false,
filters: {},
sort: {},
options: DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS,
},
initialized: false,
});
this.setEventHandlerOverrides(eventHandlerOverrides);
this.setOptions(options);
this.eventHandlers = new Map(
Object.entries<EventHandlerType<SCG>>({
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<SCG>) => {
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 };
});
};
public setEventHandlerOverrides = (eventHandlerOverrides: ChannelManagerEventHandlerOverrides<SCG> = {}) => {
const truthyEventHandlerOverrides = Object.entries(eventHandlerOverrides).reduce<
Partial<ChannelManagerEventHandlerOverrides<SCG>>
>((acc, [key, value]) => {
if (value) {
acc[key as keyof ChannelManagerEventHandlerOverrides<SCG>] = value;
}
return acc;
}, {});
this.eventHandlerOverrides = new Map(Object.entries<EventHandlerOverrideType<SCG>>(truthyEventHandlerOverrides));
};
public setOptions = (options: ChannelManagerOptions = {}) => {
this.options = { ...DEFAULT_CHANNEL_MANAGER_OPTIONS, ...options };
};
public queryChannels = async (
filters: ChannelFilters<SCG>,
sort: ChannelSort<SCG> = [],
options: ChannelOptions = {},
stateOptions: ChannelStateOptions = {},
) => {
const { offset, limit } = { ...DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS, ...options };
const {
pagination: { isLoading },
} = this.state.getLatestValue();
if (isLoading && !this.options.abortInFlightQuery) {
return;
}
try {
this.stateOptions = stateOptions;
this.state.next((currentState) => ({
...currentState,
pagination: {
...currentState.pagination,
isLoading: true,
isLoadingNext: false,
filters,
sort,
options,
},
}));
const channels = await this.client.queryChannels(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,
});
} 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.client.queryChannels(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<SCG>>([...(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 },
}));
throw error;
}
};
private notificationAddedToChannelHandler = async (event: Event<SCG>) => {
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<SCG>) => {
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<SCG>) => {
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<SCG>) => {
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<SCG>) => {
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<SCG>) => {
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<SCG>) => {
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.unsubscribeFunctions.size) {
// Already listening for events and changes
return;
}
for (const eventType of Object.keys(channelManagerEventToHandlerMapping)) {
this.unsubscribeFunctions.add(this.client.on(eventType, this.subscriptionOrOverride).unsubscribe);
}
};
public unregisterSubscriptions = () => {
this.unsubscribeFunctions.forEach((cleanupFunction) => cleanupFunction());
this.unsubscribeFunctions.clear();
};
}