stream-chat 8.41.0 → 8.42.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.
package/src/poll.ts ADDED
@@ -0,0 +1,414 @@
1
+ import { StateStore } from './store';
2
+ import type { StreamChat } from './client';
3
+ import type {
4
+ DefaultGenerics,
5
+ Event,
6
+ ExtendableGenerics,
7
+ PartialPollUpdate,
8
+ PollAnswer,
9
+ PollData,
10
+ PollEnrichData,
11
+ PollOptionData,
12
+ PollResponse,
13
+ PollVote,
14
+ QueryVotesFilters,
15
+ QueryVotesOptions,
16
+ VoteSort,
17
+ } from './types';
18
+
19
+ type PollEvent<SCG extends ExtendableGenerics = DefaultGenerics> = {
20
+ cid: string;
21
+ created_at: string;
22
+ poll: PollResponse<SCG>;
23
+ };
24
+
25
+ type PollUpdatedEvent<SCG extends ExtendableGenerics = DefaultGenerics> = PollEvent<SCG> & {
26
+ type: 'poll.updated';
27
+ };
28
+
29
+ type PollClosedEvent<SCG extends ExtendableGenerics = DefaultGenerics> = PollEvent<SCG> & {
30
+ type: 'poll.closed';
31
+ };
32
+
33
+ type PollVoteEvent<SCG extends ExtendableGenerics = DefaultGenerics> = {
34
+ cid: string;
35
+ created_at: string;
36
+ poll: PollResponse<SCG>;
37
+ poll_vote: PollVote<SCG> | PollAnswer<SCG>;
38
+ };
39
+
40
+ type PollVoteCastedEvent<SCG extends ExtendableGenerics = DefaultGenerics> = PollVoteEvent<SCG> & {
41
+ type: 'poll.vote_casted';
42
+ };
43
+
44
+ type PollVoteCastedChanged<SCG extends ExtendableGenerics = DefaultGenerics> = PollVoteEvent<SCG> & {
45
+ type: 'poll.vote_removed';
46
+ };
47
+
48
+ type PollVoteCastedRemoved<SCG extends ExtendableGenerics = DefaultGenerics> = PollVoteEvent<SCG> & {
49
+ type: 'poll.vote_removed';
50
+ };
51
+
52
+ const isPollUpdatedEvent = <SCG extends ExtendableGenerics = DefaultGenerics>(
53
+ e: Event<SCG>,
54
+ ): e is PollUpdatedEvent<SCG> => e.type === 'poll.updated';
55
+ const isPollClosedEventEvent = <SCG extends ExtendableGenerics = DefaultGenerics>(
56
+ e: Event<SCG>,
57
+ ): e is PollClosedEvent<SCG> => e.type === 'poll.closed';
58
+ const isPollVoteCastedEvent = <SCG extends ExtendableGenerics = DefaultGenerics>(
59
+ e: Event<SCG>,
60
+ ): e is PollVoteCastedEvent<SCG> => e.type === 'poll.vote_casted';
61
+ const isPollVoteChangedEvent = <SCG extends ExtendableGenerics = DefaultGenerics>(
62
+ e: Event<SCG>,
63
+ ): e is PollVoteCastedChanged<SCG> => e.type === 'poll.vote_changed';
64
+ const isPollVoteRemovedEvent = <SCG extends ExtendableGenerics = DefaultGenerics>(
65
+ e: Event<SCG>,
66
+ ): e is PollVoteCastedRemoved<SCG> => e.type === 'poll.vote_removed';
67
+
68
+ export const isVoteAnswer = <SCG extends ExtendableGenerics = DefaultGenerics>(
69
+ vote: PollVote<SCG> | PollAnswer<SCG>,
70
+ ): vote is PollAnswer<SCG> => !!(vote as PollAnswer<SCG>)?.answer_text;
71
+
72
+ export type PollAnswersQueryParams = {
73
+ filter?: QueryVotesFilters;
74
+ options?: QueryVotesOptions;
75
+ sort?: VoteSort;
76
+ };
77
+
78
+ export type PollOptionVotesQueryParams = {
79
+ filter: { option_id: string } & QueryVotesFilters;
80
+ options?: QueryVotesOptions;
81
+ sort?: VoteSort;
82
+ };
83
+
84
+ type OptionId = string;
85
+
86
+ export type PollState<SCG extends ExtendableGenerics = DefaultGenerics> = SCG['pollType'] &
87
+ Omit<PollResponse<SCG>, 'own_votes' | 'id'> & {
88
+ lastActivityAt: Date; // todo: would be ideal to get this from the BE
89
+ maxVotedOptionIds: OptionId[];
90
+ ownVotesByOptionId: Record<OptionId, PollVote<SCG>>;
91
+ ownAnswer?: PollAnswer; // each user can have only one answer
92
+ };
93
+
94
+ type PollInitOptions<SCG extends ExtendableGenerics = DefaultGenerics> = {
95
+ client: StreamChat<SCG>;
96
+ poll: PollResponse<SCG>;
97
+ };
98
+
99
+ export class Poll<SCG extends ExtendableGenerics = DefaultGenerics> {
100
+ public readonly state: StateStore<PollState<SCG>>;
101
+ public id: string;
102
+ private client: StreamChat<SCG>;
103
+ private unsubscribeFunctions: Set<() => void> = new Set();
104
+
105
+ constructor({ client, poll }: PollInitOptions<SCG>) {
106
+ this.client = client;
107
+ this.id = poll.id;
108
+
109
+ this.state = new StateStore<PollState<SCG>>(this.getInitialStateFromPollResponse(poll));
110
+ }
111
+
112
+ private getInitialStateFromPollResponse = (poll: PollInitOptions<SCG>['poll']) => {
113
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
114
+ const { own_votes, id, ...pollResponseForState } = poll;
115
+ const { ownAnswer, ownVotes } = own_votes?.reduce<{ ownVotes: PollVote<SCG>[]; ownAnswer?: PollAnswer }>(
116
+ (acc, voteOrAnswer) => {
117
+ if (isVoteAnswer(voteOrAnswer)) {
118
+ acc.ownAnswer = voteOrAnswer;
119
+ } else {
120
+ acc.ownVotes.push(voteOrAnswer);
121
+ }
122
+ return acc;
123
+ },
124
+ { ownVotes: [] },
125
+ ) ?? { ownVotes: [] };
126
+
127
+ return {
128
+ ...pollResponseForState,
129
+ lastActivityAt: new Date(),
130
+ maxVotedOptionIds: getMaxVotedOptionIds(
131
+ pollResponseForState.vote_counts_by_option as PollResponse<SCG>['vote_counts_by_option'],
132
+ ),
133
+ ownAnswer,
134
+ ownVotesByOptionId: getOwnVotesByOptionId(ownVotes),
135
+ };
136
+ };
137
+
138
+ public reinitializeState = (poll: PollInitOptions<SCG>['poll']) => {
139
+ this.state.partialNext(this.getInitialStateFromPollResponse(poll));
140
+ };
141
+
142
+ get data(): PollState<SCG> {
143
+ return this.state.getLatestValue();
144
+ }
145
+
146
+ public handlePollUpdated = (event: Event<SCG>) => {
147
+ if (event.poll?.id && event.poll.id !== this.id) return;
148
+ if (!isPollUpdatedEvent(event)) return;
149
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
150
+ const { id, ...pollData } = extractPollData(event.poll);
151
+ // @ts-ignore
152
+ this.state.partialNext({ ...pollData, lastActivityAt: new Date(event.created_at) });
153
+ };
154
+
155
+ public handlePollClosed = (event: Event<SCG>) => {
156
+ if (event.poll?.id && event.poll.id !== this.id) return;
157
+ if (!isPollClosedEventEvent(event)) return;
158
+ // @ts-ignore
159
+ this.state.partialNext({ is_closed: true, lastActivityAt: new Date(event.created_at) });
160
+ };
161
+
162
+ public handleVoteCasted = (event: Event<SCG>) => {
163
+ if (event.poll?.id && event.poll.id !== this.id) return;
164
+ if (!isPollVoteCastedEvent(event)) return;
165
+ const currentState = this.data;
166
+ const isOwnVote = event.poll_vote.user_id === this.client.userID;
167
+ let latestAnswers = [...(currentState.latest_answers as PollAnswer[])];
168
+ let ownAnswer = currentState.ownAnswer;
169
+ const ownVotesByOptionId = currentState.ownVotesByOptionId;
170
+ let maxVotedOptionIds = currentState.maxVotedOptionIds;
171
+
172
+ if (isOwnVote) {
173
+ if (isVoteAnswer(event.poll_vote)) {
174
+ ownAnswer = event.poll_vote;
175
+ } else if (event.poll_vote.option_id) {
176
+ ownVotesByOptionId[event.poll_vote.option_id] = event.poll_vote;
177
+ }
178
+ }
179
+
180
+ if (isVoteAnswer(event.poll_vote)) {
181
+ latestAnswers = [event.poll_vote, ...latestAnswers];
182
+ } else {
183
+ maxVotedOptionIds = getMaxVotedOptionIds(event.poll.vote_counts_by_option);
184
+ }
185
+
186
+ const pollEnrichData = extractPollEnrichedData(event.poll);
187
+ // @ts-ignore
188
+ this.state.partialNext({
189
+ ...pollEnrichData,
190
+ latest_answers: latestAnswers,
191
+ lastActivityAt: new Date(event.created_at),
192
+ ownAnswer,
193
+ ownVotesByOptionId,
194
+ maxVotedOptionIds,
195
+ });
196
+ };
197
+
198
+ public handleVoteChanged = (event: Event<SCG>) => {
199
+ // this event is triggered only when event.poll.enforce_unique_vote === true
200
+ if (event.poll?.id && event.poll.id !== this.id) return;
201
+ if (!isPollVoteChangedEvent(event)) return;
202
+ const currentState = this.data;
203
+ const isOwnVote = event.poll_vote.user_id === this.client.userID;
204
+ let latestAnswers = [...(currentState.latest_answers as PollAnswer[])];
205
+ let ownAnswer = currentState.ownAnswer;
206
+ let ownVotesByOptionId = currentState.ownVotesByOptionId;
207
+ let maxVotedOptionIds = currentState.maxVotedOptionIds;
208
+
209
+ if (isOwnVote) {
210
+ if (isVoteAnswer(event.poll_vote)) {
211
+ latestAnswers = [event.poll_vote, ...latestAnswers.filter((answer) => answer.id !== event.poll_vote.id)];
212
+ ownAnswer = event.poll_vote;
213
+ } else if (event.poll_vote.option_id) {
214
+ if (event.poll.enforce_unique_votes) {
215
+ ownVotesByOptionId = { [event.poll_vote.option_id]: event.poll_vote };
216
+ } else {
217
+ ownVotesByOptionId = Object.entries(ownVotesByOptionId).reduce<Record<OptionId, PollVote<SCG>>>(
218
+ (acc, [optionId, vote]) => {
219
+ if (optionId !== event.poll_vote.option_id && vote.id === event.poll_vote.id) {
220
+ return acc;
221
+ }
222
+ acc[optionId] = vote;
223
+ return acc;
224
+ },
225
+ {},
226
+ );
227
+ ownVotesByOptionId[event.poll_vote.option_id] = event.poll_vote;
228
+ }
229
+
230
+ if (ownAnswer?.id === event.poll_vote.id) {
231
+ ownAnswer = undefined;
232
+ }
233
+ maxVotedOptionIds = getMaxVotedOptionIds(event.poll.vote_counts_by_option);
234
+ }
235
+ } else if (isVoteAnswer(event.poll_vote)) {
236
+ latestAnswers = [event.poll_vote, ...latestAnswers];
237
+ } else {
238
+ maxVotedOptionIds = getMaxVotedOptionIds(event.poll.vote_counts_by_option);
239
+ }
240
+
241
+ const pollEnrichData = extractPollEnrichedData(event.poll);
242
+ // @ts-ignore
243
+ this.state.partialNext({
244
+ ...pollEnrichData,
245
+ latest_answers: latestAnswers,
246
+ lastActivityAt: new Date(event.created_at),
247
+ ownAnswer,
248
+ ownVotesByOptionId,
249
+ maxVotedOptionIds,
250
+ });
251
+ };
252
+
253
+ public handleVoteRemoved = (event: Event<SCG>) => {
254
+ if (event.poll?.id && event.poll.id !== this.id) return;
255
+ if (!isPollVoteRemovedEvent(event)) return;
256
+ const currentState = this.data;
257
+ const isOwnVote = event.poll_vote.user_id === this.client.userID;
258
+ let latestAnswers = [...(currentState.latest_answers as PollAnswer[])];
259
+ let ownAnswer = currentState.ownAnswer;
260
+ const ownVotesByOptionId = { ...currentState.ownVotesByOptionId };
261
+ let maxVotedOptionIds = currentState.maxVotedOptionIds;
262
+
263
+ if (isVoteAnswer(event.poll_vote)) {
264
+ latestAnswers = latestAnswers.filter((answer) => answer.id !== event.poll_vote.id);
265
+ if (isOwnVote) {
266
+ ownAnswer = undefined;
267
+ }
268
+ } else {
269
+ maxVotedOptionIds = getMaxVotedOptionIds(event.poll.vote_counts_by_option);
270
+ if (isOwnVote && event.poll_vote.option_id) {
271
+ delete ownVotesByOptionId[event.poll_vote.option_id];
272
+ }
273
+ }
274
+
275
+ const pollEnrichData = extractPollEnrichedData(event.poll);
276
+ // @ts-ignore
277
+ this.state.partialNext({
278
+ ...pollEnrichData,
279
+ latest_answers: latestAnswers,
280
+ lastActivityAt: new Date(event.created_at),
281
+ ownAnswer,
282
+ ownVotesByOptionId,
283
+ maxVotedOptionIds,
284
+ });
285
+ };
286
+
287
+ query = async (id: string) => {
288
+ const { poll } = await this.client.getPoll(id);
289
+ // @ts-ignore
290
+ this.state.partialNext({ ...poll, lastActivityAt: new Date() });
291
+ return poll;
292
+ };
293
+
294
+ update = async (data: Exclude<PollData<SCG>, 'id'>) => {
295
+ return await this.client.updatePoll({ ...data, id: this.id });
296
+ };
297
+
298
+ partialUpdate = async (partialPollObject: PartialPollUpdate<SCG>) => {
299
+ return await this.client.partialUpdatePoll(this.id as string, partialPollObject);
300
+ };
301
+
302
+ close = async () => {
303
+ return await this.client.closePoll(this.id as string);
304
+ };
305
+
306
+ delete = async () => {
307
+ return await this.client.deletePoll(this.id as string);
308
+ };
309
+
310
+ createOption = async (option: PollOptionData) => {
311
+ return await this.client.createPollOption(this.id as string, option);
312
+ };
313
+
314
+ updateOption = async (option: PollOptionData) => {
315
+ return await this.client.updatePollOption(this.id as string, option);
316
+ };
317
+
318
+ deleteOption = async (optionId: string) => {
319
+ return await this.client.deletePollOption(this.id as string, optionId);
320
+ };
321
+
322
+ castVote = async (optionId: string, messageId: string) => {
323
+ const { max_votes_allowed, ownVotesByOptionId } = this.data;
324
+
325
+ const reachedVoteLimit = max_votes_allowed && max_votes_allowed === Object.keys(ownVotesByOptionId).length;
326
+
327
+ if (reachedVoteLimit) {
328
+ let oldestVote = Object.values(ownVotesByOptionId)[0];
329
+ Object.values(ownVotesByOptionId)
330
+ .slice(1)
331
+ .forEach((vote) => {
332
+ if (!oldestVote?.created_at || new Date(vote.created_at) < new Date(oldestVote.created_at)) {
333
+ oldestVote = vote;
334
+ }
335
+ });
336
+ if (oldestVote?.id) {
337
+ await this.removeVote(oldestVote.id, messageId);
338
+ }
339
+ }
340
+ return await this.client.castPollVote(messageId, this.id as string, { option_id: optionId });
341
+ };
342
+
343
+ removeVote = async (voteId: string, messageId: string) => {
344
+ return await this.client.removePollVote(messageId, this.id as string, voteId);
345
+ };
346
+
347
+ addAnswer = async (answerText: string, messageId: string) => {
348
+ return await this.client.addPollAnswer(messageId, this.id as string, answerText);
349
+ };
350
+
351
+ removeAnswer = async (answerId: string, messageId: string) => {
352
+ return await this.client.removePollVote(messageId, this.id as string, answerId);
353
+ };
354
+
355
+ queryAnswers = async (params: PollAnswersQueryParams) => {
356
+ return await this.client.queryPollAnswers(this.id as string, params.filter, params.sort, params.options);
357
+ };
358
+
359
+ queryOptionVotes = async (params: PollOptionVotesQueryParams) => {
360
+ return await this.client.queryPollVotes(this.id as string, params.filter, params.sort, params.options);
361
+ };
362
+ }
363
+
364
+ function getMaxVotedOptionIds(voteCountsByOption: PollResponse['vote_counts_by_option']) {
365
+ let maxVotes = 0;
366
+ let winningOptions: string[] = [];
367
+ for (const [id, count] of Object.entries(voteCountsByOption ?? {})) {
368
+ if (count > maxVotes) {
369
+ winningOptions = [id];
370
+ maxVotes = count;
371
+ } else if (count === maxVotes) {
372
+ winningOptions.push(id);
373
+ }
374
+ }
375
+ return winningOptions;
376
+ }
377
+
378
+ function getOwnVotesByOptionId<SCG extends ExtendableGenerics = DefaultGenerics>(ownVotes: PollVote<SCG>[]) {
379
+ return !ownVotes
380
+ ? ({} as Record<OptionId, PollVote<SCG>>)
381
+ : ownVotes.reduce<Record<OptionId, PollVote<SCG>>>((acc, vote) => {
382
+ if (isVoteAnswer(vote) || !vote.option_id) return acc;
383
+ acc[vote.option_id] = vote;
384
+ return acc;
385
+ }, {});
386
+ }
387
+
388
+ export function extractPollData<SCG extends ExtendableGenerics = DefaultGenerics>(
389
+ pollResponse: PollResponse<SCG>,
390
+ ): PollData<SCG> {
391
+ return {
392
+ allow_answers: pollResponse.allow_answers,
393
+ allow_user_suggested_options: pollResponse.allow_user_suggested_options,
394
+ description: pollResponse.description,
395
+ enforce_unique_vote: pollResponse.enforce_unique_vote,
396
+ id: pollResponse.id,
397
+ is_closed: pollResponse.is_closed,
398
+ max_votes_allowed: pollResponse.max_votes_allowed,
399
+ name: pollResponse.name,
400
+ options: pollResponse.options,
401
+ voting_visibility: pollResponse.voting_visibility,
402
+ };
403
+ }
404
+
405
+ export function extractPollEnrichedData<SCG extends ExtendableGenerics = DefaultGenerics>(
406
+ pollResponse: PollResponse<SCG>,
407
+ ): Omit<PollEnrichData<SCG>, 'own_votes' | 'latest_answers'> {
408
+ return {
409
+ answers_count: pollResponse.answers_count,
410
+ latest_votes_by_option: pollResponse.latest_votes_by_option,
411
+ vote_count: pollResponse.vote_count,
412
+ vote_counts_by_option: pollResponse.vote_counts_by_option,
413
+ };
414
+ }
@@ -0,0 +1,162 @@
1
+ import type { StreamChat } from './client';
2
+ import type {
3
+ CreatePollData,
4
+ DefaultGenerics,
5
+ ExtendableGenerics,
6
+ PollResponse,
7
+ PollSort,
8
+ QueryPollsFilters,
9
+ QueryPollsOptions,
10
+ } from './types';
11
+ import { Poll } from './poll';
12
+ import { FormatMessageResponse } from './types';
13
+ import { formatMessage } from './utils';
14
+
15
+ export class PollManager<SCG extends ExtendableGenerics = DefaultGenerics> {
16
+ private client: StreamChat<SCG>;
17
+ // The pollCache contains only polls that have been created and sent as messages
18
+ // (i.e only polls that are coupled with a message, can be voted on and require a
19
+ // reactive state). It shall work as a basic look-up table for our SDK to be able
20
+ // to quickly consume poll state that will be reactive even without the polls being
21
+ // rendered within the UI.
22
+ private pollCache = new Map<string, Poll<SCG>>();
23
+ private unsubscribeFunctions: Set<() => void> = new Set();
24
+
25
+ constructor({ client }: { client: StreamChat<SCG> }) {
26
+ this.client = client;
27
+ }
28
+
29
+ get data(): Map<string, Poll<SCG>> {
30
+ return this.pollCache;
31
+ }
32
+
33
+ public fromState = (id: string) => {
34
+ return this.pollCache.get(id);
35
+ };
36
+
37
+ public registerSubscriptions = () => {
38
+ if (this.unsubscribeFunctions.size) {
39
+ // Already listening for events and changes
40
+ return;
41
+ }
42
+
43
+ this.unsubscribeFunctions.add(this.subscribeMessageNew());
44
+ this.unsubscribeFunctions.add(this.subscribePollUpdated());
45
+ this.unsubscribeFunctions.add(this.subscribePollClosed());
46
+ this.unsubscribeFunctions.add(this.subscribeVoteCasted());
47
+ this.unsubscribeFunctions.add(this.subscribeVoteChanged());
48
+ this.unsubscribeFunctions.add(this.subscribeVoteRemoved());
49
+ };
50
+
51
+ public unregisterSubscriptions = () => {
52
+ this.unsubscribeFunctions.forEach((cleanupFunction) => cleanupFunction());
53
+ this.unsubscribeFunctions.clear();
54
+ };
55
+
56
+ public createPoll = async (poll: CreatePollData<SCG>) => {
57
+ const { poll: createdPoll } = await this.client.createPoll(poll);
58
+
59
+ return new Poll({ client: this.client, poll: createdPoll });
60
+ };
61
+
62
+ public getPoll = async (id: string) => {
63
+ const cachedPoll = this.fromState(id);
64
+
65
+ // optimistically return the cached poll if it exists and update in the background
66
+ if (cachedPoll) {
67
+ this.client.getPoll(id).then(({ poll }) => this.setOrOverwriteInCache(poll, true));
68
+ return cachedPoll;
69
+ }
70
+ // fetch it, write to the cache and return otherwise
71
+ const { poll } = await this.client.getPoll(id);
72
+
73
+ this.setOrOverwriteInCache(poll);
74
+
75
+ return this.fromState(id);
76
+ };
77
+
78
+ public queryPolls = async (filter: QueryPollsFilters, sort: PollSort = [], options: QueryPollsOptions = {}) => {
79
+ const { polls, next } = await this.client.queryPolls(filter, sort, options);
80
+
81
+ const pollInstances = polls.map((poll) => {
82
+ this.setOrOverwriteInCache(poll, true);
83
+
84
+ return this.fromState(poll.id);
85
+ });
86
+
87
+ return {
88
+ polls: pollInstances,
89
+ next,
90
+ };
91
+ };
92
+
93
+ public hydratePollCache = (messages: FormatMessageResponse<SCG>[], overwriteState?: boolean) => {
94
+ for (const message of messages) {
95
+ if (!message.poll) {
96
+ continue;
97
+ }
98
+ const pollResponse = message.poll as PollResponse<SCG>;
99
+ this.setOrOverwriteInCache(pollResponse, overwriteState);
100
+ }
101
+ };
102
+
103
+ private setOrOverwriteInCache = (pollResponse: PollResponse<SCG>, overwriteState?: boolean) => {
104
+ const pollFromCache = this.fromState(pollResponse.id);
105
+ if (!pollFromCache) {
106
+ const poll = new Poll<SCG>({ client: this.client, poll: pollResponse });
107
+ this.pollCache.set(poll.id, poll);
108
+ } else if (overwriteState) {
109
+ pollFromCache.reinitializeState(pollResponse);
110
+ }
111
+ };
112
+
113
+ private subscribePollUpdated = () => {
114
+ return this.client.on('poll.updated', (event) => {
115
+ if (event.poll?.id) {
116
+ this.fromState(event.poll.id)?.handlePollUpdated(event);
117
+ }
118
+ }).unsubscribe;
119
+ };
120
+
121
+ private subscribePollClosed = () => {
122
+ return this.client.on('poll.closed', (event) => {
123
+ if (event.poll?.id) {
124
+ this.fromState(event.poll.id)?.handlePollClosed(event);
125
+ }
126
+ }).unsubscribe;
127
+ };
128
+
129
+ private subscribeVoteCasted = () => {
130
+ return this.client.on('poll.vote_casted', (event) => {
131
+ if (event.poll?.id) {
132
+ this.fromState(event.poll.id)?.handleVoteCasted(event);
133
+ }
134
+ }).unsubscribe;
135
+ };
136
+
137
+ private subscribeVoteChanged = () => {
138
+ return this.client.on('poll.vote_changed', (event) => {
139
+ if (event.poll?.id) {
140
+ this.fromState(event.poll.id)?.handleVoteChanged(event);
141
+ }
142
+ }).unsubscribe;
143
+ };
144
+
145
+ private subscribeVoteRemoved = () => {
146
+ return this.client.on('poll.vote_removed', (event) => {
147
+ if (event.poll?.id) {
148
+ this.fromState(event.poll.id)?.handleVoteRemoved(event);
149
+ }
150
+ }).unsubscribe;
151
+ };
152
+
153
+ private subscribeMessageNew = () => {
154
+ return this.client.on('message.new', (event) => {
155
+ const { message } = event;
156
+ if (message) {
157
+ const formattedMessage = formatMessage(message);
158
+ this.hydratePollCache([formattedMessage]);
159
+ }
160
+ }).unsubscribe;
161
+ };
162
+ }
package/src/store.ts CHANGED
@@ -9,6 +9,8 @@ function isPatch<T>(value: T | Patch<T>): value is Patch<T> {
9
9
  export class StateStore<T extends Record<string, unknown>> {
10
10
  private handlerSet = new Set<Handler<T>>();
11
11
 
12
+ private static logCount = 5;
13
+
12
14
  constructor(private value: T) {}
13
15
 
14
16
  public next = (newValueOrPatch: T | Patch<T>): void => {
@@ -36,13 +38,31 @@ export class StateStore<T extends Record<string, unknown>> {
36
38
  };
37
39
  };
38
40
 
39
- public subscribeWithSelector = <O extends readonly unknown[]>(selector: (nextValue: T) => O, handler: Handler<O>) => {
41
+ public subscribeWithSelector = <O extends Readonly<Record<string, unknown>> | Readonly<unknown[]>>(
42
+ selector: (nextValue: T) => O,
43
+ handler: Handler<O>,
44
+ ) => {
40
45
  // begin with undefined to reduce amount of selector calls
41
46
  let selectedValues: O | undefined;
42
47
 
43
48
  const wrappedHandler: Handler<T> = (nextValue) => {
44
49
  const newlySelectedValues = selector(nextValue);
45
- const hasUpdatedValues = selectedValues?.some((value, index) => value !== newlySelectedValues[index]) ?? true;
50
+
51
+ let hasUpdatedValues = !selectedValues;
52
+
53
+ if (Array.isArray(newlySelectedValues) && StateStore.logCount > 0) {
54
+ console.warn(
55
+ '[StreamChat]: The API of our StateStore has changed. Instead of returning an array in the selector, please return a named object of properties.',
56
+ );
57
+ StateStore.logCount--;
58
+ }
59
+
60
+ for (const key in selectedValues) {
61
+ // @ts-ignore TODO: remove array support (Readonly<unknown[]>)
62
+ if (selectedValues[key] === newlySelectedValues[key]) continue;
63
+ hasUpdatedValues = true;
64
+ break;
65
+ }
46
66
 
47
67
  if (!hasUpdatedValues) return;
48
68
 
package/src/thread.ts CHANGED
@@ -197,8 +197,11 @@ export class Thread<SCG extends ExtendableGenerics = DefaultGenerics> {
197
197
 
198
198
  private subscribeMarkActiveThreadRead = () => {
199
199
  return this.state.subscribeWithSelector(
200
- (nextValue) => [nextValue.active, ownUnreadCountSelector(this.client.userID)(nextValue)],
201
- ([active, unreadMessageCount]) => {
200
+ (nextValue) => ({
201
+ active: nextValue.active,
202
+ unreadMessageCount: ownUnreadCountSelector(this.client.userID)(nextValue),
203
+ }),
204
+ ({ active, unreadMessageCount }) => {
202
205
  if (!active || !unreadMessageCount) return;
203
206
  this.throttledMarkAsRead();
204
207
  },
@@ -207,8 +210,8 @@ export class Thread<SCG extends ExtendableGenerics = DefaultGenerics> {
207
210
 
208
211
  private subscribeReloadActiveStaleThread = () =>
209
212
  this.state.subscribeWithSelector(
210
- (nextValue) => [nextValue.active, nextValue.isStateStale],
211
- ([active, isStateStale]) => {
213
+ (nextValue) => ({ active: nextValue.active, isStateStale: nextValue.isStateStale }),
214
+ ({ active, isStateStale }) => {
212
215
  if (active && isStateStale) {
213
216
  this.reload();
214
217
  }
@@ -119,9 +119,9 @@ export class ThreadManager<SCG extends ExtendableGenerics = DefaultGenerics> {
119
119
 
120
120
  private subscribeManageThreadSubscriptions = () =>
121
121
  this.state.subscribeWithSelector(
122
- (nextValue) => [nextValue.threads] as const,
123
- ([nextThreads], prev) => {
124
- const [prevThreads = []] = prev ?? [];
122
+ (nextValue) => ({ threads: nextValue.threads }),
123
+ ({ threads: nextThreads }, prev) => {
124
+ const { threads: prevThreads = [] } = prev ?? {};
125
125
  // Thread instance was removed if there's no thread with the given id at all,
126
126
  // or it was replaced with a new instance
127
127
  const removedThreads = prevThreads.filter((thread) => thread !== this.threadsById[thread.id]);
@@ -133,8 +133,8 @@ export class ThreadManager<SCG extends ExtendableGenerics = DefaultGenerics> {
133
133
 
134
134
  private subscribeReloadOnActivation = () =>
135
135
  this.state.subscribeWithSelector(
136
- (nextValue) => [nextValue.active],
137
- ([active]) => {
136
+ (nextValue) => ({ active: nextValue.active }),
137
+ ({ active }) => {
138
138
  if (active) this.reload();
139
139
  },
140
140
  );