From eab6692985e446ef6e6fc31722d33d25fc41927e Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Fri, 13 Dec 2024 12:30:58 +0100 Subject: [PATCH 01/31] Initial commit --- src/concurrency.ts | 117 ++++++++++++++ src/live_location_manager.ts | 294 +++++++++++++++++++++++++++++++++++ src/types.ts | 8 + 3 files changed, 419 insertions(+) create mode 100644 src/concurrency.ts create mode 100644 src/live_location_manager.ts diff --git a/src/concurrency.ts b/src/concurrency.ts new file mode 100644 index 000000000..9c3c82689 --- /dev/null +++ b/src/concurrency.ts @@ -0,0 +1,117 @@ +interface PendingPromise { + onContinued: () => void; + promise: Promise; +} + +type AsyncWrapper

= ( + tag: string | symbol, + cb: (...args: P) => Promise, +) => { + cb: () => Promise; + onContinued: () => void; +}; + +/** + * Runs async functions serially. Useful for wrapping async actions that + * should never run simultaneously: if marked with the same tag, functions + * will run one after another. + * + * @param tag Async functions with the same tag will run serially. Async functions + * with different tags can run in parallel. + * @param cb Async function to run. + * @returns Promise that resolves when async functions returns. + */ +export const withoutConcurrency = createRunner(wrapWithContinuationTracking); + +/** + * Runs async functions serially, and cancels all other actions with the same tag + * when a new action is scheduled. Useful for wrapping async actions that override + * each other (e.g. enabling and disabling camera). + * + * If an async function hasn't started yet and was canceled, it will never run. + * If an async function is already running and was canceled, it will be notified + * via an abort signal passed as an argument. + * + * @param tag Async functions with the same tag will run serially and are canceled + * when a new action with the same tag is scheduled. + * @param cb Async function to run. Receives AbortSignal as the only argument. + * @returns Promise that resolves when async functions returns. If the function didn't + * start and was canceled, will resolve with 'canceled'. If the function started to run, + * it's up to the function to decide how to react to cancelation. + */ +export const withCancellation = createRunner(wrapWithCancellation); + +const pendingPromises = new Map(); + +export function hasPending(tag: string | symbol) { + return pendingPromises.has(tag); +} + +export async function settled(tag: string | symbol) { + await pendingPromises.get(tag)?.promise; +} + +/** + * Implements common functionality of running async functions serially, by chaining + * their promises one after another. + * + * Before running, async function is "wrapped" using the provided wrapper. This wrapper + * can add additional steps to run before or after the function. + * + * When async function is scheduled to run, the previous function is notified + * by calling the associated onContinued callback. This behavior of this callback + * is defined by the wrapper. + */ +function createRunner

