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.
@@ -1153,9 +1153,7 @@ export declare class StreamChat {
1153
1153
  created_at?: string;
1154
1154
  deleted_at?: string;
1155
1155
  deleted_reply_count?: number;
1156
- i18n?: import("./types").RequireAtLeastOne<Record<`${import("./types").TranslationLanguages}_text`, string>> & {
1157
- language: import("./types").TranslationLanguages;
1158
- };
1156
+ i18n?: import("./types").TranslatedField;
1159
1157
  latest_reactions?: ReactionResponse[];
1160
1158
  member?: import("./types").ChannelMemberResponse;
1161
1159
  mentioned_users?: UserResponse[];
@@ -1891,9 +1889,7 @@ export declare class StreamChat {
1891
1889
  created_at?: string;
1892
1890
  deleted_at?: string;
1893
1891
  deleted_reply_count?: number;
1894
- i18n?: import("./types").RequireAtLeastOne<Record<`${import("./types").TranslationLanguages}_text`, string>> & {
1895
- language: import("./types").TranslationLanguages;
1896
- };
1892
+ i18n?: import("./types").TranslatedField;
1897
1893
  latest_reactions?: ReactionResponse[];
1898
1894
  member?: import("./types").ChannelMemberResponse;
1899
1895
  mentioned_users?: UserResponse[];
@@ -29,10 +29,13 @@ export declare class Poll {
29
29
  readonly state: StateStore<PollState>;
30
30
  id: string;
31
31
  private client;
32
+ private optimisticVotes;
33
+ private closeConfirmed;
32
34
  constructor({ client, poll }: PollInitOptions);
33
35
  private getInitialStateFromPollResponse;
34
36
  private upsertOfflineDb;
35
37
  reinitializeState: (poll: PollInitOptions["poll"]) => void;
38
+ private updateFromVoteEvent;
36
39
  get data(): PollState;
37
40
  handlePollUpdated: (event: Event) => void;
38
41
  handlePollClosed: (event: Event) => void;
@@ -42,15 +45,30 @@ export declare class Poll {
42
45
  query: (id: string) => Promise<PollResponse>;
43
46
  update: (data: Exclude<PollData, "id">) => Promise<import("./types").APIResponse & import("./types").UpdatePollAPIResponse>;
44
47
  partialUpdate: (partialPollObject: PartialPollUpdate) => Promise<import("./types").APIResponse & import("./types").UpdatePollAPIResponse>;
45
- close: () => Promise<import("./types").APIResponse & import("./types").UpdatePollAPIResponse>;
48
+ /**
49
+ * Closes the poll. The poll is marked as closed optimistically and the change is
50
+ * reverted if the request fails, unless the server confirmed the close meanwhile.
51
+ * Does nothing if the poll is already closed.
52
+ */
53
+ close: () => Promise<(import("./types").APIResponse & import("./types").UpdatePollAPIResponse) | undefined>;
46
54
  delete: () => Promise<import("./types").APIResponse>;
47
55
  createOption: (option: PollOptionData) => Promise<import("./types").APIResponse & import("./types").CreatePollOptionAPIResponse>;
48
56
  updateOption: (option: PollOptionData) => Promise<import("./types").APIResponse & import("./types").CreatePollOptionAPIResponse>;
49
57
  deleteOption: (optionId: string) => Promise<import("./types").APIResponse>;
58
+ /**
59
+ * Casts a vote for an option. The vote is shown right away through a local placeholder vote,
60
+ * which the server's vote replaces once the WS events of all pending vote changes arrived.
61
+ * If the request fails, the change is reverted.
62
+ */
50
63
  castVote: (optionId: string, messageId: string) => Promise<(import("./types").APIResponse & import("./types").CastVoteAPIResponse) | undefined>;
51
- removeVote: (voteId: string, messageId: string) => Promise<import("./types").APIResponse & {
64
+ /**
65
+ * Removes a vote. The vote is removed right away and restored if the request fails. Vote
66
+ * requests are sent in order, so removing a vote whose cast is not confirmed yet uses the vote
67
+ * id from the cast's response; if that cast failed, there is nothing to remove.
68
+ */
69
+ removeVote: (voteId: string, messageId: string) => Promise<(import("./types").APIResponse & {
52
70
  vote: PollVote;
53
- }>;
71
+ }) | undefined>;
54
72
  addAnswer: (answerText: string, messageId: string) => Promise<import("./types").APIResponse & import("./types").CastVoteAPIResponse>;
55
73
  removeAnswer: (answerId: string, messageId: string) => Promise<import("./types").APIResponse & {
56
74
  vote: PollVote;
@@ -59,6 +77,6 @@ export declare class Poll {
59
77
  queryOptionVotes: (params: PollOptionVotesQueryParams) => Promise<import("./types").APIResponse & import("./types").PollVotesAPIResponse>;
60
78
  }
61
79
  export declare function extractPollData(pollResponse: PollResponse): PollData;
62
- export declare function mapPollStateToResponse(poll: Poll): PollResponse;
80
+ export declare function mapPollStateToResponse(poll: Poll, state?: PollState): PollResponse;
63
81
  export declare function extractPollEnrichedData(pollResponse: PollResponse): Omit<PollEnrichData, 'own_votes' | 'latest_answers'>;
64
82
  export {};
@@ -0,0 +1,63 @@
1
+ import type { StreamChat } from './client';
2
+ import type { Poll, PollState } from './poll';
3
+ import type { PollResponse, PollVote } from './types';
4
+ type OptionId = string;
5
+ /**
6
+ * Optimistic updates for the current user's votes on a poll. Vote changes are shown right away.
7
+ * While any of them is not confirmed by its WS event, vote events are only stored, as the server
8
+ * state they carry does not contain the pending changes yet. Once all are confirmed (a failed
9
+ * request counts as confirmed), the server's vote state is shown, which also reverts failed changes.
10
+ * Vote requests are sent one after another, so the server applies them in the order they were made.
11
+ * A lost WS event is recovered from after CATCH_UP_TIMEOUT_MS (see applySucceededChanges).
12
+ */
13
+ export declare class OptimisticPollVotes {
14
+ private serverVoteState;
15
+ private pendingCount;
16
+ private succeededChanges;
17
+ private readonly requestTag;
18
+ private catchUpTimeout?;
19
+ private readonly client;
20
+ private readonly poll;
21
+ constructor({ client, poll }: {
22
+ client: StreamChat;
23
+ poll: Poll;
24
+ });
25
+ get serverOwnVotes(): Record<string, PollVote>;
26
+ withServerVotes: (pollState: PollState) => PollState;
27
+ /**
28
+ * Casts a vote for an option, shown right away through a placeholder vote. In polls with unique
29
+ * votes, it replaces the existing own votes.
30
+ */
31
+ castVote: (optionId: OptionId, messageId: string) => Promise<(import("./types").APIResponse & import("./types").CastVoteAPIResponse) | undefined>;
32
+ removeVote: (voteId: string, messageId: string) => Promise<(import("./types").APIResponse & {
33
+ vote: PollVote;
34
+ }) | undefined>;
35
+ private send;
36
+ handleVoteEvent: (poll: PollResponse, { confirmsOwnVote, ownVotesByOptionId, }: {
37
+ confirmsOwnVote: boolean;
38
+ ownVotesByOptionId: Record<OptionId, PollVote>;
39
+ }) => Partial<PollState>;
40
+ reset: (pollState: PollState) => void;
41
+ private track;
42
+ private settleWithoutEvent;
43
+ private createPlaceholderVote;
44
+ /**
45
+ * The server id of a vote to remove, once everything sent before the removal has finished. Casts
46
+ * and removals of an option alternate, so the option's last succeeded change tells: a cast is the
47
+ * vote being removed (e.g. a placeholder), a removal means the cast in between failed. Without
48
+ * one, the server's own vote of the option is the vote being removed.
49
+ */
50
+ private getServerVoteId;
51
+ private takeServerVoteState;
52
+ private scheduleCatchUp;
53
+ /**
54
+ * A WS event of an own vote change got lost, so the server's own votes are stale. All requests
55
+ * have finished (the timeout starts once nothing is left to send and a new request cancels it),
56
+ * so applying the succeeded changes in order gives the server's own votes (changes whose events
57
+ * did arrive end up the same). They are shown with the vote counts of the last WS event, which
58
+ * contain the other users' votes too.
59
+ */
60
+ private applySucceededChanges;
61
+ }
62
+ export declare function getMaxVotedOptionIds(voteCountsByOption: PollResponse['vote_counts_by_option']): string[];
63
+ export {};
@@ -92,6 +92,7 @@ export type AppSettingsAPIResponse = APIResponse & {
92
92
  auto_translation_enabled?: boolean;
93
93
  before_message_send_hook_url?: string;
94
94
  before_message_send_hook_attempt_timeout_ms?: number;
95
+ before_message_send_hook_system_messages?: boolean;
95
96
  campaign_enabled?: boolean;
96
97
  cdn_expiration_seconds?: number;
97
98
  chat_primary_use_case?: string;
@@ -667,9 +668,7 @@ export type MessageResponseBase = MessageBase & {
667
668
  created_at?: string;
668
669
  deleted_at?: string;
669
670
  deleted_reply_count?: number;
670
- i18n?: RequireAtLeastOne<Record<`${TranslationLanguages}_text`, string>> & {
671
- language: TranslationLanguages;
672
- };
671
+ i18n?: TranslatedField;
673
672
  latest_reactions?: ReactionResponse[];
674
673
  member?: ChannelMemberResponse;
675
674
  mentioned_users?: UserResponse[];
@@ -2423,6 +2422,9 @@ export type TestSNSDataInput = {
2423
2422
  export type TokenOrProvider = null | string | TokenProvider | undefined;
2424
2423
  export type TokenProvider = () => Promise<string>;
2425
2424
  export type TranslationLanguages = 'af' | 'am' | 'ar' | 'az' | 'bg' | 'bn' | 'bs' | 'cs' | 'da' | 'de' | 'el' | 'en' | 'es' | 'es-MX' | 'et' | 'fa' | 'fa-AF' | 'fi' | 'fr' | 'fr-CA' | 'ha' | 'he' | 'hi' | 'hr' | 'hu' | 'id' | 'it' | 'ja' | 'ka' | 'ko' | 'lt' | 'lv' | 'ms' | 'nl' | 'no' | 'pl' | 'ps' | 'pt' | 'ro' | 'ru' | 'sk' | 'sl' | 'so' | 'sq' | 'sr' | 'sv' | 'sw' | 'ta' | 'th' | 'tl' | 'tr' | 'uk' | 'ur' | 'vi' | 'zh' | 'zh-TW' | (string & {});
2425
+ export type TranslatedField = RequireAtLeastOne<Record<`${TranslationLanguages}_text`, string>> & {
2426
+ language: TranslationLanguages;
2427
+ };
2426
2428
  export type TypingStartEvent = Event;
2427
2429
  export type ReservedUpdatedMessageFields = keyof typeof RESERVED_UPDATED_MESSAGE_FIELDS;
2428
2430
  export type UpdatedMessage = Omit<MessageResponse, ReservedUpdatedMessageFields | 'mentioned_groups'> & {
@@ -2695,7 +2697,9 @@ export type PollResponse = CustomPollData & PollEnrichData & {
2695
2697
  allow_answers?: boolean;
2696
2698
  allow_user_suggested_options?: boolean;
2697
2699
  description?: string;
2700
+ description_i18n?: TranslatedField;
2698
2701
  is_closed?: boolean;
2702
+ name_i18n?: TranslatedField;
2699
2703
  voting_visibility?: VotingVisibility;
2700
2704
  };
2701
2705
  export type PollOption = {
@@ -2705,6 +2709,7 @@ export type PollOption = {
2705
2709
  text: string;
2706
2710
  updated_at: string;
2707
2711
  vote_count: number;
2712
+ text_i18n?: TranslatedField;
2708
2713
  votes?: PollVote[];
2709
2714
  };
2710
2715
  export declare enum VotingVisibility {
@@ -2768,6 +2773,7 @@ export type PollOptionResponse = CustomPollData & {
2768
2773
  text: string;
2769
2774
  updated_at: string;
2770
2775
  vote_count: number;
2776
+ text_i18n?: TranslatedField;
2771
2777
  votes?: PollVote[];
2772
2778
  };
2773
2779
  export type PollVote = {
@@ -2782,6 +2788,7 @@ export type PollVote = {
2782
2788
  export type PollAnswer = Exclude<PollVote, 'option_id'> & {
2783
2789
  answer_text: string;
2784
2790
  is_answer: boolean;
2791
+ answer_text_i18n?: TranslatedField;
2785
2792
  };
2786
2793
  export type PollVotesAPIResponse = {
2787
2794
  votes: (PollVote | PollAnswer)[];
@@ -134,9 +134,7 @@ export declare const toDeletedMessage: ({ message, deletedAt, hardDelete, }: {
134
134
  [key: string]: number;
135
135
  } | null | undefined;
136
136
  reply_count?: number | undefined;
137
- i18n?: (import("./types").RequireAtLeastOne<Record<`${import("./types").TranslationLanguages}_text`, string>> & {
138
- language: import("./types").TranslationLanguages;
139
- }) | undefined;
137
+ i18n?: import("./types").TranslatedField | undefined;
140
138
  html?: string | undefined;
141
139
  user?: (UserResponse | null) | undefined;
142
140
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stream-chat",
3
- "version": "9.53.0",
3
+ "version": "9.54.0",
4
4
  "description": "JS SDK for the Stream Chat API",
5
5
  "homepage": "https://getstream.io/chat/",
6
6
  "author": {
@@ -32,12 +32,15 @@ export const createLinkPreviewsCompositionMiddleware = (
32
32
  LinkPreviewsManager.getPreviewData(preview),
33
33
  );
34
34
 
35
- const attachments: Attachment[] = (state.message.attachments ?? []).concat(
35
+ const messageAttachments: Attachment[] = (state.message.attachments ?? []).concat(
36
36
  linkPreviews,
37
37
  );
38
+ const localAttachments: Attachment[] = (
39
+ state.localMessage.attachments ?? []
40
+ ).concat(linkPreviews);
38
41
 
39
42
  // prevent introducing attachments array into the payload sent to the server
40
- if (!attachments.length) return forward();
43
+ if (!messageAttachments.length && !localAttachments.length) return forward();
41
44
 
42
45
  const sendOptions = { ...state.sendOptions };
43
46
  const skip_enrich_url =
@@ -47,16 +50,23 @@ export const createLinkPreviewsCompositionMiddleware = (
47
50
  sendOptions.skip_enrich_url = true;
48
51
  }
49
52
 
53
+ // Each payload gets the `attachments` key only when there is something to put in it,
54
+ // matching the attachments middleware. An empty array is not "nothing to say": on an edit
55
+ // the API reads it as "remove every attachment".
50
56
  return next({
51
57
  ...state,
52
- message: {
53
- ...state.message,
54
- attachments,
55
- },
56
- localMessage: {
57
- ...state.localMessage,
58
- attachments,
59
- },
58
+ message: messageAttachments.length
59
+ ? {
60
+ ...state.message,
61
+ attachments: messageAttachments,
62
+ }
63
+ : state.message,
64
+ localMessage: localAttachments.length
65
+ ? {
66
+ ...state.localMessage,
67
+ attachments: localAttachments,
68
+ }
69
+ : state.localMessage,
60
70
  sendOptions,
61
71
  });
62
72
  },
package/src/poll.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { StateStore } from './store';
2
2
  import type { StreamChat } from './client';
3
+ import { getMaxVotedOptionIds, OptimisticPollVotes } from './poll_optimistic_votes';
3
4
  import type {
4
5
  Event,
5
6
  PartialPollUpdate,
@@ -90,12 +91,17 @@ export class Poll {
90
91
  public readonly state: StateStore<PollState>;
91
92
  public id: string;
92
93
  private client: StreamChat;
94
+ private optimisticVotes: OptimisticPollVotes;
95
+ // Set whenever server data (WS event or rehydration) confirms that the poll is closed,
96
+ // so that a failed optimistic close does not reopen a poll that is actually closed.
97
+ private closeConfirmed = false;
93
98
 
94
99
  constructor({ client, poll }: PollInitOptions) {
95
100
  this.client = client;
96
101
  this.id = poll.id;
97
102
 
98
103
  this.state = new StateStore<PollState>(this.getInitialStateFromPollResponse(poll));
104
+ this.optimisticVotes = new OptimisticPollVotes({ client: this.client, poll: this });
99
105
  }
100
106
 
101
107
  private getInitialStateFromPollResponse = (poll: PollInitOptions['poll']) => {
@@ -127,15 +133,45 @@ export class Poll {
127
133
  };
128
134
  };
129
135
 
136
+ // persists only the server's votes, never the optimistic ones
130
137
  private upsertOfflineDb = () => {
138
+ const serverState = this.optimisticVotes.withServerVotes(this.data);
131
139
  this.client.offlineDb?.executeQuerySafely(
132
- (db) => db.upsertPoll({ poll: mapPollStateToResponse(this) }),
140
+ (db) => db.upsertPoll({ poll: mapPollStateToResponse(this, serverState) }),
133
141
  { method: 'upsertPoll' },
134
142
  );
135
143
  };
136
144
 
137
145
  public reinitializeState = (poll: PollInitOptions['poll']) => {
138
- this.state.partialNext(this.getInitialStateFromPollResponse(poll));
146
+ if (poll.is_closed) this.closeConfirmed = true;
147
+ const initialState = this.getInitialStateFromPollResponse(poll);
148
+ this.optimisticVotes.reset(initialState);
149
+ this.state.partialNext(initialState);
150
+ };
151
+
152
+ // Hands the vote state of a WS vote event to the optimistic votes, which decide whether to show it.
153
+ private updateFromVoteEvent = (
154
+ event: Event & {
155
+ created_at: string;
156
+ poll: PollResponse;
157
+ poll_vote: PollVote | PollAnswer;
158
+ },
159
+ ownVotesByOptionId: Record<OptionId, PollVote>,
160
+ answerState: Pick<PollState, 'latest_answers' | 'ownAnswer'>,
161
+ ) => {
162
+ const isOwnVote = event.poll_vote.user_id === this.client.userID;
163
+ const shownVoteState = this.optimisticVotes.handleVoteEvent(event.poll, {
164
+ confirmsOwnVote:
165
+ isOwnVote && !isVoteAnswer(event.poll_vote) && !!event.poll_vote.option_id,
166
+ ownVotesByOptionId,
167
+ });
168
+ this.state.partialNext({
169
+ ...answerState,
170
+ answers_count: event.poll.answers_count,
171
+ lastActivityAt: new Date(event.created_at),
172
+ ...shownVoteState,
173
+ });
174
+ this.upsertOfflineDb();
139
175
  };
140
176
 
141
177
  get data(): PollState {
@@ -147,6 +183,7 @@ export class Poll {
147
183
  if (!isPollUpdatedEvent(event)) return;
148
184
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
149
185
  const { id, ...pollData } = extractPollData(event.poll);
186
+ if (pollData.is_closed) this.closeConfirmed = true;
150
187
  // @ts-expect-error type mismatch
151
188
  this.state.partialNext({ ...pollData, lastActivityAt: new Date(event.created_at) });
152
189
  this.upsertOfflineDb();
@@ -155,6 +192,7 @@ export class Poll {
155
192
  public handlePollClosed = (event: Event) => {
156
193
  if (event.poll?.id && event.poll.id !== this.id) return;
157
194
  if (!isPollClosedEventEvent(event)) return;
195
+ this.closeConfirmed = true;
158
196
  this.state.partialNext({
159
197
  is_closed: true,
160
198
  lastActivityAt: new Date(event.created_at),
@@ -169,8 +207,7 @@ export class Poll {
169
207
  const isOwnVote = event.poll_vote.user_id === this.client.userID;
170
208
  let latestAnswers = [...(currentState.latest_answers as PollAnswer[])];
171
209
  let ownAnswer = currentState.ownAnswer;
172
- const ownVotesByOptionId = currentState.ownVotesByOptionId;
173
- let maxVotedOptionIds = currentState.maxVotedOptionIds;
210
+ const ownVotesByOptionId = { ...this.optimisticVotes.serverOwnVotes };
174
211
 
175
212
  if (isOwnVote) {
176
213
  if (isVoteAnswer(event.poll_vote)) {
@@ -182,20 +219,12 @@ export class Poll {
182
219
 
183
220
  if (isVoteAnswer(event.poll_vote)) {
184
221
  latestAnswers = [event.poll_vote, ...latestAnswers];
185
- } else {
186
- maxVotedOptionIds = getMaxVotedOptionIds(event.poll.vote_counts_by_option);
187
222
  }
188
223
 
189
- const pollEnrichData = extractPollEnrichedData(event.poll);
190
- this.state.partialNext({
191
- ...pollEnrichData,
224
+ this.updateFromVoteEvent(event, ownVotesByOptionId, {
192
225
  latest_answers: latestAnswers,
193
- lastActivityAt: new Date(event.created_at),
194
226
  ownAnswer,
195
- ownVotesByOptionId,
196
- maxVotedOptionIds,
197
227
  });
198
- this.upsertOfflineDb();
199
228
  };
200
229
 
201
230
  public handleVoteChanged = (event: Event) => {
@@ -206,8 +235,7 @@ export class Poll {
206
235
  const isOwnVote = event.poll_vote.user_id === this.client.userID;
207
236
  let latestAnswers = [...(currentState.latest_answers as PollAnswer[])];
208
237
  let ownAnswer = currentState.ownAnswer;
209
- let ownVotesByOptionId = currentState.ownVotesByOptionId;
210
- let maxVotedOptionIds = currentState.maxVotedOptionIds;
238
+ let ownVotesByOptionId = this.optimisticVotes.serverOwnVotes;
211
239
 
212
240
  if (isOwnVote) {
213
241
  if (isVoteAnswer(event.poll_vote)) {
@@ -238,24 +266,15 @@ export class Poll {
238
266
  if (ownAnswer?.id === event.poll_vote.id) {
239
267
  ownAnswer = undefined;
240
268
  }
241
- maxVotedOptionIds = getMaxVotedOptionIds(event.poll.vote_counts_by_option);
242
269
  }
243
270
  } else if (isVoteAnswer(event.poll_vote)) {
244
271
  latestAnswers = [event.poll_vote, ...latestAnswers];
245
- } else {
246
- maxVotedOptionIds = getMaxVotedOptionIds(event.poll.vote_counts_by_option);
247
272
  }
248
273
 
249
- const pollEnrichData = extractPollEnrichedData(event.poll);
250
- this.state.partialNext({
251
- ...pollEnrichData,
274
+ this.updateFromVoteEvent(event, ownVotesByOptionId, {
252
275
  latest_answers: latestAnswers,
253
- lastActivityAt: new Date(event.created_at),
254
276
  ownAnswer,
255
- ownVotesByOptionId,
256
- maxVotedOptionIds,
257
277
  });
258
- this.upsertOfflineDb();
259
278
  };
260
279
 
261
280
  public handleVoteRemoved = (event: Event) => {
@@ -265,31 +284,21 @@ export class Poll {
265
284
  const isOwnVote = event.poll_vote.user_id === this.client.userID;
266
285
  let latestAnswers = [...(currentState.latest_answers as PollAnswer[])];
267
286
  let ownAnswer = currentState.ownAnswer;
268
- const ownVotesByOptionId = { ...currentState.ownVotesByOptionId };
269
- let maxVotedOptionIds = currentState.maxVotedOptionIds;
287
+ const ownVotesByOptionId = { ...this.optimisticVotes.serverOwnVotes };
270
288
 
271
289
  if (isVoteAnswer(event.poll_vote)) {
272
290
  latestAnswers = latestAnswers.filter((answer) => answer.id !== event.poll_vote.id);
273
291
  if (isOwnVote) {
274
292
  ownAnswer = undefined;
275
293
  }
276
- } else {
277
- maxVotedOptionIds = getMaxVotedOptionIds(event.poll.vote_counts_by_option);
278
- if (isOwnVote && event.poll_vote.option_id) {
279
- delete ownVotesByOptionId[event.poll_vote.option_id];
280
- }
294
+ } else if (isOwnVote && event.poll_vote.option_id) {
295
+ delete ownVotesByOptionId[event.poll_vote.option_id];
281
296
  }
282
297
 
283
- const pollEnrichData = extractPollEnrichedData(event.poll);
284
- this.state.partialNext({
285
- ...pollEnrichData,
298
+ this.updateFromVoteEvent(event, ownVotesByOptionId, {
286
299
  latest_answers: latestAnswers,
287
- lastActivityAt: new Date(event.created_at),
288
300
  ownAnswer,
289
- ownVotesByOptionId,
290
- maxVotedOptionIds,
291
301
  });
292
- this.upsertOfflineDb();
293
302
  };
294
303
 
295
304
  query = async (id: string) => {
@@ -304,7 +313,26 @@ export class Poll {
304
313
  partialUpdate = async (partialPollObject: PartialPollUpdate) =>
305
314
  await this.client.partialUpdatePoll(this.id as string, partialPollObject);
306
315
 
307
- close = async () => await this.client.closePoll(this.id as string);
316
+ /**
317
+ * Closes the poll. The poll is marked as closed optimistically and the change is
318
+ * reverted if the request fails, unless the server confirmed the close meanwhile.
319
+ * Does nothing if the poll is already closed.
320
+ */
321
+ close = async () => {
322
+ if (this.data.is_closed) return;
323
+
324
+ this.closeConfirmed = false;
325
+ this.state.partialNext({ is_closed: true });
326
+
327
+ try {
328
+ return await this.client.closePoll(this.id as string);
329
+ } catch (error) {
330
+ if (!this.closeConfirmed) {
331
+ this.state.partialNext({ is_closed: false });
332
+ }
333
+ throw error;
334
+ }
335
+ };
308
336
 
309
337
  delete = async () => await this.client.deletePoll(this.id as string);
310
338
 
@@ -317,6 +345,11 @@ export class Poll {
317
345
  deleteOption = async (optionId: string) =>
318
346
  await this.client.deletePollOption(this.id as string, optionId);
319
347
 
348
+ /**
349
+ * Casts a vote for an option. The vote is shown right away through a local placeholder vote,
350
+ * which the server's vote replaces once the WS events of all pending vote changes arrived.
351
+ * If the request fails, the change is reverted.
352
+ */
320
353
  castVote = async (optionId: string, messageId: string) => {
321
354
  const { max_votes_allowed, ownVotesByOptionId } = this.data;
322
355
 
@@ -336,13 +369,17 @@ export class Poll {
336
369
  });
337
370
  return;
338
371
  }
339
- return await this.client.castPollVote(messageId, this.id as string, {
340
- option_id: optionId,
341
- });
372
+
373
+ return await this.optimisticVotes.castVote(optionId, messageId);
342
374
  };
343
375
 
376
+ /**
377
+ * Removes a vote. The vote is removed right away and restored if the request fails. Vote
378
+ * requests are sent in order, so removing a vote whose cast is not confirmed yet uses the vote
379
+ * id from the cast's response; if that cast failed, there is nothing to remove.
380
+ */
344
381
  removeVote = async (voteId: string, messageId: string) =>
345
- await this.client.removePollVote(messageId, this.id as string, voteId);
382
+ await this.optimisticVotes.removeVote(voteId, messageId);
346
383
 
347
384
  addAnswer = async (answerText: string, messageId: string) =>
348
385
  await this.client.addPollAnswer(messageId, this.id as string, answerText);
@@ -367,20 +404,6 @@ export class Poll {
367
404
  );
368
405
  }
369
406
 
370
- function getMaxVotedOptionIds(voteCountsByOption: PollResponse['vote_counts_by_option']) {
371
- let maxVotes = 0;
372
- let winningOptions: string[] = [];
373
- for (const [id, count] of Object.entries(voteCountsByOption ?? {})) {
374
- if (count > maxVotes) {
375
- winningOptions = [id];
376
- maxVotes = count;
377
- } else if (count === maxVotes) {
378
- winningOptions.push(id);
379
- }
380
- }
381
- return winningOptions;
382
- }
383
-
384
407
  function getOwnVotesByOptionId(ownVotes: PollVote[]) {
385
408
  return !ownVotes
386
409
  ? ({} as Record<OptionId, PollVote>)
@@ -406,7 +429,10 @@ export function extractPollData(pollResponse: PollResponse): PollData {
406
429
  };
407
430
  }
408
431
 
409
- export function mapPollStateToResponse(poll: Poll): PollResponse {
432
+ export function mapPollStateToResponse(
433
+ poll: Poll,
434
+ state: PollState = poll.data,
435
+ ): PollResponse {
410
436
  const {
411
437
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
412
438
  lastActivityAt,
@@ -415,7 +441,7 @@ export function mapPollStateToResponse(poll: Poll): PollResponse {
415
441
  ownVotesByOptionId,
416
442
  ownAnswer,
417
443
  ...restState
418
- } = poll.data;
444
+ } = state;
419
445
  const ownVotes = [
420
446
  ...Object.values(ownVotesByOptionId),
421
447
  ...(ownAnswer ? [ownAnswer] : []),