stream-chat
Version:
JS SDK for the Stream Chat API
876 lines (753 loc) • 27.3 kB
text/typescript
import { AttachmentManager } from './attachmentManager';
import { CustomDataManager } from './CustomDataManager';
import { LinkPreviewsManager } from './linkPreviewsManager';
import { LocationComposer } from './LocationComposer';
import { PollComposer } from './pollComposer';
import { TextComposer } from './textComposer';
import { DEFAULT_COMPOSER_CONFIG } from './configuration';
import type { MessageComposerMiddlewareValue } from './middleware';
import {
MessageComposerMiddlewareExecutor,
MessageDraftComposerMiddlewareExecutor,
} from './middleware';
import type { Unsubscribe } from '../store';
import { StateStore } from '../store';
import { formatMessage, generateUUIDv4, isLocalMessage, unformatMessage } from '../utils';
import { mergeWith } from '../utils/mergeWith';
import { Channel } from '../channel';
import { Thread } from '../thread';
import type {
ChannelAPIResponse,
DraftMessage,
DraftResponse,
EventTypes,
LocalMessage,
LocalMessageBase,
MessageResponse,
MessageResponseBase,
} from '../types';
import { WithSubscriptions } from '../utils/WithSubscriptions';
import type { StreamChat } from '../client';
import type { MessageComposerConfig } from './configuration/types';
import type { DeepPartial } from '../types.utility';
import type { MergeWithCustomizer } from '../utils/mergeWith/mergeWithCore';
type UnregisterSubscriptions = Unsubscribe;
export type LastComposerChange = { draftUpdate: number | null; stateUpdate: number };
export type EditingAuditState = {
lastChange: LastComposerChange;
};
export type LocalMessageWithLegacyThreadId = LocalMessage & { legacyThreadId?: string };
export type CompositionContext = Channel | Thread | LocalMessageWithLegacyThreadId;
export type MessageComposerState = {
id: string;
draftId: string | null;
pollId: string | null;
quotedMessage: LocalMessageBase | null;
showReplyInChannel: boolean;
};
export type MessageComposerOptions = {
client: StreamChat;
// composer can belong to a channel, thread, legacy thread or a local message (edited message)
compositionContext: CompositionContext;
// initial state like draft message or edited message
composition?: DraftResponse | MessageResponse | LocalMessage;
config?: DeepPartial<MessageComposerConfig>;
};
const compositionIsDraftResponse = (composition: unknown): composition is DraftResponse =>
!!(composition as { message?: DraftMessage })?.message;
const initEditingAuditState = (
composition?: DraftResponse | MessageResponse | LocalMessage,
): EditingAuditState => {
let draftUpdate = null;
let stateUpdate = new Date().getTime();
if (compositionIsDraftResponse(composition)) {
stateUpdate = draftUpdate = new Date(composition.created_at).getTime();
} else if (composition && isLocalMessage(composition)) {
stateUpdate = new Date(composition.updated_at).getTime();
}
return {
lastChange: {
draftUpdate,
stateUpdate,
},
};
};
const initState = (
composition?: DraftResponse | MessageResponse | LocalMessage,
): MessageComposerState => {
if (!composition) {
return {
draftId: null,
id: MessageComposer.generateId(),
pollId: null,
quotedMessage: null,
showReplyInChannel: false,
};
}
const quotedMessage = composition.quoted_message;
let message;
let draftId = null;
let id = MessageComposer.generateId(); // do not use draft id for messsage id
if (compositionIsDraftResponse(composition)) {
message = composition.message;
draftId = composition.message.id;
} else {
message = composition;
id = composition.id;
}
return {
draftId,
id,
pollId: message.poll_id ?? null,
quotedMessage: quotedMessage
? formatMessage(quotedMessage as MessageResponseBase)
: null,
showReplyInChannel: false,
};
};
export class MessageComposer extends WithSubscriptions {
readonly channel: Channel;
readonly state: StateStore<MessageComposerState>;
readonly editingAuditState: StateStore<EditingAuditState>;
readonly configState: StateStore<MessageComposerConfig>;
readonly compositionContext: CompositionContext;
readonly compositionMiddlewareExecutor: MessageComposerMiddlewareExecutor;
readonly draftCompositionMiddlewareExecutor: MessageDraftComposerMiddlewareExecutor;
editedMessage?: LocalMessage;
attachmentManager: AttachmentManager;
linkPreviewsManager: LinkPreviewsManager;
textComposer: TextComposer;
pollComposer: PollComposer;
locationComposer: LocationComposer;
customDataManager: CustomDataManager;
// todo: mediaRecorder: MediaRecorderController;
constructor({
composition,
config,
compositionContext,
client,
}: MessageComposerOptions) {
super();
this.compositionContext = compositionContext;
// channel is easily inferable from the context
if (compositionContext instanceof Channel) {
this.channel = compositionContext;
} else if (compositionContext instanceof Thread) {
this.channel = compositionContext.channel;
} else if (compositionContext.cid) {
const [type, id] = compositionContext.cid.split(':');
this.channel = client.channel(type, id);
} else {
throw new Error(
'MessageComposer requires composition context pointing to channel (channel or context.cid)',
);
}
const mergeChannelConfigCustomizer: MergeWithCustomizer<
DeepPartial<MessageComposerConfig>
> = (originalVal, channelConfigVal, key) =>
typeof originalVal === 'object'
? undefined
: originalVal === false && key === 'enabled' // prevent enabling features that are disabled client-side
? false
: ['string', 'number', 'bigint', 'boolean', 'symbol'].includes(
// prevent enabling features that are disabled server-side
typeof channelConfigVal,
)
? channelConfigVal // scalar values get overridden by server-side config
: originalVal;
this.configState = new StateStore<MessageComposerConfig>(
mergeWith(
mergeWith(DEFAULT_COMPOSER_CONFIG, config ?? {}),
{
location: {
enabled: this.channel.getConfig()?.shared_locations,
},
},
mergeChannelConfigCustomizer,
),
);
let message: LocalMessage | DraftMessage | undefined = undefined;
if (compositionIsDraftResponse(composition)) {
message = composition.message;
} else if (composition) {
message = formatMessage(composition);
this.editedMessage = message;
}
this.attachmentManager = new AttachmentManager({ composer: this, message });
this.linkPreviewsManager = new LinkPreviewsManager({ composer: this, message });
this.locationComposer = new LocationComposer({ composer: this, message });
this.textComposer = new TextComposer({ composer: this, message });
this.pollComposer = new PollComposer({ composer: this });
this.customDataManager = new CustomDataManager({ composer: this, message });
this.editingAuditState = new StateStore<EditingAuditState>(
this.initEditingAuditState(composition),
);
this.state = new StateStore<MessageComposerState>(initState(composition));
this.compositionMiddlewareExecutor = new MessageComposerMiddlewareExecutor({
composer: this,
});
this.draftCompositionMiddlewareExecutor = new MessageDraftComposerMiddlewareExecutor({
composer: this,
});
}
static evaluateContextType(compositionContext: CompositionContext) {
if (compositionContext instanceof Channel) {
return 'channel';
}
if (compositionContext instanceof Thread) {
return 'thread';
}
if (typeof compositionContext.legacyThreadId === 'string') {
return 'legacy_thread';
}
return 'message';
}
static constructTag(
compositionContext: CompositionContext,
): `${ReturnType<typeof MessageComposer.evaluateContextType>}_${string}` {
return `${this.evaluateContextType(compositionContext)}_${compositionContext.id}`;
}
get config(): MessageComposerConfig {
return this.configState.getLatestValue();
}
updateConfig(config: DeepPartial<MessageComposerConfig>) {
this.configState.partialNext(mergeWith(this.config, config));
}
get contextType() {
return MessageComposer.evaluateContextType(this.compositionContext);
}
get tag() {
return MessageComposer.constructTag(this.compositionContext);
}
get threadId() {
// TODO: ideally we'd use this.contextType but type narrowing does not work for this.compositionContext
// if (this.contextType === 'channel') {
// const context = this.compositionContext; // context is a Channel
// return null
// }
if (this.compositionContext instanceof Channel) {
return null;
}
if (this.compositionContext instanceof Thread) {
return this.compositionContext.id;
}
if (typeof this.compositionContext.legacyThreadId === 'string') {
return this.compositionContext.legacyThreadId;
}
// check if the message is a reply, get parentMessageId
if (typeof this.compositionContext.parent_id === 'string') {
return this.compositionContext.parent_id;
}
return null;
}
get client() {
return this.channel.getClient();
}
get id() {
return this.state.getLatestValue().id;
}
get draftId() {
return this.state.getLatestValue().draftId;
}
get lastChange() {
return this.editingAuditState.getLatestValue().lastChange;
}
get quotedMessage() {
return this.state.getLatestValue().quotedMessage;
}
get pollId() {
return this.state.getLatestValue().pollId;
}
get showReplyInChannel() {
return this.state.getLatestValue().showReplyInChannel;
}
get hasSendableData() {
// If the offline mode is enabled, we allow sending a message if the composition is not empty.
if (this.client.offlineDb) {
return !this.compositionIsEmpty;
}
return !!(
(!this.attachmentManager.uploadsInProgressCount &&
(!this.textComposer.textIsEmpty ||
this.attachmentManager.successfulUploadsCount > 0)) ||
this.pollId ||
!!this.locationComposer.validLocation
);
}
get compositionIsEmpty() {
return (
!this.quotedMessage &&
this.textComposer.textIsEmpty &&
!this.attachmentManager.attachments.length &&
!this.pollId &&
!this.locationComposer.validLocation
);
}
get lastChangeOriginIsLocal() {
const initiatedWithoutDraft = this.lastChange.draftUpdate === null;
const composingMessageFromScratch = initiatedWithoutDraft && !this.editedMessage;
// does not mean that the original edited message is different from the current state
const editedMessageWasUpdated =
!!this.editedMessage?.updated_at &&
new Date(this.editedMessage.updated_at).getTime() < this.lastChange.stateUpdate;
const draftWasChanged =
!!this.lastChange.draftUpdate &&
this.lastChange.draftUpdate < this.lastChange.stateUpdate;
return editedMessageWasUpdated || draftWasChanged || composingMessageFromScratch;
}
static generateId = generateUUIDv4;
refreshId = () => {
this.state.partialNext({ id: MessageComposer.generateId() });
};
initState = ({
composition,
}: { composition?: DraftResponse | MessageResponse | LocalMessage } = {}) => {
this.editingAuditState.partialNext(this.initEditingAuditState(composition));
const message: LocalMessage | DraftMessage | undefined =
typeof composition === 'undefined'
? composition
: compositionIsDraftResponse(composition)
? composition.message
: formatMessage(composition);
this.attachmentManager.initState({ message });
this.linkPreviewsManager.initState({ message });
this.locationComposer.initState({ message });
this.textComposer.initState({ message });
this.pollComposer.initState();
this.customDataManager.initState({ message });
this.state.next(initState(composition));
if (
composition &&
!compositionIsDraftResponse(composition) &&
message &&
isLocalMessage(message)
) {
this.editedMessage = message;
}
};
initStateFromChannelResponse = (channelApiResponse: ChannelAPIResponse) => {
if (this.channel.cid !== channelApiResponse.channel.cid) {
return;
}
if (channelApiResponse.draft) {
this.initState({ composition: channelApiResponse.draft });
} else if (this.state.getLatestValue().draftId) {
this.clear();
this.client.offlineDb?.executeQuerySafely(
(db) =>
db.deleteDraft({
cid: this.channel.cid,
parent_id: undefined, // makes sure that we don't delete thread drafts while upserting channels
}),
{ method: 'deleteDraft' },
);
}
};
initEditingAuditState = (
composition?: DraftResponse | MessageResponse | LocalMessage,
) => initEditingAuditState(composition);
private logStateUpdateTimestamp() {
this.editingAuditState.partialNext({
lastChange: { ...this.lastChange, stateUpdate: new Date().getTime() },
});
}
private logDraftUpdateTimestamp() {
if (!this.config.drafts.enabled) return;
const timestamp = new Date().getTime();
this.editingAuditState.partialNext({
lastChange: { draftUpdate: timestamp, stateUpdate: timestamp },
});
}
public registerDraftEventSubscriptions = () => {
const unsubscribeDraftUpdated = this.subscribeDraftUpdated();
const unsubscribeDraftDeleted = this.subscribeDraftDeleted();
return () => {
unsubscribeDraftUpdated();
unsubscribeDraftDeleted();
};
};
public registerSubscriptions = (): UnregisterSubscriptions => {
if (!this.hasSubscriptions) {
this.addUnsubscribeFunction(this.subscribeMessageComposerSetupStateChange());
this.addUnsubscribeFunction(this.subscribeMessageUpdated());
this.addUnsubscribeFunction(this.subscribeMessageDeleted());
this.addUnsubscribeFunction(this.subscribeTextComposerStateChanged());
this.addUnsubscribeFunction(this.subscribeAttachmentManagerStateChanged());
this.addUnsubscribeFunction(this.subscribeLinkPreviewsManagerStateChanged());
this.addUnsubscribeFunction(this.subscribeLocationComposerStateChanged());
this.addUnsubscribeFunction(this.subscribePollComposerStateChanged());
this.addUnsubscribeFunction(this.subscribeCustomDataManagerStateChanged());
this.addUnsubscribeFunction(this.subscribeMessageComposerStateChanged());
this.addUnsubscribeFunction(this.subscribeMessageComposerConfigStateChanged());
}
this.incrementRefCount();
return () => this.unregisterSubscriptions();
};
private subscribeMessageUpdated = () => {
// todo: test the impact of 'reaction.new', 'reaction.deleted', 'reaction.updated'
const eventTypes: EventTypes[] = [
'message.updated',
'reaction.new',
'reaction.deleted', // todo: do we need to subscribe to this especially when the whole state is overriden?
'reaction.updated', // todo: do we need to subscribe to this especially when the whole state is overriden?
];
const unsubscribeFunctions = eventTypes.map(
(eventType) =>
this.client.on(eventType, (event) => {
if (!event.message) return;
if (event.message.id === this.id) {
this.initState({ composition: event.message });
}
if (this.quotedMessage?.id && event.message.id === this.quotedMessage.id) {
this.setQuotedMessage(formatMessage(event.message));
}
}).unsubscribe,
);
return () => unsubscribeFunctions.forEach((unsubscribe) => unsubscribe());
};
private subscribeMessageComposerSetupStateChange = () => {
let tearDown: (() => void) | null = null;
const unsubscribe = this.client._messageComposerSetupState.subscribeWithSelector(
({ setupFunction: setup }) => ({
setup,
}),
({ setup }) => {
tearDown?.();
tearDown = setup?.({ composer: this }) ?? null;
},
);
return () => {
tearDown?.();
unsubscribe();
};
};
private subscribeMessageDeleted = () =>
this.client.on('message.deleted', (event) => {
if (!event.message) return;
if (event.message.id === this.id) {
this.clear();
} else if (this.quotedMessage && event.message.id === this.quotedMessage.id) {
this.setQuotedMessage(null);
}
}).unsubscribe;
private subscribeDraftUpdated = () =>
this.client.on('draft.updated', (event) => {
const draft = event.draft as DraftResponse;
if (
!draft ||
(draft.parent_id ?? null) !== (this.threadId ?? null) ||
draft.channel_cid !== this.channel.cid
)
return;
this.initState({ composition: draft });
}).unsubscribe;
private subscribeDraftDeleted = () =>
this.client.on('draft.deleted', (event) => {
const draft = event.draft as DraftResponse;
if (
!draft ||
(draft.parent_id ?? null) !== (this.threadId ?? null) ||
draft.channel_cid !== this.channel.cid
) {
return;
}
this.logDraftUpdateTimestamp();
if (this.compositionIsEmpty) {
return;
}
this.clear();
}).unsubscribe;
private subscribeTextComposerStateChanged = () =>
this.textComposer.state.subscribeWithSelector(
({ text }) => [text] as const,
([currentText], previousSelection) => {
// do not handle on initial subscription
if (typeof previousSelection === 'undefined') return;
this.logStateUpdateTimestamp();
if (this.compositionIsEmpty) {
this.deleteDraft();
return;
}
if (!this.linkPreviewsManager.enabled) return;
if (!currentText) {
this.linkPreviewsManager.clearPreviews();
} else {
this.linkPreviewsManager.findAndEnrichUrls(currentText);
}
},
);
private subscribeAttachmentManagerStateChanged = () =>
this.attachmentManager.state.subscribe((_, previousValue) => {
if (typeof previousValue === 'undefined') return;
this.logStateUpdateTimestamp();
if (this.compositionIsEmpty) {
this.deleteDraft();
return;
}
});
private subscribeLocationComposerStateChanged = () =>
this.locationComposer.state.subscribe((_, previousValue) => {
if (typeof previousValue === 'undefined') return;
this.logStateUpdateTimestamp();
if (this.compositionIsEmpty) {
this.deleteDraft();
return;
}
});
private subscribeLinkPreviewsManagerStateChanged = () =>
this.linkPreviewsManager.state.subscribe((_, previousValue) => {
if (typeof previousValue === 'undefined') return;
this.logStateUpdateTimestamp();
if (this.compositionIsEmpty) {
this.deleteDraft();
return;
}
});
private subscribePollComposerStateChanged = () =>
this.pollComposer.state.subscribe((_, previousValue) => {
if (typeof previousValue === 'undefined') return;
this.logStateUpdateTimestamp();
if (this.compositionIsEmpty) {
this.deleteDraft();
return;
}
});
private subscribeCustomDataManagerStateChanged = () =>
this.customDataManager.state.subscribe((nextValue, previousValue) => {
if (
typeof previousValue !== 'undefined' &&
// FIXME: is this check really necessary?
!this.customDataManager.isMessageDataEqual(nextValue, previousValue)
) {
this.logStateUpdateTimestamp();
}
});
private subscribeMessageComposerStateChanged = () =>
this.state.subscribe((_, previousValue) => {
if (typeof previousValue === 'undefined') return;
this.logStateUpdateTimestamp();
if (this.compositionIsEmpty) {
this.deleteDraft();
}
});
private subscribeMessageComposerConfigStateChanged = () => {
let draftUnsubscribeFunction: Unsubscribe | null;
const unsubscribe = this.configState.subscribeWithSelector(
(currentValue) => ({
textDefaultValue: currentValue.text.defaultValue,
draftsEnabled: currentValue.drafts.enabled,
}),
({ textDefaultValue, draftsEnabled }) => {
if (this.textComposer.text === '' && textDefaultValue) {
this.textComposer.insertText({
text: textDefaultValue,
selection: { start: 0, end: 0 },
});
}
if (draftsEnabled && !draftUnsubscribeFunction) {
draftUnsubscribeFunction = this.registerDraftEventSubscriptions();
} else if (!draftsEnabled && draftUnsubscribeFunction) {
draftUnsubscribeFunction();
draftUnsubscribeFunction = null;
}
},
);
return () => {
draftUnsubscribeFunction?.();
unsubscribe();
};
};
setQuotedMessage = (quotedMessage: LocalMessage | null) => {
this.state.partialNext({ quotedMessage });
};
toggleShowReplyInChannel = () => {
this.state.partialNext({ showReplyInChannel: !this.showReplyInChannel });
};
clear = () => {
this.setQuotedMessage(null);
this.initState();
};
restore = () => {
const { editedMessage } = this;
if (editedMessage) {
this.initState({ composition: editedMessage });
return;
}
this.clear();
};
compose = async (): Promise<MessageComposerMiddlewareValue['state'] | undefined> => {
const created_at = this.editedMessage?.created_at ?? new Date();
const text = '';
const result = await this.compositionMiddlewareExecutor.execute({
eventName: 'compose',
initialValue: {
message: {
id: this.id,
parent_id: this.threadId ?? undefined,
type: 'regular',
},
localMessage: {
attachments: [],
created_at, // only assigned to localMessage as this is used for optimistic update
deleted_at: null,
error: undefined,
id: this.id,
mentioned_users: [],
parent_id: this.threadId ?? undefined,
pinned_at: this.editedMessage?.pinned_at || null,
reaction_groups: null,
status: this.editedMessage ? this.editedMessage.status : 'sending',
text,
type: 'regular',
updated_at: created_at,
},
sendOptions: {},
},
});
if (result.status === 'discard') return;
return result.state;
};
composeDraft = async () => {
const { state, status } = await this.draftCompositionMiddlewareExecutor.execute({
eventName: 'compose',
initialValue: {
draft: { id: this.id, parent_id: this.threadId ?? undefined, text: '' },
},
});
if (status === 'discard') return;
return state;
};
createDraft = async () => {
// server-side drafts are not stored on message level but on thread and channel level
// therefore we don't need to create a draft if the message is edited
if (this.editedMessage || !this.config.drafts.enabled) return;
const composition = await this.composeDraft();
if (!composition) return;
const { draft } = composition;
this.state.partialNext({ draftId: draft.id });
if (this.client.offlineDb) {
try {
const optimisticDraftResponse = {
channel_cid: this.channel.cid,
created_at: new Date().toISOString(),
message: draft as DraftMessage,
parent_id: draft.parent_id,
quoted_message: this.quotedMessage
? unformatMessage(this.quotedMessage)
: undefined,
};
await this.client.offlineDb.upsertDraft({ draft: optimisticDraftResponse });
} catch (error) {
this.client.logger('error', `offlineDb:upsertDraft`, {
tags: ['channel', 'offlineDb'],
error,
});
}
}
this.logDraftUpdateTimestamp();
await this.channel.createDraft(draft);
};
deleteDraft = async () => {
if (this.editedMessage || !this.config.drafts.enabled || !this.draftId) return;
this.state.partialNext({ draftId: null }); // todo: should we clear the whole state?
const parentId = this.threadId ?? undefined;
if (this.client.offlineDb) {
try {
await this.client.offlineDb.deleteDraft({
cid: this.channel.cid,
parent_id: parentId,
});
} catch (error) {
this.client.logger('error', `offlineDb:deleteDraft`, {
tags: ['channel', 'offlineDb'],
error,
});
}
}
this.logDraftUpdateTimestamp();
await this.channel.deleteDraft({ parent_id: parentId });
};
getDraft = async () => {
if (this.editedMessage || !this.config.drafts.enabled || !this.client.userID) return;
const draftFromOfflineDB = await this.client.offlineDb?.getDraft({
cid: this.channel.cid,
userId: this.client.userID,
parent_id: this.threadId ?? undefined,
});
if (draftFromOfflineDB) {
this.initState({ composition: draftFromOfflineDB });
}
try {
const response = await this.channel.getDraft({
parent_id: this.threadId ?? undefined,
});
const { draft } = response;
if (!draft) return;
this.client.offlineDb?.executeQuerySafely(
(db) =>
db.upsertDraft({
draft,
}),
{ method: 'upsertDraft' },
);
this.initState({ composition: draft });
} catch (error) {
this.client.notifications.add({
message: 'Failed to get the draft',
origin: {
emitter: 'MessageComposer',
context: { composer: this },
},
});
}
};
createPoll = async () => {
const composition = await this.pollComposer.compose();
if (!composition || !composition.data.id) return;
try {
const poll = await this.client.polls.createPoll(composition.data);
this.state.partialNext({ pollId: poll?.id });
} catch (error) {
this.client.notifications.addError({
message: 'Failed to create the poll',
origin: {
emitter: 'MessageComposer',
context: { composer: this },
},
options: {
type: 'api:poll:create:failed',
metadata: {
reason: (error as Error).message,
},
originalError: error instanceof Error ? error : undefined,
},
});
throw error;
}
};
sendLocation = async () => {
const location = this.locationComposer.validLocation;
if (this.threadId || !location) return;
try {
await this.channel.sendSharedLocation(location);
this.refreshId();
this.locationComposer.initState();
} catch (error) {
this.client.notifications.addError({
message: 'Failed to share the location',
origin: {
emitter: 'MessageComposer',
context: { composer: this },
},
options: {
type: 'api:location:create:failed',
metadata: {
reason: (error as Error).message,
},
originalError: error instanceof Error ? error : undefined,
},
});
throw error;
}
};
}