(wrapper: AsyncWrapper) { + return function run(tag: string | symbol, cb: (...args: P) => Promise) { + const { cb: wrapped, onContinued } = wrapper(tag, cb); + const pending = pendingPromises.get(tag); + pending?.onContinued(); + const promise = pending ? pending.promise.then(wrapped, wrapped) : wrapped(); + pendingPromises.set(tag, { promise, onContinued }); + return promise; + }; +} + +/** + * Wraps an async function with an additional step run after the function: + * if the function is the last in the queue, it cleans up the whole chain + * of promises after finishing. + */ +function wrapWithContinuationTracking(tag: string | symbol, cb: () => Promise) { + let hasContinuation = false; + const wrapped = () => + cb().finally(() => { + if (!hasContinuation) { + pendingPromises.delete(tag); + } + }); + const onContinued = () => (hasContinuation = true); + return { cb: wrapped, onContinued }; +} + +/** + * Wraps an async function with additional functionalilty: + * 1. Associates an abort signal with every function, that is passed to it + * as an argument. When a new function is scheduled to run after the current + * one, current signal is aborted. + * 2. If current function didn't start and was aborted, in will never start. + * 3. If the function is the last in the queue, it cleans up the whole chain + * of promises after finishing. + */ +function wrapWithCancellation(tag: string | symbol, cb: (signal: AbortSignal) => Promise) { + const ac = new AbortController(); + const wrapped = () => { + if (ac.signal.aborted) { + return Promise.resolve('canceled' as const); + } + + return cb(ac.signal).finally(() => { + if (!ac.signal.aborted) { + pendingPromises.delete(tag); + } + }); + }; + const onContinued = () => ac.abort(); + return { cb: wrapped, onContinued }; +} diff --git a/src/live_location_manager.ts b/src/live_location_manager.ts new file mode 100644 index 000000000..c7f284ee1 --- /dev/null +++ b/src/live_location_manager.ts @@ -0,0 +1,294 @@ +/** + * RULES: + * + * 1. one loc-sharing message per channel per user + * 2. mandatory geolocation_eol (maxnow + 24h max), which should be unchangeable by anyone (set once) + * 3. serialized object must be stored + * 4. live location is per-device, no other device which did not store the message locally, should be updating the live location attachment + */ + +import { withCancellation } from './concurrency'; +import { StateStore } from './store'; +import type { MessageResponse, Attachment, EventTypes } from './types'; +import type { StreamChat } from './client'; +import type { Unsubscribe } from './store'; + +// type Unsubscribe = () => void; +type WatchLocation = (handler: (value: { latitude: number; longitude: number }) => void) => Unsubscribe; +type LiveLocationManagerState = { + ready: boolean; + targetMessages: MessageResponse[]; +}; + +// LLS - live location sharing +function isAttachmentValidLLSEntity(attachment?: Attachment) { + if (!attachment || typeof attachment.end_time !== 'string' || attachment.stopped_sharing) return false; + + const endTimeTimestamp = new Date(attachment.end_time).getTime(); + + if (Number.isNaN(endTimeTimestamp)) return false; + + const nowTimestamp = Date.now(); + + return attachment && attachment.type === 'live_location' && endTimeTimestamp > nowTimestamp; +} + +class LiveLocationManager { + private client: StreamChat; + private unsubscribeFunctions: Set<() => void> = new Set(); + private serializeAndStore: (state: MessageResponse[]) => void; + private watchLocation: WatchLocation; + public state: StateStore; + private messagesByChannelConfIdGetterCache: { + calculated: { [key: string]: [MessageResponse, number] }; + targetMessages: LiveLocationManagerState['targetMessages']; + }; + private messagesByIdGetterCache: { + calculated: { [key: string]: [MessageResponse, number] }; + targetMessages: LiveLocationManagerState['targetMessages']; + }; + + static symbol = Symbol(LiveLocationManager.name); + + constructor({ + client, + retrieveAndDeserialize, + watchLocation, + serializeAndStore, + }: { + client: StreamChat; + retrieveAndDeserialize: (userId: string) => MessageResponse[]; + serializeAndStore: (state: MessageResponse[]) => void; + watchLocation: WatchLocation; + }) { + this.client = client; + this.state = new StateStore({ + targetMessages: retrieveAndDeserialize(client.userID!), + ready: false, + }); + this.watchLocation = watchLocation; + this.serializeAndStore = serializeAndStore; + + this.messagesByIdGetterCache = { + targetMessages: this.state.getLatestValue().targetMessages, + calculated: {}, + }; + + this.messagesByChannelConfIdGetterCache = { + targetMessages: this.state.getLatestValue().targetMessages, + calculated: {}, + }; + } + + public get messagesById() { + const { targetMessages } = this.state.getLatestValue(); + + if (this.messagesByIdGetterCache.targetMessages !== targetMessages) { + this.messagesByIdGetterCache.targetMessages = targetMessages; + + this.messagesByIdGetterCache.calculated = targetMessages.reduce<{ [key: string]: [MessageResponse, number] }>( + (messagesById, message, index) => { + messagesById[message.id] = [message, index]; + return messagesById; + }, + {}, + ); + } + + return this.messagesByIdGetterCache.calculated; + } + + public get messagesByChannelConfId() { + const { targetMessages } = this.state.getLatestValue(); + + if (this.messagesByChannelConfIdGetterCache.targetMessages !== targetMessages) { + this.messagesByChannelConfIdGetterCache.targetMessages = targetMessages; + + this.messagesByChannelConfIdGetterCache.calculated = targetMessages.reduce<{ + [key: string]: [MessageResponse, number]; + }>((messagesByChannelConfIds, message, index) => { + if (!message.cid) return messagesByChannelConfIds; + + messagesByChannelConfIds[message.cid] = [message, index]; + return messagesByChannelConfIds; + }, {}); + } + + return this.messagesByChannelConfIdGetterCache.calculated; + } + + // private async getCompleteMessage(messageId: string) { + // const [cachedMessage, cachedMessageIndex] = this.messagesById[messageId] ?? []; + + // const [cachedMessageAttachment] = cachedMessage?.attachments ?? []; + + // if (isAttachmentValidLLSEntity(cachedMessageAttachment)) { + // return cachedMessage; + // } + + // const queriedMessage = (await this.client.getMessage(messageId)).message; + + // const [queriedMessageAttachment] = queriedMessage.attachments ?? []; + + // if (isAttachmentValidLLSEntity(queriedMessageAttachment)) { + // this.state.next((currentValue) => { + // const newTargetMessages = [...currentValue.targetMessages]; + + // if (typeof cachedMessageIndex === 'number') { + // newTargetMessages[cachedMessageIndex] = queriedMessage; + // } else { + // newTargetMessages.push(queriedMessage); + // } + + // return { + // ...currentValue, + // targetMessages: newTargetMessages, + // }; + // }); + + // return queriedMessage; + // } + + // return null; + // } + + public subscribeWatchLocation() { + const unsubscribe = this.watchLocation(({ latitude, longitude }) => { + withCancellation(LiveLocationManager.symbol, async () => { + const promises: Promise[] = []; + + await this.recoverAndValidateMessages(); + + const { targetMessages } = this.state.getLatestValue(); + + for (const message of targetMessages) { + const [attachment] = message.attachments!; + + const promise = this.client + .partialUpdateMessage(message.id, { + set: { attachments: [{ ...attachment, latitude, longitude }] }, + }) + // TODO: change this this + .then((v) => console.log(v)); + + promises.push(promise); + } + + const values = await Promise.allSettled(promises); + + // TODO: handle values (remove failed - based on specific error code), keep re-trying others + }); + }); + + console.log(unsubscribe); + + return unsubscribe; + } + + private async recoverAndValidateMessages() { + const { targetMessages } = this.state.getLatestValue(); + + if (!this.client.userID) return; + + const messages = await this.client.search( + { members: { $in: [this.client.userID] } }, + { id: { $in: targetMessages.map((m) => m.id) } }, + ); + + this.state.partialNext({ ready: true }); + console.log(messages); + + console.log('to consider...'); + } + + private registerMessage(message: MessageResponse) { + if (!this.client.userID || message?.user?.id !== this.client.userID) return; + + const [attachment] = message.attachments ?? []; + + const messagesById = this.messagesById; + + // FIXME: get associatedChannelConfIds.indexOf(message.cid) + if (message.cid && this.messagesByChannelConfId[message.cid]) { + const [m] = messagesById[message.id]; + throw new Error( + `[LocationUpdater.registerMessage]: one live location sharing message per channel limit has been reached, unregister message "${m.id}" first`, + ); + } + + if (!attachment || attachment.type !== 'geolocation' || !attachment.geolocation_eol) { + throw new Error( + '[LocationUpdater.registerMessage]: Message has either no attachment, the attachment is not of type "geolocation" or the attachment is missing `geolocation_eol` property', + ); + } + + if (typeof attachment.geolocation_eol !== 'string') { + throw new Error( + '[LocationUpdater.registerMessage]: `geolocation_eol` property is of incorrect type, should be date and time ISO 8601 string', + ); + } + + const nowTimestamp = Date.now(); + const eolTimestamp = new Date(attachment.geolocation_eol).getTime(); + + if (Number.isNaN(eolTimestamp) || eolTimestamp < nowTimestamp) { + throw new Error( + '[LocationUpdater.registerMessage]: `geolocation_eol` has either improper format or has not been set to some time in the future (is lesser than now)', + ); + } + + this.state.next((currentValue) => ({ ...currentValue, targetMessages: [...currentValue.targetMessages, message] })); + } + + private unregisterMessage(message: MessageResponse) { + this.state.next((currentValue) => { + const [, messageIndex] = this.messagesById[message.id]; + + if (typeof messageIndex !== 'number') return currentValue; + + const newTargetMessages = [...currentValue.targetMessages]; + + newTargetMessages.splice(messageIndex, 1); + + return { + ...currentValue, + targetMessages: newTargetMessages, + }; + }); + } + + public unregisterSubscriptions = () => { + this.unsubscribeFunctions.forEach((cleanupFunction) => cleanupFunction()); + this.unsubscribeFunctions.clear(); + }; + + private subscribeNewMessages() { + const subscriptions = (['notification.message_new', 'message.new'] as EventTypes[]).map((eventType) => + this.client.on(eventType, (event) => { + // TODO: switch to targeted event based on userId + if (!event.message) return; + + try { + this.registerMessage(event.message); + } catch { + // do nothing + } + }), + ); + + return () => subscriptions.forEach((subscription) => subscription.unsubscribe()); + } + + public registerSubscriptions = () => { + if (this.unsubscribeFunctions.size) { + // LocationUpdater is already listening for events and changes + return; + } + + this.unsubscribeFunctions.add(this.subscribeNewMessages()); + // this.unsubscribeFunctions.add() + // TODO - handle message registration during message updates too, message updated eol added + // TODO - handle message unregistration during message updates - message updated, eol removed + // this.unsubscribeFunctions.add(this.subscribeMessagesUpdated()); + }; +} diff --git a/src/types.ts b/src/types.ts index 0a7b7e384..95ad267b0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2145,6 +2145,10 @@ export type Attachment< author_name?: string; color?: string; duration?: number; + /** + * Location-related, should be an ISO timestamp if type of the attachment is `live_location` + */ + end_time?: string; fallback?: string; fields?: Field[]; file_size?: number | string; @@ -2159,6 +2163,10 @@ export type Attachment< original_height?: number; original_width?: number; pretext?: string; + /** + * Location-related, true when user forcibly stops live location sharing + */ + stopped_sharing?: boolean; text?: string; thumb_url?: string; title?: string; From 2b6b412219dfb5fffe70413346068b90a21741e6 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Fri, 13 Dec 2024 17:20:18 +0100 Subject: [PATCH 02/31] LiveLocationManager updates --- src/live_location_manager.ts | 193 +++++++++++++++++++++-------------- 1 file changed, 114 insertions(+), 79 deletions(-) diff --git a/src/live_location_manager.ts b/src/live_location_manager.ts index c7f284ee1..62b298ece 100644 --- a/src/live_location_manager.ts +++ b/src/live_location_manager.ts @@ -15,13 +15,78 @@ import type { Unsubscribe } from './store'; // type Unsubscribe = () => void; type WatchLocation = (handler: (value: { latitude: number; longitude: number }) => void) => Unsubscribe; +type SerializeAndStore = (state: MessageResponse[], userId: string) => void; +type RetrieveAndDeserialize = (userId: string) => MessageResponse[]; + type LiveLocationManagerState = { ready: boolean; targetMessages: MessageResponse[]; }; -// LLS - live location sharing -function isAttachmentValidLLSEntity(attachment?: Attachment) { +// if (message.cid && this.messagesByChannelConfId[message.cid]) { +// const [m] = this.messagesByChannelConfId[message.cid]; +// throw new Error( +// `[LocationUpdater.registerMessage]: one live location sharing message per channel limit has been reached, unregister message "${m.id}" first`, +// ); +// } + +// if (!attachment || attachment.type !== 'geolocation' || !attachment.geolocation_eol) { +// throw new Error( +// '[LocationUpdater.registerMessage]: Message has either no attachment, the attachment is not of type "geolocation" or the attachment is missing `geolocation_eol` property', +// ); +// } + +// if (typeof attachment.geolocation_eol !== 'string') { +// throw new Error( +// '[LocationUpdater.registerMessage]: `geolocation_eol` property is of incorrect type, should be date and time ISO 8601 string', +// ); +// } + +// const nowTimestamp = Date.now(); +// const eolTimestamp = new Date(attachment.geolocation_eol).getTime(); + +// if (Number.isNaN(eolTimestamp) || eolTimestamp < nowTimestamp) { +// throw new Error( +// '[LocationUpdater.registerMessage]: `geolocation_eol` has either improper format or has not been set to some time in the future (is lesser than now)', +// ); +// } + +// private async getCompleteMessage(messageId: string) { +// const [cachedMessage, cachedMessageIndex] = this.messagesById[messageId] ?? []; + +// const [cachedMessageAttachment] = cachedMessage?.attachments ?? []; + +// if (isAttachmentValidLLSEntity(cachedMessageAttachment)) { +// return cachedMessage; +// } + +// const queriedMessage = (await this.client.getMessage(messageId)).message; + +// const [queriedMessageAttachment] = queriedMessage.attachments ?? []; + +// if (isAttachmentValidLLSEntity(queriedMessageAttachment)) { +// this.state.next((currentValue) => { +// const newTargetMessages = [...currentValue.targetMessages]; + +// if (typeof cachedMessageIndex === 'number') { +// newTargetMessages[cachedMessageIndex] = queriedMessage; +// } else { +// newTargetMessages.push(queriedMessage); +// } + +// return { +// ...currentValue, +// targetMessages: newTargetMessages, +// }; +// }); + +// return queriedMessage; +// } + +// return null; +// } + +function isValidLiveLocationAttachment(attachment?: Attachment) { if (!attachment || typeof attachment.end_time !== 'string' || attachment.stopped_sharing) return false; const endTimeTimestamp = new Date(attachment.end_time).getTime(); @@ -33,10 +98,10 @@ function isAttachmentValidLLSEntity(attachment?: Attachment) { return attachment && attachment.type === 'live_location' && endTimeTimestamp > nowTimestamp; } -class LiveLocationManager { +export class LiveLocationManager { private client: StreamChat; private unsubscribeFunctions: Set<() => void> = new Set(); - private serializeAndStore: (state: MessageResponse[]) => void; + private serializeAndStore: SerializeAndStore; private watchLocation: WatchLocation; public state: StateStore; private messagesByChannelConfIdGetterCache: { @@ -52,14 +117,20 @@ class LiveLocationManager { constructor({ client, - retrieveAndDeserialize, watchLocation, - serializeAndStore, + retrieveAndDeserialize = (userId) => { + const targetMessagesString = localStorage.getItem(`${userId}-${LiveLocationManager.name}`); + if (!targetMessagesString) return []; + return JSON.parse(targetMessagesString); + }, + serializeAndStore = (messages, userId) => { + localStorage.setItem(`${userId}-${LiveLocationManager.name}`, JSON.stringify(messages)); + }, }: { client: StreamChat; - retrieveAndDeserialize: (userId: string) => MessageResponse[]; - serializeAndStore: (state: MessageResponse[]) => void; watchLocation: WatchLocation; + retrieveAndDeserialize?: RetrieveAndDeserialize; + serializeAndStore?: SerializeAndStore; }) { this.client = client; this.state = new StateStore({ @@ -117,53 +188,26 @@ class LiveLocationManager { return this.messagesByChannelConfIdGetterCache.calculated; } - // private async getCompleteMessage(messageId: string) { - // const [cachedMessage, cachedMessageIndex] = this.messagesById[messageId] ?? []; - - // const [cachedMessageAttachment] = cachedMessage?.attachments ?? []; - - // if (isAttachmentValidLLSEntity(cachedMessageAttachment)) { - // return cachedMessage; - // } - - // const queriedMessage = (await this.client.getMessage(messageId)).message; - - // const [queriedMessageAttachment] = queriedMessage.attachments ?? []; - - // if (isAttachmentValidLLSEntity(queriedMessageAttachment)) { - // this.state.next((currentValue) => { - // const newTargetMessages = [...currentValue.targetMessages]; - - // if (typeof cachedMessageIndex === 'number') { - // newTargetMessages[cachedMessageIndex] = queriedMessage; - // } else { - // newTargetMessages.push(queriedMessage); - // } - - // return { - // ...currentValue, - // targetMessages: newTargetMessages, - // }; - // }); - - // return queriedMessage; - // } - - // return null; - // } - public subscribeWatchLocation() { const unsubscribe = this.watchLocation(({ latitude, longitude }) => { withCancellation(LiveLocationManager.symbol, async () => { const promises: Promise[] = []; - await this.recoverAndValidateMessages(); + if (!this.state.getLatestValue().ready) { + await this.recoverAndValidateMessages(); + } const { targetMessages } = this.state.getLatestValue(); for (const message of targetMessages) { - const [attachment] = message.attachments!; + const [attachment] = message.attachments ?? []; + if (!isValidLiveLocationAttachment(attachment)) { + this.unregisterMessage(message); + continue; + } + + // TODO: revisit const promise = this.client .partialUpdateMessage(message.id, { set: { attachments: [{ ...attachment, latitude, longitude }] }, @@ -175,7 +219,7 @@ class LiveLocationManager { } const values = await Promise.allSettled(promises); - + console.log(values); // TODO: handle values (remove failed - based on specific error code), keep re-trying others }); }); @@ -190,15 +234,24 @@ class LiveLocationManager { if (!this.client.userID) return; - const messages = await this.client.search( + const response = await this.client.search( { members: { $in: [this.client.userID] } }, - { id: { $in: targetMessages.map((m) => m.id) } }, + { id: { $in: targetMessages.map(({ id }) => id) } }, ); - this.state.partialNext({ ready: true }); - console.log(messages); + const newTargetMessages = []; + + for (const result of response.results) { + const { message } = result; - console.log('to consider...'); + const [attachment] = message.attachments ?? []; + + if (isValidLiveLocationAttachment(attachment)) { + newTargetMessages.push(message); + } + } + + this.state.partialNext({ ready: true, targetMessages: newTargetMessages }); } private registerMessage(message: MessageResponse) { @@ -206,38 +259,15 @@ class LiveLocationManager { const [attachment] = message.attachments ?? []; - const messagesById = this.messagesById; - - // FIXME: get associatedChannelConfIds.indexOf(message.cid) - if (message.cid && this.messagesByChannelConfId[message.cid]) { - const [m] = messagesById[message.id]; - throw new Error( - `[LocationUpdater.registerMessage]: one live location sharing message per channel limit has been reached, unregister message "${m.id}" first`, - ); - } - - if (!attachment || attachment.type !== 'geolocation' || !attachment.geolocation_eol) { - throw new Error( - '[LocationUpdater.registerMessage]: Message has either no attachment, the attachment is not of type "geolocation" or the attachment is missing `geolocation_eol` property', - ); - } - - if (typeof attachment.geolocation_eol !== 'string') { - throw new Error( - '[LocationUpdater.registerMessage]: `geolocation_eol` property is of incorrect type, should be date and time ISO 8601 string', - ); + if (!isValidLiveLocationAttachment(attachment)) { + return; } - const nowTimestamp = Date.now(); - const eolTimestamp = new Date(attachment.geolocation_eol).getTime(); + this.state.next((currentValue) => ({ ...currentValue, targetMessages: [...currentValue.targetMessages, message] })); - if (Number.isNaN(eolTimestamp) || eolTimestamp < nowTimestamp) { - throw new Error( - '[LocationUpdater.registerMessage]: `geolocation_eol` has either improper format or has not been set to some time in the future (is lesser than now)', - ); + if (this.client.userID) { + this.serializeAndStore(this.state.getLatestValue().targetMessages, this.client.userID); } - - this.state.next((currentValue) => ({ ...currentValue, targetMessages: [...currentValue.targetMessages, message] })); } private unregisterMessage(message: MessageResponse) { @@ -255,6 +285,10 @@ class LiveLocationManager { targetMessages: newTargetMessages, }; }); + + if (this.client.userID) { + this.serializeAndStore(this.state.getLatestValue().targetMessages, this.client.userID); + } } public unregisterSubscriptions = () => { @@ -286,6 +320,7 @@ class LiveLocationManager { } this.unsubscribeFunctions.add(this.subscribeNewMessages()); + this.unsubscribeFunctions.add(this.subscribeWatchLocation()); // this.unsubscribeFunctions.add() // TODO - handle message registration during message updates too, message updated eol added // TODO - handle message unregistration during message updates - message updated, eol removed From 1fa5f14d54d72f37ef03d091dda416eb7d46ce15 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Tue, 17 Dec 2024 20:15:08 +0100 Subject: [PATCH 03/31] Adjust LLM methods and subscriptions --- src/channel.ts | 30 +++++++++++++++++ src/events.ts | 2 ++ src/index.ts | 1 + src/live_location_manager.ts | 63 +++++++++++++++++++++++++----------- 4 files changed, 77 insertions(+), 19 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 538c6c42a..eee8d13e5 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -61,6 +61,7 @@ import { PartialUpdateMemberAPIResponse, AIState, MessageOptions, + Attachment, } from './types'; import { Role } from './permissions'; import { DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE } from './constants'; @@ -471,6 +472,35 @@ export class Channel, + ) { + const { latitude, longitude, end_time } = attachmentMetadata; + + const message: Message = { + attachments: [ + { + ...attachmentMetadata, + type: 'live_location', + latitude, + longitude, + end_time, + }, + ], + }; + + // TODO: find existing, cancel and send new one + // const existing = this.search({ user_id: attachments: { type: { $eq: 'live_location' } } }); + + const response = await this.sendMessage(message); + + this.getClient().dispatchEvent({ message: response.message, type: 'live_location_sharing.started' }); + } + + public stopLiveLocationSharing(message: MessageResponse) { + this.getClient().dispatchEvent({ message, type: 'live_location_sharing.stopped' }); + } + /** * delete - Delete the channel. Messages are permanently removed. * diff --git a/src/events.ts b/src/events.ts index e145074a3..f4e74194a 100644 --- a/src/events.ts +++ b/src/events.ts @@ -59,4 +59,6 @@ export const EVENT_MAP = { 'connection.recovered': true, 'transport.changed': true, 'capabilities.changed': true, + 'live_location_sharing.started': true, + 'live_location_sharing.stopped': true, }; diff --git a/src/index.ts b/src/index.ts index c0d0901f6..4bbf28d8c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,4 +18,5 @@ export * from './thread'; export * from './thread_manager'; export * from './token_manager'; export * from './types'; +export * from './live_location_manager'; export { isOwnUser, chatCodes, logChatPromiseExecution, formatMessage } from './utils'; diff --git a/src/live_location_manager.ts b/src/live_location_manager.ts index 62b298ece..2f8444914 100644 --- a/src/live_location_manager.ts +++ b/src/live_location_manager.ts @@ -98,12 +98,22 @@ function isValidLiveLocationAttachment(attachment?: Attachment) { return attachment && attachment.type === 'live_location' && endTimeTimestamp > nowTimestamp; } +export type LiveLocationManagerConstructorParameters = { + client: StreamChat; + watchLocation: WatchLocation; + retrieveAndDeserialize?: RetrieveAndDeserialize; + serializeAndStore?: SerializeAndStore; + // watchThrottleTimeout?: number; +}; + +const MIN_THROTTLE_TIMEOUT = 1000; + export class LiveLocationManager { + public state: StateStore; private client: StreamChat; private unsubscribeFunctions: Set<() => void> = new Set(); private serializeAndStore: SerializeAndStore; private watchLocation: WatchLocation; - public state: StateStore; private messagesByChannelConfIdGetterCache: { calculated: { [key: string]: [MessageResponse, number] }; targetMessages: LiveLocationManagerState['targetMessages']; @@ -126,27 +136,26 @@ export class LiveLocationManager { serializeAndStore = (messages, userId) => { localStorage.setItem(`${userId}-${LiveLocationManager.name}`, JSON.stringify(messages)); }, - }: { - client: StreamChat; - watchLocation: WatchLocation; - retrieveAndDeserialize?: RetrieveAndDeserialize; - serializeAndStore?: SerializeAndStore; - }) { + }: LiveLocationManagerConstructorParameters) { this.client = client; + + const retreivedTargetMessages = retrieveAndDeserialize(client.userID!); + this.state = new StateStore({ - targetMessages: retrieveAndDeserialize(client.userID!), - ready: false, + targetMessages: retreivedTargetMessages, + // If there are no messages to validate, the manager is considered "ready" + ready: retreivedTargetMessages.length === 0, }); this.watchLocation = watchLocation; this.serializeAndStore = serializeAndStore; this.messagesByIdGetterCache = { - targetMessages: this.state.getLatestValue().targetMessages, + targetMessages: retreivedTargetMessages, calculated: {}, }; this.messagesByChannelConfIdGetterCache = { - targetMessages: this.state.getLatestValue().targetMessages, + targetMessages: retreivedTargetMessages, calculated: {}, }; } @@ -189,7 +198,15 @@ export class LiveLocationManager { } public subscribeWatchLocation() { + let nextWatcherCallTimestamp = 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() < nextWatcherCallTimestamp) return; + + nextWatcherCallTimestamp = Date.now() + MIN_THROTTLE_TIMEOUT; + withCancellation(LiveLocationManager.symbol, async () => { const promises: Promise[] = []; @@ -207,7 +224,7 @@ export class LiveLocationManager { continue; } - // TODO: revisit + // TODO: client.updateLiveLocation instead const promise = this.client .partialUpdateMessage(message.id, { set: { attachments: [{ ...attachment, latitude, longitude }] }, @@ -229,10 +246,13 @@ export class LiveLocationManager { return unsubscribe; } + /** + * Messages stored locally might've been updated while the device which registered message for updates has been offline. + */ private async recoverAndValidateMessages() { const { targetMessages } = this.state.getLatestValue(); - if (!this.client.userID) return; + if (!this.client.userID || !targetMessages.length) return; const response = await this.client.search( { members: { $in: [this.client.userID] } }, @@ -296,16 +316,20 @@ export class LiveLocationManager { this.unsubscribeFunctions.clear(); }; - private subscribeNewMessages() { - const subscriptions = (['notification.message_new', 'message.new'] as EventTypes[]).map((eventType) => + private subscribeLiveLocationSharingUpdates() { + const subscriptions = ([ + 'live_location_sharing.started', + 'live_location_sharing.stopped', + 'message.deleted', + ] as EventTypes[]).map((eventType) => this.client.on(eventType, (event) => { // TODO: switch to targeted event based on userId if (!event.message) return; - try { + if (event.type === 'live_location_sharing.started') { this.registerMessage(event.message); - } catch { - // do nothing + } else { + this.unregisterMessage(event.message); } }), ); @@ -319,7 +343,8 @@ export class LiveLocationManager { return; } - this.unsubscribeFunctions.add(this.subscribeNewMessages()); + // FIXME: maybe not do this? (find out whether connection-id check would work) + this.unsubscribeFunctions.add(this.subscribeLiveLocationSharingUpdates()); this.unsubscribeFunctions.add(this.subscribeWatchLocation()); // this.unsubscribeFunctions.add() // TODO - handle message registration during message updates too, message updated eol added From bb8afdca0bb70e0d6565dea1ee6c2e224fb23bea Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Thu, 19 Dec 2024 23:53:51 +0100 Subject: [PATCH 04/31] Updates to LLM and channel --- src/channel.ts | 78 ++++++++++++++++++++++++++++++++++-- src/live_location_manager.ts | 44 ++++++++++++++++++-- 2 files changed, 114 insertions(+), 8 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index eee8d13e5..887a8b859 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -472,9 +472,31 @@ export class Channel, + ) { + const { latitude, longitude } = attachmentMetadata; + + const message: Message = { + attachments: [ + { + ...attachmentMetadata, + type: 'static_location', + latitude, + longitude, + }, + ], + }; + + return await this.sendMessage(message); + } + public async startLiveLocationSharing( attachmentMetadata: { end_time: string; latitude: number; longitude: number } & Attachment, ) { + const client = this.getClient(); + if (!client.userID) return; + const { latitude, longitude, end_time } = attachmentMetadata; const message: Message = { @@ -489,12 +511,60 @@ export class Channel[] = []; + + for (const result of existing.results) { + const [attachment] = result.message.attachments ?? []; + + promises.push( + client.partialUpdateMessage(result.message.id, { + // @ts-expect-error + set: { + attachments: [ + { + ...attachment, + stopped_sharing: true, + }, + ], + }, + }), + ); + } + + // FIXME: sending message if the previous part failed/did not happen + // should result in BE error + promises.unshift(this.sendMessage(message)); - const response = await this.sendMessage(message); + const [response] = await Promise.allSettled(promises); - this.getClient().dispatchEvent({ message: response.message, type: 'live_location_sharing.started' }); + if (response.status === 'fulfilled') { + this.getClient().dispatchEvent({ message: response.value.message, type: 'live_location_sharing.started' }); + } } public stopLiveLocationSharing(message: MessageResponse) { diff --git a/src/live_location_manager.ts b/src/live_location_manager.ts index 2f8444914..d4021b5f7 100644 --- a/src/live_location_manager.ts +++ b/src/live_location_manager.ts @@ -200,6 +200,7 @@ export class LiveLocationManager { public subscribeWatchLocation() { let nextWatcherCallTimestamp = Date.now(); + // eslint-disable-next-line sonarjs/prefer-immediate-return 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) @@ -209,12 +210,15 @@ export class LiveLocationManager { withCancellation(LiveLocationManager.symbol, async () => { const promises: Promise[] = []; + const { ready } = this.state.getLatestValue(); - if (!this.state.getLatestValue().ready) { + if (!ready) { await this.recoverAndValidateMessages(); } const { targetMessages } = this.state.getLatestValue(); + // if validator removes messages, we need to check + if (!targetMessages.length) return; for (const message of targetMessages) { const [attachment] = message.attachments ?? []; @@ -241,8 +245,6 @@ export class LiveLocationManager { }); }); - console.log(unsubscribe); - return unsubscribe; } @@ -290,9 +292,30 @@ export class LiveLocationManager { } } + private updateRegisteredMessage(message: MessageResponse) { + if (!this.client.userID || message?.user?.id !== this.client.userID) return; + + const [, targetMessageIndex] = this.messagesById[message.id]; + + this.state.next((currentValue) => { + const newTargetMessages = [...currentValue.targetMessages]; + + newTargetMessages[targetMessageIndex] = message; + + return { + ...currentValue, + targetMessages: newTargetMessages, + }; + }); + + if (this.client.userID) { + this.serializeAndStore(this.state.getLatestValue().targetMessages, this.client.userID); + } + } + private unregisterMessage(message: MessageResponse) { this.state.next((currentValue) => { - const [, messageIndex] = this.messagesById[message.id]; + const [, messageIndex] = this.messagesById[message.id] ?? []; if (typeof messageIndex !== 'number') return currentValue; @@ -321,6 +344,7 @@ export class LiveLocationManager { 'live_location_sharing.started', 'live_location_sharing.stopped', 'message.deleted', + 'message.updated', ] as EventTypes[]).map((eventType) => this.client.on(eventType, (event) => { // TODO: switch to targeted event based on userId @@ -328,6 +352,18 @@ export class LiveLocationManager { if (event.type === 'live_location_sharing.started') { this.registerMessage(event.message); + } else if (event.type === 'message.updated') { + const localMessage = this.messagesById[event.message.id]; + + if (!localMessage) return; + + const [attachment] = event.message.attachments ?? []; + + if (!isValidLiveLocationAttachment(attachment)) { + this.unregisterMessage(event.message); + } else { + this.updateRegisteredMessage(event.message); + } } else { this.unregisterMessage(event.message); } From c2a418566737b05b189dfc0a3f56a7a34715d62a Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Tue, 7 Jan 2025 20:15:53 +0100 Subject: [PATCH 05/31] LLM adjustments --- src/channel.ts | 10 ++++- src/live_location_manager.ts | 86 ++++++++++++++++++++++-------------- 2 files changed, 61 insertions(+), 35 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 887a8b859..653a41005 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -567,8 +567,14 @@ export class Channel) { - this.getClient().dispatchEvent({ message, type: 'live_location_sharing.stopped' }); + public async stopLiveLocationSharing(message: MessageResponse) { + const [attachment] = message.attachments ?? []; + const response = await this.getClient().partialUpdateMessage(message.id, { + // @ts-expect-error this is a valid update + set: { attachments: [{ ...attachment, stopped_sharing: true }] }, + }); + + this.getClient().dispatchEvent({ message: response.message, type: 'live_location_sharing.stopped' }); } /** diff --git a/src/live_location_manager.ts b/src/live_location_manager.ts index d4021b5f7..e1f17bd47 100644 --- a/src/live_location_manager.ts +++ b/src/live_location_manager.ts @@ -9,7 +9,7 @@ import { withCancellation } from './concurrency'; import { StateStore } from './store'; -import type { MessageResponse, Attachment, EventTypes } from './types'; +import type { MessageResponse, Attachment, EventTypes, ExtendableGenerics } from './types'; import type { StreamChat } from './client'; import type { Unsubscribe } from './store'; @@ -18,7 +18,7 @@ type WatchLocation = (handler: (value: { latitude: number; longitude: number }) type SerializeAndStore = (state: MessageResponse[], userId: string) => void; type RetrieveAndDeserialize = (userId: string) => MessageResponse[]; -type LiveLocationManagerState = { +export type LiveLocationManagerState = { ready: boolean; targetMessages: MessageResponse[]; }; @@ -98,19 +98,18 @@ function isValidLiveLocationAttachment(attachment?: Attachment) { return attachment && attachment.type === 'live_location' && endTimeTimestamp > nowTimestamp; } -export type LiveLocationManagerConstructorParameters = { - client: StreamChat; +export type LiveLocationManagerConstructorParameters = { + client: StreamChat; watchLocation: WatchLocation; retrieveAndDeserialize?: RetrieveAndDeserialize; serializeAndStore?: SerializeAndStore; - // watchThrottleTimeout?: number; }; const MIN_THROTTLE_TIMEOUT = 1000; -export class LiveLocationManager { +export class LiveLocationManager { public state: StateStore; - private client: StreamChat; + private client: StreamChat; private unsubscribeFunctions: Set<() => void> = new Set(); private serializeAndStore: SerializeAndStore; private watchLocation: WatchLocation; @@ -134,9 +133,13 @@ export class LiveLocationManager { return JSON.parse(targetMessagesString); }, serializeAndStore = (messages, userId) => { - localStorage.setItem(`${userId}-${LiveLocationManager.name}`, JSON.stringify(messages)); + localStorage.setItem( + `${userId}-${LiveLocationManager.name}`, + // Strip sensitive data (these will be recovered at on first location watch call) + JSON.stringify(messages.map((message) => ({ id: message.id }))), + ); }, - }: LiveLocationManagerConstructorParameters) { + }: LiveLocationManagerConstructorParameters) { this.client = client; const retreivedTargetMessages = retrieveAndDeserialize(client.userID!); @@ -197,7 +200,34 @@ export class LiveLocationManager { return this.messagesByChannelConfIdGetterCache.calculated; } - public subscribeWatchLocation() { + 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 active/instantiated otherwise + const unsubscribe = this.state.subscribeWithSelector( + ({ targetMessages }) => ({ targetMessages }), + ({ targetMessages }) => { + if (!targetMessages.length) { + unsubscribeWatchLocation?.(); + unsubscribeWatchLocation = null; + } else if (targetMessages.length && !unsubscribeWatchLocation) { + unsubscribeWatchLocation = this.subscribeWatchLocation(); + } + + if (this.client.userID) { + this.serializeAndStore(this.state.getLatestValue().targetMessages, this.client.userID); + } + }, + ); + + return () => { + unsubscribe(); + unsubscribeWatchLocation?.(); + }; + } + + private subscribeWatchLocation() { let nextWatcherCallTimestamp = Date.now(); // eslint-disable-next-line sonarjs/prefer-immediate-return @@ -231,6 +261,7 @@ export class LiveLocationManager { // TODO: client.updateLiveLocation instead const promise = this.client .partialUpdateMessage(message.id, { + // @ts-expect-error valid update set: { attachments: [{ ...attachment, latitude, longitude }] }, }) // TODO: change this this @@ -257,6 +288,7 @@ export class LiveLocationManager { if (!this.client.userID || !targetMessages.length) return; const response = await this.client.search( + // @ts-expect-error valid filter { members: { $in: [this.client.userID] } }, { id: { $in: targetMessages.map(({ id }) => id) } }, ); @@ -286,10 +318,6 @@ export class LiveLocationManager { } this.state.next((currentValue) => ({ ...currentValue, targetMessages: [...currentValue.targetMessages, message] })); - - if (this.client.userID) { - this.serializeAndStore(this.state.getLatestValue().targetMessages, this.client.userID); - } } private updateRegisteredMessage(message: MessageResponse) { @@ -307,18 +335,14 @@ export class LiveLocationManager { targetMessages: newTargetMessages, }; }); - - if (this.client.userID) { - this.serializeAndStore(this.state.getLatestValue().targetMessages, this.client.userID); - } } private unregisterMessage(message: MessageResponse) { - this.state.next((currentValue) => { - const [, messageIndex] = this.messagesById[message.id] ?? []; + const [, messageIndex] = this.messagesById[message.id] ?? []; - if (typeof messageIndex !== 'number') return currentValue; + if (typeof messageIndex !== 'number') return; + this.state.next((currentValue) => { const newTargetMessages = [...currentValue.targetMessages]; newTargetMessages.splice(messageIndex, 1); @@ -328,10 +352,6 @@ export class LiveLocationManager { targetMessages: newTargetMessages, }; }); - - if (this.client.userID) { - this.serializeAndStore(this.state.getLatestValue().targetMessages, this.client.userID); - } } public unregisterSubscriptions = () => { @@ -342,12 +362,16 @@ export class LiveLocationManager { private subscribeLiveLocationSharingUpdates() { const subscriptions = ([ 'live_location_sharing.started', + /** + * Both message.updated & live_location_sharing.stopped get emitted when message attachment 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 + */ + 'message.updated', 'live_location_sharing.stopped', 'message.deleted', - 'message.updated', ] as EventTypes[]).map((eventType) => this.client.on(eventType, (event) => { - // TODO: switch to targeted event based on userId if (!event.message) return; if (event.type === 'live_location_sharing.started') { @@ -379,12 +403,8 @@ export class LiveLocationManager { return; } - // FIXME: maybe not do this? (find out whether connection-id check would work) this.unsubscribeFunctions.add(this.subscribeLiveLocationSharingUpdates()); - this.unsubscribeFunctions.add(this.subscribeWatchLocation()); - // this.unsubscribeFunctions.add() - // TODO - handle message registration during message updates too, message updated eol added - // TODO - handle message unregistration during message updates - message updated, eol removed - // this.unsubscribeFunctions.add(this.subscribeMessagesUpdated()); + this.unsubscribeFunctions.add(this.subscribeTargetMessagesChange()); + // TODO? - handle message registration during message updates too, message updated eol added (I hope not) }; } From 36d56e131f1d6c3b186c13f4c59f7f085874a234 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Thu, 9 Jan 2025 15:36:12 +0100 Subject: [PATCH 06/31] Validate messages instead (check deleted) --- src/client.ts | 18 ++++++++ src/live_location_manager.ts | 90 ++++++++++++++++++------------------ 2 files changed, 63 insertions(+), 45 deletions(-) diff --git a/src/client.ts b/src/client.ts index 4f8ee4dec..562f39f8e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2552,6 +2552,24 @@ export class StreamChat, + { latitude, longitude }: { latitude: number; longitude: number }, + ) { + const [attachment] = message.attachments ?? []; + + if (!attachment || attachment.type !== 'live_location') { + throw new Error( + 'Supplied message either has no attachments to update or attachment is not of type "live_location"', + ); + } + + return this.partialUpdateMessage(message.id, { + // @ts-expect-error valid update + set: { attachments: [{ ...attachment, latitude, longitude }] }, + }); + } + /** * pinMessage - pins the message * @param {string | { id: string }} messageOrMessageId message object or message id diff --git a/src/live_location_manager.ts b/src/live_location_manager.ts index e1f17bd47..7aaa947f2 100644 --- a/src/live_location_manager.ts +++ b/src/live_location_manager.ts @@ -9,18 +9,18 @@ import { withCancellation } from './concurrency'; import { StateStore } from './store'; -import type { MessageResponse, Attachment, EventTypes, ExtendableGenerics } from './types'; +import type { MessageResponse, Attachment, EventTypes, ExtendableGenerics, UpdateMessageAPIResponse } from './types'; import type { StreamChat } from './client'; import type { Unsubscribe } from './store'; // type Unsubscribe = () => void; type WatchLocation = (handler: (value: { latitude: number; longitude: number }) => void) => Unsubscribe; -type SerializeAndStore = (state: MessageResponse[], userId: string) => void; -type RetrieveAndDeserialize = (userId: string) => MessageResponse[]; +type SerializeAndStore = (state: MessageResponse[], userId: string) => void; +type RetrieveAndDeserialize = (userId: string) => MessageResponse[]; -export type LiveLocationManagerState = { +export type LiveLocationManagerState = { ready: boolean; - targetMessages: MessageResponse[]; + targetMessages: MessageResponse[]; }; // if (message.cid && this.messagesByChannelConfId[message.cid]) { @@ -87,39 +87,55 @@ export type LiveLocationManagerState = { // } function isValidLiveLocationAttachment(attachment?: Attachment) { - if (!attachment || typeof attachment.end_time !== 'string' || attachment.stopped_sharing) return false; + if (!attachment || attachment.type !== 'live_location' || attachment.stopped_sharing) { + return false; + } + + // If end_time has been defined, consider it + if (typeof attachment.end_time === 'string') { + const endTimeTimestamp = new Date(attachment.end_time).getTime(); - const endTimeTimestamp = new Date(attachment.end_time).getTime(); + if (Number.isNaN(endTimeTimestamp)) return false; - if (Number.isNaN(endTimeTimestamp)) return false; + const nowTimestamp = Date.now(); - const nowTimestamp = Date.now(); + return nowTimestamp < endTimeTimestamp; + } - return attachment && attachment.type === 'live_location' && endTimeTimestamp > nowTimestamp; + return true; +} + +function isValidLiveLocationMessage(message?: MessageResponse) { + if (!message || message.type === 'deleted') return false; + + const [attachment] = message.attachments ?? []; + + return isValidLiveLocationAttachment(attachment); } export type LiveLocationManagerConstructorParameters = { client: StreamChat; watchLocation: WatchLocation; - retrieveAndDeserialize?: RetrieveAndDeserialize; - serializeAndStore?: SerializeAndStore; + retrieveAndDeserialize?: RetrieveAndDeserialize; + serializeAndStore?: SerializeAndStore; }; -const MIN_THROTTLE_TIMEOUT = 1000; +// Hard-coded minimal throttle timeout +const MIN_THROTTLE_TIMEOUT = 3000; export class LiveLocationManager { - public state: StateStore; + public state: StateStore>; private client: StreamChat; private unsubscribeFunctions: Set<() => void> = new Set(); - private serializeAndStore: SerializeAndStore; + private serializeAndStore: SerializeAndStore; private watchLocation: WatchLocation; private messagesByChannelConfIdGetterCache: { calculated: { [key: string]: [MessageResponse, number] }; - targetMessages: LiveLocationManagerState['targetMessages']; + targetMessages: LiveLocationManagerState['targetMessages']; }; private messagesByIdGetterCache: { calculated: { [key: string]: [MessageResponse, number] }; - targetMessages: LiveLocationManagerState['targetMessages']; + targetMessages: LiveLocationManagerState['targetMessages']; }; static symbol = Symbol(LiveLocationManager.name); @@ -144,7 +160,7 @@ export class LiveLocationManager { const retreivedTargetMessages = retrieveAndDeserialize(client.userID!); - this.state = new StateStore({ + this.state = new StateStore>({ targetMessages: retreivedTargetMessages, // If there are no messages to validate, the manager is considered "ready" ready: retreivedTargetMessages.length === 0, @@ -204,7 +220,7 @@ export class LiveLocationManager { let unsubscribeWatchLocation: null | (() => void) = null; // Subscribe to location updates only if there are relevant messages to - // update, no need for the location watcher to active/instantiated otherwise + // update, no need for the location watcher to be active/instantiated otherwise const unsubscribe = this.state.subscribeWithSelector( ({ targetMessages }) => ({ targetMessages }), ({ targetMessages }) => { @@ -239,7 +255,7 @@ export class LiveLocationManager { nextWatcherCallTimestamp = Date.now() + MIN_THROTTLE_TIMEOUT; withCancellation(LiveLocationManager.symbol, async () => { - const promises: Promise[] = []; + const promises: Promise>[] = []; const { ready } = this.state.getLatestValue(); if (!ready) { @@ -247,31 +263,21 @@ export class LiveLocationManager { } const { targetMessages } = this.state.getLatestValue(); - // if validator removes messages, we need to check + // If validator removes messages, we need to check if (!targetMessages.length) return; for (const message of targetMessages) { - const [attachment] = message.attachments ?? []; - - if (!isValidLiveLocationAttachment(attachment)) { + if (!isValidLiveLocationMessage(message)) { this.unregisterMessage(message); continue; } - // TODO: client.updateLiveLocation instead - const promise = this.client - .partialUpdateMessage(message.id, { - // @ts-expect-error valid update - set: { attachments: [{ ...attachment, latitude, longitude }] }, - }) - // TODO: change this this - .then((v) => console.log(v)); + const promise = this.client.updateLiveLocation(message, { latitude, longitude }); promises.push(promise); } - const values = await Promise.allSettled(promises); - console.log(values); + await Promise.allSettled(promises); // TODO: handle values (remove failed - based on specific error code), keep re-trying others }); }); @@ -298,9 +304,7 @@ export class LiveLocationManager { for (const result of response.results) { const { message } = result; - const [attachment] = message.attachments ?? []; - - if (isValidLiveLocationAttachment(attachment)) { + if (isValidLiveLocationMessage(message)) { newTargetMessages.push(message); } } @@ -308,19 +312,17 @@ export class LiveLocationManager { this.state.partialNext({ ready: true, targetMessages: newTargetMessages }); } - private registerMessage(message: MessageResponse) { + private registerMessage(message: MessageResponse) { if (!this.client.userID || message?.user?.id !== this.client.userID) return; - const [attachment] = message.attachments ?? []; - - if (!isValidLiveLocationAttachment(attachment)) { + if (!isValidLiveLocationMessage(message)) { return; } this.state.next((currentValue) => ({ ...currentValue, targetMessages: [...currentValue.targetMessages, message] })); } - private updateRegisteredMessage(message: MessageResponse) { + private updateRegisteredMessage(message: MessageResponse) { if (!this.client.userID || message?.user?.id !== this.client.userID) return; const [, targetMessageIndex] = this.messagesById[message.id]; @@ -381,9 +383,7 @@ export class LiveLocationManager { if (!localMessage) return; - const [attachment] = event.message.attachments ?? []; - - if (!isValidLiveLocationAttachment(attachment)) { + if (!isValidLiveLocationMessage(event.message)) { this.unregisterMessage(event.message); } else { this.updateRegisteredMessage(event.message); From 6722dd64e63d3fd9b48ae0604bf7be44a99815fc Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 8 Jul 2025 10:19:06 +0200 Subject: [PATCH 07/31] feat: add LocationComposer --- src/channel.ts | 140 +++++++++--------- src/client.ts | 25 +--- src/live_location_manager.ts | 81 +++++----- src/messageComposer/LocationComposer.ts | 93 ++++++++++++ .../configuration/configuration.ts | 8 + src/messageComposer/configuration/types.ts | 13 ++ src/messageComposer/index.ts | 1 + src/messageComposer/messageComposer.ts | 56 ++++++- .../MessageComposerMiddlewareExecutor.ts | 6 + .../middleware/messageComposer/index.ts | 1 + .../messageComposer/sharedLocation.ts | 69 +++++++++ src/types.ts | 21 ++- 12 files changed, 379 insertions(+), 135 deletions(-) create mode 100644 src/messageComposer/LocationComposer.ts create mode 100644 src/messageComposer/middleware/messageComposer/sharedLocation.ts diff --git a/src/channel.ts b/src/channel.ts index 5312a77fe..479526b77 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -11,7 +11,6 @@ import type { AIState, APIResponse, AscDesc, - Attachment, BanUserOptions, ChannelAPIResponse, ChannelData, @@ -32,6 +31,7 @@ import type { GetMultipleMessagesAPIResponse, GetReactionsAPIResponse, GetRepliesAPIResponse, + LiveLocationPayload, LocalMessage, MarkReadOptions, MarkUnreadOptions, @@ -63,6 +63,8 @@ import type { SearchPayload, SendMessageAPIResponse, SendMessageOptions, + SharedLocationResponse, + StaticLocationPayload, TruncateChannelAPIResponse, TruncateOptions, UpdateChannelAPIResponse, @@ -669,50 +671,51 @@ export class Channel { return data; } - public async sendStaticLocation( - attachmentMetadata: { latitude: number; longitude: number } & Attachment, + public async sendSharedLocation( + location: StaticLocationPayload | LiveLocationPayload, + userId?: string, ) { - const { latitude, longitude } = attachmentMetadata; - - const message: Message = { - attachments: [ - { - ...attachmentMetadata, - type: 'static_location', - latitude, - longitude, - }, - ], - }; + const result = await this.sendMessage({ + id: location.message_id, + shared_location: location, + user: userId ? { id: userId } : undefined, + }); - return await this.sendMessage(message); + if ((location as LiveLocationPayload).end_at) { + this.getClient().dispatchEvent({ + message: result.message, + type: 'live_location_sharing.started', + }); + } + + return result; } - public async startLiveLocationSharing( - attachmentMetadata: { - end_time: string; - latitude: number; - longitude: number; - } & Attachment, - ) { + public async sendStaticLocation(location: StaticLocationPayload) { const client = this.getClient(); if (!client.userID) return; - // todo: live location is not an attachment (end_time) - const { latitude, longitude } = attachmentMetadata; - // todo: live location is not an attachment - const message: Message = { - attachments: [ - { - ...attachmentMetadata, - type: 'live_location', - latitude, - longitude, - }, - ], - }; + return await this.sendMessage({ + id: location.message_id, + shared_location: location, + }); + } + + public async startLiveLocationSharing(location: LiveLocationPayload) { + const client = this.getClient(); + if (!client.userID) return; + + if (!location.end_at) { + throw new Error('Live location sharing requires end_at parameter'); + } + // todo: live location is not an attachment (end_time) + // const { latitude, longitude } = attachmentMetadata; // todo: live location is not an attachment + // const message: Message = { + // + // }; + // todo: what will happen if we create a new location even though one already exists? // FIXME: this is wrong and could easily be walked around by integrators // const existing = await this.getClient().search( // { @@ -720,29 +723,20 @@ export class Channel { // }, // { // $and: [ - // { 'attachments.type': { $eq: 'live_location' } }, - // // has not been manually stopped - // { - // // 'attachments.stopped_sharing': { - // // $nin: [true], - // // }, - // }, - // // has not ended - // { - // // 'attachments.end_time': { - // // $gt: new Date().toISOString(), - // // }, - // }, + // // @ts-expect-error + // { 'shared_location.end_at': { $exists: true} }, + // // @ts-expect-error + // { 'shared_location.end_at': { $gt: new Date().toISOString()} }, // ], // }, // ); - - const promises: Promise[] = []; - + // + // const promises: Promise[] = []; + // // for (const result of existing.results) { - // todo: live location is not an attachment - // const [attachment] = result.message.attachments ?? []; - // todo: live location is not an attachment + // // todo: live location is not an attachment + // const [attachment] = result.message.shared_location ?? []; + // // todo: live location is not an attachment // promises.push( // client.partialUpdateMessage(result.message.id, { // // @ts-expect-error @@ -760,27 +754,33 @@ export class Channel { // FIXME: sending message if the previous part failed/did not happen // should result in BE error - promises.unshift(this.sendMessage(message)); + // promises.unshift(this.sendMessage(message)); - const [response] = await Promise.allSettled(promises); + // const [response] = await Promise.allSettled(promises); - if (response.status === 'fulfilled') { - this.getClient().dispatchEvent({ - message: response.value.message, - type: 'live_location_sharing.started', - }); - } + // if (response.status === 'fulfilled') { + // this.getClient().dispatchEvent({ + // message: response.value.message, + // type: 'live_location_sharing.started', + // }); + // } + const { message } = await this.sendMessage({ + id: location.message_id, + shared_location: location, + }); + this.getClient().dispatchEvent({ + message, + type: 'live_location_sharing.started', + }); } - public async stopLiveLocationSharing(message: MessageResponse) { - const [attachment] = message.attachments ?? []; - const response = await this.getClient().partialUpdateMessage(message.id, { - // @ts-expect-error this is a valid update - set: { attachments: [{ ...attachment, stopped_sharing: true }] }, + public async stopLiveLocationSharing(locationToStop: SharedLocationResponse) { + const location = await this.getClient().updateLocation({ + ...locationToStop, + end_at: new Date().toISOString(), }); - this.getClient().dispatchEvent({ - message: response.message, + live_location: location, type: 'live_location_sharing.stopped', }); } diff --git a/src/client.ts b/src/client.ts index 6a53f6dfd..c3170d430 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, @@ -2928,23 +2928,6 @@ export class StreamChat { return messageId; } - public updateLiveLocation( - message: MessageResponse, - { latitude, longitude }: { latitude: number; longitude: number }, - ) { - const [attachment] = message.attachments ?? []; - - if (!attachment || attachment.type !== 'live_location') { - throw new Error( - 'Supplied message either has no attachments to update or attachment is not of type "live_location"', - ); - } - // todo: live location is not an attachment - return this.partialUpdateMessage(message.id, { - set: { attachments: [{ ...attachment, latitude, longitude }] }, - }); - } - /** * pinMessage - pins the message * @param {string | { id: string }} messageOrMessageId message object or message id @@ -4601,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} The server response + * @returns {Promise} The server response */ - async updateLocation(location: SharedLocationRequest) { + async updateLocation(location: UpdateLocationPayload) { return await this.put( this.baseURL + `/users/live_locations`, location, diff --git a/src/live_location_manager.ts b/src/live_location_manager.ts index d4eb17596..45ac052b4 100644 --- a/src/live_location_manager.ts +++ b/src/live_location_manager.ts @@ -13,8 +13,9 @@ import type { Attachment, EventTypes, MessageResponse, - UpdateMessageAPIResponse, + SharedLocationResponse, } from './types'; +import { WithSubscriptions } from './utils/WithSubscriptions'; import type { StreamChat } from './client'; import type { Unsubscribe } from './store'; @@ -24,14 +25,15 @@ type WatchLocation = ( ) => Unsubscribe; type SerializeAndStore = (state: MessageResponse[], userId: string) => void; type RetrieveAndDeserialize = (userId: string) => MessageResponse[]; +type DeviceIdGenerator = () => string; export type LiveLocationManagerState = { ready: boolean; targetMessages: MessageResponse[]; }; -// if (message.cid && this.messagesByChannelConfId[message.cid]) { -// const [m] = this.messagesByChannelConfId[message.cid]; +// if (message.cid && this.messagesByChannelCID[message.cid]) { +// const [m] = this.messagesByChannelCID[message.cid]; // throw new Error( // `[LocationUpdater.registerMessage]: one live location sharing message per channel limit has been reached, unregister message "${m.id}" first`, // ); @@ -125,6 +127,7 @@ function isValidLiveLocationMessage(message?: MessageResponse) { export type LiveLocationManagerConstructorParameters = { client: StreamChat; + getDeviceId: DeviceIdGenerator; watchLocation: WatchLocation; retrieveAndDeserialize?: RetrieveAndDeserialize; serializeAndStore?: SerializeAndStore; @@ -133,13 +136,13 @@ export type LiveLocationManagerConstructorParameters = { // Hard-coded minimal throttle timeout const MIN_THROTTLE_TIMEOUT = 3000; -export class LiveLocationManager { +export class LiveLocationManager extends WithSubscriptions { public state: StateStore; private client: StreamChat; - private unsubscribeFunctions: Set<() => void> = new Set(); + private getDeviceId: DeviceIdGenerator; private serializeAndStore: SerializeAndStore; private watchLocation: WatchLocation; - private messagesByChannelConfIdGetterCache: { + private messagesByChannelCIDGetterCache: { calculated: { [key: string]: [MessageResponse, number] }; targetMessages: LiveLocationManagerState['targetMessages']; }; @@ -152,6 +155,7 @@ export class LiveLocationManager { constructor({ client, + getDeviceId, watchLocation, retrieveAndDeserialize = (userId) => { const targetMessagesString = localStorage.getItem( @@ -168,29 +172,32 @@ export class LiveLocationManager { ); }, }: LiveLocationManagerConstructorParameters) { + super(); + this.client = client; if (!client.userID) { throw new Error('Live-location sharing is reserved for client-side use only'); } - const retreivedTargetMessages = retrieveAndDeserialize(client.userID); + const retrievedTargetMessages = retrieveAndDeserialize(client.userID); this.state = new StateStore({ - targetMessages: retreivedTargetMessages, + targetMessages: retrievedTargetMessages, // If there are no messages to validate, the manager is considered "ready" - ready: retreivedTargetMessages.length === 0, + ready: retrievedTargetMessages.length === 0, }); + this.getDeviceId = getDeviceId; this.watchLocation = watchLocation; this.serializeAndStore = serializeAndStore; this.messagesByIdGetterCache = { - targetMessages: retreivedTargetMessages, + targetMessages: retrievedTargetMessages, calculated: {}, }; - this.messagesByChannelConfIdGetterCache = { - targetMessages: retreivedTargetMessages, + this.messagesByChannelCIDGetterCache = { + targetMessages: retrievedTargetMessages, calculated: {}, }; } @@ -212,23 +219,23 @@ export class LiveLocationManager { return this.messagesByIdGetterCache.calculated; } - public get messagesByChannelConfId() { + public get messagesByChannelCID() { const { targetMessages } = this.state.getLatestValue(); - if (this.messagesByChannelConfIdGetterCache.targetMessages !== targetMessages) { - this.messagesByChannelConfIdGetterCache.targetMessages = targetMessages; + if (this.messagesByChannelCIDGetterCache.targetMessages !== targetMessages) { + this.messagesByChannelCIDGetterCache.targetMessages = targetMessages; - this.messagesByChannelConfIdGetterCache.calculated = targetMessages.reduce<{ + this.messagesByChannelCIDGetterCache.calculated = targetMessages.reduce<{ [key: string]: [MessageResponse, number]; - }>((messagesByChannelConfIds, message, index) => { - if (!message.cid) return messagesByChannelConfIds; + }>((messagesByChannelCIDs, message, index) => { + if (!message.cid) return messagesByChannelCIDs; - messagesByChannelConfIds[message.cid] = [message, index]; - return messagesByChannelConfIds; + messagesByChannelCIDs[message.cid] = [message, index]; + return messagesByChannelCIDs; }, {}); } - return this.messagesByChannelConfIdGetterCache.calculated; + return this.messagesByChannelCIDGetterCache.calculated; } private subscribeTargetMessagesChange() { @@ -272,7 +279,7 @@ export class LiveLocationManager { nextWatcherCallTimestamp = Date.now() + MIN_THROTTLE_TIMEOUT; withCancellation(LiveLocationManager.symbol, async () => { - const promises: Promise[] = []; + const promises: Promise[] = []; const { ready } = this.state.getLatestValue(); if (!ready) { @@ -285,11 +292,13 @@ export class LiveLocationManager { for (const message of targetMessages) { if (!isValidLiveLocationMessage(message)) { - this.unregisterMessage(message); + this.unregisterMessage(message.id); continue; } - const promise = this.client.updateLiveLocation(message, { + const promise = this.client.updateLocation({ + created_by_device_id: this.getDeviceId(), + message_id: message.id, latitude, longitude, }); @@ -361,8 +370,8 @@ export class LiveLocationManager { }); } - private unregisterMessage(message: MessageResponse) { - const [, messageIndex] = this.messagesById[message.id] ?? []; + private unregisterMessage(messageId: string) { + const [, messageIndex] = this.messagesById[messageId] ?? []; if (typeof messageIndex !== 'number') return; @@ -378,10 +387,7 @@ export class LiveLocationManager { }); } - public unregisterSubscriptions = () => { - this.unsubscribeFunctions.forEach((cleanupFunction) => cleanupFunction()); - this.unsubscribeFunctions.clear(); - }; + public unregisterSubscriptions = () => super.unregisterSubscriptions(); private subscribeLiveLocationSharingUpdates() { const subscriptions = ( @@ -408,12 +414,14 @@ export class LiveLocationManager { if (!localMessage) return; if (!isValidLiveLocationMessage(event.message)) { - this.unregisterMessage(event.message); + this.unregisterMessage(event.message.id); } else { this.updateRegisteredMessage(event.message); } } else { - this.unregisterMessage(event.message); + this.unregisterMessage( + event.message.id ?? { id: event.live_location?.message_id }, + ); } }), ); @@ -422,13 +430,10 @@ export class LiveLocationManager { } public registerSubscriptions = () => { - if (this.unsubscribeFunctions.size) { - // LocationUpdater is already listening for events and changes - return; - } + if (this.hasSubscriptions) return; - this.unsubscribeFunctions.add(this.subscribeLiveLocationSharingUpdates()); - this.unsubscribeFunctions.add(this.subscribeTargetMessagesChange()); + this.addUnsubscribeFunction(this.subscribeLiveLocationSharingUpdates()); + this.addUnsubscribeFunction(this.subscribeTargetMessagesChange()); // TODO? - handle message registration during message updates too, message updated eol added (I hope not) }; } diff --git a/src/messageComposer/LocationComposer.ts b/src/messageComposer/LocationComposer.ts new file mode 100644 index 000000000..6c1314640 --- /dev/null +++ b/src/messageComposer/LocationComposer.ts @@ -0,0 +1,93 @@ +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 StaticLocationComposerLocation = Omit & { + durationMs?: number; +}; + +export type LiveLocationComposerLocation = Omit & { + durationMs?: number; +}; + +export type LocationComposerState = { + location: StaticLocationComposerLocation | LiveLocationComposerLocation | null; +}; + +const initState = ({ + message, +}: { + message?: DraftMessage | LocalMessage; +}): LocationComposerState => ({ + location: message?.shared_location ?? null, +}); + +export class LocationComposer { + readonly state: StateStore; + readonly composer: MessageComposer; + private _deviceId: string; + + constructor({ composer, message }: LocationComposerOptions) { + this.composer = composer; + this.state = new StateStore(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 StaticLocationComposerLocation | LiveLocationComposerLocation); + if ( + !!location?.created_by_device_id && + location.message_id && + location.latitude && + location.longitude + ) { + 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; + + this.state.partialNext({ + location: { + ...data, + message_id: this.composer.id, + created_by_device_id: this.deviceId, + }, + }); + }; +} 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..32f358897 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,15 @@ 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 +71,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 8f0b3a7e3..68bd619d3 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,10 @@ 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'; type UnregisterSubscriptions = Unsubscribe; @@ -129,6 +130,7 @@ export class MessageComposer extends WithSubscriptions { linkPreviewsManager: LinkPreviewsManager; textComposer: TextComposer; pollComposer: PollComposer; + locationComposer: LocationComposer; customDataManager: CustomDataManager; // todo: mediaRecorder: MediaRecorderController; @@ -170,6 +172,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 }); @@ -298,7 +301,8 @@ export class MessageComposer extends WithSubscriptions { !this.quotedMessage && this.textComposer.textIsEmpty && !this.attachmentManager.attachments.length && - !this.pollId + !this.pollId && + !this.locationComposer.validLocation ); } @@ -320,6 +324,10 @@ export class MessageComposer extends WithSubscriptions { static generateId = generateUUIDv4; + refreshId = () => { + this.state.partialNext({ id: MessageComposer.generateId() }); + }; + initState = ({ composition, }: { composition?: DraftResponse | MessageResponse | LocalMessage } = {}) => { @@ -333,6 +341,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 +412,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 +545,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 +822,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..d9ee1b335 100644 --- a/src/messageComposer/middleware/messageComposer/MessageComposerMiddlewareExecutor.ts +++ b/src/messageComposer/middleware/messageComposer/MessageComposerMiddlewareExecutor.ts @@ -32,6 +32,10 @@ import { } from './customData'; import { createUserDataInjectionMiddleware } from './userDataInjection'; import { createPollOnlyCompositionMiddleware } from './pollOnly'; +import { + createDraftSharedLocationCompositionMiddleware, + createSharedLocationCompositionMiddleware, +} from './sharedLocation'; export class MessageComposerMiddlewareExecutor extends MiddlewareExecutor< MessageComposerMiddlewareState, @@ -47,6 +51,7 @@ export class MessageComposerMiddlewareExecutor extends MiddlewareExecutor< createTextComposerCompositionMiddleware(composer), createAttachmentsCompositionMiddleware(composer), createLinkPreviewsCompositionMiddleware(composer), + createSharedLocationCompositionMiddleware(composer), createMessageComposerStateCompositionMiddleware(composer), createCustomDataCompositionMiddleware(composer), createCompositionValidationMiddleware(composer), @@ -67,6 +72,7 @@ export class MessageDraftComposerMiddlewareExecutor extends MiddlewareExecutor< createDraftTextComposerCompositionMiddleware(composer), createDraftAttachmentsCompositionMiddleware(composer), createDraftLinkPreviewsCompositionMiddleware(composer), + createDraftSharedLocationCompositionMiddleware(composer), createDraftMessageComposerStateCompositionMiddleware(composer), createDraftCustomDataCompositionMiddleware(composer), createDraftCompositionValidationMiddleware(composer), 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..7fab74325 --- /dev/null +++ b/src/messageComposer/middleware/messageComposer/sharedLocation.ts @@ -0,0 +1,69 @@ +import type { MiddlewareHandlerParams } from '../../../middleware'; +import type { MessageComposer } from '../../messageComposer'; +import type { + MessageComposerMiddlewareState, + MessageCompositionMiddleware, + MessageDraftComposerMiddlewareValueState, + MessageDraftCompositionMiddleware, +} from './types'; + +export const createSharedLocationCompositionMiddleware = ( + composer: MessageComposer, +): MessageCompositionMiddleware => ({ + id: 'stream-io/message-composer-middleware/shared-location', + handlers: { + compose: ({ + state, + next, + forward, + }: MiddlewareHandlerParams) => { + 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, + }, + }); + }, + }, +}); + +export const createDraftSharedLocationCompositionMiddleware = ( + composer: MessageComposer, +): MessageDraftCompositionMiddleware => ({ + id: 'stream-io/message-composer-middleware/draft-shared-location', + handlers: { + compose: ({ + state, + next, + forward, + }: MiddlewareHandlerParams) => { + const { locationComposer } = composer; + const location = locationComposer.validLocation; + if (!locationComposer || !location) return forward(); + + return next({ + ...state, + draft: { + ...state.draft, + shared_location: location, + }, + }); + }, + }, +}); diff --git a/src/types.ts b/src/types.ts index 31281721a..1e6d5876a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2345,6 +2345,7 @@ export type ChannelConfigFields = { read_events?: boolean; replies?: boolean; search?: boolean; + shared_locations?: boolean; typing_events?: boolean; uploads?: boolean; url_enrichment?: boolean; @@ -2702,7 +2703,7 @@ export type Logger = ( export type Message = Partial< MessageBase & { mentioned_users: string[]; - shared_location?: SharedLocationRequest; + shared_location?: StaticLocationPayload | LiveLocationPayload; } >; @@ -3956,6 +3957,7 @@ 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; @@ -3977,7 +3979,7 @@ export type SharedLocationResponse = { user_id: string; }; -export type SharedLocationRequest = { +export type UpdateLocationPayload = { created_by_device_id: string; end_at?: string; latitude?: number; @@ -3985,6 +3987,21 @@ export type SharedLocationRequest = { message_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; +}; + export type ThreadSort = ThreadSortBase | Array; export type ThreadSortBase = { From 9a751c38ed28a1bf67982fdb6156d672df66ec9b Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 8 Jul 2025 16:24:00 +0200 Subject: [PATCH 08/31] refactor: remove channel.sendStaticLocation and channel.startLiveLocationSharing --- src/channel.ts | 83 -------------------------------------------------- 1 file changed, 83 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 479526b77..d4b40ab98 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -691,89 +691,6 @@ export class Channel { return result; } - public async sendStaticLocation(location: StaticLocationPayload) { - const client = this.getClient(); - if (!client.userID) return; - - return await this.sendMessage({ - id: location.message_id, - shared_location: location, - }); - } - - public async startLiveLocationSharing(location: LiveLocationPayload) { - const client = this.getClient(); - if (!client.userID) return; - - if (!location.end_at) { - throw new Error('Live location sharing requires end_at parameter'); - } - - // todo: live location is not an attachment (end_time) - // const { latitude, longitude } = attachmentMetadata; - // todo: live location is not an attachment - // const message: Message = { - // - // }; - // todo: what will happen if we create a new location even though one already exists? - // FIXME: this is wrong and could easily be walked around by integrators - // const existing = await this.getClient().search( - // { - // cid: this.cid, - // }, - // { - // $and: [ - // // @ts-expect-error - // { 'shared_location.end_at': { $exists: true} }, - // // @ts-expect-error - // { 'shared_location.end_at': { $gt: new Date().toISOString()} }, - // ], - // }, - // ); - // - // const promises: Promise[] = []; - // - // for (const result of existing.results) { - // // todo: live location is not an attachment - // const [attachment] = result.message.shared_location ?? []; - // // todo: live location is not an attachment - // promises.push( - // client.partialUpdateMessage(result.message.id, { - // // @ts-expect-error - // set: { - // attachments: [ - // { - // ...attachment, - // stopped_sharing: true, - // }, - // ], - // }, - // }), - // ); - // } - - // FIXME: sending message if the previous part failed/did not happen - // should result in BE error - // promises.unshift(this.sendMessage(message)); - - // const [response] = await Promise.allSettled(promises); - - // if (response.status === 'fulfilled') { - // this.getClient().dispatchEvent({ - // message: response.value.message, - // type: 'live_location_sharing.started', - // }); - // } - const { message } = await this.sendMessage({ - id: location.message_id, - shared_location: location, - }); - this.getClient().dispatchEvent({ - message, - type: 'live_location_sharing.started', - }); - } - public async stopLiveLocationSharing(locationToStop: SharedLocationResponse) { const location = await this.getClient().updateLocation({ ...locationToStop, From 0e9064b1575dfdf34a01a87c220eb569aaca961e Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 8 Jul 2025 16:25:19 +0200 Subject: [PATCH 09/31] refactor: rename StaticLocationComposerLocation to StaticLocationPreview and LiveLocationComposerLocation to LiveLocationPreview --- src/messageComposer/LocationComposer.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/messageComposer/LocationComposer.ts b/src/messageComposer/LocationComposer.ts index 6c1314640..e875c0654 100644 --- a/src/messageComposer/LocationComposer.ts +++ b/src/messageComposer/LocationComposer.ts @@ -14,16 +14,16 @@ export type LocationComposerOptions = { message?: DraftMessage | LocalMessage; }; -export type StaticLocationComposerLocation = Omit & { +export type StaticLocationPreview = Omit & { durationMs?: number; }; -export type LiveLocationComposerLocation = Omit & { +export type LiveLocationPreview = Omit & { durationMs?: number; }; export type LocationComposerState = { - location: StaticLocationComposerLocation | LiveLocationComposerLocation | null; + location: StaticLocationPreview | LiveLocationPreview | null; }; const initState = ({ @@ -59,8 +59,7 @@ export class LocationComposer { get validLocation(): StaticLocationPayload | LiveLocationPayload | null { const { durationMs, ...location } = - this.location ?? - ({} as StaticLocationComposerLocation | LiveLocationComposerLocation); + this.location ?? ({} as StaticLocationPreview | LiveLocationPreview); if ( !!location?.created_by_device_id && location.message_id && From 3e7aa2b0e36d5173ba708cb85c7689c39d930127 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 8 Jul 2025 16:25:55 +0200 Subject: [PATCH 10/31] feat: add isSharedLocationResponse identity function --- src/messageComposer/attachmentIdentity.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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; From 6287851da9734e11e5a9bab6162308ef4bd4cd83 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 10 Jul 2025 11:36:01 +0200 Subject: [PATCH 11/31] feat: add types SharedStaticLocationResponse & SharedLiveLocationResponse --- src/types.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/types.ts b/src/types.ts index 09918acdf..0e6d81c2a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3964,7 +3964,7 @@ export type DraftMessage = { }; export type ActiveLiveLocationsAPIResponse = APIResponse & { - active_live_locations: SharedLocationResponse[]; + active_live_locations: SharedLiveLocationResponse[]; }; export type SharedLocationResponse = { @@ -3979,6 +3979,29 @@ export type SharedLocationResponse = { user_id: string; }; +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 = { created_by_device_id: string; end_at?: string; From c3e305ff0da19818ccc56fe1e5ce98ec3a785250 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 10 Jul 2025 11:37:50 +0200 Subject: [PATCH 12/31] feat: adapt LiveLocationManager to the new back-end --- src/live_location_manager.ts | 377 +++++++++-------------------------- 1 file changed, 95 insertions(+), 282 deletions(-) diff --git a/src/live_location_manager.ts b/src/live_location_manager.ts index 45ac052b4..07bc5ff34 100644 --- a/src/live_location_manager.ts +++ b/src/live_location_manager.ts @@ -9,128 +9,43 @@ import { withCancellation } from './concurrency'; import { StateStore } from './store'; +import { WithSubscriptions } from './utils/WithSubscriptions'; +import type { StreamChat } from './client'; +import type { Unsubscribe } from './store'; import type { - Attachment, EventTypes, MessageResponse, + SharedLiveLocationResponse, SharedLocationResponse, } from './types'; -import { WithSubscriptions } from './utils/WithSubscriptions'; -import type { StreamChat } from './client'; -import type { Unsubscribe } from './store'; // type Unsubscribe = () => void; type WatchLocation = ( handler: (value: { latitude: number; longitude: number }) => void, ) => Unsubscribe; -type SerializeAndStore = (state: MessageResponse[], userId: string) => void; -type RetrieveAndDeserialize = (userId: string) => MessageResponse[]; type DeviceIdGenerator = () => string; +type MessageId = string; export type LiveLocationManagerState = { ready: boolean; - targetMessages: MessageResponse[]; + messages: Map; }; -// if (message.cid && this.messagesByChannelCID[message.cid]) { -// const [m] = this.messagesByChannelCID[message.cid]; -// throw new Error( -// `[LocationUpdater.registerMessage]: one live location sharing message per channel limit has been reached, unregister message "${m.id}" first`, -// ); -// } - -// if (!attachment || attachment.type !== 'geolocation' || !attachment.geolocation_eol) { -// throw new Error( -// '[LocationUpdater.registerMessage]: Message has either no attachment, the attachment is not of type "geolocation" or the attachment is missing `geolocation_eol` property', -// ); -// } - -// if (typeof attachment.geolocation_eol !== 'string') { -// throw new Error( -// '[LocationUpdater.registerMessage]: `geolocation_eol` property is of incorrect type, should be date and time ISO 8601 string', -// ); -// } - -// const nowTimestamp = Date.now(); -// const eolTimestamp = new Date(attachment.geolocation_eol).getTime(); - -// if (Number.isNaN(eolTimestamp) || eolTimestamp < nowTimestamp) { -// throw new Error( -// '[LocationUpdater.registerMessage]: `geolocation_eol` has either improper format or has not been set to some time in the future (is lesser than now)', -// ); -// } - -// private async getCompleteMessage(messageId: string) { -// const [cachedMessage, cachedMessageIndex] = this.messagesById[messageId] ?? []; - -// const [cachedMessageAttachment] = cachedMessage?.attachments ?? []; - -// if (isAttachmentValidLLSEntity(cachedMessageAttachment)) { -// return cachedMessage; -// } - -// const queriedMessage = (await this.client.getMessage(messageId)).message; - -// const [queriedMessageAttachment] = queriedMessage.attachments ?? []; - -// if (isAttachmentValidLLSEntity(queriedMessageAttachment)) { -// this.state.next((currentValue) => { -// const newTargetMessages = [...currentValue.targetMessages]; - -// if (typeof cachedMessageIndex === 'number') { -// newTargetMessages[cachedMessageIndex] = queriedMessage; -// } else { -// newTargetMessages.push(queriedMessage); -// } - -// return { -// ...currentValue, -// targetMessages: newTargetMessages, -// }; -// }); - -// return queriedMessage; -// } - -// return null; -// } - -function isValidLiveLocationAttachment(attachment?: Attachment) { - // @ts-expect-error live location is not an attachment - if (!attachment || attachment.type !== 'live_location' || attachment.stopped_sharing) { +function isValidLiveLocationMessage( + message?: MessageResponse, +): message is MessageResponse & { shared_location: SharedLiveLocationResponse } { + if (!message || message.type === 'deleted' || !message.shared_location?.end_at) return false; - } - - // If end_time has been defined, consider it - // @ts-expect-error live location is not an attachment - if (typeof attachment.end_time === 'string') { - // @ts-expect-error live location is not an attachment - const endTimeTimestamp = new Date(attachment.end_time).getTime(); - - if (Number.isNaN(endTimeTimestamp)) return false; - - const nowTimestamp = Date.now(); - return nowTimestamp < endTimeTimestamp; - } - - return true; -} - -function isValidLiveLocationMessage(message?: MessageResponse) { - if (!message || message.type === 'deleted') return false; + const endTimeTimestamp = new Date(message.shared_location.end_at).getTime(); - const [attachment] = message.attachments ?? []; - - return isValidLiveLocationAttachment(attachment); + return Date.now() < endTimeTimestamp; } export type LiveLocationManagerConstructorParameters = { client: StreamChat; getDeviceId: DeviceIdGenerator; watchLocation: WatchLocation; - retrieveAndDeserialize?: RetrieveAndDeserialize; - serializeAndStore?: SerializeAndStore; }; // Hard-coded minimal throttle timeout @@ -140,16 +55,8 @@ export class LiveLocationManager extends WithSubscriptions { public state: StateStore; private client: StreamChat; private getDeviceId: DeviceIdGenerator; - private serializeAndStore: SerializeAndStore; + private _deviceId: string; private watchLocation: WatchLocation; - private messagesByChannelCIDGetterCache: { - calculated: { [key: string]: [MessageResponse, number] }; - targetMessages: LiveLocationManagerState['targetMessages']; - }; - private messagesByIdGetterCache: { - calculated: { [key: string]: [MessageResponse, number] }; - targetMessages: LiveLocationManagerState['targetMessages']; - }; static symbol = Symbol(LiveLocationManager.name); @@ -157,86 +64,63 @@ export class LiveLocationManager extends WithSubscriptions { client, getDeviceId, watchLocation, - retrieveAndDeserialize = (userId) => { - const targetMessagesString = localStorage.getItem( - `${userId}-${LiveLocationManager.name}`, - ); - if (!targetMessagesString) return []; - return JSON.parse(targetMessagesString); - }, - serializeAndStore = (messages, userId) => { - localStorage.setItem( - `${userId}-${LiveLocationManager.name}`, - // Strip sensitive data (these will be recovered at on first location watch call) - JSON.stringify(messages.map((message) => ({ id: message.id }))), - ); - }, }: LiveLocationManagerConstructorParameters) { - super(); - - this.client = client; - if (!client.userID) { throw new Error('Live-location sharing is reserved for client-side use only'); } - const retrievedTargetMessages = retrieveAndDeserialize(client.userID); + super(); + this.client = client; this.state = new StateStore({ - targetMessages: retrievedTargetMessages, - // If there are no messages to validate, the manager is considered "ready" - ready: retrievedTargetMessages.length === 0, + messages: new Map(), + ready: false, }); + this._deviceId = getDeviceId(); this.getDeviceId = getDeviceId; this.watchLocation = watchLocation; - this.serializeAndStore = serializeAndStore; + } - this.messagesByIdGetterCache = { - targetMessages: retrievedTargetMessages, - calculated: {}, - }; + private async assureStateInit() { + if (this.stateIsReady) return; + const { active_live_locations } = await this.client.getSharedLocations(); + this.state.next({ + messages: new Map( + active_live_locations.map((location) => [location.message_id, location]), + ), + ready: true, + }); + } - this.messagesByChannelCIDGetterCache = { - targetMessages: retrievedTargetMessages, - calculated: {}, - }; + public async init() { + await this.assureStateInit(); + this.registerSubscriptions(); } - public get messagesById() { - const { targetMessages } = this.state.getLatestValue(); + get messages() { + return this.state.getLatestValue().messages; + } - if (this.messagesByIdGetterCache.targetMessages !== targetMessages) { - this.messagesByIdGetterCache.targetMessages = targetMessages; + get stateIsReady() { + return this.state.getLatestValue().ready; + } - this.messagesByIdGetterCache.calculated = targetMessages.reduce<{ - [key: string]: [MessageResponse, number]; - }>((messagesById, message, index) => { - messagesById[message.id] = [message, index]; - return messagesById; - }, {}); + get deviceId() { + if (!this._deviceId) { + this._deviceId = this.getDeviceId(); } - - return this.messagesByIdGetterCache.calculated; + return this._deviceId; } - public get messagesByChannelCID() { - const { targetMessages } = this.state.getLatestValue(); - - if (this.messagesByChannelCIDGetterCache.targetMessages !== targetMessages) { - this.messagesByChannelCIDGetterCache.targetMessages = targetMessages; - - this.messagesByChannelCIDGetterCache.calculated = targetMessages.reduce<{ - [key: string]: [MessageResponse, number]; - }>((messagesByChannelCIDs, message, index) => { - if (!message.cid) return messagesByChannelCIDs; + public registerSubscriptions = () => { + if (this.hasSubscriptions) return; - messagesByChannelCIDs[message.cid] = [message, index]; - return messagesByChannelCIDs; - }, {}); - } + this.addUnsubscribeFunction(this.subscribeLiveLocationSharingUpdates()); + this.addUnsubscribeFunction(this.subscribeTargetMessagesChange()); + // TODO? - handle message registration during message updates too, message updated eol added (I hope not) + }; - return this.messagesByChannelCIDGetterCache.calculated; - } + public unregisterSubscriptions = () => super.unregisterSubscriptions(); private subscribeTargetMessagesChange() { let unsubscribeWatchLocation: null | (() => void) = null; @@ -244,21 +128,14 @@ export class LiveLocationManager extends WithSubscriptions { // 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( - ({ targetMessages }) => ({ targetMessages }), - ({ targetMessages }) => { - if (!targetMessages.length) { + ({ messages }) => ({ messages }), + ({ messages }) => { + if (!messages.size) { unsubscribeWatchLocation?.(); unsubscribeWatchLocation = null; - } else if (targetMessages.length && !unsubscribeWatchLocation) { + } else if (messages.size && !unsubscribeWatchLocation) { unsubscribeWatchLocation = this.subscribeWatchLocation(); } - - if (this.client.userID) { - this.serializeAndStore( - this.state.getLatestValue().targetMessages, - this.client.userID, - ); - } }, ); @@ -280,25 +157,14 @@ export class LiveLocationManager extends WithSubscriptions { withCancellation(LiveLocationManager.symbol, async () => { const promises: Promise[] = []; - const { ready } = this.state.getLatestValue(); - - if (!ready) { - await this.recoverAndValidateMessages(); - } + await this.assureStateInit(); - const { targetMessages } = this.state.getLatestValue(); - // If validator removes messages, we need to check - if (!targetMessages.length) return; - - for (const message of targetMessages) { - if (!isValidLiveLocationMessage(message)) { - this.unregisterMessage(message.id); + for (const [messageId, location] of this.messages) { + if (location.latitude === latitude && location.longitude === longitude) continue; - } - const promise = this.client.updateLocation({ - created_by_device_id: this.getDeviceId(), - message_id: message.id, + created_by_device_id: this.deviceId, + message_id: messageId, latitude, longitude, }); @@ -306,7 +172,9 @@ export class LiveLocationManager extends WithSubscriptions { promises.push(promise); } - await Promise.allSettled(promises); + if (promises.length > 0) { + await Promise.allSettled(promises); + } // TODO: handle values (remove failed - based on specific error code), keep re-trying others }); }); @@ -314,87 +182,12 @@ export class LiveLocationManager extends WithSubscriptions { return unsubscribe; } - /** - * Messages stored locally might've been updated while the device which registered message for updates has been offline. - */ - private async recoverAndValidateMessages() { - const { targetMessages } = this.state.getLatestValue(); - - if (!this.client.userID || !targetMessages.length) return; - - const response = await this.client.search( - { members: { $in: [this.client.userID] } }, - { id: { $in: targetMessages.map(({ id }) => id) } }, - ); - - const newTargetMessages = []; - - for (const result of response.results) { - const { message } = result; - - if (isValidLiveLocationMessage(message)) { - newTargetMessages.push(message); - } - } - - this.state.partialNext({ ready: true, targetMessages: newTargetMessages }); - } - - private registerMessage(message: MessageResponse) { - if (!this.client.userID || message?.user?.id !== this.client.userID) return; - - if (!isValidLiveLocationMessage(message)) { - return; - } - - this.state.next((currentValue) => ({ - ...currentValue, - targetMessages: [...currentValue.targetMessages, message], - })); - } - - private updateRegisteredMessage(message: MessageResponse) { - if (!this.client.userID || message?.user?.id !== this.client.userID) return; - - const [, targetMessageIndex] = this.messagesById[message.id]; - - this.state.next((currentValue) => { - const newTargetMessages = [...currentValue.targetMessages]; - - newTargetMessages[targetMessageIndex] = message; - - return { - ...currentValue, - targetMessages: newTargetMessages, - }; - }); - } - - private unregisterMessage(messageId: string) { - const [, messageIndex] = this.messagesById[messageId] ?? []; - - if (typeof messageIndex !== 'number') return; - - this.state.next((currentValue) => { - const newTargetMessages = [...currentValue.targetMessages]; - - newTargetMessages.splice(messageIndex, 1); - - return { - ...currentValue, - targetMessages: newTargetMessages, - }; - }); - } - - public unregisterSubscriptions = () => super.unregisterSubscriptions(); - private subscribeLiveLocationSharingUpdates() { const subscriptions = ( [ 'live_location_sharing.started', /** - * Both message.updated & live_location_sharing.stopped get emitted when message attachment gets an + * 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 */ @@ -409,14 +202,12 @@ export class LiveLocationManager extends WithSubscriptions { if (event.type === 'live_location_sharing.started') { this.registerMessage(event.message); } else if (event.type === 'message.updated') { - const localMessage = this.messagesById[event.message.id]; - - if (!localMessage) return; - - if (!isValidLiveLocationMessage(event.message)) { + const isRegistered = this.messages.has(event.message.id); + if (isRegistered && !isValidLiveLocationMessage(event.message)) { this.unregisterMessage(event.message.id); - } else { - this.updateRegisteredMessage(event.message); + } + if (!isRegistered && !isValidLiveLocationMessage(event.message)) { + this.registerMessage(event.message); } } else { this.unregisterMessage( @@ -429,11 +220,33 @@ export class LiveLocationManager extends WithSubscriptions { return () => subscriptions.forEach((subscription) => subscription.unsubscribe()); } - public registerSubscriptions = () => { - if (this.hasSubscriptions) return; + private registerMessage(message: MessageResponse) { + if ( + !this.client.userID || + message?.user?.id !== this.client.userID || + !isValidLiveLocationMessage(message) + ) + return; - this.addUnsubscribeFunction(this.subscribeLiveLocationSharingUpdates()); - this.addUnsubscribeFunction(this.subscribeTargetMessagesChange()); - // TODO? - handle message registration during message updates too, message updated eol added (I hope not) - }; + this.state.next((currentValue) => { + const messages = new Map(currentValue.messages); + messages.set(message.id, message.shared_location); + return { + ...currentValue, + messages, + }; + }); + } + + private unregisterMessage(messageId: string) { + const messages = this.messages; + const newMessages = new Map(messages); + newMessages.delete(messageId); + + if (newMessages.size === messages.size) return; + + this.state.partialNext({ + messages: newMessages, + }); + } } From 941d94af5d17b8f75f1c2659e2640ac54aab4bbf Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 10 Jul 2025 11:38:15 +0200 Subject: [PATCH 13/31] feat: add location related validation to message composer --- src/messageComposer/LocationComposer.ts | 6 +++++- src/messageComposer/messageComposer.ts | 3 ++- .../middleware/messageComposer/compositionValidation.ts | 6 +----- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/messageComposer/LocationComposer.ts b/src/messageComposer/LocationComposer.ts index e875c0654..f1842417b 100644 --- a/src/messageComposer/LocationComposer.ts +++ b/src/messageComposer/LocationComposer.ts @@ -26,6 +26,8 @@ export type LocationComposerState = { location: StaticLocationPreview | LiveLocationPreview | null; }; +const MIN_LIVE_LOCATION_SHARE_DURATION = 60 * 1000; // 1 minute; + const initState = ({ message, }: { @@ -64,7 +66,9 @@ export class LocationComposer { !!location?.created_by_device_id && location.message_id && location.latitude && - location.longitude + location.longitude && + (typeof durationMs === 'undefined' || + durationMs >= MIN_LIVE_LOCATION_SHARE_DURATION) ) { return { ...location, diff --git a/src/messageComposer/messageComposer.ts b/src/messageComposer/messageComposer.ts index 58e6dc6f7..3311a17b5 100644 --- a/src/messageComposer/messageComposer.ts +++ b/src/messageComposer/messageComposer.ts @@ -292,7 +292,8 @@ export class MessageComposer extends WithSubscriptions { (!this.attachmentManager.uploadsInProgressCount && (!this.textComposer.textIsEmpty || this.attachmentManager.successfulUploadsCount > 0)) || - this.pollId + this.pollId || + !!this.locationComposer.validLocation ); } 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) => { 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(); } From 2ca820f6b56001444ac544cabe5e49c8ea5686ac Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 14 Jul 2025 09:51:16 +0200 Subject: [PATCH 14/31] fix: add missing UpdateLocationLocationPayload properties --- src/types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/types.ts b/src/types.ts index 0e6d81c2a..4d2c718ab 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4008,6 +4008,8 @@ export type UpdateLocationPayload = { latitude?: number; longitude?: number; message_id: string; + user?: { id: string }; + user_id?: string; }; export type StaticLocationPayload = { From 617e2002ffa9f7c457356e3b0b7d3d96ca4a5e5f Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 14 Jul 2025 13:59:56 +0200 Subject: [PATCH 15/31] refactor: remove duplicate concurrency.ts module --- src/concurrency.ts | 120 ----------------------------------- src/live_location_manager.ts | 2 +- 2 files changed, 1 insertion(+), 121 deletions(-) delete mode 100644 src/concurrency.ts diff --git a/src/concurrency.ts b/src/concurrency.ts deleted file mode 100644 index 886be8bf8..000000000 --- a/src/concurrency.ts +++ /dev/null @@ -1,120 +0,0 @@ -interface PendingPromise { - onContinued: () => void; - promise: Promise; -} - -type AsyncWrapper

