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
package/dist/types/client.d.ts
CHANGED
|
@@ -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").
|
|
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").
|
|
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[];
|
package/dist/types/poll.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {};
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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?:
|
|
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)[];
|
package/dist/types/utils.d.ts
CHANGED
|
@@ -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?:
|
|
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
|
@@ -32,12 +32,15 @@ export const createLinkPreviewsCompositionMiddleware = (
|
|
|
32
32
|
LinkPreviewsManager.getPreviewData(preview),
|
|
33
33
|
);
|
|
34
34
|
|
|
35
|
-
const
|
|
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 (!
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 = { ...
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
340
|
-
|
|
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.
|
|
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(
|
|
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
|
-
} =
|
|
444
|
+
} = state;
|
|
419
445
|
const ownVotes = [
|
|
420
446
|
...Object.values(ownVotesByOptionId),
|
|
421
447
|
...(ownAnswer ? [ownAnswer] : []),
|