diff --git a/src/LiveLocationManager.ts b/src/LiveLocationManager.ts new file mode 100644 index 000000000..49df7c157 --- /dev/null +++ b/src/LiveLocationManager.ts @@ -0,0 +1,297 @@ +/** + * RULES: + * + * 1. one loc-sharing message per channel per user + * 2. live location is intended to be per device + * but created_by_device_id has currently no checks, + * and user can update the location from another device + * thus making location sharing based on user and channel + */ + +import { withCancellation } from './utils/concurrency'; +import { StateStore } from './store'; +import { WithSubscriptions } from './utils/WithSubscriptions'; +import type { StreamChat } from './client'; +import type { Unsubscribe } from './store'; +import type { + EventTypes, + MessageResponse, + SharedLiveLocationResponse, + SharedLocationResponse, +} from './types'; +import type { Coords } from './messageComposer'; + +export type WatchLocationHandler = (value: Coords) => void; +export type WatchLocation = (handler: WatchLocationHandler) => Unsubscribe; +type DeviceIdGenerator = () => string; +type MessageId = string; + +export type ScheduledLiveLocationSharing = SharedLiveLocationResponse & { + stopSharingTimeout: ReturnType<typeof setTimeout> | null; +}; + +export type LiveLocationManagerState = { + ready: boolean; + messages: Map<MessageId, ScheduledLiveLocationSharing>; +}; + +const isExpiredLocation = (location: SharedLiveLocationResponse) => { + const endTimeTimestamp = new Date(location.end_at).getTime(); + + return endTimeTimestamp < Date.now(); +}; + +function isValidLiveLocationMessage( + message?: MessageResponse, +): message is MessageResponse & { shared_location: SharedLiveLocationResponse } { + if (!message || message.type === 'deleted' || !message.shared_location?.end_at) + return false; + + return !isExpiredLocation(message.shared_location as SharedLiveLocationResponse); +} + +export type LiveLocationManagerConstructorParameters = { + client: StreamChat; + getDeviceId: DeviceIdGenerator; + watchLocation: WatchLocation; +}; + +// Hard-coded minimal throttle timeout +export const UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT = 3000; + +export class LiveLocationManager extends WithSubscriptions { + public state: StateStore<LiveLocationManagerState>; + private client: StreamChat; + private getDeviceId: DeviceIdGenerator; + private _deviceId: string; + private watchLocation: WatchLocation; + + static symbol = Symbol(LiveLocationManager.name); + + constructor({ + client, + getDeviceId, + watchLocation, + }: LiveLocationManagerConstructorParameters) { + if (!client.userID) { + throw new Error('Live-location sharing is reserved for client-side use only'); + } + + super(); + + this.client = client; + this.state = new StateStore<LiveLocationManagerState>({ + messages: new Map(), + ready: false, + }); + this._deviceId = getDeviceId(); + this.getDeviceId = getDeviceId; + this.watchLocation = watchLocation; + } + + public async init() { + await this.assureStateInit(); + this.registerSubscriptions(); + } + + public registerSubscriptions = () => { + this.incrementRefCount(); + if (this.hasSubscriptions) return; + + this.addUnsubscribeFunction(this.subscribeLiveLocationSharingUpdates()); + this.addUnsubscribeFunction(this.subscribeTargetMessagesChange()); + }; + + public unregisterSubscriptions = () => super.unregisterSubscriptions(); + + get messages() { + return this.state.getLatestValue().messages; + } + + get stateIsReady() { + return this.state.getLatestValue().ready; + } + + get deviceId() { + if (!this._deviceId) { + this._deviceId = this.getDeviceId(); + } + return this._deviceId; + } + + private async assureStateInit() { + if (this.stateIsReady) return; + const { active_live_locations } = await this.client.getSharedLocations(); + this.state.next({ + messages: new Map( + active_live_locations + .filter((location) => !isExpiredLocation(location)) + .map((location) => [ + location.message_id, + { + ...location, + stopSharingTimeout: setTimeout( + () => { + this.unregisterMessages([location.message_id]); + }, + new Date(location.end_at).getTime() - Date.now(), + ), + }, + ]), + ), + ready: true, + }); + } + + private subscribeTargetMessagesChange() { + let unsubscribeWatchLocation: null | (() => void) = null; + + // Subscribe to location updates only if there are relevant messages to + // update, no need for the location watcher to be active/instantiated otherwise + const unsubscribe = this.state.subscribeWithSelector( + ({ messages }) => ({ messages }), + ({ messages }) => { + if (!messages.size) { + unsubscribeWatchLocation?.(); + unsubscribeWatchLocation = null; + } else if (messages.size && !unsubscribeWatchLocation) { + unsubscribeWatchLocation = this.subscribeWatchLocation(); + } + }, + ); + + return () => { + unsubscribe(); + unsubscribeWatchLocation?.(); + }; + } + + private subscribeWatchLocation() { + let nextAllowedUpdateCallTimestamp = Date.now(); + + const unsubscribe = this.watchLocation(({ latitude, longitude }) => { + // Integrators can adjust the update interval by supplying custom watchLocation subscription, + // but the minimal timeout still has to be set as a failsafe (to prevent rate-limitting) + if (Date.now() < nextAllowedUpdateCallTimestamp) return; + + nextAllowedUpdateCallTimestamp = + Date.now() + UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT; + + withCancellation(LiveLocationManager.symbol, async () => { + const promises: Promise<SharedLocationResponse>[] = []; + await this.assureStateInit(); + const expiredLocations: string[] = []; + + for (const [messageId, location] of this.messages) { + if (isExpiredLocation(location)) { + expiredLocations.push(location.message_id); + continue; + } + if (location.latitude === latitude && location.longitude === longitude) + continue; + const promise = this.client.updateLocation({ + created_by_device_id: location.created_by_device_id, + message_id: messageId, + latitude, + longitude, + }); + + promises.push(promise); + } + this.unregisterMessages(expiredLocations); + if (promises.length > 0) { + await Promise.allSettled(promises); + } + // TODO: handle values (remove failed - based on specific error code), keep re-trying others + }); + }); + + return unsubscribe; + } + + private subscribeLiveLocationSharingUpdates() { + /** + * Both message.updated & live_location_sharing.stopped get emitted when message gets an + * update, live_location_sharing.stopped gets emitted only locally and only if the update goes + * through, it's a failsafe for when channel is no longer being watched for whatever reason + */ + const subscriptions = [ + ...( + [ + 'live_location_sharing.started', + 'message.updated', + 'message.deleted', + ] as EventTypes[] + ).map((eventType) => + this.client.on(eventType, (event) => { + if (!event.message) return; + + if (event.type === 'live_location_sharing.started') { + this.registerMessage(event.message); + } else if (event.type === 'message.updated') { + const isRegistered = this.messages.has(event.message.id); + if (isRegistered && !isValidLiveLocationMessage(event.message)) { + this.unregisterMessages([event.message.id]); + } + this.registerMessage(event.message); + } else { + this.unregisterMessages([event.message.id]); + } + }), + ), + this.client.on('live_location_sharing.stopped', (event) => { + if (!event.live_location) return; + + this.unregisterMessages([event.live_location?.message_id]); + }), + ]; + + return () => subscriptions.forEach((subscription) => subscription.unsubscribe()); + } + + private registerMessage(message: MessageResponse) { + if ( + !this.client.userID || + message?.user?.id !== this.client.userID || + !isValidLiveLocationMessage(message) + ) + return; + + this.state.next((currentValue) => { + const messages = new Map(currentValue.messages); + messages.set(message.id, { + ...message.shared_location, + stopSharingTimeout: setTimeout( + () => { + this.unregisterMessages([message.id]); + }, + new Date(message.shared_location.end_at).getTime() - Date.now(), + ), + }); + return { + ...currentValue, + messages, + }; + }); + } + + private unregisterMessages(messageIds: string[]) { + const messages = this.messages; + const removedMessages = new Set(messageIds); + const newMessages = new Map( + Array.from(messages).filter(([messageId, location]) => { + if (removedMessages.has(messageId) && location.stopSharingTimeout) { + clearTimeout(location.stopSharingTimeout); + location.stopSharingTimeout = null; + } + return !removedMessages.has(messageId); + }), + ); + + if (newMessages.size === messages.size) return; + + this.state.partialNext({ + messages: newMessages, + }); + } +} diff --git a/src/channel.ts b/src/channel.ts index de792803d..a0a5e9f25 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -31,6 +31,7 @@ import type { GetMultipleMessagesAPIResponse, GetReactionsAPIResponse, GetRepliesAPIResponse, + LiveLocationPayload, LocalMessage, MarkReadOptions, MarkUnreadOptions, @@ -63,10 +64,12 @@ import type { SendMessageAPIResponse, SendMessageOptions, SendReactionOptions, + StaticLocationPayload, TruncateChannelAPIResponse, TruncateOptions, UpdateChannelAPIResponse, UpdateChannelOptions, + UpdateLocationPayload, UserResponse, } from './types'; import type { Role } from './permissions'; @@ -669,6 +672,37 @@ export class Channel { return data; } + public async sendSharedLocation( + location: StaticLocationPayload | LiveLocationPayload, + userId?: string, + ) { + const result = await this.sendMessage({ + id: location.message_id, + shared_location: location, + user: userId ? { id: userId } : undefined, + }); + + if ((location as LiveLocationPayload).end_at) { + this.getClient().dispatchEvent({ + message: result.message, + type: 'live_location_sharing.started', + }); + } + + return result; + } + + public async stopLiveLocationSharing(payload: UpdateLocationPayload) { + const location = await this.getClient().updateLocation({ + ...payload, + end_at: new Date().toISOString(), + }); + this.getClient().dispatchEvent({ + live_location: location, + type: 'live_location_sharing.stopped', + }); + } + /** * delete - Delete the channel. Messages are permanently removed. * diff --git a/src/client.ts b/src/client.ts index ffc030541..f83ce5042 100644 --- a/src/client.ts +++ b/src/client.ts @@ -191,7 +191,6 @@ import type { SegmentTargetsResponse, SegmentType, SendFileAPIResponse, - SharedLocationRequest, SharedLocationResponse, SortParam, StreamChatOptions, @@ -209,6 +208,7 @@ import type { UpdateChannelTypeResponse, UpdateCommandOptions, UpdateCommandResponse, + UpdateLocationPayload, UpdateMessageAPIResponse, UpdateMessageOptions, UpdatePollAPIResponse, @@ -4584,11 +4584,11 @@ export class StreamChat { /** * updateLocation - Updates a location * - * @param location UserLocation the location data to update + * @param location SharedLocationRequest the location data to update * - * @returns {Promise<APIResponse>} The server response + * @returns {Promise<SharedLocationResponse>} The server response */ - async updateLocation(location: SharedLocationRequest) { + async updateLocation(location: UpdateLocationPayload) { return await this.put<SharedLocationResponse>( this.baseURL + `/users/live_locations`, location, diff --git a/src/events.ts b/src/events.ts index bbd8542ac..78861d5e6 100644 --- a/src/events.ts +++ b/src/events.ts @@ -63,6 +63,8 @@ export const EVENT_MAP = { 'connection.recovered': true, 'transport.changed': true, 'capabilities.changed': true, + 'live_location_sharing.started': true, + 'live_location_sharing.stopped': true, // Reminder events 'reminder.created': true, diff --git a/src/index.ts b/src/index.ts index e32209a55..202df56fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,7 @@ export * from './token_manager'; export * from './types'; export * from './channel_manager'; export * from './offline-support'; +export * from './LiveLocationManager'; // Don't use * here, that can break module augmentation https://github.com/microsoft/TypeScript/issues/46617 export type { CustomAttachmentData, diff --git a/src/messageComposer/LocationComposer.ts b/src/messageComposer/LocationComposer.ts new file mode 100644 index 000000000..438d37e35 --- /dev/null +++ b/src/messageComposer/LocationComposer.ts @@ -0,0 +1,94 @@ +import { StateStore } from '../store'; +import type { MessageComposer } from './messageComposer'; +import type { + DraftMessage, + LiveLocationPayload, + LocalMessage, + StaticLocationPayload, +} from '../types'; + +export type Coords = { latitude: number; longitude: number }; + +export type LocationComposerOptions = { + composer: MessageComposer; + message?: DraftMessage | LocalMessage; +}; + +export type StaticLocationPreview = StaticLocationPayload; + +export type LiveLocationPreview = Omit<LiveLocationPayload, 'end_at'> & { + durationMs?: number; +}; + +export type LocationComposerState = { + location: StaticLocationPreview | LiveLocationPreview | null; +}; + +const MIN_LIVE_LOCATION_SHARE_DURATION = 60 * 1000; // 1 minute; + +const initState = ({ + message, +}: { + message?: DraftMessage | LocalMessage; +}): LocationComposerState => ({ + location: message?.shared_location ?? null, +}); + +export class LocationComposer { + readonly state: StateStore<LocationComposerState>; + readonly composer: MessageComposer; + private _deviceId: string; + + constructor({ composer, message }: LocationComposerOptions) { + this.composer = composer; + this.state = new StateStore<LocationComposerState>(initState({ message })); + this._deviceId = this.config.getDeviceId(); + } + + get config() { + return this.composer.config.location; + } + + get deviceId() { + return this._deviceId; + } + + get location() { + return this.state.getLatestValue().location; + } + + get validLocation(): StaticLocationPayload | LiveLocationPayload | null { + const { durationMs, ...location } = (this.location ?? {}) as LiveLocationPreview; + if ( + !!location?.created_by_device_id && + location.message_id && + location.latitude && + location.longitude && + (typeof durationMs === 'undefined' || + durationMs >= MIN_LIVE_LOCATION_SHARE_DURATION) + ) { + return { + ...location, + end_at: durationMs && new Date(Date.now() + durationMs).toISOString(), + } as StaticLocationPayload | LiveLocationPayload; + } + return null; + } + + initState = ({ message }: { message?: DraftMessage | LocalMessage } = {}) => { + this.state.next(initState({ message })); + }; + + setData = (data: { durationMs?: number } & Coords) => { + if (!this.config.enabled) return; + if (!data.latitude || !data.longitude) return; + + this.state.partialNext({ + location: { + ...data, + message_id: this.composer.id, + created_by_device_id: this.deviceId, + }, + }); + }; +} diff --git a/src/messageComposer/attachmentIdentity.ts b/src/messageComposer/attachmentIdentity.ts index 87a448280..d775948c3 100644 --- a/src/messageComposer/attachmentIdentity.ts +++ b/src/messageComposer/attachmentIdentity.ts @@ -1,4 +1,4 @@ -import type { Attachment } from '../types'; +import type { Attachment, SharedLocationResponse } from '../types'; import type { AudioAttachment, FileAttachment, @@ -90,3 +90,10 @@ export const isUploadedAttachment = ( isImageAttachment(attachment) || isVideoAttachment(attachment) || isVoiceRecordingAttachment(attachment); + +export const isSharedLocationResponse = ( + location: unknown, +): location is SharedLocationResponse => + !!(location as SharedLocationResponse).latitude && + !!(location as SharedLocationResponse).longitude && + !!(location as SharedLocationResponse).channel_cid; diff --git a/src/messageComposer/configuration/configuration.ts b/src/messageComposer/configuration/configuration.ts index 77282c9dc..0b47f5e99 100644 --- a/src/messageComposer/configuration/configuration.ts +++ b/src/messageComposer/configuration/configuration.ts @@ -3,9 +3,11 @@ import { API_MAX_FILES_ALLOWED_PER_MESSAGE } from '../../constants'; import type { AttachmentManagerConfig, LinkPreviewsManagerConfig, + LocationComposerConfig, MessageComposerConfig, } from './types'; import type { TextComposerConfig } from './types'; +import { generateUUIDv4 } from '../../utils'; export const DEFAULT_LINK_PREVIEW_MANAGER_CONFIG: LinkPreviewsManagerConfig = { debounceURLEnrichmentMs: 1500, @@ -36,9 +38,15 @@ export const DEFAULT_TEXT_COMPOSER_CONFIG: TextComposerConfig = { publishTypingEvents: true, }; +export const DEFAULT_LOCATION_COMPOSER_CONFIG: LocationComposerConfig = { + enabled: true, + getDeviceId: () => generateUUIDv4(), +}; + export const DEFAULT_COMPOSER_CONFIG: MessageComposerConfig = { attachments: DEFAULT_ATTACHMENT_MANAGER_CONFIG, drafts: { enabled: false }, linkPreviews: DEFAULT_LINK_PREVIEW_MANAGER_CONFIG, + location: DEFAULT_LOCATION_COMPOSER_CONFIG, text: DEFAULT_TEXT_COMPOSER_CONFIG, }; diff --git a/src/messageComposer/configuration/types.ts b/src/messageComposer/configuration/types.ts index b2a3e603b..e94d17f78 100644 --- a/src/messageComposer/configuration/types.ts +++ b/src/messageComposer/configuration/types.ts @@ -11,6 +11,7 @@ export type UploadRequestFn = ( export type DraftsConfiguration = { enabled: boolean; }; + export type TextComposerConfig = { /** If false, the text input, change and selection events are disabled */ enabled: boolean; @@ -23,6 +24,7 @@ export type TextComposerConfig = { /** Prevents sending a message longer than this length */ maxLengthOnSend?: number; }; + export type AttachmentManagerConfig = { // todo: document removal of noFiles prop showing how to achieve the same with custom fileUploadFilter function /** @@ -53,6 +55,16 @@ export type LinkPreviewsManagerConfig = { onLinkPreviewDismissed?: (linkPreview: LinkPreview) => void; }; +export type LocationComposerConfig = { + /** + * Allows for toggling the location addition. + * By default, the feature is enabled but has to be enabled also on channel level config via shared_locations. + */ + enabled: boolean; + /** Function that provides a stable id for a device from which the location is shared */ + getDeviceId: () => string; +}; + export type MessageComposerConfig = { /** If true, enables creating drafts on the server */ drafts: DraftsConfiguration; @@ -60,6 +72,8 @@ export type MessageComposerConfig = { attachments: AttachmentManagerConfig; /** Configuration for the link previews manager */ linkPreviews: LinkPreviewsManagerConfig; + /** Configuration for the location composer */ + location: LocationComposerConfig; /** Maximum number of characters in a message */ text: TextComposerConfig; }; diff --git a/src/messageComposer/index.ts b/src/messageComposer/index.ts index 8e4eb92ce..59d930444 100644 --- a/src/messageComposer/index.ts +++ b/src/messageComposer/index.ts @@ -4,6 +4,7 @@ export * from './configuration'; export * from './CustomDataManager'; export * from './fileUtils'; export * from './linkPreviewsManager'; +export * from './LocationComposer'; export * from './messageComposer'; export * from './middleware'; export * from './pollComposer'; diff --git a/src/messageComposer/messageComposer.ts b/src/messageComposer/messageComposer.ts index f23453783..65337eb75 100644 --- a/src/messageComposer/messageComposer.ts +++ b/src/messageComposer/messageComposer.ts @@ -1,14 +1,16 @@ 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/configuration'; +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'; @@ -24,11 +26,11 @@ import type { 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 { Unsubscribe } from '../store'; -import { WithSubscriptions } from '../utils/WithSubscriptions'; +import type { MergeWithCustomizer } from '../utils/mergeWith/mergeWithCore'; type UnregisterSubscriptions = Unsubscribe; @@ -129,6 +131,7 @@ export class MessageComposer extends WithSubscriptions { linkPreviewsManager: LinkPreviewsManager; textComposer: TextComposer; pollComposer: PollComposer; + locationComposer: LocationComposer; customDataManager: CustomDataManager; // todo: mediaRecorder: MediaRecorderController; @@ -142,10 +145,6 @@ export class MessageComposer extends WithSubscriptions { this.compositionContext = compositionContext; - this.configState = new StateStore<MessageComposerConfig>( - mergeWith(DEFAULT_COMPOSER_CONFIG, config ?? {}), - ); - // channel is easily inferable from the context if (compositionContext instanceof Channel) { this.channel = compositionContext; @@ -160,6 +159,32 @@ export class MessageComposer extends WithSubscriptions { ); } + 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; @@ -170,6 +195,7 @@ export class MessageComposer extends WithSubscriptions { 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 }); @@ -289,7 +315,8 @@ export class MessageComposer extends WithSubscriptions { (!this.attachmentManager.uploadsInProgressCount && (!this.textComposer.textIsEmpty || this.attachmentManager.successfulUploadsCount > 0)) || - this.pollId + this.pollId || + !!this.locationComposer.validLocation ); } @@ -298,7 +325,8 @@ export class MessageComposer extends WithSubscriptions { !this.quotedMessage && this.textComposer.textIsEmpty && !this.attachmentManager.attachments.length && - !this.pollId + !this.pollId && + !this.locationComposer.validLocation ); } @@ -320,6 +348,10 @@ export class MessageComposer extends WithSubscriptions { static generateId = generateUUIDv4; + refreshId = () => { + this.state.partialNext({ id: MessageComposer.generateId() }); + }; + initState = ({ composition, }: { composition?: DraftResponse | MessageResponse | LocalMessage } = {}) => { @@ -333,6 +365,7 @@ export class MessageComposer extends WithSubscriptions { : 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 }); @@ -403,6 +436,7 @@ export class MessageComposer extends WithSubscriptions { 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()); @@ -535,6 +569,18 @@ export class MessageComposer extends WithSubscriptions { } }); + 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; @@ -800,4 +846,30 @@ export class MessageComposer extends WithSubscriptions { 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; + } + }; } diff --git a/src/messageComposer/middleware/messageComposer/MessageComposerMiddlewareExecutor.ts b/src/messageComposer/middleware/messageComposer/MessageComposerMiddlewareExecutor.ts index d578c1b37..adf06bc91 100644 --- a/src/messageComposer/middleware/messageComposer/MessageComposerMiddlewareExecutor.ts +++ b/src/messageComposer/middleware/messageComposer/MessageComposerMiddlewareExecutor.ts @@ -32,6 +32,7 @@ import { } from './customData'; import { createUserDataInjectionMiddleware } from './userDataInjection'; import { createPollOnlyCompositionMiddleware } from './pollOnly'; +import { createSharedLocationCompositionMiddleware } from './sharedLocation'; export class MessageComposerMiddlewareExecutor extends MiddlewareExecutor< MessageComposerMiddlewareState, @@ -47,6 +48,7 @@ export class MessageComposerMiddlewareExecutor extends MiddlewareExecutor< createTextComposerCompositionMiddleware(composer), createAttachmentsCompositionMiddleware(composer), createLinkPreviewsCompositionMiddleware(composer), + createSharedLocationCompositionMiddleware(composer), createMessageComposerStateCompositionMiddleware(composer), createCustomDataCompositionMiddleware(composer), createCompositionValidationMiddleware(composer), diff --git a/src/messageComposer/middleware/messageComposer/compositionValidation.ts b/src/messageComposer/middleware/messageComposer/compositionValidation.ts index 90c11d315..d737ce1eb 100644 --- a/src/messageComposer/middleware/messageComposer/compositionValidation.ts +++ b/src/messageComposer/middleware/messageComposer/compositionValidation.ts @@ -20,15 +20,11 @@ export const createCompositionValidationMiddleware = ( }: MiddlewareHandlerParams<MessageComposerMiddlewareState>) => { const { maxLengthOnSend } = composer.config.text ?? {}; const inputText = state.message.text ?? ''; - const isEmptyMessage = - textIsEmpty(inputText) && - !state.message.attachments?.length && - !state.message.poll_id; const hasExceededMaxLength = typeof maxLengthOnSend === 'number' && inputText.length > maxLengthOnSend; - if (isEmptyMessage || hasExceededMaxLength) { + if (composer.compositionIsEmpty || hasExceededMaxLength) { return await discard(); } diff --git a/src/messageComposer/middleware/messageComposer/index.ts b/src/messageComposer/middleware/messageComposer/index.ts index ef3111674..86ccc475e 100644 --- a/src/messageComposer/middleware/messageComposer/index.ts +++ b/src/messageComposer/middleware/messageComposer/index.ts @@ -5,6 +5,7 @@ export * from './compositionValidation'; export * from './linkPreviews'; export * from './MessageComposerMiddlewareExecutor'; export * from './messageComposerState'; +export * from './sharedLocation'; export * from './textComposer'; export * from './types'; export * from './commandInjection'; diff --git a/src/messageComposer/middleware/messageComposer/sharedLocation.ts b/src/messageComposer/middleware/messageComposer/sharedLocation.ts new file mode 100644 index 000000000..00e17b68d --- /dev/null +++ b/src/messageComposer/middleware/messageComposer/sharedLocation.ts @@ -0,0 +1,42 @@ +import type { MiddlewareHandlerParams } from '../../../middleware'; +import type { MessageComposer } from '../../messageComposer'; +import type { + MessageComposerMiddlewareState, + MessageCompositionMiddleware, +} from './types'; + +export const createSharedLocationCompositionMiddleware = ( + composer: MessageComposer, +): MessageCompositionMiddleware => ({ + id: 'stream-io/message-composer-middleware/shared-location', + handlers: { + compose: ({ + state, + next, + forward, + }: MiddlewareHandlerParams<MessageComposerMiddlewareState>) => { + const { locationComposer } = composer; + const location = locationComposer.validLocation; + if (!locationComposer || !location || !composer.client.user) return forward(); + const timestamp = new Date().toISOString(); + + return next({ + ...state, + localMessage: { + ...state.localMessage, + shared_location: { + ...location, + channel_cid: composer.channel.cid, + created_at: timestamp, + updated_at: timestamp, + user_id: composer.client.user.id, + }, + }, + message: { + ...state.message, + shared_location: location, + }, + }); + }, + }, +}); diff --git a/src/types.ts b/src/types.ts index 764529764..b57fc9de4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2287,7 +2287,6 @@ export type Attachment = CustomAttachmentData & { original_height?: number; original_width?: number; pretext?: string; - stopped_sharing?: boolean; text?: string; thumb_url?: string; title?: string; @@ -2348,6 +2347,7 @@ export type ChannelConfigFields = { read_events?: boolean; replies?: boolean; search?: boolean; + shared_locations?: boolean; typing_events?: boolean; uploads?: boolean; url_enrichment?: boolean; @@ -2705,7 +2705,7 @@ export type Logger = ( export type Message = Partial< MessageBase & { mentioned_users: string[]; - shared_location?: SharedLocationRequest; + shared_location?: StaticLocationPayload | LiveLocationPayload; } >; @@ -3966,13 +3966,14 @@ export type DraftMessage = { parent_id?: string; poll_id?: string; quoted_message_id?: string; + shared_location?: StaticLocationPayload | LiveLocationPayload; // todo: live-location verify if possible show_in_channel?: boolean; silent?: boolean; type?: MessageLabel; }; export type ActiveLiveLocationsAPIResponse = APIResponse & { - active_live_locations: SharedLocationResponse[]; + active_live_locations: SharedLiveLocationResponse[]; }; export type SharedLocationResponse = { @@ -3987,11 +3988,51 @@ export type SharedLocationResponse = { user_id: string; }; -export type SharedLocationRequest = { +export type SharedStaticLocationResponse = { + channel_cid: string; + created_at: string; created_by_device_id: string; + latitude: number; + longitude: number; + message_id: string; + updated_at: string; + user_id: string; +}; + +export type SharedLiveLocationResponse = { + channel_cid: string; + created_at: string; + created_by_device_id: string; + end_at: string; + latitude: number; + longitude: number; + message_id: string; + updated_at: string; + user_id: string; +}; + +export type UpdateLocationPayload = { + message_id: string; + created_by_device_id?: string; end_at?: string; latitude?: number; longitude?: number; + user?: { id: string }; + user_id?: string; +}; + +export type StaticLocationPayload = { + created_by_device_id: string; + latitude: number; + longitude: number; + message_id: string; +}; + +export type LiveLocationPayload = { + created_by_device_id: string; + end_at: string; + latitude: number; + longitude: number; message_id: string; }; @@ -4064,9 +4105,9 @@ export type ReminderResponseBase = { }; export type ReminderResponse = ReminderResponseBase & { - channel: ChannelResponse; user: UserResponse; message: MessageResponse; + channel?: ChannelResponse; }; export type ReminderAPIResponse = APIResponse & { diff --git a/test/unit/LiveLocationManager.test.ts b/test/unit/LiveLocationManager.test.ts new file mode 100644 index 000000000..114810637 --- /dev/null +++ b/test/unit/LiveLocationManager.test.ts @@ -0,0 +1,798 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + Coords, + LiveLocationManager, + LiveLocationManagerConstructorParameters, + SharedLiveLocationResponse, + StreamChat, + UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT, + WatchLocationHandler, +} from '../../src'; +import { getClientWithUser } from './test-utils/getClient'; +import { sleep } from '../../src/utils'; + +const makeWatchLocation = + ( + coords: Coords[], + captureHandler?: (handler: (c: Coords) => void) => void, + ): LiveLocationManagerConstructorParameters['watchLocation'] => + (handler) => { + if (captureHandler) { + captureHandler(handler); + } else { + coords.forEach((coord) => handler(coord)); + } + + return () => null; + }; + +describe('LiveLocationManager', () => { + const deviceId = 'deviceId'; + const getDeviceId = vi.fn().mockReturnValue(deviceId); + const watchLocation = vi.fn().mockReturnValue(() => null); + const user = { id: 'user-id' }; + const liveLocation: SharedLiveLocationResponse = { + channel_cid: 'channel_cid', + created_at: 'created_at', + created_by_device_id: 'created_by_device_id', + end_at: '9999-12-31T23:59:59.535Z', + latitude: 1, + longitude: 2, + message_id: 'liveLocation_message_id', + updated_at: 'updated_at', + user_id: user.id, + }; + const liveLocation2: SharedLiveLocationResponse = { + channel_cid: 'channel_cid2', + created_at: 'created_at', + created_by_device_id: 'created_by_device_id', + end_at: '9999-12-31T23:59:59.535Z', + latitude: 1, + longitude: 2, + message_id: 'liveLocation_message_id2', + updated_at: 'updated_at', + user_id: user.id, + }; + + describe('constructor', () => { + it('throws if the user is unknown', () => { + expect( + () => + new LiveLocationManager({ + client: {} as StreamChat, + getDeviceId, + watchLocation, + }), + ).toThrow(expect.any(Error)); + }); + + it('sets up the initial state', async () => { + const client = await getClientWithUser({ id: 'user-abc' }); + const manager = new LiveLocationManager({ + client, + getDeviceId, + watchLocation, + }); + expect(manager.deviceId).toEqual(deviceId); + expect(manager.getDeviceId).toEqual(getDeviceId); + expect(manager.watchLocation).toEqual(watchLocation); + expect(manager.state.getLatestValue()).toEqual({ + messages: new Map(), + ready: false, + }); + }); + }); + + describe('live location management', () => { + it('retrieves the active live locations and registers subscriptions on init', async () => { + const client = await getClientWithUser({ id: 'user-abc' }); + const getSharedLocationsSpy = vi + .spyOn(client, 'getSharedLocations') + .mockResolvedValue({ active_live_locations: [], duration: '' }); + const manager = new LiveLocationManager({ + client, + getDeviceId, + watchLocation, + }); + + expect(getSharedLocationsSpy).toHaveBeenCalledTimes(0); + expect(manager.stateIsReady).toBeFalsy(); + await manager.init(); + expect(getSharedLocationsSpy).toHaveBeenCalledTimes(1); + expect(manager.hasSubscriptions).toBeTruthy(); + // @ts-expect-error accessing private attribute + expect(manager.refCount).toBe(1); + + await manager.init(); + expect(getSharedLocationsSpy).toHaveBeenCalledTimes(1); + expect(manager.hasSubscriptions).toBeTruthy(); + expect(manager.stateIsReady).toBeTruthy(); + // @ts-expect-error accessing private attribute + expect(manager.refCount).toBe(2); + }); + + it('unregisters subscriptions', async () => { + const client = await getClientWithUser({ id: 'user-abc' }); + const getSharedLocationsSpy = vi + .spyOn(client, 'getSharedLocations') + .mockResolvedValue({ active_live_locations: [], duration: '' }); + const manager = new LiveLocationManager({ + client, + getDeviceId, + watchLocation, + }); + + await manager.init(); + manager.unregisterSubscriptions(); + expect(manager.hasSubscriptions).toBeFalsy(); + }); + + describe('message addition or removal', () => { + it('does not update active location if there are no active live locations', async () => { + const client = await getClientWithUser({ id: 'user-abc' }); + const getSharedLocationsSpy = vi + .spyOn(client, 'getSharedLocations') + .mockResolvedValue({ active_live_locations: [], duration: '' }); + const updateLocationSpy = vi + .spyOn(client, 'updateLocation') + .mockResolvedValue(liveLocation); + const newCoords = { latitude: 2, longitude: 2 }; + const manager = new LiveLocationManager({ + client, + getDeviceId, + watchLocation: makeWatchLocation([newCoords]), + }); + + await manager.init(); + expect(updateLocationSpy).not.toHaveBeenCalled(); + }); + + it('does not update active location if there are no coordinate updates', async () => { + // starting from 0 + const client = await getClientWithUser({ id: 'user-abc' }); + const getSharedLocationsSpy = vi + .spyOn(client, 'getSharedLocations') + .mockResolvedValue({ active_live_locations: [liveLocation], duration: '' }); + const updateLocationSpy = vi + .spyOn(client, 'updateLocation') + .mockResolvedValue(liveLocation); + const manager = new LiveLocationManager({ + client, + getDeviceId, + watchLocation, + }); + + await manager.init(); + expect(updateLocationSpy).not.toHaveBeenCalled(); + }); + + it('updates active location on coordinate updates', async () => { + const client = await getClientWithUser({ id: 'user-abc' }); + vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + active_live_locations: [liveLocation], + duration: '', + }); + const updateLocationSpy = vi + .spyOn(client, 'updateLocation') + .mockResolvedValue(liveLocation); + const newCoords = { latitude: 2, longitude: 2 }; + const manager = new LiveLocationManager({ + client, + getDeviceId, + watchLocation: makeWatchLocation([newCoords]), + }); + + await manager.init(); + expect(updateLocationSpy).toHaveBeenCalledTimes(1); + expect(updateLocationSpy).toHaveBeenCalledWith({ + created_by_device_id: liveLocation.created_by_device_id, + message_id: liveLocation.message_id, + ...newCoords, + }); + expect(manager.messages).toHaveLength(1); + }); + + it('does not update active location if returning to 0 locations', async () => { + const client = await getClientWithUser({ id: 'user-abc' }); + vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + active_live_locations: [liveLocation], + duration: '', + }); + const updateLocationSpy = vi + .spyOn(client, 'updateLocation') + .mockResolvedValue(liveLocation); + const newCoords = { latitude: 2, longitude: 2 }; + const manager = new LiveLocationManager({ + client, + getDeviceId, + watchLocation: makeWatchLocation([newCoords]), + }); + + await manager.init(); + + // @ts-expect-error accessing private property + manager.unregisterMessages([liveLocation.message_id]); + expect(updateLocationSpy).toHaveBeenCalledTimes(1); + expect(manager.messages).toHaveLength(0); + }); + + it('requests the live location upon adding a first message', async () => { + const client = await getClientWithUser(user); + vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + active_live_locations: [], + duration: '', + }); + const updateLocationSpy = vi + .spyOn(client, 'updateLocation') + .mockResolvedValue(liveLocation); + const newCoords = { latitude: 2, longitude: 2 }; + const manager = new LiveLocationManager({ + client, + getDeviceId, + watchLocation: makeWatchLocation([newCoords]), + }); + + await manager.init(); + expect(updateLocationSpy).not.toHaveBeenCalled(); + // @ts-expect-error accessing private property + manager.registerMessage({ + id: liveLocation.message_id, + shared_location: liveLocation, + user, + }); + vi.waitFor(() => { + expect(updateLocationSpy).toHaveBeenCalledTimes(1); + expect(updateLocationSpy).toHaveBeenCalledWith({ + created_by_device_id: manager.deviceId, + message_id: liveLocation.message_id, + ...newCoords, + }); + expect(manager.messages).toHaveLength(1); + }); + }); + + it('does not perform live location update request upon adding subsequent messages within min throttle timeout', async () => { + const client = await getClientWithUser(user); + vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + active_live_locations: [], + duration: '', + }); + const updateLocationSpy = vi + .spyOn(client, 'updateLocation') + .mockResolvedValue(liveLocation); + const newCoords = { latitude: 2, longitude: 2 }; + const manager = new LiveLocationManager({ + client, + getDeviceId, + watchLocation: makeWatchLocation([newCoords]), + }); + + await manager.init(); + // @ts-expect-error accessing private property + manager.registerMessage({ + id: liveLocation.message_id, + shared_location: liveLocation, + user, + }); + await sleep(0); // registerMessage is async under the hood + // @ts-expect-error accessing private property + manager.registerMessage({ + id: liveLocation2.message_id, + shared_location: liveLocation2, + user, + }); + + vi.waitFor(() => { + expect(updateLocationSpy).toHaveBeenCalledTimes(1); + expect(updateLocationSpy).toHaveBeenCalledWith({ + created_by_device_id: manager.deviceId, + message_id: liveLocation.message_id, + ...newCoords, + }); + expect(manager.messages).toHaveLength(2); + }); + }); + + it('does not request live location upon adding subsequent messages beyond min throttle timeout', async () => { + vi.useFakeTimers(); + const client = await getClientWithUser(user); + vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + active_live_locations: [], + duration: '', + }); + const updateLocationSpy = vi + .spyOn(client, 'updateLocation') + .mockResolvedValueOnce(liveLocation) + .mockResolvedValueOnce(liveLocation2); + const newCoords = { latitude: 2, longitude: 2 }; + const manager = new LiveLocationManager({ + client, + getDeviceId, + watchLocation: makeWatchLocation([newCoords]), + }); + + await manager.init(); + // @ts-expect-error accessing private property + manager.registerMessage({ + id: liveLocation.message_id, + shared_location: liveLocation, + user, + }); + let sleepPromise = sleep(0); // registerMessage is async under the hood + vi.advanceTimersByTime(UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT); + await sleepPromise; + // @ts-expect-error accessing private property + manager.registerMessage({ + id: liveLocation2.message_id, + shared_location: liveLocation2, + user, + }); + sleepPromise = sleep(0); // registerMessage is async under the hood + vi.advanceTimersByTime(0); + await sleepPromise; + + vi.waitFor(() => { + expect(updateLocationSpy).toHaveBeenCalledTimes(1); + expect(updateLocationSpy).toHaveBeenCalledWith({ + created_by_device_id: liveLocation.created_by_device_id, + message_id: liveLocation.message_id, + ...newCoords, + }); + expect(manager.messages).toHaveLength(2); + }); + vi.useRealTimers(); + }); + + it('throttles live location update requests upon multiple watcher coords emissions under min throttle timeout', async () => { + const client = await getClientWithUser(user); + vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + active_live_locations: [liveLocation], + duration: '', + }); + const updateLocationSpy = vi + .spyOn(client, 'updateLocation') + .mockResolvedValue(liveLocation); + let watchHandler: WatchLocationHandler = () => { + throw new Error('XX'); + }; + const captureHandler = (handler: WatchLocationHandler) => { + watchHandler = handler; + }; + const manager = new LiveLocationManager({ + client, + getDeviceId, + watchLocation: makeWatchLocation([], captureHandler), + }); + + await manager.init(); + + watchHandler({ latitude: 1, longitude: 1 }); + + await sleep(0); // async under the hood + expect(updateLocationSpy).toHaveBeenCalledTimes(1); + + watchHandler({ latitude: 1, longitude: 2 }); + + await sleep(0); // async under the hood + expect(updateLocationSpy).toHaveBeenCalledTimes(1); + }); + + it('allows live location update requests upon multiple watcher coords emissions beyond min throttle timeout', async () => { + vi.useFakeTimers(); + const client = await getClientWithUser(user); + vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + active_live_locations: [liveLocation], + duration: '', + }); + const updateLocationSpy = vi + .spyOn(client, 'updateLocation') + .mockResolvedValue(liveLocation); + let watchHandler: WatchLocationHandler = () => { + throw new Error('XX'); + }; + const captureHandler = (handler: WatchLocationHandler) => { + watchHandler = handler; + }; + const manager = new LiveLocationManager({ + client, + getDeviceId, + watchLocation: makeWatchLocation([], captureHandler), + }); + + await manager.init(); + watchHandler({ latitude: 1, longitude: 1 }); + + vi.waitFor(() => { + expect(updateLocationSpy).toHaveBeenCalledTimes(1); + }); + + const sleepPromise = sleep(0); + vi.advanceTimersByTime(UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT); + await sleepPromise; + + watchHandler({ latitude: 3, longitude: 4 }); + + vi.waitFor(() => { + expect(updateLocationSpy).toHaveBeenCalledTimes(2); + }); + + vi.useRealTimers(); + }); + + it('prevents live location update requests for expired live locations', async () => { + vi.useFakeTimers(); + const client = await getClientWithUser(user); + vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + active_live_locations: [ + { + ...liveLocation, + end_at: new Date( + Date.now() + UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT - 1000, + ).toISOString(), + }, + ], + duration: '', + }); + const updateLocationSpy = vi + .spyOn(client, 'updateLocation') + .mockResolvedValue(liveLocation); + let watchHandler: WatchLocationHandler = () => { + throw new Error('XX'); + }; + const captureHandler = (handler: WatchLocationHandler) => { + watchHandler = handler; + }; + const manager = new LiveLocationManager({ + client, + getDeviceId, + watchLocation: makeWatchLocation([], captureHandler), + }); + + await manager.init(); + watchHandler({ latitude: 1, longitude: 1 }); + + vi.waitFor(() => { + expect(updateLocationSpy).toHaveBeenCalledTimes(1); + }); + + const sleepPromise = sleep(0); + vi.advanceTimersByTime(UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT); + await sleepPromise; + + watchHandler({ latitude: 3, longitude: 4 }); + + vi.waitFor(() => { + expect(updateLocationSpy).toHaveBeenCalledTimes(1); + }); + + vi.useRealTimers(); + }); + }); + + describe('live_location_sharing.started', () => { + it('registers a new message', async () => { + const client = await getClientWithUser(user); + vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + active_live_locations: [], + duration: '', + }); + vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + const newCoords = { latitude: 2, longitude: 2 }; + const manager = new LiveLocationManager({ + client, + getDeviceId, + watchLocation: makeWatchLocation([newCoords]), + }); + + await manager.init(); + expect(manager.messages.size).toBe(0); + client.dispatchEvent({ + message: { + id: liveLocation.message_id, + shared_location: liveLocation, + type: 'regular', + user, + }, + type: 'live_location_sharing.started', + }); + vi.waitFor(() => { + expect(manager.messages.size).toBe(1); + }); + }); + }); + + describe('message.updated', () => { + it('registers a new message if not yet registered', async () => { + const client = await getClientWithUser(user); + vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + active_live_locations: [], + duration: '', + }); + vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + const newCoords = { latitude: 2, longitude: 2 }; + const manager = new LiveLocationManager({ + client, + getDeviceId, + watchLocation: makeWatchLocation([newCoords]), + }); + + await manager.init(); + expect(manager.messages.size).toBe(0); + client.dispatchEvent({ + message: { + id: liveLocation.message_id, + shared_location: liveLocation, + type: 'regular', + user, + }, + type: 'message.updated', + }); + vi.waitFor(() => { + expect(manager.messages.size).toBe(1); + }); + }); + + it('updates location for registered message', async () => { + const client = await getClientWithUser(user); + vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + active_live_locations: [{ ...liveLocation, end_at: new Date().toISOString() }], + duration: '', + }); + vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + const newCoords = { latitude: 2, longitude: 2 }; + const manager = new LiveLocationManager({ + client, + getDeviceId, + watchLocation: makeWatchLocation([newCoords]), + }); + + await manager.init(); + vi.waitFor(() => { + expect(manager.messages).toHaveLength(1); + }); + client.dispatchEvent({ + message: { + id: liveLocation.message_id, + shared_location: liveLocation, + type: 'regular', + user, + }, + type: 'message.updated', + }); + vi.waitFor(() => { + expect(manager.messages).toHaveLength(1); + expect(manager.messages.get(liveLocation.message_id)?.end_at).toBe( + liveLocation.end_at, + ); + }); + }); + + it('does not register a new message if it does not contain a live location', async () => { + const client = await getClientWithUser(user); + vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + active_live_locations: [], + duration: '', + }); + vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + const newCoords = { latitude: 2, longitude: 2 }; + const manager = new LiveLocationManager({ + client, + getDeviceId, + watchLocation: makeWatchLocation([newCoords]), + }); + + await manager.init(); + expect(manager.messages.size).toBe(0); + client.dispatchEvent({ + message: { id: liveLocation.message_id, type: 'regular', user }, + type: 'message.updated', + }); + vi.waitFor(() => { + expect(manager.messages.size).toBe(0); + }); + }); + + it('does not register a new message if it does not contain user', async () => { + const client = await getClientWithUser(user); + vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + active_live_locations: [], + duration: '', + }); + vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + const newCoords = { latitude: 2, longitude: 2 }; + const manager = new LiveLocationManager({ + client, + getDeviceId, + watchLocation: makeWatchLocation([newCoords]), + }); + + await manager.init(); + expect(manager.messages.size).toBe(0); + client.dispatchEvent({ + message: { + id: liveLocation.message_id, + shared_location: liveLocation, + type: 'regular', + }, + type: 'message.updated', + }); + vi.waitFor(() => { + expect(manager.messages.size).toBe(0); + }); + }); + + it('unregisters a message if the updated message does not contain a live location', async () => { + const client = await getClientWithUser(user); + vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + active_live_locations: [liveLocation], + duration: '', + }); + vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + const newCoords = { latitude: 2, longitude: 2 }; + const manager = new LiveLocationManager({ + client, + getDeviceId, + watchLocation: makeWatchLocation([newCoords]), + }); + + await manager.init(); + expect(manager.messages).toHaveLength(1); + client.dispatchEvent({ + message: { + id: liveLocation.message_id, + shared_location: undefined, + type: 'regular', + user, + }, + type: 'message.updated', + }); + vi.waitFor(() => { + expect(manager.messages).toHaveLength(0); + }); + }); + + it('unregisters a message if its live location has been changed to static location', async () => { + const client = await getClientWithUser(user); + vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + active_live_locations: [liveLocation], + duration: '', + }); + vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + const newCoords = { latitude: 2, longitude: 2 }; + const manager = new LiveLocationManager({ + client, + getDeviceId, + watchLocation: makeWatchLocation([newCoords]), + }); + + await manager.init(); + expect(manager.messages).toHaveLength(1); + const newEndAt = '1970-01-01T08:08:08.532Z'; + client.dispatchEvent({ + message: { + id: liveLocation.message_id, + shared_location: { ...liveLocation, end_at: undefined }, + type: 'regular', + user, + }, + type: 'message.updated', + }); + vi.waitFor(() => { + expect(manager.messages).toHaveLength(0); + }); + }); + + it('unregisters a message if the updated message has end_at in the past', async () => { + const client = await getClientWithUser(user); + vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + active_live_locations: [liveLocation], + duration: '', + }); + vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + const newCoords = { latitude: 2, longitude: 2 }; + const manager = new LiveLocationManager({ + client, + getDeviceId, + watchLocation: makeWatchLocation([newCoords]), + }); + + await manager.init(); + expect(manager.messages).toHaveLength(1); + const newEndAt = '1970-01-01T08:08:08.532Z'; + client.dispatchEvent({ + message: { + id: liveLocation.message_id, + shared_location: { ...liveLocation, end_at: newEndAt }, + type: 'regular', + user, + }, + type: 'message.updated', + }); + vi.waitFor(() => { + expect(manager.messages).toHaveLength(0); + }); + }); + }); + + describe('live_location_sharing.stopped', () => { + it('unregisters a message', async () => { + const client = await getClientWithUser(user); + vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + active_live_locations: [liveLocation], + duration: '', + }); + vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + const newCoords = { latitude: 2, longitude: 2 }; + const manager = new LiveLocationManager({ + client, + getDeviceId, + watchLocation: makeWatchLocation([newCoords]), + }); + + await manager.init(); + expect(manager.messages).toHaveLength(1); + client.dispatchEvent({ + live_location: liveLocation, + type: 'live_location_sharing.stopped', + }); + vi.waitFor(() => { + expect(manager.messages).toHaveLength(0); + }); + }); + }); + + describe('message.deleted', () => { + it('unregisters a message', async () => { + const client = await getClientWithUser(user); + vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + active_live_locations: [liveLocation], + duration: '', + }); + vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + const newCoords = { latitude: 2, longitude: 2 }; + const manager = new LiveLocationManager({ + client, + getDeviceId, + watchLocation: makeWatchLocation([newCoords]), + }); + + await manager.init(); + expect(manager.messages).toHaveLength(1); + client.dispatchEvent({ + message: { + id: liveLocation.message_id, + shared_location: liveLocation, + type: 'regular', + user, + }, + type: 'message.deleted', + }); + vi.waitFor(() => { + expect(manager.messages).toHaveLength(0); + }); + }); + }); + }); + + describe('getters', async () => { + it('deviceId is calculated only once', async () => { + const client = await getClientWithUser(user); + vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + active_live_locations: [liveLocation], + duration: '', + }); + vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + const getDeviceId = vi + .fn() + .mockReturnValueOnce(deviceId) + .mockReturnValueOnce('xxx'); + const manager = new LiveLocationManager({ + client, + getDeviceId, + watchLocation, + }); + expect(manager.deviceId).toBe(deviceId); + expect(manager.deviceId).toBe(deviceId); + }); + }); +}); diff --git a/test/unit/MessageComposer/LocationComposer.test.ts b/test/unit/MessageComposer/LocationComposer.test.ts new file mode 100644 index 000000000..e86267ed7 --- /dev/null +++ b/test/unit/MessageComposer/LocationComposer.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + DraftResponse, + LocalMessage, + LocationComposerConfig, + MessageComposer, + StreamChat, +} from '../../../src'; + +const deviceId = 'deviceId'; + +const defaultConfig: LocationComposerConfig = { + enabled: true, + getDeviceId: () => deviceId, +}; + +const user = { id: 'user-id' }; + +const setup = ({ + composition, + config, +}: { + composition?: DraftResponse | LocalMessage; + config?: Partial<LocationComposerConfig>; +} = {}) => { + // Reset mocks + vi.clearAllMocks(); + + // Setup mocks + const mockClient = new StreamChat('apiKey', 'apiSecret'); + mockClient.user = user; + + const mockChannel = mockClient.channel('channelType', 'channelId'); + mockChannel.getClient = vi.fn().mockReturnValue(mockClient); + const messageComposer = new MessageComposer({ + client: mockClient, + composition, + compositionContext: mockChannel, + config: { location: { ...defaultConfig, ...config } }, + }); + return { mockClient, mockChannel, messageComposer }; +}; +const locationMessage: LocalMessage = { + created_at: new Date(), + updated_at: new Date(), + deleted_at: null, + pinned_at: null, + type: 'regular', + status: 'received', + id: 'messageId', + shared_location: { + channel_cid: 'channel_cid', + created_at: 'created_at', + created_by_device_id: 'created_by_device_id', + end_at: '9999-12-31T23:59:59.535Z', + latitude: 1, + longitude: 2, + message_id: 'liveLocation_message_id', + updated_at: 'updated_at', + user_id: user.id, + }, +}; +describe('LocationComposer', () => { + it('constructor initiates state and variables', () => { + const { + messageComposer: { locationComposer }, + } = setup(); + expect(locationComposer.state.getLatestValue()).toEqual({ + location: null, + }); + expect(locationComposer.deviceId).toBe(deviceId); + expect(locationComposer.config).toEqual(defaultConfig); + }); + + it('overrides state with initState', () => { + const { + messageComposer: { locationComposer }, + } = setup(); + locationComposer.initState({ message: locationMessage }); + expect(locationComposer.state.getLatestValue()).toEqual({ + location: locationMessage.shared_location, + }); + }); + + it('does not override state with initState with message without shared_location', () => { + const { + messageComposer: { locationComposer }, + } = setup(); + locationComposer.initState({ + message: { ...locationMessage, shared_location: undefined }, + }); + expect(locationComposer.state.getLatestValue()).toEqual({ + location: null, + }); + }); + + it('does not override state with initState without message', () => { + const { + messageComposer: { locationComposer }, + } = setup(); + locationComposer.initState(); + expect(locationComposer.state.getLatestValue()).toEqual({ + location: null, + }); + }); + + it('sets the data', () => { + const { + messageComposer: { locationComposer }, + } = setup(); + const data = { + durationMs: 1, + latitude: 2, + longitude: 3, + }; + locationComposer.setData(data); + const messageId = locationComposer.composer.id; + expect(locationComposer.location).toEqual({ + message_id: messageId, + created_by_device_id: deviceId, + ...data, + }); + }); + + it('does not set the data in case latitude or longitude is missing', () => { + const { + messageComposer: { locationComposer }, + } = setup(); + locationComposer.setData({}); + expect(locationComposer.location).toBeNull(); + }); + + it('does not generate location payload for send message request if expires in less than 60 seconds', () => { + const { + messageComposer: { locationComposer }, + } = setup(); + const data = { + durationMs: 59 * 1000, + latitude: 2, + longitude: 3, + }; + locationComposer.setData(data); + expect(locationComposer.validLocation).toEqual(null); + }); + + it('generate location payload for send message request', () => { + const { + messageComposer: { locationComposer }, + } = setup(); + const data = { + durationMs: 60 * 1000, + latitude: 2, + longitude: 3, + }; + const messageId = locationComposer.composer.id; + locationComposer.setData(data); + expect(locationComposer.validLocation).toEqual({ + message_id: messageId, + created_by_device_id: deviceId, + latitude: data.latitude, + longitude: data.longitude, + end_at: expect.any(String), + }); + + const endAt = new Date(locationComposer.validLocation!.end_at); + const expectedEndAt = new Date(Date.now() + data.durationMs); + expect(endAt.getTime()).toBeCloseTo(expectedEndAt.getTime(), -2); // Within 100ms + }); + + it('generates null in case of invalid location state', () => { + const { + messageComposer: { locationComposer }, + } = setup(); + const invalidStates = [ + { + location: { + latitude: 1, + created_by_device_id: deviceId, + message_id: locationComposer.composer.id, + }, + }, + { + location: { + longitude: 1, + created_by_device_id: deviceId, + message_id: locationComposer.composer.id, + }, + }, + { + location: { + latitude: 1, + longitude: 1, + message_id: locationComposer.composer.id, + }, + }, + { + location: { + latitude: 1, + longitude: 1, + created_by_device_id: deviceId, + }, + }, + ]; + invalidStates.forEach((state) => { + locationComposer.state.next(state); + expect(locationComposer.validLocation).toBeNull(); + }); + }); +}); diff --git a/test/unit/MessageComposer/messageComposer.test.ts b/test/unit/MessageComposer/messageComposer.test.ts index cda63a6a4..a5f79559b 100644 --- a/test/unit/MessageComposer/messageComposer.test.ts +++ b/test/unit/MessageComposer/messageComposer.test.ts @@ -1,16 +1,16 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AbstractOfflineDB, Channel, ChannelAPIResponse, LocalMessage, MessageComposerConfig, + StaticLocationPayload, StreamChat, Thread, } from '../../../src'; import { DeepPartial } from '../../../src/types.utility'; import { MessageComposer } from '../../../src/messageComposer/messageComposer'; -import { StateStore } from '../../../src/store'; import { DraftResponse, MessageResponse } from '../../../src/types'; import { MockOfflineDB } from '../offline-support/MockOfflineDB'; @@ -32,24 +32,6 @@ vi.mock('../../../src/utils', () => ({ throttle: vi.fn().mockImplementation((fn) => fn), })); -vi.mock('../../../src/messageComposer/attachmentManager', () => ({ - AttachmentManager: vi.fn().mockImplementation(() => ({ - state: new StateStore({ attachments: [] }), - initState: vi.fn(), - clear: vi.fn(), - attachments: [], - })), -})); - -vi.mock('../../../src/messageComposer/pollComposer', () => ({ - PollComposer: vi.fn().mockImplementation(() => ({ - state: new StateStore({ poll: null }), - initState: vi.fn(), - clear: vi.fn(), - compose: vi.fn(), - })), -})); - vi.mock('../../../src/messageComposer/middleware/messageComposer', () => ({ MessageComposerMiddlewareExecutor: vi.fn().mockImplementation(() => ({ execute: vi.fn().mockResolvedValue({ state: {} }), @@ -106,15 +88,25 @@ const getThread = (channel: Channel, client: StreamChat, threadId: string) => const setup = ({ composition, compositionContext, + channelConfig, config, }: { composition?: LocalMessage | DraftResponse | MessageResponse | undefined; compositionContext?: Channel | Thread | LocalMessage | undefined; + channelConfig?: { + polls?: boolean; + shared_locations?: boolean; + }; config?: DeepPartial<MessageComposerConfig>; } = {}) => { const mockClient = new StreamChat('test-api-key'); mockClient.user = user; mockClient.userID = user.id; + const cid = 'messaging:test-channel-id'; + if (channelConfig) { + // @ts-expect-error incomplete channel config object + mockClient.configs[cid] = channelConfig; + } // Create a proper Channel instance with only the necessary attributes mocked const mockChannel = new Channel(mockClient, 'messaging', 'test-channel-id', { id: 'test-channel-id', @@ -201,6 +193,61 @@ describe('MessageComposer', () => { expect(messageComposer.config.text?.maxLengthOnEdit).toBe(1000); }); + it('should initialize with custom config overridden with back-end configuration', () => { + [ + { + customConfig: { location: { enabled: true } }, + channelConfig: { shared_locations: undefined }, + expectedResult: { location: { enabled: true } }, // default is true + }, + { + customConfig: { location: { enabled: true } }, + channelConfig: { shared_locations: false }, + expectedResult: { location: { enabled: false } }, + }, + { + customConfig: { location: { enabled: true } }, + channelConfig: { shared_locations: true }, + expectedResult: { location: { enabled: true } }, + }, + { + customConfig: { location: { enabled: undefined } }, + channelConfig: { shared_locations: undefined }, + expectedResult: { location: { enabled: true } }, // default is true + }, + { + customConfig: { location: { enabled: undefined } }, + channelConfig: { shared_locations: false }, + expectedResult: { location: { enabled: false } }, + }, + { + customConfig: { location: { enabled: undefined } }, + channelConfig: { shared_locations: true }, + expectedResult: { location: { enabled: true } }, + }, + { + customConfig: { location: { enabled: false } }, + channelConfig: { shared_locations: false }, + expectedResult: { location: { enabled: false } }, + }, + { + customConfig: { location: { enabled: false } }, + channelConfig: { shared_locations: undefined }, + expectedResult: { location: { enabled: false } }, + }, + { + customConfig: { location: { enabled: false } }, + channelConfig: { shared_locations: true }, + expectedResult: { location: { enabled: false } }, + }, + ].forEach(({ customConfig, channelConfig, expectedResult }) => { + const { messageComposer } = setup({ channelConfig, config: customConfig }); + expect(messageComposer.config.location.enabled).toBe( + expectedResult.location.enabled, + ); + }); + }); + it('should initialize with message', () => { const message = { id: 'test-message-id', @@ -468,28 +515,115 @@ describe('MessageComposer', () => { expect(messageComposer.lastChangeOriginIsLocal).toBe(true); }); - it('should return the correct compositionIsEmpty', () => { + it('should return the correct hasSendableData', () => { const { messageComposer } = setup(); - const spyTextComposerTextIsEmpty = vi - .spyOn(messageComposer.textComposer, 'textIsEmpty', 'get') - .mockReturnValueOnce(true) - .mockReturnValueOnce(false); - // First case - empty composition + messageComposer.textComposer.state.partialNext({ text: '', mentionedUsers: [], selection: { start: 0, end: 0 }, }); - expect(messageComposer.compositionIsEmpty).toBe(true); + expect(messageComposer.hasSendableData).toBe(false); - // Second case - non-empty composition messageComposer.textComposer.state.partialNext({ text: 'Hello world', + }); + expect(messageComposer.hasSendableData).toBe(true); + messageComposer.textComposer.state.partialNext({ + text: '', + }); + + messageComposer.setQuotedMessage({ + id: 'id', + type: 'regular', + status: 'delivered', + created_at: new Date(), + updated_at: new Date(), + deleted_at: null, + pinned_at: null, + }); + expect(messageComposer.hasSendableData).toBe(false); + messageComposer.setQuotedMessage(null); + + messageComposer.attachmentManager.state.partialNext({ + attachments: [ + { type: 'x', localMetadata: { id: 'x,', uploadState: 'finished', file: {} } }, + ], + }); + expect(messageComposer.hasSendableData).toBe(true); + messageComposer.attachmentManager.state.partialNext({ + attachments: [ + { type: 'x', localMetadata: { id: 'x,', uploadState: 'finished', file: {} } }, + { type: 'x', localMetadata: { id: 'x,', uploadState: 'uploading', file: {} } }, + ], + }); + expect(messageComposer.hasSendableData).toBe(false); + messageComposer.attachmentManager.state.partialNext({ + attachments: [], + }); + + messageComposer.state.partialNext({ pollId: 'pollId' }); + expect(messageComposer.hasSendableData).toBe(true); + messageComposer.state.partialNext({ pollId: null }); + + messageComposer.updateConfig({ location: { enabled: true } }); + messageComposer.locationComposer.setData({ latitude: 1, longitude: 1 }); + expect(messageComposer.hasSendableData).toBe(true); + messageComposer.locationComposer.initState(); + + expect(messageComposer.hasSendableData).toBe(false); + }); + + it('should return the correct compositionIsEmpty', () => { + const { messageComposer } = setup(); + + messageComposer.textComposer.state.partialNext({ + text: '', mentionedUsers: [], selection: { start: 0, end: 0 }, }); + expect(messageComposer.compositionIsEmpty).toBe(true); + + messageComposer.textComposer.state.partialNext({ + text: 'Hello world', + }); + expect(messageComposer.compositionIsEmpty).toBe(false); + messageComposer.textComposer.state.partialNext({ + text: '', + }); + + messageComposer.setQuotedMessage({ + id: 'id', + type: 'regular', + status: 'delivered', + created_at: new Date(), + updated_at: new Date(), + deleted_at: null, + pinned_at: null, + }); expect(messageComposer.compositionIsEmpty).toBe(false); - spyTextComposerTextIsEmpty.mockRestore(); + messageComposer.setQuotedMessage(null); + + messageComposer.attachmentManager.state.partialNext({ + attachments: [ + { type: 'x', localMetadata: { id: 'x,', uploadState: 'finished', file: {} } }, + ], + }); + expect(messageComposer.compositionIsEmpty).toBe(false); + messageComposer.attachmentManager.state.partialNext({ + attachments: [], + }); + + messageComposer.state.partialNext({ pollId: 'pollId' }); + expect(messageComposer.compositionIsEmpty).toBe(false); + messageComposer.state.partialNext({ pollId: null }); + + messageComposer.updateConfig({ location: { enabled: true } }); + messageComposer.locationComposer.setData({ latitude: 1, longitude: 1 }); + expect(messageComposer.compositionIsEmpty).toBe(false); + messageComposer.locationComposer.initState(); + + expect(messageComposer.compositionIsEmpty).toBe(true); }); }); @@ -687,6 +821,7 @@ describe('MessageComposer', () => { ); const spyTextComposer = vi.spyOn(messageComposer.textComposer, 'initState'); const spyPollComposer = vi.spyOn(messageComposer.pollComposer, 'initState'); + const spyLocationComposer = vi.spyOn(messageComposer.locationComposer, 'initState'); const spyCustomDataManager = vi.spyOn( messageComposer.customDataManager, 'initState', @@ -701,6 +836,7 @@ describe('MessageComposer', () => { expect(spyPollComposer).toHaveBeenCalled(); expect(spyCustomDataManager).toHaveBeenCalled(); expect(spyInitState).toHaveBeenCalled(); + expect(spyLocationComposer).toHaveBeenCalled(); expect(messageComposer.quotedMessage).to.be.null; }); @@ -1139,6 +1275,10 @@ describe('MessageComposer', () => { const spyCompose = vi.spyOn(messageComposer.pollComposer, 'compose'); spyCompose.mockResolvedValue({ data: mockPoll }); + const spyPollComposerInitState = vi.spyOn( + messageComposer.pollComposer, + 'initState', + ); const spyCreatePoll = vi.spyOn(mockClient, 'createPoll'); spyCreatePoll.mockResolvedValue({ poll: mockPoll }); @@ -1147,7 +1287,7 @@ describe('MessageComposer', () => { expect(spyCompose).toHaveBeenCalled(); expect(spyCreatePoll).toHaveBeenCalledWith(mockPoll); - expect(messageComposer.pollComposer.initState).not.toHaveBeenCalled(); + expect(spyPollComposerInitState).not.toHaveBeenCalled(); expect(messageComposer.state.getLatestValue().pollId).toBe('test-poll-id'); }); @@ -1197,6 +1337,91 @@ describe('MessageComposer', () => { }, }); }); + + it('sends location message', async () => { + const { messageComposer, mockChannel } = setup(); + messageComposer.locationComposer.setData({ latitude: 1, longitude: 1 }); + const messageId = messageComposer.id; + const spySendSharedLocation = vi + .spyOn(mockChannel, 'sendSharedLocation') + .mockResolvedValue({ + message: { id: 'x', status: 'received', type: 'regular' }, + duration: '', + }); + + await messageComposer.sendLocation(); + + expect(spySendSharedLocation).toHaveBeenCalled(); + expect(spySendSharedLocation).toHaveBeenCalledWith({ + message_id: messageId, + created_by_device_id: messageComposer.locationComposer.deviceId, + latitude: 1, + longitude: 1, + } as StaticLocationPayload); + expect(messageComposer.locationComposer.state.getLatestValue()).toEqual({ + location: null, + }); + }); + + it('prevents sending location message when location data is invalid', async () => { + const { messageComposer, mockChannel } = setup(); + const spySendSharedLocation = vi + .spyOn(mockChannel, 'sendSharedLocation') + .mockResolvedValue({ + message: { id: 'x', status: 'received', type: 'regular' }, + duration: '', + }); + + await messageComposer.sendLocation(); + + expect(spySendSharedLocation).not.toHaveBeenCalled(); + }); + it('prevents sending location message in thread', async () => { + const { mockChannel, mockClient } = setup(); + const mockThread = getThread(mockChannel, mockClient, 'test-thread-id'); + const { messageComposer: threadComposer } = setup({ + compositionContext: mockThread, + }); + threadComposer.locationComposer.setData({ latitude: 1, longitude: 1 }); + const spySendSharedLocation = vi + .spyOn(mockChannel, 'sendSharedLocation') + .mockResolvedValue({ + message: { id: 'x', status: 'received', type: 'regular' }, + duration: '', + }); + + await threadComposer.sendLocation(); + + expect(spySendSharedLocation).not.toHaveBeenCalled(); + }); + + it('handles failed location message request', async () => { + const { messageComposer, mockChannel, mockClient } = setup(); + const error = new Error('Failed location request'); + messageComposer.locationComposer.setData({ latitude: 1, longitude: 1 }); + const messageId = messageComposer.id; + const spySendSharedLocation = vi + .spyOn(mockChannel, 'sendSharedLocation') + .mockRejectedValue(error); + const spyAddNotification = vi.spyOn(mockClient.notifications, 'add'); + + await expect(messageComposer.sendLocation()).rejects.toThrow(error.message); + expect(spyAddNotification).toHaveBeenCalledWith({ + message: 'Failed to share the location', + origin: { + emitter: 'MessageComposer', + context: { composer: messageComposer }, + }, + options: { + type: 'api:location:create:failed', + metadata: { + reason: error.message, + }, + originalError: expect.any(Error), + severity: 'error', + }, + }); + }); }); describe('getDraft', () => { @@ -1728,6 +1953,37 @@ describe('MessageComposer', () => { }); }); + describe('subscribeLocationComposerStateChanged', () => { + it('should log state update timestamp when attachments change', () => { + const { messageComposer } = setup(); + const spy = vi.spyOn(messageComposer, 'logStateUpdateTimestamp'); + const spyDeleteDraft = vi.spyOn(messageComposer, 'deleteDraft'); + + messageComposer.registerSubscriptions(); + messageComposer.locationComposer.setData({ + latitude: 1, + longitude: 1, + }); + + expect(spy).toHaveBeenCalled(); + expect(spyDeleteDraft).not.toHaveBeenCalled(); + }); + it('deletes the draft when composition becomes empty', () => { + const { messageComposer } = setup(); + vi.spyOn(messageComposer, 'logStateUpdateTimestamp'); + const spyDeleteDraft = vi.spyOn(messageComposer, 'deleteDraft'); + messageComposer.registerSubscriptions(); + messageComposer.locationComposer.setData({ + latitude: 1, + longitude: 1, + }); + + messageComposer.locationComposer.state.next({ location: null }); + + expect(spyDeleteDraft).toHaveBeenCalled(); + }); + }); + it('should toggle the registration of draft WS event subscriptions when drafts are disabled / enabled', () => { const { messageComposer } = setup({ config: { drafts: { enabled: false } }, diff --git a/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts b/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts index ba89ac9c4..0a881867e 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts @@ -13,91 +13,31 @@ import { import { MiddlewareStatus } from '../../../../../src/middleware'; import { MessageComposerMiddlewareState } from '../../../../../src/messageComposer/middleware/messageComposer/types'; import { MessageDraftComposerMiddlewareValueState } from '../../../../../src/messageComposer/middleware/messageComposer/types'; -import { LocalMessage } from '../../../../../src'; - -const setupMiddleware = (custom: { composer?: MessageComposer } = {}) => { - const client = { - userID: 'currentUser', - user: { id: 'currentUser' }, - } as any; - - const channel = { - getClient: vi.fn().mockReturnValue(client), - state: { - members: {}, - watchers: {}, - }, - getConfig: vi.fn().mockReturnValue({ commands: [] }), - } as any; - - const textComposer = { - get text() { - return ''; - }, - get mentionedUsers() { - return []; - }, - }; - - const attachmentManager = { - get uploadsInProgressCount() { - return 0; - }, - get successfulUploads() { - return []; - }, - }; - - const linkPreviewsManager = { - state: { - getLatestValue: () => ({ - previews: new Map(), - }), - }, - }; +import { LocalMessage, MessageResponse } from '../../../../../src'; +import { generateChannel } from '../../../test-utils/generateChannel'; - const pollComposer = { - state: { - getLatestValue: () => ({ - data: { - options: [], - name: '', - max_votes_allowed: '', - id: '', - user_id: '', - voting_visibility: 'public', - allow_answers: false, - allow_user_suggested_options: false, - description: '', - enforce_unique_vote: true, - }, - errors: {}, - }), - }, - get canCreatePoll() { - return false; - }, - }; +const setupMiddleware = ( + custom: { composer?: MessageComposer; editedMessage?: MessageResponse } = {}, +) => { + const user = { id: 'user' }; + const client = new StreamChat('apiKey'); + client.user = user; + client.userID = user.id; + + const channelResponse = generateChannel(); + const channel = client.channel( + channelResponse.channel.type, + channelResponse.channel.id, + ); + channel.initialized = true; const messageComposer = custom.composer ?? - ({ - channel, - config: {}, - threadId: undefined, + new MessageComposer({ client, - textComposer, - attachmentManager, - linkPreviewsManager, - pollComposer, - get lastChangeOriginIsLocal() { - return true; - }, - editedMessage: undefined, - get quotedMessage() { - return undefined; - }, - } as any); + compositionContext: channel, + composition: custom.editedMessage, + }); return { messageComposer, @@ -323,12 +263,9 @@ describe('stream-io/message-composer-middleware/data-validation', () => { }); it('should not discard composition for edited message without any local change', async () => { - const { messageComposer, validationMiddleware } = setupMiddleware(); - const localMessage: LocalMessage = { + const editedMessage: MessageResponse = { attachments: [], - created_at: new Date(), - deleted_at: null, - error: undefined, + created_at: new Date().toISOString(), id: 'test-id', mentioned_users: [], parent_id: undefined, @@ -337,20 +274,27 @@ describe('stream-io/message-composer-middleware/data-validation', () => { status: 'sending', text: 'Hello world', type: 'regular', - updated_at: new Date(), + updated_at: new Date().toISOString(), }; - messageComposer.editedMessage = localMessage; + const { messageComposer, validationMiddleware } = setupMiddleware({ editedMessage }); + vi.spyOn(messageComposer, 'lastChangeOriginIsLocal', 'get').mockReturnValue(false); const result = await validationMiddleware.handlers.compose( setupMiddlewareInputs({ message: { - id: localMessage.id, - parent_id: localMessage.parent_id, - text: localMessage.text, - type: localMessage.type, + id: editedMessage.id, + parent_id: editedMessage.parent_id, + text: editedMessage.text, + type: editedMessage.type, }, - localMessage, + localMessage: { + ...editedMessage, + created_at: new Date(editedMessage.created_at as string), + deleted_at: null, + pinned_at: null, + updated_at: new Date(editedMessage.updated_at as string), + } as LocalMessage, sendOptions: {}, }), ); diff --git a/test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts b/test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts new file mode 100644 index 000000000..81d932676 --- /dev/null +++ b/test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createSharedLocationCompositionMiddleware, + DraftResponse, + LocalMessage, + MessageComposer, + MessageComposerMiddlewareState, + MiddlewareStatus, + StreamChat, +} from '../../../../../src'; + +const user = { id: 'user-id' }; + +const setup = ({ + composition, +}: { + composition?: DraftResponse | LocalMessage; +} = {}) => { + // Reset mocks + vi.clearAllMocks(); + + // Setup mocks + const mockClient = new StreamChat('apiKey', 'apiSecret'); + mockClient.user = user; + + const mockChannel = mockClient.channel('channelType', 'channelId'); + mockChannel.getClient = vi.fn().mockReturnValue(mockClient); + const messageComposer = new MessageComposer({ + client: mockClient, + composition, + compositionContext: mockChannel, + config: { location: { enabled: true } }, + }); + return { mockClient, mockChannel, messageComposer }; +}; + +const setupMiddlewareHandlerParams = ( + initialState: MessageComposerMiddlewareState = { + message: {}, + localMessage: {}, + sendOptions: {}, + }, +) => { + return { + state: initialState, + next: async (state: MessageComposerMiddlewareState) => ({ state }), + complete: async (state: MessageComposerMiddlewareState) => ({ + state, + status: 'complete' as MiddlewareStatus, + }), + discard: async () => ({ state: initialState, status: 'discard' as MiddlewareStatus }), + forward: async () => ({ state: initialState }), + }; +}; + +describe('stream-io/message-composer-middleware/shared-location', () => { + it('injects shared_location to localMessage and message payloads', async () => { + const { messageComposer } = setup(); + const middleware = createSharedLocationCompositionMiddleware(messageComposer); + const coords = { latitude: 1, longitude: 1 }; + messageComposer.locationComposer.setData(coords); + const result = await middleware.handlers.compose(setupMiddlewareHandlerParams()); + expect(result).toEqual({ + state: { + localMessage: { + shared_location: { + channel_cid: messageComposer.channel.cid, + created_at: expect.any(String), + created_by_device_id: messageComposer.locationComposer.deviceId, + message_id: messageComposer.id, + updated_at: expect.any(String), + user_id: user.id, + ...coords, + }, + }, + message: { + shared_location: { + created_by_device_id: messageComposer.locationComposer.deviceId, + message_id: messageComposer.id, + ...coords, + }, + }, + sendOptions: {}, + }, + }); + }); + + it('does not inject shared_location to localMessage and message payloads if none is set', async () => { + const { messageComposer } = setup(); + const middleware = createSharedLocationCompositionMiddleware(messageComposer); + const result = await middleware.handlers.compose(setupMiddlewareHandlerParams()); + expect(result).toEqual({ + state: { + localMessage: {}, + message: {}, + sendOptions: {}, + }, + }); + }); + + it('does not inject shared_location to localMessage and message payloads if the location state is corrupted', async () => { + const { messageComposer } = setup(); + const middleware = createSharedLocationCompositionMiddleware(messageComposer); + // @ts-expect-error invalid location payload + messageComposer.locationComposer.state.next({ + location: { + latitude: 1, + created_by_device_id: 'da', + message_id: messageComposer.id, + }, + }); + const result = await middleware.handlers.compose(setupMiddlewareHandlerParams()); + expect(result).toEqual({ + state: { + localMessage: {}, + message: {}, + sendOptions: {}, + }, + }); + }); + + it('does not inject shared_location to localMessage and message payloads if the user is unknown', async () => { + const { messageComposer, mockClient } = setup(); + const middleware = createSharedLocationCompositionMiddleware(messageComposer); + const coords = { latitude: 1, longitude: 1 }; + messageComposer.locationComposer.setData(coords); + // @ts-expect-error setting user to invalid value + mockClient.user = null; + const result = await middleware.handlers.compose(setupMiddlewareHandlerParams()); + expect(result).toEqual({ + state: { + localMessage: {}, + message: {}, + sendOptions: {}, + }, + }); + }); +}); diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index c77346be8..a647ffaa7 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -1859,3 +1859,103 @@ describe('message sending flow', () => { }); }); }); + +describe('share location', () => { + const userId = 'user-id'; + const staticLocation = { + created_by_device_id: 'created_by_device_id', + latitude: 1, + longitude: 2, + message_id: 'staticLocation_message_id', + }; + const liveLocation = { + created_by_device_id: 'created_by_device_id', + end_at: 'end_at', + latitude: 1, + longitude: 2, + message_id: 'liveLocation_message_id', + }; + + const setup = async () => { + const client = await getClientWithUser({ id: 'user-abc' }); + const channel = client.channel('messaging', 'test'); + const sendMessageSpy = vi.spyOn(channel, 'sendMessage').mockResolvedValue({}); + const dispatchEventSpy = vi.spyOn(client, 'dispatchEvent').mockResolvedValue({}); + const updateLocationSpy = vi.spyOn(client, 'updateLocation').mockResolvedValue({}); + return { + channel, + client, + dispatchEventSpy, + sendMessageSpy, + updateLocationSpy, + }; + }; + + it('forwards the location object', async () => { + const { channel, sendMessageSpy } = await setup(); + + await channel.sendSharedLocation(staticLocation); + expect(sendMessageSpy).toHaveBeenCalledWith({ + id: staticLocation.message_id, + shared_location: staticLocation, + user: undefined, + }); + + await channel.sendSharedLocation(liveLocation); + expect(sendMessageSpy).toHaveBeenCalledWith({ + id: liveLocation.message_id, + shared_location: liveLocation, + user: undefined, + }); + }); + + it('injects the user object into the request payload', async () => { + const { channel, sendMessageSpy } = await setup(); + + await channel.sendSharedLocation(staticLocation, userId); + expect(sendMessageSpy).toHaveBeenCalledWith({ + id: staticLocation.message_id, + shared_location: staticLocation, + user: { id: userId }, + }); + + await channel.sendSharedLocation(liveLocation, userId); + expect(sendMessageSpy).toHaveBeenCalledWith({ + id: liveLocation.message_id, + shared_location: liveLocation, + user: { id: userId }, + }); + }); + it('emits live_location_sharing.started local event', async () => { + const { channel, dispatchEventSpy, sendMessageSpy } = await setup(); + + sendMessageSpy.mockResolvedValueOnce({ message: { id: staticLocation.message_id } }); + await channel.sendSharedLocation(staticLocation); + expect(dispatchEventSpy).not.toHaveBeenCalled(); + + sendMessageSpy.mockResolvedValueOnce({ message: { id: liveLocation.message_id } }); + await channel.sendSharedLocation(liveLocation); + expect(dispatchEventSpy).toHaveBeenCalledWith({ + message: { id: liveLocation.message_id }, + type: 'live_location_sharing.started', + }); + }); + + it('stops live location sharing', async () => { + const { channel, dispatchEventSpy, updateLocationSpy } = await setup(); + + updateLocationSpy.mockResolvedValueOnce(staticLocation); + await channel.stopLiveLocationSharing(staticLocation); + expect(dispatchEventSpy).toHaveBeenCalledWith({ + live_location: expect.objectContaining(staticLocation), + type: 'live_location_sharing.stopped', + }); + + updateLocationSpy.mockResolvedValueOnce(liveLocation); + await channel.stopLiveLocationSharing(liveLocation); + expect(dispatchEventSpy).toHaveBeenCalledWith({ + live_location: expect.objectContaining(liveLocation), + type: 'live_location_sharing.stopped', + }); + }); +});