stream-chat 9.11.0 → 9.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/cjs/index.browser.cjs +437 -23
  2. package/dist/cjs/index.browser.cjs.map +4 -4
  3. package/dist/cjs/index.node.cjs +451 -30
  4. package/dist/cjs/index.node.cjs.map +4 -4
  5. package/dist/esm/index.js +437 -23
  6. package/dist/esm/index.js.map +4 -4
  7. package/dist/types/LiveLocationManager.d.ts +54 -0
  8. package/dist/types/channel.d.ts +3 -1
  9. package/dist/types/channel_manager.d.ts +5 -1
  10. package/dist/types/client.d.ts +14 -6
  11. package/dist/types/events.d.ts +2 -0
  12. package/dist/types/index.d.ts +1 -0
  13. package/dist/types/messageComposer/LocationComposer.d.ts +34 -0
  14. package/dist/types/messageComposer/attachmentIdentity.d.ts +2 -1
  15. package/dist/types/messageComposer/configuration/configuration.d.ts +2 -1
  16. package/dist/types/messageComposer/configuration/types.d.ts +11 -0
  17. package/dist/types/messageComposer/index.d.ts +1 -0
  18. package/dist/types/messageComposer/messageComposer.d.ts +7 -2
  19. package/dist/types/messageComposer/middleware/messageComposer/index.d.ts +1 -0
  20. package/dist/types/messageComposer/middleware/messageComposer/sharedLocation.d.ts +3 -0
  21. package/dist/types/types.d.ts +58 -5
  22. package/package.json +1 -1
  23. package/src/LiveLocationManager.ts +297 -0
  24. package/src/channel.ts +34 -0
  25. package/src/channel_manager.ts +33 -4
  26. package/src/client.ts +27 -5
  27. package/src/events.ts +2 -0
  28. package/src/index.ts +1 -0
  29. package/src/messageComposer/LocationComposer.ts +94 -0
  30. package/src/messageComposer/attachmentIdentity.ts +8 -1
  31. package/src/messageComposer/configuration/configuration.ts +8 -0
  32. package/src/messageComposer/configuration/types.ts +14 -0
  33. package/src/messageComposer/index.ts +1 -0
  34. package/src/messageComposer/messageComposer.ts +81 -9
  35. package/src/messageComposer/middleware/messageComposer/MessageComposerMiddlewareExecutor.ts +2 -0
  36. package/src/messageComposer/middleware/messageComposer/compositionValidation.ts +1 -5
  37. package/src/messageComposer/middleware/messageComposer/index.ts +1 -0
  38. package/src/messageComposer/middleware/messageComposer/sharedLocation.ts +42 -0
  39. package/src/offline-support/offline_sync_manager.ts +16 -0
  40. package/src/types.ts +63 -5