= ( - tag: string | symbol, - cb: (...args: P) => Promise, -) => { - cb: () => Promise; - onContinued: () => void; -}; - -/** - * Runs async functions serially. Useful for wrapping async actions that - * should never run simultaneously: if marked with the same tag, functions - * will run one after another. - * - * @param tag Async functions with the same tag will run serially. Async functions - * with different tags can run in parallel. - * @param cb Async function to run. - * @returns Promise that resolves when async functions returns. - */ -export const withoutConcurrency = createRunner(wrapWithContinuationTracking); - -/** - * Runs async functions serially, and cancels all other actions with the same tag - * when a new action is scheduled. Useful for wrapping async actions that override - * each other (e.g. enabling and disabling camera). - * - * If an async function hasn't started yet and was canceled, it will never run. - * If an async function is already running and was canceled, it will be notified - * via an abort signal passed as an argument. - * - * @param tag Async functions with the same tag will run serially and are canceled - * when a new action with the same tag is scheduled. - * @param cb Async function to run. Receives AbortSignal as the only argument. - * @returns Promise that resolves when async functions returns. If the function didn't - * start and was canceled, will resolve with 'canceled'. If the function started to run, - * it's up to the function to decide how to react to cancelation. - */ -export const withCancellation = createRunner(wrapWithCancellation); - -const pendingPromises = new Map(); - -export function hasPending(tag: string | symbol) { - return pendingPromises.has(tag); -} - -export async function settled(tag: string | symbol) { - await pendingPromises.get(tag)?.promise; -} - -/** - * Implements common functionality of running async functions serially, by chaining - * their promises one after another. - * - * Before running, async function is "wrapped" using the provided wrapper. This wrapper - * can add additional steps to run before or after the function. - * - * When async function is scheduled to run, the previous function is notified - * by calling the associated onContinued callback. This behavior of this callback - * is defined by the wrapper. - */ -function createRunner

