diff --git a/src/channel_manager.ts b/src/channel_manager.ts index 0d5f0bcaf..92f47d021 100644 --- a/src/channel_manager.ts +++ b/src/channel_manager.ts @@ -20,6 +20,7 @@ import { shouldConsiderPinnedChannels, uniqBy, } from './utils'; +import { WithSubscriptions } from './utils/WithSubscriptions'; export type ChannelManagerPagination = { filters: ChannelFilters; @@ -142,10 +143,9 @@ export const DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS = { * * @internal */ -export class ChannelManager { +export class ChannelManager extends WithSubscriptions { public readonly state: StateStore; private client: StreamChat; - private unsubscribeFunctions: Set<() => void> = new Set(); private eventHandlers: Map = new Map(); private eventHandlerOverrides: Map = new Map(); private options: ChannelManagerOptions = {}; @@ -160,6 +160,8 @@ export class ChannelManager { eventHandlerOverrides?: ChannelManagerEventHandlerOverrides; options?: ChannelManagerOptions; }) { + super(); + this.client = client; this.state = new StateStore({ channels: [], @@ -606,20 +608,15 @@ export class ChannelManager { }; public registerSubscriptions = () => { - if (this.unsubscribeFunctions.size) { + if (this.hasSubscriptions) { // Already listening for events and changes return; } for (const eventType of Object.keys(channelManagerEventToHandlerMapping)) { - this.unsubscribeFunctions.add( + this.addUnsubscribeFunction( this.client.on(eventType, this.subscriptionOrOverride).unsubscribe, ); } }; - - public unregisterSubscriptions = () => { - this.unsubscribeFunctions.forEach((cleanupFunction) => cleanupFunction()); - this.unsubscribeFunctions.clear(); - }; } diff --git a/src/messageComposer/attachmentManager.ts b/src/messageComposer/attachmentManager.ts index 230de18bd..0733e1176 100644 --- a/src/messageComposer/attachmentManager.ts +++ b/src/messageComposer/attachmentManager.ts @@ -71,10 +71,35 @@ const initState = ({ export class AttachmentManager { readonly state: StateStore; readonly composer: MessageComposer; + private attachmentsByIdGetterCache: { + attachmentsById: Record; + attachments: LocalAttachment[]; + }; constructor({ composer, message }: AttachmentManagerOptions) { this.composer = composer; this.state = new StateStore(initState({ message })); + this.attachmentsByIdGetterCache = { attachmentsById: {}, attachments: [] }; + } + + get attachmentsById() { + const { attachments } = this.state.getLatestValue(); + + if (attachments !== this.attachmentsByIdGetterCache.attachments) { + this.attachmentsByIdGetterCache.attachments = attachments; + this.attachmentsByIdGetterCache.attachmentsById = attachments.reduce< + Record + >((newAttachmentsById, attachment) => { + // should never happen but does not hurt to check + if (!attachment.localMetadata.id) return newAttachmentsById; + + newAttachmentsById[attachment.localMetadata.id] ??= attachment; + + return newAttachmentsById; + }, {}); + } + + return this.attachmentsByIdGetterCache.attachmentsById; } get client() { @@ -176,44 +201,47 @@ export class AttachmentManager { this.state.next(initState({ message })); }; - getAttachmentIndex = (localId: string) => - this.attachments.findIndex( - (attachment) => - attachment.localMetadata.id && localId === attachment.localMetadata?.id, - ); + getAttachmentIndex = (localId: string) => { + const attachmentsById = this.attachmentsById; + + return this.attachments.indexOf(attachmentsById[localId]); + }; upsertAttachments = (attachmentsToUpsert: LocalAttachment[]) => { if (!attachmentsToUpsert.length) return; - const stateAttachments = this.attachments; - const attachments = [...this.attachments]; - attachmentsToUpsert.forEach((upsertedAttachment) => { - const attachmentIndex = this.getAttachmentIndex( - upsertedAttachment.localMetadata.id, - ); - if (attachmentIndex === -1) { - const localAttachment = ensureIsLocalAttachment(upsertedAttachment); - if (localAttachment) attachments.push(localAttachment); + const currentAttachments = this.attachments; + const newAttachments = [...currentAttachments]; + + attachmentsToUpsert.forEach((attachment) => { + const targetAttachmentIndex = this.getAttachmentIndex(attachment.localMetadata?.id); + + if (targetAttachmentIndex < 0) { + const localAttachment = ensureIsLocalAttachment(attachment); + if (localAttachment) newAttachments.push(localAttachment); } else { + // do not re-organize newAttachments array otherwise indexing would no longer work + // replace in place only with the attachments with the same id's const merged = mergeWithDiff( - stateAttachments[attachmentIndex] ?? {}, - upsertedAttachment, + currentAttachments[targetAttachmentIndex], + attachment, ); const updatesOnMerge = merged.diff && Object.keys(merged.diff.children).length; if (updatesOnMerge) { const localAttachment = ensureIsLocalAttachment(merged.result); - if (localAttachment) attachments.splice(attachmentIndex, 1, localAttachment); + if (localAttachment) + newAttachments.splice(targetAttachmentIndex, 1, localAttachment); } } }); - this.state.partialNext({ attachments }); + this.state.partialNext({ attachments: newAttachments }); }; removeAttachments = (localAttachmentIds: string[]) => { this.state.partialNext({ attachments: this.attachments.filter( - (att) => !localAttachmentIds.includes(att.localMetadata?.id), + (attachment) => !localAttachmentIds.includes(attachment.localMetadata?.id), ), }); }; diff --git a/src/messageComposer/configuration/types.ts b/src/messageComposer/configuration/types.ts index 7f524f952..b2a3e603b 100644 --- a/src/messageComposer/configuration/types.ts +++ b/src/messageComposer/configuration/types.ts @@ -41,17 +41,16 @@ export type AttachmentManagerConfig = { /** Function that allows to customize the upload request. */ doUploadRequest?: UploadRequestFn; }; -export type LinkPreviewConfig = { - /** Custom function to react to link preview dismissal */ - onLinkPreviewDismissed?: (linkPreview: LinkPreview) => void; -}; -export type LinkPreviewsManagerConfig = LinkPreviewConfig & { + +export type LinkPreviewsManagerConfig = { /** Number of milliseconds to debounce firing the URL enrichment queries when typing. The default value is 1500(ms). */ debounceURLEnrichmentMs: number; /** Allows for toggling the URL enrichment and link previews in `MessageInput`. By default, the feature is disabled. */ enabled: boolean; /** Custom function to identify URLs in a string and request OG data */ findURLFn: (text: string) => string[]; + /** Custom function to react to link preview dismissal */ + onLinkPreviewDismissed?: (linkPreview: LinkPreview) => void; }; export type MessageComposerConfig = { diff --git a/src/messageComposer/messageComposer.ts b/src/messageComposer/messageComposer.ts index fad6b33e8..e0e0ca331 100644 --- a/src/messageComposer/messageComposer.ts +++ b/src/messageComposer/messageComposer.ts @@ -26,6 +26,10 @@ import type { import type { StreamChat } from '../client'; import type { MessageComposerConfig } from './configuration/types'; import type { DeepPartial } from '../types.utility'; +import type { Unsubscribe } from '../store'; +import { WithSubscriptions } from '../utils/WithSubscriptions'; + +type UnregisterSubscriptions = Unsubscribe; export type LastComposerChange = { draftUpdate: number | null; stateUpdate: number }; @@ -109,7 +113,7 @@ const initState = ( const noop = () => undefined; -export class MessageComposer { +export class MessageComposer extends WithSubscriptions { readonly channel: Channel; readonly state: StateStore; readonly editingAuditState: StateStore; @@ -126,14 +130,14 @@ export class MessageComposer { customDataManager: CustomDataManager; // todo: mediaRecorder: MediaRecorderController; - private unsubscribeFunctions: Set<() => void> = new Set(); - constructor({ composition, config, compositionContext, client, }: MessageComposerOptions) { + super(); + this.compositionContext = compositionContext; this.configState = new StateStore( @@ -342,6 +346,7 @@ export class MessageComposer { lastChange: { ...this.lastChange, stateUpdate: new Date().getTime() }, }); } + private logDraftUpdateTimestamp() { if (!this.config.drafts.enabled) return; const timestamp = new Date().getTime(); @@ -350,36 +355,26 @@ export class MessageComposer { }); } - public registerSubscriptions = () => { - if (this.unsubscribeFunctions.size) { + public registerSubscriptions = (): UnregisterSubscriptions => { + if (this.hasSubscriptions) { // Already listening for events and changes return noop; } - this.unsubscribeFunctions.add(this.subscribeMessageComposerSetupStateChange()); - this.unsubscribeFunctions.add(this.subscribeMessageUpdated()); - this.unsubscribeFunctions.add(this.subscribeMessageDeleted()); - - this.unsubscribeFunctions.add(this.subscribeTextComposerStateChanged()); - this.unsubscribeFunctions.add(this.subscribeAttachmentManagerStateChanged()); - this.unsubscribeFunctions.add(this.subscribeLinkPreviewsManagerStateChanged()); - this.unsubscribeFunctions.add(this.subscribePollComposerStateChanged()); - this.unsubscribeFunctions.add(this.subscribeCustomDataManagerStateChanged()); - this.unsubscribeFunctions.add(this.subscribeMessageComposerStateChanged()); - this.unsubscribeFunctions.add(this.subscribeMessageComposerConfigStateChanged()); - if (this.config.drafts.enabled) { - this.unsubscribeFunctions.add(this.subscribeDraftUpdated()); - this.unsubscribeFunctions.add(this.subscribeDraftDeleted()); - } + 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.subscribePollComposerStateChanged()); + this.addUnsubscribeFunction(this.subscribeCustomDataManagerStateChanged()); + this.addUnsubscribeFunction(this.subscribeMessageComposerStateChanged()); + this.addUnsubscribeFunction(this.subscribeMessageComposerConfigStateChanged()); return this.unregisterSubscriptions; }; - // TODO: maybe make these private across the SDK - public unregisterSubscriptions = () => { - this.unsubscribeFunctions.forEach((cleanupFunction) => cleanupFunction()); - this.unsubscribeFunctions.clear(); - }; - private subscribeMessageUpdated = () => { // todo: test the impact of 'reaction.new', 'reaction.deleted', 'reaction.updated' const eventTypes: EventTypes[] = [ @@ -452,38 +447,49 @@ export class MessageComposer { !draft || !!draft.parent_id !== !!this.threadId || draft.channel_cid !== this.channel.cid - ) + ) { return; + } this.logDraftUpdateTimestamp(); + if (this.compositionIsEmpty) { return; } + this.clear(); }).unsubscribe; private subscribeTextComposerStateChanged = () => - this.textComposer.state.subscribe((nextValue, previousValue) => { - if (previousValue && nextValue.text !== previousValue?.text) { + 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 || nextValue.text === previousValue?.text) - return; - if (!nextValue.text) { - this.linkPreviewsManager.clearPreviews(); - } else { - this.linkPreviewsManager.findAndEnrichUrls(nextValue.text); - } - }); + + if (!this.linkPreviewsManager.enabled) return; + + if (!currentText) { + this.linkPreviewsManager.clearPreviews(); + } else { + this.linkPreviewsManager.findAndEnrichUrls(currentText); + } + }, + ); private subscribeAttachmentManagerStateChanged = () => - this.attachmentManager.state.subscribe((nextValue, previousValue) => { + this.attachmentManager.state.subscribe((_, previousValue) => { if (typeof previousValue === 'undefined') return; + this.logStateUpdateTimestamp(); + if (this.compositionIsEmpty) { this.deleteDraft(); return; @@ -491,9 +497,11 @@ export class MessageComposer { }); private subscribeLinkPreviewsManagerStateChanged = () => - this.linkPreviewsManager.state.subscribe((nextValue, previousValue) => { + this.linkPreviewsManager.state.subscribe((_, previousValue) => { if (typeof previousValue === 'undefined') return; + this.logStateUpdateTimestamp(); + if (this.compositionIsEmpty) { this.deleteDraft(); return; @@ -501,9 +509,11 @@ export class MessageComposer { }); private subscribePollComposerStateChanged = () => - this.pollComposer.state.subscribe((nextValue, previousValue) => { + this.pollComposer.state.subscribe((_, previousValue) => { if (typeof previousValue === 'undefined') return; + this.logStateUpdateTimestamp(); + if (this.compositionIsEmpty) { this.deleteDraft(); return; @@ -514,6 +524,7 @@ export class MessageComposer { this.customDataManager.state.subscribe((nextValue, previousValue) => { if ( typeof previousValue !== 'undefined' && + // FIXME: is this check really necessary? !this.customDataManager.isMessageDataEqual(nextValue, previousValue) ) { this.logStateUpdateTimestamp(); @@ -521,25 +532,49 @@ export class MessageComposer { }); private subscribeMessageComposerStateChanged = () => - this.state.subscribe((nextValue, previousValue) => { + this.state.subscribe((_, previousValue) => { if (typeof previousValue === 'undefined') return; + this.logStateUpdateTimestamp(); + if (this.compositionIsEmpty) { this.deleteDraft(); - return; } }); - private subscribeMessageComposerConfigStateChanged = () => - this.configState.subscribe((nextValue) => { - const { text } = nextValue; - if (this.textComposer.text === '' && text.defaultValue) { - this.textComposer.insertText({ - text: text.defaultValue, - selection: { start: 0, end: 0 }, - }); - } - }); + private subscribeMessageComposerConfigStateChanged = () => { + let draftUnsubscribeFunctions: 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 && !draftUnsubscribeFunctions) { + draftUnsubscribeFunctions = [ + this.subscribeDraftUpdated(), + this.subscribeDraftDeleted(), + ]; + } else if (!draftsEnabled && draftUnsubscribeFunctions) { + draftUnsubscribeFunctions.forEach((fn) => fn()); + draftUnsubscribeFunctions = null; + } + }, + ); + + return () => { + draftUnsubscribeFunctions?.forEach((unsubscribe) => unsubscribe()); + unsubscribe(); + }; + }; setQuotedMessage = (quotedMessage: LocalMessage | null) => { this.state.partialNext({ quotedMessage }); @@ -560,6 +595,7 @@ export class MessageComposer { compose = async (): Promise => { const created_at = this.editedMessage?.created_at ?? new Date(); + const text = ''; const result = await this.compositionMiddlewareExecutor.execute({ eventName: 'compose', @@ -587,6 +623,7 @@ export class MessageComposer { sendOptions: {}, }, }); + if (result.status === 'discard') return; return result.state; @@ -600,6 +637,7 @@ export class MessageComposer { }, }); if (status === 'discard') return; + return state; }; diff --git a/src/poll.ts b/src/poll.ts index 906a32429..9879fbfe7 100644 --- a/src/poll.ts +++ b/src/poll.ts @@ -90,7 +90,6 @@ export class Poll { public readonly state: StateStore; public id: string; private client: StreamChat; - private unsubscribeFunctions: Set<() => void> = new Set(); constructor({ client, poll }: PollInitOptions) { this.client = client; diff --git a/src/poll_manager.ts b/src/poll_manager.ts index 521d16b8e..ceb6d82dc 100644 --- a/src/poll_manager.ts +++ b/src/poll_manager.ts @@ -10,8 +10,9 @@ import type { } from './types'; import { Poll } from './poll'; import { formatMessage } from './utils'; +import { WithSubscriptions } from './utils/WithSubscriptions'; -export class PollManager { +export class PollManager extends WithSubscriptions { private client: StreamChat; // The pollCache contains only polls that have been created and sent as messages // (i.e only polls that are coupled with a message, can be voted on and require a @@ -19,9 +20,9 @@ export class PollManager { // to quickly consume poll state that will be reactive even without the polls being // rendered within the UI. private pollCache = new Map(); - private unsubscribeFunctions: Set<() => void> = new Set(); constructor({ client }: { client: StreamChat }) { + super(); this.client = client; } @@ -32,22 +33,17 @@ export class PollManager { public fromState = (id: string) => this.pollCache.get(id); public registerSubscriptions = () => { - if (this.unsubscribeFunctions.size) { + if (this.hasSubscriptions) { // Already listening for events and changes return; } - this.unsubscribeFunctions.add(this.subscribeMessageNew()); - this.unsubscribeFunctions.add(this.subscribePollUpdated()); - this.unsubscribeFunctions.add(this.subscribePollClosed()); - this.unsubscribeFunctions.add(this.subscribeVoteCasted()); - this.unsubscribeFunctions.add(this.subscribeVoteChanged()); - this.unsubscribeFunctions.add(this.subscribeVoteRemoved()); - }; - - public unregisterSubscriptions = () => { - this.unsubscribeFunctions.forEach((cleanupFunction) => cleanupFunction()); - this.unsubscribeFunctions.clear(); + this.addUnsubscribeFunction(this.subscribeMessageNew()); + this.addUnsubscribeFunction(this.subscribePollUpdated()); + this.addUnsubscribeFunction(this.subscribePollClosed()); + this.addUnsubscribeFunction(this.subscribeVoteCasted()); + this.addUnsubscribeFunction(this.subscribeVoteChanged()); + this.addUnsubscribeFunction(this.subscribeVoteRemoved()); }; public createPoll = async (poll: CreatePollData) => { diff --git a/src/store.ts b/src/store.ts index 8c71116f5..9e5e6c2d4 100644 --- a/src/store.ts +++ b/src/store.ts @@ -46,25 +46,30 @@ export class StateStore> { handler: Handler, ) => { // begin with undefined to reduce amount of selector calls - let selectedValues: O | undefined; + let previouslySelectedValues: O | undefined; const wrappedHandler: Handler = (nextValue) => { const newlySelectedValues = selector(nextValue); - let hasUpdatedValues = !selectedValues; + let hasUpdatedValues = typeof previouslySelectedValues === 'undefined'; - for (const key in selectedValues) { - if (selectedValues[key] === newlySelectedValues[key]) continue; + for (const key in previouslySelectedValues) { + if (previouslySelectedValues[key] === newlySelectedValues[key]) continue; hasUpdatedValues = true; break; } if (!hasUpdatedValues) return; - const oldSelectedValues = selectedValues; - selectedValues = newlySelectedValues; + // save a copy of previouslySelectedValues before running + // handler - if previouslySelectedValues are set to + // newlySelectedValues after the handler call, there's a chance + // that it'll never get set as handler can throw and flow might + // go out of sync + const previouslySelectedValuesCopy = previouslySelectedValues; + previouslySelectedValues = newlySelectedValues; - handler(newlySelectedValues, oldSelectedValues); + handler(newlySelectedValues, previouslySelectedValuesCopy); }; return this.subscribe(wrappedHandler); diff --git a/src/thread.ts b/src/thread.ts index 779bf1596..9e30c226b 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -19,6 +19,7 @@ import type { Channel } from './channel'; import type { StreamChat } from './client'; import type { CustomThreadData } from './custom_types'; import { MessageComposer } from './messageComposer'; +import { WithSubscriptions } from './utils/WithSubscriptions'; type QueryRepliesOptions = { sort?: { created_at: AscDesc }[]; @@ -108,13 +109,12 @@ const constructCustomDataObject = (threadData: T) => { return custom; }; -export class Thread { +export class Thread extends WithSubscriptions { public readonly state: StateStore; public readonly id: string; public readonly messageComposer: MessageComposer; private client: StreamChat; - private unsubscribeFunctions: Set<() => void> = new Set(); private failedRepliesMap: Map = new Map(); constructor({ @@ -124,6 +124,8 @@ export class Thread { client: StreamChat; threadData: ThreadResponse; }) { + super(); + const channel = client.channel(threadData.channel.type, threadData.channel.id, { // @ts-expect-error name is a "custom" property name: threadData.channel.name, @@ -259,19 +261,19 @@ export class Thread { }; public registerSubscriptions = () => { - if (this.unsubscribeFunctions.size) { + if (this.hasSubscriptions) { // Thread is already listening for events and changes return; } - this.unsubscribeFunctions.add(this.subscribeThreadUpdated()); - this.unsubscribeFunctions.add(this.subscribeMarkActiveThreadRead()); - this.unsubscribeFunctions.add(this.subscribeReloadActiveStaleThread()); - this.unsubscribeFunctions.add(this.subscribeMarkThreadStale()); - this.unsubscribeFunctions.add(this.subscribeNewReplies()); - this.unsubscribeFunctions.add(this.subscribeRepliesRead()); - this.unsubscribeFunctions.add(this.subscribeMessageDeleted()); - this.unsubscribeFunctions.add(this.subscribeMessageUpdated()); + this.addUnsubscribeFunction(this.subscribeThreadUpdated()); + this.addUnsubscribeFunction(this.subscribeMarkActiveThreadRead()); + this.addUnsubscribeFunction(this.subscribeReloadActiveStaleThread()); + this.addUnsubscribeFunction(this.subscribeMarkThreadStale()); + this.addUnsubscribeFunction(this.subscribeNewReplies()); + this.addUnsubscribeFunction(this.subscribeRepliesRead()); + this.addUnsubscribeFunction(this.subscribeMessageDeleted()); + this.addUnsubscribeFunction(this.subscribeMessageUpdated()); }; private subscribeThreadUpdated = () => @@ -446,9 +448,9 @@ export class Thread { }; public unregisterSubscriptions = () => { - this.unsubscribeFunctions.forEach((cleanupFunction) => cleanupFunction()); - this.unsubscribeFunctions.clear(); + const symbol = super.unregisterSubscriptions(); this.state.partialNext({ isStateStale: true }); + return symbol; }; public deleteReplyLocally = ({ message }: { message: MessageResponse }) => { diff --git a/src/thread_manager.ts b/src/thread_manager.ts index b843efa84..ed7b28dd1 100644 --- a/src/thread_manager.ts +++ b/src/thread_manager.ts @@ -4,6 +4,7 @@ import { throttle } from './utils'; import type { StreamChat } from './client'; import type { Thread } from './thread'; import type { Event, OwnUserResponse, QueryThreadsOptions } from './types'; +import { WithSubscriptions } from './utils/WithSubscriptions'; const DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION = 1000; const MAX_QUERY_THREADS_LIMIT = 25; @@ -43,10 +44,9 @@ export type ThreadManagerPagination = { nextCursor: string | null; }; -export class ThreadManager { +export class ThreadManager extends WithSubscriptions { public readonly state: StateStore; private client: StreamChat; - private unsubscribeFunctions: Set<() => void> = new Set(); private threadsByIdGetterCache: { threads: ThreadManagerState['threads']; threadsById: Record; @@ -56,6 +56,8 @@ export class ThreadManager { // private threadCache: Record = {}; constructor({ client }: { client: StreamChat }) { + super(); + this.client = client; this.state = new StateStore(THREAD_MANAGER_INITIAL_STATE); @@ -96,14 +98,14 @@ export class ThreadManager { }; public registerSubscriptions = () => { - if (this.unsubscribeFunctions.size) return; - - this.unsubscribeFunctions.add(this.subscribeUnreadThreadsCountChange()); - this.unsubscribeFunctions.add(this.subscribeManageThreadSubscriptions()); - this.unsubscribeFunctions.add(this.subscribeReloadOnActivation()); - this.unsubscribeFunctions.add(this.subscribeNewReplies()); - this.unsubscribeFunctions.add(this.subscribeRecoverAfterConnectionDrop()); - this.unsubscribeFunctions.add(this.subscribeChannelDeleted()); + if (this.hasSubscriptions) return; + + this.addUnsubscribeFunction(this.subscribeUnreadThreadsCountChange()); + this.addUnsubscribeFunction(this.subscribeManageThreadSubscriptions()); + this.addUnsubscribeFunction(this.subscribeReloadOnActivation()); + this.addUnsubscribeFunction(this.subscribeNewReplies()); + this.addUnsubscribeFunction(this.subscribeRecoverAfterConnectionDrop()); + this.addUnsubscribeFunction(this.subscribeChannelDeleted()); }; private subscribeUnreadThreadsCountChange = () => { @@ -217,8 +219,7 @@ export class ThreadManager { this.state .getLatestValue() .threads.forEach((thread) => thread.unregisterSubscriptions()); - this.unsubscribeFunctions.forEach((cleanupFunction) => cleanupFunction()); - this.unsubscribeFunctions.clear(); + return super.unregisterSubscriptions(); }; public reload = async ({ force = false } = {}) => { diff --git a/src/utils/WithSubscriptions.ts b/src/utils/WithSubscriptions.ts new file mode 100644 index 000000000..09c6c3cf2 --- /dev/null +++ b/src/utils/WithSubscriptions.ts @@ -0,0 +1,51 @@ +import type { Unsubscribe } from '../store'; + +/** + * @private + * Class to use as a template for subscribable entities. + */ +export abstract class WithSubscriptions { + private unsubscribeFunctions: Set = new Set(); + /** + * Workaround for the missing TS keyword - ensures that inheritants + * overriding `unregisterSubscriptions` call the base method and return + * its unique symbol value. + */ + private static symbol = Symbol(WithSubscriptions.name); + + public abstract registerSubscriptions(): void; + + /** + * Returns a boolean, provides information of whether `registerSubscriptions` + * method has already been called for this instance. + */ + public get hasSubscriptions() { + return this.unsubscribeFunctions.size > 0; + } + + public addUnsubscribeFunction(unsubscribeFunction: Unsubscribe) { + this.unsubscribeFunctions.add(unsubscribeFunction); + } + + /** + * If you re-declare `unregisterSubscriptions` method within your class + * make sure to run the original too. + * + * @example + * ```ts + * class T extends WithSubscriptions { + * ... + * public unregisterSubscriptions = () => { + * this.customThing(); + * return super.unregisterSubscriptions(); + * } + * } + * ``` + */ + public unregisterSubscriptions(): typeof WithSubscriptions.symbol { + this.unsubscribeFunctions.forEach((unsubscribe) => unsubscribe()); + this.unsubscribeFunctions.clear(); + + return WithSubscriptions.symbol; + } +} diff --git a/test/unit/MessageComposer/messageComposer.test.ts b/test/unit/MessageComposer/messageComposer.test.ts index a6ff48b09..af3afd103 100644 --- a/test/unit/MessageComposer/messageComposer.test.ts +++ b/test/unit/MessageComposer/messageComposer.test.ts @@ -1055,5 +1055,43 @@ describe('MessageComposer', () => { spy.mockRestore(); }); }); + + it('should toggle the registration of draft WS event subscriptions when drafts are disabled / enabled', () => { + const { messageComposer } = setup({ + config: { drafts: { enabled: false } }, + }); + + const unsubscribeDraftUpdated = vi.fn(); + const unsubscribeDraftDeleted = vi.fn(); + + // @ts-expect-error - we are testing private properties + const subscribeDraftUpdatedSpy = vi + .spyOn(messageComposer, 'subscribeDraftUpdated') + .mockImplementation(() => unsubscribeDraftUpdated); + // @ts-expect-error - we are testing private properties + const subscribeDraftDeletedSpy = vi + .spyOn(messageComposer, 'subscribeDraftDeleted') + .mockImplementation(() => unsubscribeDraftDeleted); + + messageComposer.registerSubscriptions(); + + expect(subscribeDraftUpdatedSpy).not.toHaveBeenCalled(); + expect(subscribeDraftDeletedSpy).not.toHaveBeenCalled(); + + messageComposer.updateConfig({ drafts: { enabled: true } }); + + expect(subscribeDraftUpdatedSpy).toHaveBeenCalledTimes(1); + expect(subscribeDraftDeletedSpy).toHaveBeenCalledTimes(1); + + subscribeDraftUpdatedSpy.mockClear(); + subscribeDraftDeletedSpy.mockClear(); + + messageComposer.updateConfig({ drafts: { enabled: false } }); + + expect(unsubscribeDraftUpdated).toHaveBeenCalledTimes(1); + expect(unsubscribeDraftDeleted).toHaveBeenCalledTimes(1); + expect(subscribeDraftUpdatedSpy).not.toHaveBeenCalled(); + expect(subscribeDraftDeletedSpy).not.toHaveBeenCalled(); + }); }); });