@@ -0,0 +1,297 @@
1
+ /**
2
+ * RULES:
3
+ *
4
+ * 1. one loc-sharing message per channel per user
5
+ * 2. live location is intended to be per device
6
+ * but created_by_device_id has currently no checks,
7
+ * and user can update the location from another device
8
+ * thus making location sharing based on user and channel
9
+ */
10
+
11
+ import { withCancellation } from './utils/concurrency';
12
+ import { StateStore } from './store';
13
+ import { WithSubscriptions } from './utils/WithSubscriptions';
14
+ import type { StreamChat } from './client';
15
+ import type { Unsubscribe } from './store';
16
+ import type {
17
+ EventTypes,
18
+ MessageResponse,
19
+ SharedLiveLocationResponse,
20
+ SharedLocationResponse,
21
+ } from './types';
22
+ import type { Coords } from './messageComposer';
23
+
24
+ export type WatchLocationHandler = (value: Coords) => void;
25
+ export type WatchLocation = (handler: WatchLocationHandler) => Unsubscribe;
26
+ type DeviceIdGenerator = () => string;
27
+ type MessageId = string;
28
+
29
+ export type ScheduledLiveLocationSharing = SharedLiveLocationResponse & {
30
+ stopSharingTimeout: ReturnType<typeof setTimeout> | null;
31
+ };
32
+
33
+ export type LiveLocationManagerState = {
34
+ ready: boolean;
35
+ messages: Map<MessageId, ScheduledLiveLocationSharing>;
36
+ };
37
+
38
+ const isExpiredLocation = (location: SharedLiveLocationResponse) => {
39
+ const endTimeTimestamp = new Date(location.end_at).getTime();
40
+
41
+ return endTimeTimestamp < Date.now();
42
+ };
43
+
44
+ function isValidLiveLocationMessage(
45
+ message?: MessageResponse,
46
+ ): message is MessageResponse & { shared_location: SharedLiveLocationResponse } {
47
+ if (!message || message.type === 'deleted' || !message.shared_location?.end_at)
48
+ return false;
49
+
50
+ return !isExpiredLocation(message.shared_location as SharedLiveLocationResponse);
51
+ }
52
+
53
+ export type LiveLocationManagerConstructorParameters = {
54
+ client: StreamChat;
55
+ getDeviceId: DeviceIdGenerator;
56
+ watchLocation: WatchLocation;
57
+ };
58
+
59
+ // Hard-coded minimal throttle timeout
60
+ export const UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT = 3000;
61
+
62
+ export class LiveLocationManager extends WithSubscriptions {
63
+ public state: StateStore<LiveLocationManagerState>;
64
+ private client: StreamChat;
65
+ private getDeviceId: DeviceIdGenerator;
66
+ private _deviceId: string;
67
+ private watchLocation: WatchLocation;
68
+
69
+ static symbol = Symbol(LiveLocationManager.name);
70
+
71
+ constructor({
72
+ client,
73
+ getDeviceId,
74
+ watchLocation,
75
+ }: LiveLocationManagerConstructorParameters) {
76
+ if (!client.userID) {
77
+ throw new Error('Live-location sharing is reserved for client-side use only');
78
+ }
79
+
80
+ super();
81
+
82
+ this.client = client;
83
+ this.state = new StateStore<LiveLocationManagerState>({
84
+ messages: new Map(),
85
+ ready: false,
86
+ });
87
+ this._deviceId = getDeviceId();
88
+ this.getDeviceId = getDeviceId;
89
+ this.watchLocation = watchLocation;
90
+ }
91
+
92
+ public async init() {
93
+ await this.assureStateInit();
94
+ this.registerSubscriptions();
95
+ }
96
+
97
+ public registerSubscriptions = () => {
98
+ this.incrementRefCount();
99
+ if (this.hasSubscriptions) return;
100
+
101
+ this.addUnsubscribeFunction(this.subscribeLiveLocationSharingUpdates());
102
+ this.addUnsubscribeFunction(this.subscribeTargetMessagesChange());
103
+ };
104
+
105
+ public unregisterSubscriptions = () => super.unregisterSubscriptions();
106
+
107
+ get messages() {
108
+ return this.state.getLatestValue().messages;
109
+ }
110
+
111
+ get stateIsReady() {
112
+ return this.state.getLatestValue().ready;
113
+ }
114
+
115
+ get deviceId() {
116
+ if (!this._deviceId) {
117
+ this._deviceId = this.getDeviceId();
118
+ }
119
+ return this._deviceId;
120
+ }
121
+
122
+ private async assureStateInit() {
123
+ if (this.stateIsReady) return;
124
+ const { active_live_locations } = await this.client.getSharedLocations();
125
+ this.state.next({
126
+ messages: new Map(
127
+ active_live_locations
128
+ .filter((location) => !isExpiredLocation(location))
129
+ .map((location) => [
130
+ location.message_id,
131
+ {
132
+ ...location,
133
+ stopSharingTimeout: setTimeout(
134
+ () => {
135
+ this.unregisterMessages([location.message_id]);
136
+ },
137
+ new Date(location.end_at).getTime() - Date.now(),
138
+ ),
139
+ },
140
+ ]),
141
+ ),
142
+ ready: true,
143
+ });
144
+ }
145
+
146
+ private subscribeTargetMessagesChange() {
147
+ let unsubscribeWatchLocation: null | (() => void) = null;
148
+
149
+ // Subscribe to location updates only if there are relevant messages to
150
+ // update, no need for the location watcher to be active/instantiated otherwise
151
+ const unsubscribe = this.state.subscribeWithSelector(
152
+ ({ messages }) => ({ messages }),
153
+ ({ messages }) => {
154
+ if (!messages.size) {
155
+ unsubscribeWatchLocation?.();
156
+ unsubscribeWatchLocation = null;
157
+ } else if (messages.size && !unsubscribeWatchLocation) {
158
+ unsubscribeWatchLocation = this.subscribeWatchLocation();
159
+ }
160
+ },
161
+ );
162
+
163
+ return () => {
164
+ unsubscribe();
165
+ unsubscribeWatchLocation?.();
166
+ };
167
+ }
168
+
169
+ private subscribeWatchLocation() {
170
+ let nextAllowedUpdateCallTimestamp = Date.now();
171
+
172
+ const unsubscribe = this.watchLocation(({ latitude, longitude }) => {
173
+ // Integrators can adjust the update interval by supplying custom watchLocation subscription,
174
+ // but the minimal timeout still has to be set as a failsafe (to prevent rate-limitting)
175
+ if (Date.now() < nextAllowedUpdateCallTimestamp) return;
176
+
177
+ nextAllowedUpdateCallTimestamp =
178
+ Date.now() + UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT;
179
+
180
+ withCancellation(LiveLocationManager.symbol, async () => {
181
+ const promises: Promise<SharedLocationResponse>[] = [];
182
+ await this.assureStateInit();
183
+ const expiredLocations: string[] = [];
184
+
185
+ for (const [messageId, location] of this.messages) {
186
+ if (isExpiredLocation(location)) {
187
+ expiredLocations.push(location.message_id);
188
+ continue;
189
+ }
190
+ if (location.latitude === latitude && location.longitude === longitude)
191
+ continue;
192
+ const promise = this.client.updateLocation({
193
+ created_by_device_id: location.created_by_device_id,
194
+ message_id: messageId,
195
+ latitude,
196
+ longitude,
197
+ });
198
+
199
+ promises.push(promise);
200
+ }
201
+ this.unregisterMessages(expiredLocations);
202
+ if (promises.length > 0) {
203
+ await Promise.allSettled(promises);
204
+ }
205
+ // TODO: handle values (remove failed - based on specific error code), keep re-trying others
206
+ });
207
+ });
208
+
209
+ return unsubscribe;
210
+ }
211
+
212
+ private subscribeLiveLocationSharingUpdates() {
213
+ /**
214
+ * Both message.updated & live_location_sharing.stopped get emitted when message gets an
215
+ * update, live_location_sharing.stopped gets emitted only locally and only if the update goes
216
+ * through, it's a failsafe for when channel is no longer being watched for whatever reason
217
+ */
218
+ const subscriptions = [
219
+ ...(
220
+ [
221
+ 'live_location_sharing.started',
222
+ 'message.updated',
223
+ 'message.deleted',
224
+ ] as EventTypes[]
225
+ ).map((eventType) =>
226
+ this.client.on(eventType, (event) => {
227
+ if (!event.message) return;
228
+
229
+ if (event.type === 'live_location_sharing.started') {
230
+ this.registerMessage(event.message);
231
+ } else if (event.type === 'message.updated') {
232
+ const isRegistered = this.messages.has(event.message.id);
233
+ if (isRegistered && !isValidLiveLocationMessage(event.message)) {
234
+ this.unregisterMessages([event.message.id]);
235
+ }
236
+ this.registerMessage(event.message);
237
+ } else {
238
+ this.unregisterMessages([event.message.id]);
239
+ }
240
+ }),
241
+ ),
242
+ this.client.on('live_location_sharing.stopped', (event) => {
243
+ if (!event.live_location) return;
244
+
245
+ this.unregisterMessages([event.live_location?.message_id]);
246
+ }),
247
+ ];
248
+
249
+ return () => subscriptions.forEach((subscription) => subscription.unsubscribe());
250
+ }
251
+
252
+ private registerMessage(message: MessageResponse) {
253
+ if (
254
+ !this.client.userID ||
255
+ message?.user?.id !== this.client.userID ||
256
+ !isValidLiveLocationMessage(message)
257
+ )
258
+ return;
259
+
260
+ this.state.next((currentValue) => {
261
+ const messages = new Map(currentValue.messages);
262
+ messages.set(message.id, {
263
+ ...message.shared_location,
264
+ stopSharingTimeout: setTimeout(
265
+ () => {
266
+ this.unregisterMessages([message.id]);
267
+ },
268
+ new Date(message.shared_location.end_at).getTime() - Date.now(),
269
+ ),
270
+ });
271
+ return {
272
+ ...currentValue,
273
+ messages,
274
+ };
275
+ });
276
+ }
277
+
278
+ private unregisterMessages(messageIds: string[]) {
279
+ const messages = this.messages;
280
+ const removedMessages = new Set(messageIds);
281
+ const newMessages = new Map(
282
+ Array.from(messages).filter(([messageId, location]) => {
283
+ if (removedMessages.has(messageId) && location.stopSharingTimeout) {
284
+ clearTimeout(location.stopSharingTimeout);
285
+ location.stopSharingTimeout = null;
286
+ }
287
+ return !removedMessages.has(messageId);
288
+ }),
289
+ );
290
+
291
+ if (newMessages.size === messages.size) return;
292
+
293
+ this.state.partialNext({
294
+ messages: newMessages,
295
+ });
296
+ }
297
+ }
package/src/channel.ts CHANGED
@@ -31,6 +31,7 @@ import type {
31
31
  GetMultipleMessagesAPIResponse,
32
32
  GetReactionsAPIResponse,
33
33
  GetRepliesAPIResponse,
34
+ LiveLocationPayload,
34
35
  LocalMessage,
35
36
  MarkReadOptions,
36
37
  MarkUnreadOptions,
@@ -63,10 +64,12 @@ import type {
63
64
  SendMessageAPIResponse,
64
65
  SendMessageOptions,
65
66
  SendReactionOptions,
67
+ StaticLocationPayload,
66
68
  TruncateChannelAPIResponse,
67
69
  TruncateOptions,
68
70
  UpdateChannelAPIResponse,
69
71
  UpdateChannelOptions,
72
+ UpdateLocationPayload,
70
73
  UserResponse,
71
74
  } from './types';
72
75
  import type { Role } from './permissions';
@@ -669,6 +672,37 @@ export class Channel {
669
672
  return data;
670
673
  }
671
674
 
675
+ public async sendSharedLocation(
676
+ location: StaticLocationPayload | LiveLocationPayload,
677
+ userId?: string,
678
+ ) {
679
+ const result = await this.sendMessage({
680
+ id: location.message_id,
681
+ shared_location: location,
682
+ user: userId ? { id: userId } : undefined,
683
+ });
684
+
685
+ if ((location as LiveLocationPayload).end_at) {
686
+ this.getClient().dispatchEvent({
687
+ message: result.message,
688
+ type: 'live_location_sharing.started',
689
+ });
690
+ }
691
+
692
+ return result;
693
+ }
694
+
695
+ public async stopLiveLocationSharing(payload: UpdateLocationPayload) {
696
+ const location = await this.getClient().updateLocation({
697
+ ...payload,
698
+ end_at: new Date().toISOString(),
699
+ });
700
+ this.getClient().dispatchEvent({
701
+ live_location: location,
702
+ type: 'live_location_sharing.stopped',
703
+ });
704
+ }
705
+
672
706
  /**
673
707
  * delete - Delete the channel. Messages are permanently removed.
674
708
  *
@@ -133,6 +133,10 @@ export type ChannelManagerOptions = {
133
133
  lockChannelOrder?: boolean;
134
134
  };
135
135
 
136
+ export type QueryChannelsRequestType = (
137
+ ...params: Parameters<StreamChat['queryChannels']>
138
+ ) => Promise<Channel[]>;
139
+
136
140
  export const DEFAULT_CHANNEL_MANAGER_OPTIONS = {
137
141
  abortInFlightQuery: false,
138
142
  allowNotLoadedChannelPromotionForEvent: {
@@ -160,6 +164,7 @@ export class ChannelManager extends WithSubscriptions {
160
164
  private client: StreamChat;
161
165
  private eventHandlers: Map<string, EventHandlerType> = new Map();
162
166
  private eventHandlerOverrides: Map<string, EventHandlerOverrideType> = new Map();
167
+ private queryChannelsRequest: QueryChannelsRequestType;
163
168
  private options: ChannelManagerOptions = {};
164
169
  private stateOptions: ChannelStateOptions = {};
165
170
  private id: string;
@@ -168,10 +173,12 @@ export class ChannelManager extends WithSubscriptions {
168
173
  client,
169
174
  eventHandlerOverrides = {},
170
175
  options = {},
176
+ queryChannelsOverride,
171
177
  }: {
172
178
  client: StreamChat;
173
179
  eventHandlerOverrides?: ChannelManagerEventHandlerOverrides;
174
180
  options?: ChannelManagerOptions;
181
+ queryChannelsOverride?: QueryChannelsRequestType;
175
182
  }) {
176
183
  super();
177
184
 
@@ -192,6 +199,8 @@ export class ChannelManager extends WithSubscriptions {
192
199
  });
193
200
  this.setEventHandlerOverrides(eventHandlerOverrides);
194
201
  this.setOptions(options);
202
+ this.queryChannelsRequest =
203
+ queryChannelsOverride ?? ((...params) => this.client.queryChannels(...params));
195
204
  this.eventHandlers = new Map(
196
205
  Object.entries<EventHandlerType>({
197
206
  channelDeletedHandler: this.channelDeletedHandler,
@@ -252,6 +261,10 @@ export class ChannelManager extends WithSubscriptions {
252
261
  );
253
262
  };
254
263
 
264
+ public setQueryChannelsRequest = (queryChannelsRequest: QueryChannelsRequestType) => {
265
+ this.queryChannelsRequest = queryChannelsRequest;
266
+ };
267
+
255
268
  public setOptions = (options: ChannelManagerOptions = {}) => {
256
269
  this.options = { ...DEFAULT_CHANNEL_MANAGER_OPTIONS, ...options };
257
270
  };
@@ -266,7 +279,7 @@ export class ChannelManager extends WithSubscriptions {
266
279
  ...options,
267
280
  };
268
281
  try {
269
- const channels = await this.client.queryChannels(
282
+ const channels = await this.queryChannelsRequest(
270
283
  filters,
271
284
  sort,
272
285
  options,
@@ -304,7 +317,19 @@ export class ChannelManager extends WithSubscriptions {
304
317
  `Maximum number of retries reached in queryChannels. Last error message is: ${err}`,
305
318
  );
306
319
 
307
- this.state.partialNext({ error: wrappedError });
320
+ const state = this.state.getLatestValue();
321
+ // If the offline support is enabled, and there are channels in the DB, we should not error out.
322
+ const isOfflineSupportEnabledWithChannels =
323
+ this.client.offlineDb && state.channels.length > 0;
324
+
325
+ this.state.partialNext({
326
+ error: isOfflineSupportEnabledWithChannels ? undefined : wrappedError,
327
+ pagination: {
328
+ ...state.pagination,
329
+ isLoading: false,
330
+ isLoadingNext: false,
331
+ },
332
+ });
308
333
  return;
309
334
  }
310
335
 
@@ -407,7 +432,7 @@ export class ChannelManager extends WithSubscriptions {
407
432
  this.state.partialNext({
408
433
  pagination: { ...pagination, isLoading: false, isLoadingNext: true },
409
434
  });
410
- const nextChannels = await this.client.queryChannels(
435
+ const nextChannels = await this.queryChannelsRequest(
411
436
  filters,
412
437
  sort,
413
438
  options,
@@ -431,7 +456,11 @@ export class ChannelManager extends WithSubscriptions {
431
456
  this.client.logger('error', (error as Error).message);
432
457
  this.state.next((currentState) => ({
433
458
  ...currentState,
434
- pagination: { ...currentState.pagination, isLoadingNext: false },
459
+ pagination: {
460
+ ...currentState.pagination,
461
+ isLoadingNext: false,
462
+ isLoading: false,
463
+ },
435
464
  }));
436
465
  throw error;
437
466
  }
package/src/client.ts CHANGED
@@ -106,6 +106,7 @@ import type {
106
106
  GetCampaignOptions,
107
107
  GetChannelTypeResponse,
108
108
  GetCommandResponse,
109
+ GetHookEventsResponse,
109
110
  GetImportResponse,
110
111
  GetMessageAPIResponse,
111
112
  GetMessageOptions,
@@ -148,6 +149,7 @@ import type {
148
149
  PollVote,
149
150
  PollVoteData,
150
151
  PollVotesAPIResponse,
152
+ Product,
151
153
  PushPreference,
152
154
  PushProvider,
153
155
  PushProviderConfig,
@@ -191,7 +193,6 @@ import type {
191
193
  SegmentTargetsResponse,
192
194
  SegmentType,
193
195
  SendFileAPIResponse,
194
- SharedLocationRequest,
195
196
  SharedLocationResponse,
196
197
  SortParam,
197
198
  StreamChatOptions,
@@ -209,6 +210,7 @@ import type {
209
210
  UpdateChannelTypeResponse,
210
211
  UpdateCommandOptions,
211
212
  UpdateCommandResponse,
213
+ UpdateLocationPayload,
212
214
  UpdateMessageAPIResponse,
213
215
  UpdateMessageOptions,
214
216
  UpdatePollAPIResponse,
@@ -233,6 +235,7 @@ import { PollManager } from './poll_manager';
233
235
  import type {
234
236
  ChannelManagerEventHandlerOverrides,
235
237
  ChannelManagerOptions,
238
+ QueryChannelsRequestType,
236
239
  } from './channel_manager';
237
240
  import { ChannelManager } from './channel_manager';
238
241
  import { NotificationManager } from './notifications';
@@ -720,10 +723,18 @@ export class StreamChat {
720
723
  createChannelManager = ({
721
724
  eventHandlerOverrides = {},
722
725
  options = {},
726
+ queryChannelsOverride,
723
727
  }: {
724
728
  eventHandlerOverrides?: ChannelManagerEventHandlerOverrides;
725
729
  options?: ChannelManagerOptions;
726
- }) => new ChannelManager({ client: this, eventHandlerOverrides, options });
730
+ queryChannelsOverride?: QueryChannelsRequestType;
731
+ }) =>
732
+ new ChannelManager({
733
+ client: this,
734
+ eventHandlerOverrides,
735
+ options,
736
+ queryChannelsOverride,
737
+ });
727
738
 
728
739
  /**
729
740
  * Creates a new WebSocket connection with the current user. Returns empty promise, if there is an active connection
@@ -2155,6 +2166,17 @@ export class StreamChat {
2155
2166
  });
2156
2167
  }
2157
2168
 
2169
+ /**
2170
+ * getHookEvents - Get available events for hooks (webhook, SQS, and SNS)
2171
+ *
2172
+ * @param {Product[]} [products] Optional array of products to filter events by (e.g., [Product.Chat, Product.Video])
2173
+ * @returns {Promise<GetHookEventsResponse>} Response containing available hook events
2174
+ */
2175
+ async getHookEvents(products?: Product[]) {
2176
+ const params = products && products.length > 0 ? { product: products.join(',') } : {};
2177
+ return await this.get<GetHookEventsResponse>(this.baseURL + '/hook/events', params);
2178
+ }
2179
+
2158
2180
  _addChannelConfig({ cid, config }: ChannelResponse) {
2159
2181
  if (this._cacheEnabled()) {
2160
2182
  this.configs[cid] = config;
@@ -4584,11 +4606,11 @@ export class StreamChat {
4584
4606
  /**
4585
4607
  * updateLocation - Updates a location
4586
4608
  *
4587
- * @param location UserLocation the location data to update
4609
+ * @param location SharedLocationRequest the location data to update
4588
4610
  *
4589
- * @returns {Promise<APIResponse>} The server response
4611
+ * @returns {Promise<SharedLocationResponse>} The server response
4590
4612
  */
4591
- async updateLocation(location: SharedLocationRequest) {
4613
+ async updateLocation(location: UpdateLocationPayload) {
4592
4614
  return await this.put<SharedLocationResponse>(
4593
4615
  this.baseURL + `/users/live_locations`,
4594
4616
  location,
package/src/events.ts CHANGED
@@ -63,6 +63,8 @@ export const EVENT_MAP = {
63
63
  'connection.recovered': true,
64
64
  'transport.changed': true,
65
65
  'capabilities.changed': true,
66
+ 'live_location_sharing.started': true,
67
+ 'live_location_sharing.stopped': true,
66
68
 
67
69
  // Reminder events
68
70
  'reminder.created': true,
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export * from './token_manager';
32
32
  export * from './types';
33
33
  export * from './channel_manager';
34
34
  export * from './offline-support';
35
+ export * from './LiveLocationManager';
35
36
  // Don't use * here, that can break module augmentation https://github.com/microsoft/TypeScript/issues/46617
36
37
  export type {
37
38
  CustomAttachmentData,
@@ -0,0 +1,94 @@
1
+ import { StateStore } from '../store';
2
+ import type { MessageComposer } from './messageComposer';
3
+ import type {
4
+ DraftMessage,
5
+ LiveLocationPayload,
6
+ LocalMessage,
7
+ StaticLocationPayload,
8
+ } from '../types';
9
+
10
+ export type Coords = { latitude: number; longitude: number };
11
+
12
+ export type LocationComposerOptions = {
13
+ composer: MessageComposer;
14
+ message?: DraftMessage | LocalMessage;
15
+ };
16
+
17
+ export type StaticLocationPreview = StaticLocationPayload;
18
+
19
+ export type LiveLocationPreview = Omit<LiveLocationPayload, 'end_at'> & {
20
+ durationMs?: number;
21
+ };
22
+
23
+ export type LocationComposerState = {
24
+ location: StaticLocationPreview | LiveLocationPreview | null;
25
+ };
26
+
27
+ const MIN_LIVE_LOCATION_SHARE_DURATION = 60 * 1000; // 1 minute;
28
+
29
+ const initState = ({
30
+ message,
31
+ }: {
32
+ message?: DraftMessage | LocalMessage;
33
+ }): LocationComposerState => ({
34
+ location: message?.shared_location ?? null,
35
+ });
36
+
37
+ export class LocationComposer {
38
+ readonly state: StateStore<LocationComposerState>;
39
+ readonly composer: MessageComposer;
40
+ private _deviceId: string;
41
+
42
+ constructor({ composer, message }: LocationComposerOptions) {
43
+ this.composer = composer;
44
+ this.state = new StateStore<LocationComposerState>(initState({ message }));
45
+ this._deviceId = this.config.getDeviceId();
46
+ }
47
+
48
+ get config() {
49
+ return this.composer.config.location;
50
+ }
51
+
52
+ get deviceId() {
53
+ return this._deviceId;
54
+ }
55
+
56
+ get location() {
57
+ return this.state.getLatestValue().location;
58
+ }
59
+
60
+ get validLocation(): StaticLocationPayload | LiveLocationPayload | null {
61
+ const { durationMs, ...location } = (this.location ?? {}) as LiveLocationPreview;
62
+ if (
63
+ !!location?.created_by_device_id &&
64
+ location.message_id &&
65
+ location.latitude &&
66
+ location.longitude &&
67
+ (typeof durationMs === 'undefined' ||
68
+ durationMs >= MIN_LIVE_LOCATION_SHARE_DURATION)
69
+ ) {
70
+ return {
71
+ ...location,
72
+ end_at: durationMs && new Date(Date.now() + durationMs).toISOString(),
73
+ } as StaticLocationPayload | LiveLocationPayload;
74
+ }
75
+ return null;
76
+ }
77
+
78
+ initState = ({ message }: { message?: DraftMessage | LocalMessage } = {}) => {
79
+ this.state.next(initState({ message }));
80
+ };
81
+
82
+ setData = (data: { durationMs?: number } & Coords) => {
83
+ if (!this.config.enabled) return;
84
+ if (!data.latitude || !data.longitude) return;
85
+
86
+ this.state.partialNext({
87
+ location: {
88
+ ...data,
89
+ message_id: this.composer.id,
90
+ created_by_device_id: this.deviceId,
91
+ },
92
+ });
93
+ };
94
+ }
@@ -1,4 +1,4 @@
1
- import type { Attachment } from '../types';
1
+ import type { Attachment, SharedLocationResponse } from '../types';
2
2
  import type {
3
3
  AudioAttachment,
4
4
  FileAttachment,
@@ -90,3 +90,10 @@ export const isUploadedAttachment = (
90
90
  isImageAttachment(attachment) ||
91
91
  isVideoAttachment(attachment) ||
92
92
  isVoiceRecordingAttachment(attachment);
93
+
94
+ export const isSharedLocationResponse = (
95
+ location: unknown,
96
+ ): location is SharedLocationResponse =>
97
+ !!(location as SharedLocationResponse).latitude &&
98
+ !!(location as SharedLocationResponse).longitude &&
99
+ !!(location as SharedLocationResponse).channel_cid;
@@ -3,9 +3,11 @@ import { API_MAX_FILES_ALLOWED_PER_MESSAGE } from '../../constants';
3
3
  import type {
4
4
  AttachmentManagerConfig,
5
5
  LinkPreviewsManagerConfig,
6
+ LocationComposerConfig,
6
7
  MessageComposerConfig,
7
8
  } from './types';
8
9
  import type { TextComposerConfig } from './types';
10
+ import { generateUUIDv4 } from '../../utils';
9
11
 
10
12
  export const DEFAULT_LINK_PREVIEW_MANAGER_CONFIG: LinkPreviewsManagerConfig = {
11
13
  debounceURLEnrichmentMs: 1500,
@@ -36,9 +38,15 @@ export const DEFAULT_TEXT_COMPOSER_CONFIG: TextComposerConfig = {
36
38
  publishTypingEvents: true,
37
39
  };
38
40
 
41
+ export const DEFAULT_LOCATION_COMPOSER_CONFIG: LocationComposerConfig = {
42
+ enabled: true,
43
+ getDeviceId: () => generateUUIDv4(),
44
+ };
45
+
39
46
  export const DEFAULT_COMPOSER_CONFIG: MessageComposerConfig = {
40
47
  attachments: DEFAULT_ATTACHMENT_MANAGER_CONFIG,
41
48
  drafts: { enabled: false },
42
49
  linkPreviews: DEFAULT_LINK_PREVIEW_MANAGER_CONFIG,
50
+ location: DEFAULT_LOCATION_COMPOSER_CONFIG,
43
51
  text: DEFAULT_TEXT_COMPOSER_CONFIG,
44
52
  };