(wrapper: AsyncWrapper) { - return function run(tag: string | symbol, cb: (...args: P) => Promise) { - const { cb: wrapped, onContinued } = wrapper(tag, cb); - const pending = pendingPromises.get(tag); - pending?.onContinued(); - const promise = pending ? pending.promise.then(wrapped, wrapped) : wrapped(); - pendingPromises.set(tag, { promise, onContinued }); - return promise; - }; -} - -/** - * Wraps an async function with an additional step run after the function: - * if the function is the last in the queue, it cleans up the whole chain - * of promises after finishing. - */ -function wrapWithContinuationTracking(tag: string | symbol, cb: () => Promise) { - let hasContinuation = false; - const wrapped = () => - cb().finally(() => { - if (!hasContinuation) { - pendingPromises.delete(tag); - } - }); - const onContinued = () => (hasContinuation = true); - return { cb: wrapped, onContinued }; -} - -/** - * Wraps an async function with additional functionalilty: - * 1. Associates an abort signal with every function, that is passed to it - * as an argument. When a new function is scheduled to run after the current - * one, current signal is aborted. - * 2. If current function didn't start and was aborted, in will never start. - * 3. If the function is the last in the queue, it cleans up the whole chain - * of promises after finishing. - */ -function wrapWithCancellation( - tag: string | symbol, - cb: (signal: AbortSignal) => Promise, -) { - const ac = new AbortController(); - const wrapped = () => { - if (ac.signal.aborted) { - return Promise.resolve('canceled' as const); - } - - return cb(ac.signal).finally(() => { - if (!ac.signal.aborted) { - pendingPromises.delete(tag); - } - }); - }; - const onContinued = () => ac.abort(); - return { cb: wrapped, onContinued }; -} diff --git a/src/live_location_manager.ts b/src/live_location_manager.ts index 07bc5ff34..43714b6be 100644 --- a/src/live_location_manager.ts +++ b/src/live_location_manager.ts @@ -7,7 +7,7 @@ * 4. live location is per-device, no other device which did not store the message locally, should be updating the live location attachment */ -import { withCancellation } from './concurrency'; +import { withCancellation } from './utils/concurrency'; import { StateStore } from './store'; import { WithSubscriptions } from './utils/WithSubscriptions'; import type { StreamChat } from './client'; From 435bc1ce473220247614227c30fda997e34f0d51 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 14 Jul 2025 14:01:20 +0200 Subject: [PATCH 16/31] test: fix composition validation test --- .../compositionValidation.test.ts | 128 +++++------------- 1 file changed, 36 insertions(+), 92 deletions(-) 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: {}, }), ); From 6ca98aa95795597ece4d58765e56704549ca8743 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 14 Jul 2025 14:01:40 +0200 Subject: [PATCH 17/31] test: add channel location sharing tests --- test/unit/channel.test.js | 100 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) 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', + }); + }); +}); From 2308990f4726e14b93c3326e198c53464f44420f Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 16 Jul 2025 13:00:19 +0200 Subject: [PATCH 18/31] test: add LiveLocationManager tests --- ...tion_manager.ts => LiveLocationManager.ts} | 118 +-- src/index.ts | 2 +- test/unit/LiveLocationManager.test.ts | 744 ++++++++++++++++++ 3 files changed, 806 insertions(+), 58 deletions(-) rename src/{live_location_manager.ts => LiveLocationManager.ts} (78%) create mode 100644 test/unit/LiveLocationManager.test.ts diff --git a/src/live_location_manager.ts b/src/LiveLocationManager.ts similarity index 78% rename from src/live_location_manager.ts rename to src/LiveLocationManager.ts index 43714b6be..75827a100 100644 --- a/src/live_location_manager.ts +++ b/src/LiveLocationManager.ts @@ -18,11 +18,12 @@ import type { SharedLiveLocationResponse, SharedLocationResponse, } from './types'; +import type { Coords } from './messageComposer'; + +export type WatchLocationHandler = (value: Coords) => void; // type Unsubscribe = () => void; -type WatchLocation = ( - handler: (value: { latitude: number; longitude: number }) => void, -) => Unsubscribe; +type WatchLocation = (handler: WatchLocationHandler) => Unsubscribe; type DeviceIdGenerator = () => string; type MessageId = string; @@ -49,7 +50,7 @@ export type LiveLocationManagerConstructorParameters = { }; // Hard-coded minimal throttle timeout -const MIN_THROTTLE_TIMEOUT = 3000; +export const UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT = 3000; export class LiveLocationManager extends WithSubscriptions { public state: StateStore; @@ -81,22 +82,21 @@ export class LiveLocationManager extends WithSubscriptions { this.watchLocation = watchLocation; } - private async assureStateInit() { - if (this.stateIsReady) return; - const { active_live_locations } = await this.client.getSharedLocations(); - this.state.next({ - messages: new Map( - active_live_locations.map((location) => [location.message_id, location]), - ), - ready: true, - }); - } - 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; } @@ -112,15 +112,16 @@ export class LiveLocationManager extends WithSubscriptions { return this._deviceId; } - public registerSubscriptions = () => { - if (this.hasSubscriptions) return; - - this.addUnsubscribeFunction(this.subscribeLiveLocationSharingUpdates()); - this.addUnsubscribeFunction(this.subscribeTargetMessagesChange()); - // TODO? - handle message registration during message updates too, message updated eol added (I hope not) - }; - - public unregisterSubscriptions = () => super.unregisterSubscriptions(); + private async assureStateInit() { + if (this.stateIsReady) return; + const { active_live_locations } = await this.client.getSharedLocations(); + this.state.next({ + messages: new Map( + active_live_locations.map((location) => [location.message_id, location]), + ), + ready: true, + }); + } private subscribeTargetMessagesChange() { let unsubscribeWatchLocation: null | (() => void) = null; @@ -146,14 +147,15 @@ export class LiveLocationManager extends WithSubscriptions { } private subscribeWatchLocation() { - let nextWatcherCallTimestamp = Date.now(); + 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() < nextWatcherCallTimestamp) return; + if (Date.now() < nextAllowedUpdateCallTimestamp) return; - nextWatcherCallTimestamp = Date.now() + MIN_THROTTLE_TIMEOUT; + nextAllowedUpdateCallTimestamp = + Date.now() + UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT; withCancellation(LiveLocationManager.symbol, async () => { const promises: Promise[] = []; @@ -183,39 +185,41 @@ export class LiveLocationManager extends WithSubscriptions { } private subscribeLiveLocationSharingUpdates() { - const subscriptions = ( - [ - 'live_location_sharing.started', - /** - * 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 - */ - 'message.updated', - 'live_location_sharing.stopped', - '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.unregisterMessage(event.message.id); - } - if (!isRegistered && !isValidLiveLocationMessage(event.message)) { + /** + * 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.unregisterMessage(event.message.id); + } this.registerMessage(event.message); + } else { + this.unregisterMessage(event.message.id); } - } else { - this.unregisterMessage( - event.message.id ?? { id: event.live_location?.message_id }, - ); - } + }), + ), + this.client.on('live_location_sharing.stopped', (event) => { + if (!event.live_location) return; + + this.unregisterMessage(event.live_location?.message_id); }), - ); + ]; return () => subscriptions.forEach((subscription) => subscription.unsubscribe()); } diff --git a/src/index.ts b/src/index.ts index 660e79101..202df56fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,7 +32,7 @@ export * from './token_manager'; export * from './types'; export * from './channel_manager'; export * from './offline-support'; -export * from './live_location_manager'; +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/test/unit/LiveLocationManager.test.ts b/test/unit/LiveLocationManager.test.ts new file mode 100644 index 000000000..0e230bdc3 --- /dev/null +++ b/test/unit/LiveLocationManager.test.ts @@ -0,0 +1,744 @@ +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: manager.deviceId, + 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.unregisterMessage(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; + expect(updateLocationSpy).toHaveBeenCalledTimes(1); + expect(updateLocationSpy).toHaveBeenCalledWith({ + created_by_device_id: manager.deviceId, + 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(); + }); + }); + + 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(); + 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); + }); + }); +}); From c2ff1f7e67220857241b04b7944e0ad95866252f Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 16 Jul 2025 16:50:45 +0200 Subject: [PATCH 19/31] test: add LocationComposer tests --- src/messageComposer/LocationComposer.ts | 1 + src/messageComposer/configuration/types.ts | 3 +- .../MessageComposer/LocationComposer.test.ts | 209 ++++++++++++++++++ 3 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 test/unit/MessageComposer/LocationComposer.test.ts diff --git a/src/messageComposer/LocationComposer.ts b/src/messageComposer/LocationComposer.ts index f1842417b..bf249b83b 100644 --- a/src/messageComposer/LocationComposer.ts +++ b/src/messageComposer/LocationComposer.ts @@ -84,6 +84,7 @@ export class LocationComposer { setData = (data: { durationMs?: number } & Coords) => { if (!this.config.enabled) return; + if (!data.latitude || !data.longitude) return; this.state.partialNext({ location: { diff --git a/src/messageComposer/configuration/types.ts b/src/messageComposer/configuration/types.ts index 32f358897..e94d17f78 100644 --- a/src/messageComposer/configuration/types.ts +++ b/src/messageComposer/configuration/types.ts @@ -56,7 +56,8 @@ export type LinkPreviewsManagerConfig = { }; export type LocationComposerConfig = { - /** Allows for toggling the location addition. + /** + * 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; 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; +} = {}) => { + // 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(); + }); + }); +}); From 81d96c7d1ff7154e5d8d52669ddd95e7438704ef Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 17 Jul 2025 12:37:14 +0200 Subject: [PATCH 20/31] test: add sharedLocation middleware tests --- .../messageComposer/sharedLocation.test.ts | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts 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..6f6438963 --- /dev/null +++ b/test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createDraftSharedLocationCompositionMiddleware, + createSharedLocationCompositionMiddleware, + DraftResponse, + LocalMessage, + MessageComposer, + MessageComposerMiddlewareState, + type MessageCompositionMiddleware, + MessageDraftComposerMiddlewareValueState, + 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: {}, + }, + }); + }); +}); + +const setupDraftMiddlewareHandlerParams = ( + initialState: MessageDraftComposerMiddlewareValueState = {}, +) => { + return { + state: initialState, + next: async (state: MessageDraftComposerMiddlewareValueState) => ({ state }), + complete: async (state: MessageDraftComposerMiddlewareValueState) => ({ + state, + status: 'complete' as MiddlewareStatus, + }), + discard: async () => ({ state: initialState, status: 'discard' as MiddlewareStatus }), + forward: async () => ({ state: initialState }), + }; +}; + +describe('stream-io/message-composer-middleware/draft-shared-location', () => { + it('injects shared_location to localMessage and message payloads', async () => { + const { messageComposer } = setup(); + const middleware = createDraftSharedLocationCompositionMiddleware(messageComposer); + const coords = { latitude: 1, longitude: 1 }; + messageComposer.locationComposer.setData(coords); + const result = await middleware.handlers.compose(setupDraftMiddlewareHandlerParams()); + expect(result).toEqual({ + state: { + draft: { + shared_location: { + created_by_device_id: messageComposer.locationComposer.deviceId, + message_id: messageComposer.id, + ...coords, + }, + }, + }, + }); + }); + + it('does not inject shared_location to localMessage and message payloads if none is set', async () => { + const { messageComposer } = setup(); + const middleware = createDraftSharedLocationCompositionMiddleware(messageComposer); + const result = await middleware.handlers.compose(setupDraftMiddlewareHandlerParams()); + expect(result).toEqual({ + state: {}, + }); + }); + + it('does not inject shared_location to localMessage and message payloads if the location state is corrupted', async () => { + const { messageComposer } = setup(); + const middleware = createDraftSharedLocationCompositionMiddleware(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(setupDraftMiddlewareHandlerParams()); + expect(result).toEqual({ + state: {}, + }); + }); +}); From 573ea49adb6f8c6e26c368877d6fdd529ee5ec10 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 17 Jul 2025 16:43:55 +0200 Subject: [PATCH 21/31] test: add MessageComposer tests middleware tests --- src/messageComposer/messageComposer.ts | 31 +- .../MessageComposer/messageComposer.test.ts | 316 ++++++++++++++++-- 2 files changed, 313 insertions(+), 34 deletions(-) diff --git a/src/messageComposer/messageComposer.ts b/src/messageComposer/messageComposer.ts index 3311a17b5..65337eb75 100644 --- a/src/messageComposer/messageComposer.ts +++ b/src/messageComposer/messageComposer.ts @@ -30,6 +30,7 @@ import { WithSubscriptions } from '../utils/WithSubscriptions'; import type { StreamChat } from '../client'; import type { MessageComposerConfig } from './configuration/types'; import type { DeepPartial } from '../types.utility'; +import type { MergeWithCustomizer } from '../utils/mergeWith/mergeWithCore'; type UnregisterSubscriptions = Unsubscribe; @@ -144,10 +145,6 @@ export class MessageComposer extends WithSubscriptions { this.compositionContext = compositionContext; - this.configState = new StateStore( - mergeWith(DEFAULT_COMPOSER_CONFIG, config ?? {}), - ); - // channel is easily inferable from the context if (compositionContext instanceof Channel) { this.channel = compositionContext; @@ -162,6 +159,32 @@ export class MessageComposer extends WithSubscriptions { ); } + const mergeChannelConfigCustomizer: MergeWithCustomizer< + DeepPartial + > = (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( + 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; 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; } = {}) => { 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 } }, From 74621096158e9561c9c9cb808b2f48c8c9585e0b Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 18 Jul 2025 09:29:23 +0200 Subject: [PATCH 22/31] fix: send location updates with the original creator device id --- src/LiveLocationManager.ts | 2 +- test/unit/LiveLocationManager.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/LiveLocationManager.ts b/src/LiveLocationManager.ts index 75827a100..dbe3da10d 100644 --- a/src/LiveLocationManager.ts +++ b/src/LiveLocationManager.ts @@ -165,7 +165,7 @@ export class LiveLocationManager extends WithSubscriptions { if (location.latitude === latitude && location.longitude === longitude) continue; const promise = this.client.updateLocation({ - created_by_device_id: this.deviceId, + created_by_device_id: location.created_by_device_id, message_id: messageId, latitude, longitude, diff --git a/test/unit/LiveLocationManager.test.ts b/test/unit/LiveLocationManager.test.ts index 0e230bdc3..706d1c973 100644 --- a/test/unit/LiveLocationManager.test.ts +++ b/test/unit/LiveLocationManager.test.ts @@ -185,7 +185,7 @@ describe('LiveLocationManager', () => { await manager.init(); expect(updateLocationSpy).toHaveBeenCalledTimes(1); expect(updateLocationSpy).toHaveBeenCalledWith({ - created_by_device_id: manager.deviceId, + created_by_device_id: liveLocation.created_by_device_id, message_id: liveLocation.message_id, ...newCoords, }); @@ -332,7 +332,7 @@ describe('LiveLocationManager', () => { await sleepPromise; expect(updateLocationSpy).toHaveBeenCalledTimes(1); expect(updateLocationSpy).toHaveBeenCalledWith({ - created_by_device_id: manager.deviceId, + created_by_device_id: liveLocation.created_by_device_id, message_id: liveLocation.message_id, ...newCoords, }); From 942b0303129cfaaae26b6dac70515c5fea31e120 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 18 Jul 2025 09:29:57 +0200 Subject: [PATCH 23/31] fix: make channel property optional in ReminderResponse --- src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types.ts b/src/types.ts index 94019ad07..dc3e476f6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4105,9 +4105,9 @@ export type ReminderResponseBase = { }; export type ReminderResponse = ReminderResponseBase & { - channel: ChannelResponse; user: UserResponse; message: MessageResponse; + channel?: ChannelResponse; }; export type ReminderAPIResponse = APIResponse & { From 4fa7a1483fc0bd6de7320ced97fdb4dc8582d473 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 18 Jul 2025 09:48:20 +0200 Subject: [PATCH 24/31] fix: stop sharing live location once expired --- src/LiveLocationManager.ts | 31 ++++++++++++++++++--------- test/unit/LiveLocationManager.test.ts | 2 +- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/LiveLocationManager.ts b/src/LiveLocationManager.ts index dbe3da10d..88f066457 100644 --- a/src/LiveLocationManager.ts +++ b/src/LiveLocationManager.ts @@ -32,15 +32,19 @@ export type LiveLocationManagerState = { messages: Map; }; +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; - const endTimeTimestamp = new Date(message.shared_location.end_at).getTime(); - - return Date.now() < endTimeTimestamp; + return !isExpiredLocation(message.shared_location as SharedLiveLocationResponse); } export type LiveLocationManagerConstructorParameters = { @@ -160,8 +164,13 @@ export class LiveLocationManager extends WithSubscriptions { withCancellation(LiveLocationManager.symbol, async () => { const promises: Promise[] = []; 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({ @@ -173,7 +182,7 @@ export class LiveLocationManager extends WithSubscriptions { promises.push(promise); } - + this.unregisterMessages(expiredLocations); if (promises.length > 0) { await Promise.allSettled(promises); } @@ -206,18 +215,18 @@ export class LiveLocationManager extends WithSubscriptions { } else if (event.type === 'message.updated') { const isRegistered = this.messages.has(event.message.id); if (isRegistered && !isValidLiveLocationMessage(event.message)) { - this.unregisterMessage(event.message.id); + this.unregisterMessages([event.message.id]); } this.registerMessage(event.message); } else { - this.unregisterMessage(event.message.id); + this.unregisterMessages([event.message.id]); } }), ), this.client.on('live_location_sharing.stopped', (event) => { if (!event.live_location) return; - this.unregisterMessage(event.live_location?.message_id); + this.unregisterMessages([event.live_location?.message_id]); }), ]; @@ -242,10 +251,12 @@ export class LiveLocationManager extends WithSubscriptions { }); } - private unregisterMessage(messageId: string) { + private unregisterMessages(messageIds: string[]) { const messages = this.messages; - const newMessages = new Map(messages); - newMessages.delete(messageId); + const removedMessages = new Set(messageIds); + const newMessages = new Map( + Array.from(messages).filter(([messageId]) => !removedMessages.has(messageId)), + ); if (newMessages.size === messages.size) return; diff --git a/test/unit/LiveLocationManager.test.ts b/test/unit/LiveLocationManager.test.ts index 706d1c973..b4ce2b19f 100644 --- a/test/unit/LiveLocationManager.test.ts +++ b/test/unit/LiveLocationManager.test.ts @@ -211,7 +211,7 @@ describe('LiveLocationManager', () => { await manager.init(); // @ts-expect-error accessing private property - manager.unregisterMessage(liveLocation.message_id); + manager.unregisterMessages([liveLocation.message_id]); expect(updateLocationSpy).toHaveBeenCalledTimes(1); expect(manager.messages).toHaveLength(0); }); From 06eb498a1344695d7fd7484b52121d610fda4349 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 18 Jul 2025 10:08:54 +0200 Subject: [PATCH 25/31] test: stop sharing live location once expired --- test/unit/LiveLocationManager.test.ts | 49 +++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/test/unit/LiveLocationManager.test.ts b/test/unit/LiveLocationManager.test.ts index b4ce2b19f..73b62a0d2 100644 --- a/test/unit/LiveLocationManager.test.ts +++ b/test/unit/LiveLocationManager.test.ts @@ -415,6 +415,55 @@ describe('LiveLocationManager', () => { 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', () => { From da8aaa9ea0f89254de29c55e1d156ee112ea1000 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 18 Jul 2025 10:09:35 +0200 Subject: [PATCH 26/31] feat: export WatchLocation type --- src/LiveLocationManager.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/LiveLocationManager.ts b/src/LiveLocationManager.ts index 88f066457..ead934d7d 100644 --- a/src/LiveLocationManager.ts +++ b/src/LiveLocationManager.ts @@ -21,9 +21,7 @@ import type { import type { Coords } from './messageComposer'; export type WatchLocationHandler = (value: Coords) => void; - -// type Unsubscribe = () => void; -type WatchLocation = (handler: WatchLocationHandler) => Unsubscribe; +export type WatchLocation = (handler: WatchLocationHandler) => Unsubscribe; type DeviceIdGenerator = () => string; type MessageId = string; From 5d4ee7cd88f4dbbb4dec8f9c0dd96a858cd23990 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 22 Jul 2025 12:56:06 +0200 Subject: [PATCH 27/31] refactor: make created_by_device_id optional --- src/channel.ts | 6 +++--- src/types.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index a21a0dca1..a0a5e9f25 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -64,12 +64,12 @@ import type { SendMessageAPIResponse, SendMessageOptions, SendReactionOptions, - SharedLocationResponse, StaticLocationPayload, TruncateChannelAPIResponse, TruncateOptions, UpdateChannelAPIResponse, UpdateChannelOptions, + UpdateLocationPayload, UserResponse, } from './types'; import type { Role } from './permissions'; @@ -692,9 +692,9 @@ export class Channel { return result; } - public async stopLiveLocationSharing(locationToStop: SharedLocationResponse) { + public async stopLiveLocationSharing(payload: UpdateLocationPayload) { const location = await this.getClient().updateLocation({ - ...locationToStop, + ...payload, end_at: new Date().toISOString(), }); this.getClient().dispatchEvent({ diff --git a/src/types.ts b/src/types.ts index dc3e476f6..b57fc9de4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4012,11 +4012,11 @@ export type SharedLiveLocationResponse = { }; export type UpdateLocationPayload = { - created_by_device_id: string; + message_id: string; + created_by_device_id?: string; end_at?: string; latitude?: number; longitude?: number; - message_id: string; user?: { id: string }; user_id?: string; }; From 1e010782f8c93d171075babc538d39968fda4f35 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 22 Jul 2025 13:31:18 +0200 Subject: [PATCH 28/31] fix: make sure the expired live locations are unregistered --- src/LiveLocationManager.ts | 39 ++++++++++++++++++++++++--- test/unit/LiveLocationManager.test.ts | 15 ++++++----- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/src/LiveLocationManager.ts b/src/LiveLocationManager.ts index ead934d7d..a8b19d272 100644 --- a/src/LiveLocationManager.ts +++ b/src/LiveLocationManager.ts @@ -25,9 +25,13 @@ export type WatchLocation = (handler: WatchLocationHandler) => Unsubscribe; type DeviceIdGenerator = () => string; type MessageId = string; +export type ScheduledLiveLocationSharing = SharedLiveLocationResponse & { + stopSharingTimeout: ReturnType | null; +}; + export type LiveLocationManagerState = { ready: boolean; - messages: Map; + messages: Map; }; const isExpiredLocation = (location: SharedLiveLocationResponse) => { @@ -119,7 +123,20 @@ export class LiveLocationManager extends WithSubscriptions { const { active_live_locations } = await this.client.getSharedLocations(); this.state.next({ messages: new Map( - active_live_locations.map((location) => [location.message_id, location]), + 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, }); @@ -241,7 +258,15 @@ export class LiveLocationManager extends WithSubscriptions { this.state.next((currentValue) => { const messages = new Map(currentValue.messages); - messages.set(message.id, message.shared_location); + 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, @@ -253,7 +278,13 @@ export class LiveLocationManager extends WithSubscriptions { const messages = this.messages; const removedMessages = new Set(messageIds); const newMessages = new Map( - Array.from(messages).filter(([messageId]) => !removedMessages.has(messageId)), + 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; diff --git a/test/unit/LiveLocationManager.test.ts b/test/unit/LiveLocationManager.test.ts index 73b62a0d2..256791929 100644 --- a/test/unit/LiveLocationManager.test.ts +++ b/test/unit/LiveLocationManager.test.ts @@ -330,13 +330,16 @@ describe('LiveLocationManager', () => { sleepPromise = sleep(0); // registerMessage is async under the hood vi.advanceTimersByTime(0); await sleepPromise; - expect(updateLocationSpy).toHaveBeenCalledTimes(1); - expect(updateLocationSpy).toHaveBeenCalledWith({ - created_by_device_id: liveLocation.created_by_device_id, - message_id: liveLocation.message_id, - ...newCoords, + + 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); }); - expect(manager.messages).toHaveLength(2); vi.useRealTimers(); }); From 31c4ef8033ac10d2a2858485933f34a74206ec34 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 22 Jul 2025 14:09:41 +0200 Subject: [PATCH 29/31] fix: fix StaticLocationPreview type declaration --- src/messageComposer/LocationComposer.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/messageComposer/LocationComposer.ts b/src/messageComposer/LocationComposer.ts index bf249b83b..438d37e35 100644 --- a/src/messageComposer/LocationComposer.ts +++ b/src/messageComposer/LocationComposer.ts @@ -14,9 +14,7 @@ export type LocationComposerOptions = { message?: DraftMessage | LocalMessage; }; -export type StaticLocationPreview = Omit & { - durationMs?: number; -}; +export type StaticLocationPreview = StaticLocationPayload; export type LiveLocationPreview = Omit & { durationMs?: number; @@ -60,8 +58,7 @@ export class LocationComposer { } get validLocation(): StaticLocationPayload | LiveLocationPayload | null { - const { durationMs, ...location } = - this.location ?? ({} as StaticLocationPreview | LiveLocationPreview); + const { durationMs, ...location } = (this.location ?? {}) as LiveLocationPreview; if ( !!location?.created_by_device_id && location.message_id && From 9b126e7637692d7b03b2b27eae9246278aa15242 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 22 Jul 2025 15:26:06 +0200 Subject: [PATCH 30/31] fix: avoid storing shared_location in drafts --- .../MessageComposerMiddlewareExecutor.ts | 6 +- .../messageComposer/sharedLocation.ts | 27 -------- test/unit/LiveLocationManager.test.ts | 4 +- .../messageComposer/sharedLocation.test.ts | 65 ------------------- 4 files changed, 4 insertions(+), 98 deletions(-) diff --git a/src/messageComposer/middleware/messageComposer/MessageComposerMiddlewareExecutor.ts b/src/messageComposer/middleware/messageComposer/MessageComposerMiddlewareExecutor.ts index d9ee1b335..adf06bc91 100644 --- a/src/messageComposer/middleware/messageComposer/MessageComposerMiddlewareExecutor.ts +++ b/src/messageComposer/middleware/messageComposer/MessageComposerMiddlewareExecutor.ts @@ -32,10 +32,7 @@ import { } from './customData'; import { createUserDataInjectionMiddleware } from './userDataInjection'; import { createPollOnlyCompositionMiddleware } from './pollOnly'; -import { - createDraftSharedLocationCompositionMiddleware, - createSharedLocationCompositionMiddleware, -} from './sharedLocation'; +import { createSharedLocationCompositionMiddleware } from './sharedLocation'; export class MessageComposerMiddlewareExecutor extends MiddlewareExecutor< MessageComposerMiddlewareState, @@ -72,7 +69,6 @@ export class MessageDraftComposerMiddlewareExecutor extends MiddlewareExecutor< createDraftTextComposerCompositionMiddleware(composer), createDraftAttachmentsCompositionMiddleware(composer), createDraftLinkPreviewsCompositionMiddleware(composer), - createDraftSharedLocationCompositionMiddleware(composer), createDraftMessageComposerStateCompositionMiddleware(composer), createDraftCustomDataCompositionMiddleware(composer), createDraftCompositionValidationMiddleware(composer), diff --git a/src/messageComposer/middleware/messageComposer/sharedLocation.ts b/src/messageComposer/middleware/messageComposer/sharedLocation.ts index 7fab74325..00e17b68d 100644 --- a/src/messageComposer/middleware/messageComposer/sharedLocation.ts +++ b/src/messageComposer/middleware/messageComposer/sharedLocation.ts @@ -3,8 +3,6 @@ import type { MessageComposer } from '../../messageComposer'; import type { MessageComposerMiddlewareState, MessageCompositionMiddleware, - MessageDraftComposerMiddlewareValueState, - MessageDraftCompositionMiddleware, } from './types'; export const createSharedLocationCompositionMiddleware = ( @@ -42,28 +40,3 @@ export const createSharedLocationCompositionMiddleware = ( }, }, }); - -export const createDraftSharedLocationCompositionMiddleware = ( - composer: MessageComposer, -): MessageDraftCompositionMiddleware => ({ - id: 'stream-io/message-composer-middleware/draft-shared-location', - handlers: { - compose: ({ - state, - next, - forward, - }: MiddlewareHandlerParams) => { - const { locationComposer } = composer; - const location = locationComposer.validLocation; - if (!locationComposer || !location) return forward(); - - return next({ - ...state, - draft: { - ...state.draft, - shared_location: location, - }, - }); - }, - }, -}); diff --git a/test/unit/LiveLocationManager.test.ts b/test/unit/LiveLocationManager.test.ts index 256791929..114810637 100644 --- a/test/unit/LiveLocationManager.test.ts +++ b/test/unit/LiveLocationManager.test.ts @@ -547,7 +547,9 @@ describe('LiveLocationManager', () => { }); await manager.init(); - expect(manager.messages).toHaveLength(1); + vi.waitFor(() => { + expect(manager.messages).toHaveLength(1); + }); client.dispatchEvent({ message: { id: liveLocation.message_id, diff --git a/test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts b/test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts index 6f6438963..81d932676 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts @@ -1,13 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; import { - createDraftSharedLocationCompositionMiddleware, createSharedLocationCompositionMiddleware, DraftResponse, LocalMessage, MessageComposer, MessageComposerMiddlewareState, - type MessageCompositionMiddleware, - MessageDraftComposerMiddlewareValueState, MiddlewareStatus, StreamChat, } from '../../../../../src'; @@ -139,65 +136,3 @@ describe('stream-io/message-composer-middleware/shared-location', () => { }); }); }); - -const setupDraftMiddlewareHandlerParams = ( - initialState: MessageDraftComposerMiddlewareValueState = {}, -) => { - return { - state: initialState, - next: async (state: MessageDraftComposerMiddlewareValueState) => ({ state }), - complete: async (state: MessageDraftComposerMiddlewareValueState) => ({ - state, - status: 'complete' as MiddlewareStatus, - }), - discard: async () => ({ state: initialState, status: 'discard' as MiddlewareStatus }), - forward: async () => ({ state: initialState }), - }; -}; - -describe('stream-io/message-composer-middleware/draft-shared-location', () => { - it('injects shared_location to localMessage and message payloads', async () => { - const { messageComposer } = setup(); - const middleware = createDraftSharedLocationCompositionMiddleware(messageComposer); - const coords = { latitude: 1, longitude: 1 }; - messageComposer.locationComposer.setData(coords); - const result = await middleware.handlers.compose(setupDraftMiddlewareHandlerParams()); - expect(result).toEqual({ - state: { - draft: { - shared_location: { - created_by_device_id: messageComposer.locationComposer.deviceId, - message_id: messageComposer.id, - ...coords, - }, - }, - }, - }); - }); - - it('does not inject shared_location to localMessage and message payloads if none is set', async () => { - const { messageComposer } = setup(); - const middleware = createDraftSharedLocationCompositionMiddleware(messageComposer); - const result = await middleware.handlers.compose(setupDraftMiddlewareHandlerParams()); - expect(result).toEqual({ - state: {}, - }); - }); - - it('does not inject shared_location to localMessage and message payloads if the location state is corrupted', async () => { - const { messageComposer } = setup(); - const middleware = createDraftSharedLocationCompositionMiddleware(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(setupDraftMiddlewareHandlerParams()); - expect(result).toEqual({ - state: {}, - }); - }); -}); From b24aca80b3b08096985c2afb089a97327a2f014f Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 22 Jul 2025 16:54:32 +0200 Subject: [PATCH 31/31] docs: clarify the current location sharing rules --- src/LiveLocationManager.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/LiveLocationManager.ts b/src/LiveLocationManager.ts index a8b19d272..49df7c157 100644 --- a/src/LiveLocationManager.ts +++ b/src/LiveLocationManager.ts @@ -2,9 +2,10 @@ * RULES: * * 1. one loc-sharing message per channel per user - * 2. mandatory geolocation_eol (maxnow + 24h max), which should be unchangeable by anyone (set once) - * 3. serialized object must be stored - * 4. live location is per-device, no other device which did not store the message locally, should be updating the live location attachment + * 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';