stream-chat 9.53.0 → 9.54.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/dist/cjs/index.browser.js +317 -71
- package/dist/cjs/index.browser.js.map +3 -3
- package/dist/cjs/index.node.js +317 -71
- package/dist/cjs/index.node.js.map +3 -3
- package/dist/esm/index.mjs +317 -71
- package/dist/esm/index.mjs.map +3 -3
- package/dist/types/client.d.ts +2 -6
- package/dist/types/poll.d.ts +22 -4
- package/dist/types/poll_optimistic_votes.d.ts +63 -0
- package/dist/types/types.d.ts +10 -3
- package/dist/types/utils.d.ts +1 -3
- package/package.json +1 -1
- package/src/messageComposer/middleware/messageComposer/linkPreviews.ts +20 -10
- package/src/poll.ts +86 -60
- package/src/poll_optimistic_votes.ts +321 -0
- package/src/types.ts +14 -3
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import type { StreamChat } from './client';
|
|
2
|
+
import type { Poll, PollState } from './poll';
|
|
3
|
+
import type { PollResponse, PollVote } from './types';
|
|
4
|
+
import { generateUUIDv4 } from './utils';
|
|
5
|
+
import { hasPending, withoutConcurrency } from './utils/concurrency';
|
|
6
|
+
|
|
7
|
+
type OptionId = string;
|
|
8
|
+
|
|
9
|
+
// The vote fields of the poll state that optimistic updates change
|
|
10
|
+
type VoteState = Pick<
|
|
11
|
+
PollState,
|
|
12
|
+
'latest_votes_by_option' | 'ownVotesByOptionId' | 'vote_count' | 'vote_counts_by_option'
|
|
13
|
+
>;
|
|
14
|
+
|
|
15
|
+
// An own vote change is the server vote of a cast, or the option of a removal
|
|
16
|
+
type OwnVoteChange = { optionId: OptionId; vote?: PollVote };
|
|
17
|
+
|
|
18
|
+
// How long to wait for missing WS events of own vote changes after the last request finished
|
|
19
|
+
const CATCH_UP_TIMEOUT_MS = 3000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Optimistic updates for the current user's votes on a poll. Vote changes are shown right away.
|
|
23
|
+
* While any of them is not confirmed by its WS event, vote events are only stored, as the server
|
|
24
|
+
* state they carry does not contain the pending changes yet. Once all are confirmed (a failed
|
|
25
|
+
* request counts as confirmed), the server's vote state is shown, which also reverts failed changes.
|
|
26
|
+
* Vote requests are sent one after another, so the server applies them in the order they were made.
|
|
27
|
+
* A lost WS event is recovered from after CATCH_UP_TIMEOUT_MS (see applySucceededChanges).
|
|
28
|
+
*/
|
|
29
|
+
export class OptimisticPollVotes {
|
|
30
|
+
// The server's vote state. WS events only carry the vote that changed, so own votes are kept here.
|
|
31
|
+
private serverVoteState: VoteState;
|
|
32
|
+
// Own vote changes not confirmed by a WS event yet
|
|
33
|
+
private pendingCount = 0;
|
|
34
|
+
// Own vote changes whose requests succeeded since the server's vote state was last shown
|
|
35
|
+
private succeededChanges: OwnVoteChange[] = [];
|
|
36
|
+
// Vote requests with this tag are sent one after another. A symbol, so it is unique per instance.
|
|
37
|
+
private readonly requestTag = Symbol('poll-votes');
|
|
38
|
+
private catchUpTimeout?: ReturnType<typeof setTimeout>;
|
|
39
|
+
|
|
40
|
+
private readonly client: StreamChat;
|
|
41
|
+
private readonly poll: Poll;
|
|
42
|
+
|
|
43
|
+
constructor({ client, poll }: { client: StreamChat; poll: Poll }) {
|
|
44
|
+
this.client = client;
|
|
45
|
+
this.poll = poll;
|
|
46
|
+
this.serverVoteState = pickVoteState(poll.data);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get serverOwnVotes() {
|
|
50
|
+
return this.serverVoteState.ownVotesByOptionId;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
withServerVotes = (pollState: PollState): PollState => ({
|
|
54
|
+
...pollState,
|
|
55
|
+
...this.serverVoteState,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Casts a vote for an option, shown right away through a placeholder vote. In polls with unique
|
|
60
|
+
* votes, it replaces the existing own votes.
|
|
61
|
+
*/
|
|
62
|
+
castVote = (optionId: OptionId, messageId: string) => {
|
|
63
|
+
const sendRequest = () =>
|
|
64
|
+
this.client.castPollVote(messageId, this.poll.id, { option_id: optionId });
|
|
65
|
+
const state = this.poll.data;
|
|
66
|
+
// already voted for this option, there is nothing to show
|
|
67
|
+
if (state.ownVotesByOptionId[optionId]) return this.send(sendRequest);
|
|
68
|
+
|
|
69
|
+
const replacedVotes = state.enforce_unique_vote
|
|
70
|
+
? Object.values(state.ownVotesByOptionId)
|
|
71
|
+
: [];
|
|
72
|
+
this.poll.state.partialNext(
|
|
73
|
+
applyOwnVoteDelta(state, {
|
|
74
|
+
add: [this.createPlaceholderVote(optionId)],
|
|
75
|
+
remove: replacedVotes,
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
return this.track(sendRequest, (response) => ({
|
|
79
|
+
optionId,
|
|
80
|
+
vote: response.vote as PollVote,
|
|
81
|
+
}));
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Removes a vote, right away.
|
|
85
|
+
removeVote = (voteId: string, messageId: string) => {
|
|
86
|
+
const sendRequest = (id: string) =>
|
|
87
|
+
this.client.removePollVote(messageId, this.poll.id, id);
|
|
88
|
+
const state = this.poll.data;
|
|
89
|
+
const vote = Object.values(state.ownVotesByOptionId).find(({ id }) => id === voteId);
|
|
90
|
+
// not a shown own vote, there is nothing to show
|
|
91
|
+
if (!vote?.option_id) return this.send(() => sendRequest(voteId));
|
|
92
|
+
|
|
93
|
+
const optionId = vote.option_id;
|
|
94
|
+
this.poll.state.partialNext(applyOwnVoteDelta(state, { remove: [vote] }));
|
|
95
|
+
return this.track(
|
|
96
|
+
// runs when the removal's turn comes, so a preceding cast of the vote has finished
|
|
97
|
+
() => {
|
|
98
|
+
const serverVoteId = this.getServerVoteId(vote);
|
|
99
|
+
// the vote's cast failed, so there is nothing to remove
|
|
100
|
+
return serverVoteId ? sendRequest(serverVoteId) : Promise.resolve(undefined);
|
|
101
|
+
},
|
|
102
|
+
() => ({ optionId }),
|
|
103
|
+
);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Sends a vote request once the previous one has finished, whether it succeeded or not.
|
|
107
|
+
private send = async <T>(sendRequest: () => Promise<T>): Promise<T> => {
|
|
108
|
+
try {
|
|
109
|
+
return (await withoutConcurrency(this.requestTag, sendRequest)) as T;
|
|
110
|
+
} finally {
|
|
111
|
+
// once nothing is left to send, wait for the WS events of the pending changes
|
|
112
|
+
if (!hasPending(this.requestTag)) this.scheduleCatchUp();
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Stores the vote state of a WS vote event and returns the vote fields to show, if any.
|
|
117
|
+
handleVoteEvent = (
|
|
118
|
+
poll: PollResponse,
|
|
119
|
+
{
|
|
120
|
+
confirmsOwnVote,
|
|
121
|
+
ownVotesByOptionId,
|
|
122
|
+
}: { confirmsOwnVote: boolean; ownVotesByOptionId: Record<OptionId, PollVote> },
|
|
123
|
+
): Partial<PollState> => {
|
|
124
|
+
this.serverVoteState = {
|
|
125
|
+
latest_votes_by_option: poll.latest_votes_by_option,
|
|
126
|
+
ownVotesByOptionId,
|
|
127
|
+
vote_count: poll.vote_count,
|
|
128
|
+
vote_counts_by_option: poll.vote_counts_by_option,
|
|
129
|
+
};
|
|
130
|
+
if (confirmsOwnVote) this.pendingCount = Math.max(this.pendingCount - 1, 0);
|
|
131
|
+
return this.pendingCount > 0 ? {} : this.takeServerVoteState();
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Fresh server state (e.g. a refetch) replaces everything, pending changes are dropped.
|
|
135
|
+
reset = (pollState: PollState) => {
|
|
136
|
+
this.serverVoteState = pickVoteState(pollState);
|
|
137
|
+
this.pendingCount = 0;
|
|
138
|
+
this.succeededChanges = [];
|
|
139
|
+
clearTimeout(this.catchUpTimeout);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Tracks an own vote change until its request settles. `sendRequest` resolves to undefined
|
|
143
|
+
// when no request was needed.
|
|
144
|
+
private track = async <T>(
|
|
145
|
+
sendRequest: () => Promise<T | undefined>,
|
|
146
|
+
toChange: (response: T) => OwnVoteChange,
|
|
147
|
+
) => {
|
|
148
|
+
this.pendingCount += 1;
|
|
149
|
+
clearTimeout(this.catchUpTimeout);
|
|
150
|
+
|
|
151
|
+
let response: T | undefined;
|
|
152
|
+
try {
|
|
153
|
+
response = await this.send(async () => {
|
|
154
|
+
const result = await sendRequest();
|
|
155
|
+
// recorded before the next request is sent, as a removal looks up the vote id in here
|
|
156
|
+
if (result !== undefined) this.succeededChanges.push(toChange(result));
|
|
157
|
+
return result;
|
|
158
|
+
});
|
|
159
|
+
} catch (error) {
|
|
160
|
+
this.settleWithoutEvent();
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
if (response === undefined) this.settleWithoutEvent();
|
|
164
|
+
return response;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// A failed or skipped request gets no WS event, so it counts as confirmed right away.
|
|
168
|
+
private settleWithoutEvent = () => {
|
|
169
|
+
this.pendingCount = Math.max(this.pendingCount - 1, 0);
|
|
170
|
+
if (this.pendingCount === 0) this.poll.state.partialNext(this.takeServerVoteState());
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// The vote shown for a cast until the server's vote arrives
|
|
174
|
+
private createPlaceholderVote = (optionId: OptionId): PollVote => {
|
|
175
|
+
const now = new Date().toISOString();
|
|
176
|
+
return {
|
|
177
|
+
created_at: now,
|
|
178
|
+
id: `local-${generateUUIDv4()}`,
|
|
179
|
+
option_id: optionId,
|
|
180
|
+
poll_id: this.poll.id,
|
|
181
|
+
updated_at: now,
|
|
182
|
+
user: this.client.user,
|
|
183
|
+
user_id: this.client.userID,
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The server id of a vote to remove, once everything sent before the removal has finished. Casts
|
|
189
|
+
* and removals of an option alternate, so the option's last succeeded change tells: a cast is the
|
|
190
|
+
* vote being removed (e.g. a placeholder), a removal means the cast in between failed. Without
|
|
191
|
+
* one, the server's own vote of the option is the vote being removed.
|
|
192
|
+
*/
|
|
193
|
+
private getServerVoteId = (vote: PollVote) => {
|
|
194
|
+
for (let i = this.succeededChanges.length - 1; i >= 0; i--) {
|
|
195
|
+
const change = this.succeededChanges[i];
|
|
196
|
+
if (change.optionId === vote.option_id) return change.vote?.id;
|
|
197
|
+
}
|
|
198
|
+
return this.serverVoteState.ownVotesByOptionId[vote.option_id as OptionId]?.id;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
private takeServerVoteState = () => {
|
|
202
|
+
clearTimeout(this.catchUpTimeout);
|
|
203
|
+
this.succeededChanges = [];
|
|
204
|
+
return {
|
|
205
|
+
...this.serverVoteState,
|
|
206
|
+
maxVotedOptionIds: getMaxVotedOptionIds(this.serverVoteState.vote_counts_by_option),
|
|
207
|
+
};
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
private scheduleCatchUp = () => {
|
|
211
|
+
clearTimeout(this.catchUpTimeout);
|
|
212
|
+
if (this.pendingCount === 0) return;
|
|
213
|
+
this.catchUpTimeout = setTimeout(this.applySucceededChanges, CATCH_UP_TIMEOUT_MS);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* A WS event of an own vote change got lost, so the server's own votes are stale. All requests
|
|
218
|
+
* have finished (the timeout starts once nothing is left to send and a new request cancels it),
|
|
219
|
+
* so applying the succeeded changes in order gives the server's own votes (changes whose events
|
|
220
|
+
* did arrive end up the same). They are shown with the vote counts of the last WS event, which
|
|
221
|
+
* contain the other users' votes too.
|
|
222
|
+
*/
|
|
223
|
+
private applySucceededChanges = () => {
|
|
224
|
+
const { enforce_unique_vote } = this.poll.data;
|
|
225
|
+
let ownVotesByOptionId = { ...this.serverVoteState.ownVotesByOptionId };
|
|
226
|
+
for (const { optionId, vote } of this.succeededChanges) {
|
|
227
|
+
if (!vote) {
|
|
228
|
+
delete ownVotesByOptionId[optionId];
|
|
229
|
+
} else if (enforce_unique_vote) {
|
|
230
|
+
ownVotesByOptionId = { [optionId]: vote };
|
|
231
|
+
} else {
|
|
232
|
+
ownVotesByOptionId[optionId] = vote;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
this.serverVoteState = { ...this.serverVoteState, ownVotesByOptionId };
|
|
237
|
+
this.pendingCount = 0;
|
|
238
|
+
// also drops a failed vote that was still shown
|
|
239
|
+
this.poll.state.partialNext(this.takeServerVoteState());
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function pickVoteState(state: PollState): VoteState {
|
|
244
|
+
return {
|
|
245
|
+
latest_votes_by_option: state.latest_votes_by_option,
|
|
246
|
+
ownVotesByOptionId: state.ownVotesByOptionId,
|
|
247
|
+
vote_count: state.vote_count,
|
|
248
|
+
vote_counts_by_option: state.vote_counts_by_option,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Adds and removes the current user's votes: own votes, vote counts, latest votes and the
|
|
254
|
+
* max-voted options. Returns new objects so that state selectors pick up the change.
|
|
255
|
+
*/
|
|
256
|
+
function applyOwnVoteDelta(
|
|
257
|
+
state: PollState,
|
|
258
|
+
{ add = [], remove = [] }: { add?: PollVote[]; remove?: PollVote[] },
|
|
259
|
+
): VoteState & Pick<PollState, 'maxVotedOptionIds'> {
|
|
260
|
+
const ownVotesByOptionId = { ...state.ownVotesByOptionId };
|
|
261
|
+
const voteCountsByOption = { ...state.vote_counts_by_option };
|
|
262
|
+
// null for anonymous polls, in which case there is nothing to update
|
|
263
|
+
const latestVotesByOption = state.latest_votes_by_option
|
|
264
|
+
? { ...state.latest_votes_by_option }
|
|
265
|
+
: state.latest_votes_by_option;
|
|
266
|
+
let voteCount = state.vote_count ?? 0;
|
|
267
|
+
|
|
268
|
+
for (const vote of remove) {
|
|
269
|
+
const optionId = vote.option_id;
|
|
270
|
+
if (!optionId) continue;
|
|
271
|
+
delete ownVotesByOptionId[optionId];
|
|
272
|
+
// the server omits options without votes, so a count that drops to 0 is removed
|
|
273
|
+
const optionVoteCount = (voteCountsByOption[optionId] ?? 0) - 1;
|
|
274
|
+
if (optionVoteCount > 0) {
|
|
275
|
+
voteCountsByOption[optionId] = optionVoteCount;
|
|
276
|
+
} else {
|
|
277
|
+
delete voteCountsByOption[optionId];
|
|
278
|
+
}
|
|
279
|
+
voteCount = Math.max(voteCount - 1, 0);
|
|
280
|
+
if (latestVotesByOption?.[optionId]) {
|
|
281
|
+
latestVotesByOption[optionId] = latestVotesByOption[optionId].filter(
|
|
282
|
+
(latestVote) => latestVote.id !== vote.id,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
for (const vote of add) {
|
|
288
|
+
const optionId = vote.option_id;
|
|
289
|
+
if (!optionId) continue;
|
|
290
|
+
ownVotesByOptionId[optionId] = vote;
|
|
291
|
+
voteCountsByOption[optionId] = (voteCountsByOption[optionId] ?? 0) + 1;
|
|
292
|
+
voteCount += 1;
|
|
293
|
+
if (latestVotesByOption && state.voting_visibility !== 'anonymous') {
|
|
294
|
+
latestVotesByOption[optionId] = [vote, ...(latestVotesByOption[optionId] ?? [])];
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
latest_votes_by_option: latestVotesByOption,
|
|
300
|
+
maxVotedOptionIds: getMaxVotedOptionIds(voteCountsByOption),
|
|
301
|
+
ownVotesByOptionId,
|
|
302
|
+
vote_count: voteCount,
|
|
303
|
+
vote_counts_by_option: voteCountsByOption,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function getMaxVotedOptionIds(
|
|
308
|
+
voteCountsByOption: PollResponse['vote_counts_by_option'],
|
|
309
|
+
) {
|
|
310
|
+
let maxVotes = 0;
|
|
311
|
+
let winningOptions: string[] = [];
|
|
312
|
+
for (const [id, count] of Object.entries(voteCountsByOption ?? {})) {
|
|
313
|
+
if (count > maxVotes) {
|
|
314
|
+
winningOptions = [id];
|
|
315
|
+
maxVotes = count;
|
|
316
|
+
} else if (count === maxVotes) {
|
|
317
|
+
winningOptions.push(id);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return winningOptions;
|
|
321
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -138,6 +138,7 @@ export type AppSettingsAPIResponse = APIResponse & {
|
|
|
138
138
|
auto_translation_enabled?: boolean;
|
|
139
139
|
before_message_send_hook_url?: string;
|
|
140
140
|
before_message_send_hook_attempt_timeout_ms?: number;
|
|
141
|
+
before_message_send_hook_system_messages?: boolean;
|
|
141
142
|
campaign_enabled?: boolean;
|
|
142
143
|
cdn_expiration_seconds?: number;
|
|
143
144
|
chat_primary_use_case?: string;
|
|
@@ -803,9 +804,7 @@ export type MessageResponseBase = MessageBase & {
|
|
|
803
804
|
created_at?: string;
|
|
804
805
|
deleted_at?: string;
|
|
805
806
|
deleted_reply_count?: number;
|
|
806
|
-
i18n?:
|
|
807
|
-
language: TranslationLanguages;
|
|
808
|
-
};
|
|
807
|
+
i18n?: TranslatedField;
|
|
809
808
|
latest_reactions?: ReactionResponse[];
|
|
810
809
|
member?: ChannelMemberResponse;
|
|
811
810
|
mentioned_users?: UserResponse[];
|
|
@@ -3285,6 +3284,13 @@ export type TranslationLanguages =
|
|
|
3285
3284
|
| 'zh-TW'
|
|
3286
3285
|
| (string & {});
|
|
3287
3286
|
|
|
3287
|
+
// Server-owned translations for a single text field, keyed by language.
|
|
3288
|
+
export type TranslatedField = RequireAtLeastOne<
|
|
3289
|
+
Record<`${TranslationLanguages}_text`, string>
|
|
3290
|
+
> & {
|
|
3291
|
+
language: TranslationLanguages;
|
|
3292
|
+
};
|
|
3293
|
+
|
|
3288
3294
|
export type TypingStartEvent = Event;
|
|
3289
3295
|
|
|
3290
3296
|
export type ReservedUpdatedMessageFields = keyof typeof RESERVED_UPDATED_MESSAGE_FIELDS;
|
|
@@ -3647,7 +3653,9 @@ export type PollResponse = CustomPollData &
|
|
|
3647
3653
|
allow_answers?: boolean;
|
|
3648
3654
|
allow_user_suggested_options?: boolean;
|
|
3649
3655
|
description?: string;
|
|
3656
|
+
description_i18n?: TranslatedField;
|
|
3650
3657
|
is_closed?: boolean;
|
|
3658
|
+
name_i18n?: TranslatedField;
|
|
3651
3659
|
voting_visibility?: VotingVisibility;
|
|
3652
3660
|
};
|
|
3653
3661
|
|
|
@@ -3658,6 +3666,7 @@ export type PollOption = {
|
|
|
3658
3666
|
text: string;
|
|
3659
3667
|
updated_at: string;
|
|
3660
3668
|
vote_count: number;
|
|
3669
|
+
text_i18n?: TranslatedField;
|
|
3661
3670
|
votes?: PollVote[];
|
|
3662
3671
|
};
|
|
3663
3672
|
|
|
@@ -3733,6 +3742,7 @@ export type PollOptionResponse = CustomPollData & {
|
|
|
3733
3742
|
text: string;
|
|
3734
3743
|
updated_at: string;
|
|
3735
3744
|
vote_count: number;
|
|
3745
|
+
text_i18n?: TranslatedField;
|
|
3736
3746
|
votes?: PollVote[];
|
|
3737
3747
|
};
|
|
3738
3748
|
|
|
@@ -3749,6 +3759,7 @@ export type PollVote = {
|
|
|
3749
3759
|
export type PollAnswer = Exclude<PollVote, 'option_id'> & {
|
|
3750
3760
|
answer_text: string;
|
|
3751
3761
|
is_answer: boolean; // this is absolutely redundant prop as answer_text indicates that a vote is an answer
|
|
3762
|
+
answer_text_i18n?: TranslatedField;
|
|
3752
3763
|
};
|
|
3753
3764
|
|
|
3754
3765
|
export type PollVotesAPIResponse = {
|