diff --git a/src/channel.ts b/src/channel.ts index 2117816fe..692bf19c0 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -1462,31 +1462,6 @@ export class Channel, - poll: PollResponse, - messageId: string, - ) => { - const message = this.findMessage(messageId); - if (!message) return; - - if (message.poll_id !== pollVote.poll_id) return; - - const updatedPoll = { ...poll }; - let ownVotes = [...(message.poll?.own_votes || [])]; - - if (pollVote.user_id === this._channel.getClient().userID) { - if (pollVote.option_id && poll.enforce_unique_vote) { - // remove all previous votes where option_id is not empty - ownVotes = ownVotes.filter((vote) => !vote.option_id); - } else if (pollVote.answer_text) { - // remove all previous votes where option_id is empty - ownVotes = ownVotes.filter((vote) => vote.answer_text); - } - - ownVotes.push(pollVote); - } - - updatedPoll.own_votes = ownVotes as PollVote[]; - const newMessage = { ...message, poll: updatedPoll }; - - this.addMessageSorted((newMessage as unknown) as MessageResponse, false, false); - }; - - addPollVote = (pollVote: PollVote, poll: PollResponse, messageId: string) => { - const message = this.findMessage(messageId); - if (!message) return; - - if (message.poll_id !== pollVote.poll_id) return; - - const updatedPoll = { ...poll }; - const ownVotes = [...(message.poll?.own_votes || [])]; - - if (pollVote.user_id === this._channel.getClient().userID) { - ownVotes.push(pollVote); - } - - updatedPoll.own_votes = ownVotes as PollVote[]; - const newMessage = { ...message, poll: updatedPoll }; - - this.addMessageSorted((newMessage as unknown) as MessageResponse, false, false); - }; - - removePollVote = ( - pollVote: PollVote, - poll: PollResponse, - messageId: string, - ) => { - const message = this.findMessage(messageId); - if (!message) return; - - if (message.poll_id !== pollVote.poll_id) return; - - const updatedPoll = { ...poll }; - const ownVotes = [...(message.poll?.own_votes || [])]; - if (pollVote.user_id === this._channel.getClient().userID) { - const index = ownVotes.findIndex((vote) => vote.option_id === pollVote.option_id); - if (index > -1) { - ownVotes.splice(index, 1); - } - } - - updatedPoll.own_votes = ownVotes as PollVote[]; - - const newMessage = { ...message, poll: updatedPoll }; - this.addMessageSorted((newMessage as unknown) as MessageResponse, false, false); - }; - - updatePoll = (poll: PollResponse, messageId: string) => { - const message = this.findMessage(messageId); - if (!message) return; - - const updatedPoll = { - ...poll, - own_votes: [...(message.poll?.own_votes || [])], - }; - - const newMessage = { ...message, poll: updatedPoll }; - - this.addMessageSorted((newMessage as unknown) as MessageResponse, false, false); - }; - /** * Updates the message.user property with updated user object, for messages. * diff --git a/src/client.ts b/src/client.ts index 38af3c5c6..870aea930 100644 --- a/src/client.ts +++ b/src/client.ts @@ -42,10 +42,12 @@ import { BlockList, BlockListResponse, BlockUserAPIResponse, - CampaignResponse, CampaignData, CampaignFilters, CampaignQueryOptions, + CampaignResponse, + CampaignSort, + CastVoteAPIResponse, ChannelAPIResponse, ChannelData, ChannelFilters, @@ -66,6 +68,9 @@ import { CreateImportOptions, CreateImportResponse, CreateImportURLResponse, + CreatePollAPIResponse, + CreatePollData, + CreatePollOptionAPIResponse, CustomPermissionOptions, DeactivateUsersOptions, DefaultGenerics, @@ -92,13 +97,18 @@ import { FlagsPaginationOptions, FlagsResponse, FlagUserResponse, + GetBlockedUsersAPIResponse, GetCallTokenResponse, GetChannelTypeResponse, GetCommandResponse, GetImportResponse, GetMessageAPIResponse, + GetMessageOptions, + GetPollAPIResponse, + GetPollOptionAPIResponse, GetRateLimitsResponse, - QueryThreadsAPIResponse, + GetThreadAPIResponse, + GetThreadOptions, GetUnreadCountAPIResponse, GetUnreadCountBatchAPIResponse, ListChannelResponse, @@ -120,11 +130,15 @@ import { OwnUserResponse, PartialMessageUpdate, PartialPollUpdate, + PartialThreadUpdate, PartialUserUpdate, PermissionAPIResponse, PermissionsAPIResponse, + PollAnswersAPIResponse, PollData, PollOptionData, + PollSort, + PollVote, PollVoteData, PollVotesAPIResponse, PushProvider, @@ -133,9 +147,24 @@ import { PushProviderListResponse, PushProviderUpsertResponse, QueryChannelsAPIResponse, - QuerySegmentsOptions, + QueryMessageHistoryFilters, + QueryMessageHistoryOptions, + QueryMessageHistoryResponse, + QueryMessageHistorySort, + QueryPollsFilters, + QueryPollsOptions, QueryPollsResponse, + QueryReactionsAPIResponse, + QueryReactionsOptions, + QuerySegmentsOptions, + QuerySegmentTargetsFilter, + QueryThreadsAPIResponse, + QueryThreadsOptions, + QueryVotesFilters, + QueryVotesOptions, + ReactionFilters, ReactionResponse, + ReactionSort, ReactivateUserOptions, ReactivateUsersOptions, ReservedMessageFields, @@ -145,10 +174,12 @@ import { SearchMessageSortBase, SearchOptions, SearchPayload, - SegmentResponse, SegmentData, + SegmentResponse, + SegmentTargetsResponse, SegmentType, SendFileAPIResponse, + SortParam, StreamChatOptions, SyncOptions, SyncResponse, @@ -166,50 +197,22 @@ import { UpdatedMessage, UpdateMessageAPIResponse, UpdateMessageOptions, + UpdatePollAPIResponse, + UpdatePollOptionAPIResponse, UpdateSegmentData, UserCustomEvent, UserFilters, UserOptions, UserResponse, UserSort, - GetThreadAPIResponse, - PartialThreadUpdate, - QueryThreadsOptions, - GetThreadOptions, - CampaignSort, - SegmentTargetsResponse, - QuerySegmentTargetsFilter, - SortParam, - GetMessageOptions, - GetBlockedUsersAPIResponse, - QueryVotesFilters, VoteSort, - CreatePollAPIResponse, - GetPollAPIResponse, - UpdatePollAPIResponse, - CreatePollOptionAPIResponse, - GetPollOptionAPIResponse, - UpdatePollOptionAPIResponse, - PollVote, - CastVoteAPIResponse, - QueryPollsFilters, - PollSort, - QueryPollsOptions, - QueryVotesOptions, - ReactionFilters, - ReactionSort, - QueryReactionsAPIResponse, - QueryReactionsOptions, - QueryMessageHistoryFilters, - QueryMessageHistorySort, - QueryMessageHistoryOptions, - QueryMessageHistoryResponse, } from './types'; import { InsightMetrics, postInsights } from './insights'; import { Thread } from './thread'; import { Moderation } from './moderation'; import { ThreadManager } from './thread_manager'; import { DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE } from './constants'; +import { PollManager } from './poll_manager'; function isString(x: unknown): x is string { return typeof x === 'string' || x instanceof String; @@ -223,6 +226,7 @@ export class StreamChat; }; threads: ThreadManager; + polls: PollManager; anonymous: boolean; persistUserOnConnectionFailure?: boolean; axiosInstance: AxiosInstance; @@ -410,6 +414,7 @@ export class StreamChat null; this.recoverStateOnReconnect = this.options.recoverStateOnReconnect; this.threads = new ThreadManager({ client: this }); + this.polls = new PollManager({ client: this }); } /** @@ -3544,12 +3549,12 @@ export class StreamChat(this.baseURL + `/polls`, { + async createPoll(poll: CreatePollData, userId?: string) { + return await this.post>(this.baseURL + `/polls`, { ...poll, ...(userId ? { user_id: userId } : {}), }); @@ -3561,8 +3566,8 @@ export class StreamChat { - return await this.get( + async getPoll(id: string, userId?: string): Promise> { + return await this.get>( this.baseURL + `/polls/${encodeURIComponent(id)}`, userId ? { user_id: userId } : {}, ); @@ -3574,8 +3579,8 @@ export class StreamChat(this.baseURL + `/polls`, { + async updatePoll(poll: PollData, userId?: string) { + return await this.put>(this.baseURL + `/polls`, { ...poll, ...(userId ? { user_id: userId } : {}), }); @@ -3591,13 +3596,16 @@ export class StreamChat, userId?: string, - ): Promise { - return await this.patch(this.baseURL + `/polls/${encodeURIComponent(id)}`, { - ...partialPollObject, - ...(userId ? { user_id: userId } : {}), - }); + ): Promise> { + return await this.patch>( + this.baseURL + `/polls/${encodeURIComponent(id)}`, + { + ...partialPollObject, + ...(userId ? { user_id: userId } : {}), + }, + ); } /** @@ -3618,13 +3626,16 @@ export class StreamChat { - return this.partialUpdatePoll(id, { - set: { - is_closed: true, + async closePoll(id: string, userId?: string): Promise> { + return this.partialUpdatePoll( + id, + { + set: { + is_closed: true, + } as PartialPollUpdate['set'], }, - ...(userId ? { user_id: userId } : {}), - }); + userId, + ); } /** @@ -3634,8 +3645,8 @@ export class StreamChat( + async createPollOption(pollId: string, option: PollOptionData, userId?: string) { + return await this.post>( this.baseURL + `/polls/${encodeURIComponent(pollId)}/options`, { ...option, @@ -3652,7 +3663,7 @@ export class StreamChat( + return await this.get>( this.baseURL + `/polls/${encodeURIComponent(pollId)}/options/${encodeURIComponent(optionId)}`, userId ? { user_id: userId } : {}, ); @@ -3665,8 +3676,8 @@ export class StreamChat( + async updatePollOption(pollId: string, option: PollOptionData, userId?: string) { + return await this.put>( this.baseURL + `/polls/${encodeURIComponent(pollId)}/options`, { ...option, @@ -3698,7 +3709,7 @@ export class StreamChat( + return await this.post>( this.baseURL + `/messages/${encodeURIComponent(messageId)}/polls/${encodeURIComponent(pollId)}/vote`, { vote, @@ -3750,9 +3761,9 @@ export class StreamChat { + ): Promise> { const q = userId ? `?user_id=${userId}` : ''; - return await this.post(this.baseURL + `/polls/query${q}`, { + return await this.post>(this.baseURL + `/polls/query${q}`, { filter, sort: normalizeQuerySort(sort), ...options, @@ -3774,9 +3785,9 @@ export class StreamChat { + ): Promise> { const q = userId ? `?user_id=${userId}` : ''; - return await this.post( + return await this.post>( this.baseURL + `/polls/${encodeURIComponent(pollId)}/votes${q}`, { filter, @@ -3786,6 +3797,33 @@ export class StreamChat> { + const q = userId ? `?user_id=${userId}` : ''; + return await this.post>( + this.baseURL + `/polls/${encodeURIComponent(pollId)}/votes${q}`, + { + filter: { ...filter, is_answer: true }, + sort: normalizeQuerySort(sort), + ...options, + }, + ); + } + /** * Query message history * @param filter @@ -3797,12 +3835,15 @@ export class StreamChat { - return await this.post(this.baseURL + '/messages/history', { - filter, - sort: normalizeQuerySort(sort), - ...options, - }); + ): Promise> { + return await this.post>( + this.baseURL + '/messages/history', + { + filter, + sort: normalizeQuerySort(sort), + ...options, + }, + ); } /** diff --git a/src/index.ts b/src/index.ts index 64c3c9231..c0d0901f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,19 +1,21 @@ export * from './base64'; +export * from './campaign'; export * from './client'; export * from './client_state'; export * from './channel'; export * from './channel_state'; -export * from './thread'; -export * from './thread_manager'; export * from './connection'; export * from './events'; +export * from './insights'; export * from './moderation'; export * from './permissions'; +export * from './poll'; +export * from './poll_manager'; +export * from './segment'; export * from './signing'; +export * from './store'; +export * from './thread'; +export * from './thread_manager'; export * from './token_manager'; -export * from './insights'; export * from './types'; -export * from './segment'; -export * from './campaign'; export { isOwnUser, chatCodes, logChatPromiseExecution, formatMessage } from './utils'; -export * from './store'; diff --git a/src/poll.ts b/src/poll.ts new file mode 100644 index 000000000..7bcb4900f --- /dev/null +++ b/src/poll.ts @@ -0,0 +1,408 @@ +import { StateStore } from './store'; +import type { StreamChat } from './client'; +import type { + DefaultGenerics, + Event, + ExtendableGenerics, + PartialPollUpdate, + PollAnswer, + PollData, + PollEnrichData, + PollOptionData, + PollResponse, + PollVote, + QueryVotesFilters, + QueryVotesOptions, + VoteSort, +} from './types'; + +type PollEvent = { + cid: string; + created_at: string; + poll: PollResponse; +}; + +type PollUpdatedEvent = PollEvent & { + type: 'poll.updated'; +}; + +type PollClosedEvent = PollEvent & { + type: 'poll.closed'; +}; + +type PollVoteEvent = { + cid: string; + created_at: string; + poll: PollResponse; + poll_vote: PollVote | PollAnswer; +}; + +type PollVoteCastedEvent = PollVoteEvent & { + type: 'poll.vote_casted'; +}; + +type PollVoteCastedChanged = PollVoteEvent & { + type: 'poll.vote_removed'; +}; + +type PollVoteCastedRemoved = PollVoteEvent & { + type: 'poll.vote_removed'; +}; + +const isPollUpdatedEvent = ( + e: Event, +): e is PollUpdatedEvent => e.type === 'poll.updated'; +const isPollClosedEventEvent = ( + e: Event, +): e is PollClosedEvent => e.type === 'poll.closed'; +const isPollVoteCastedEvent = ( + e: Event, +): e is PollVoteCastedEvent => e.type === 'poll.vote_casted'; +const isPollVoteChangedEvent = ( + e: Event, +): e is PollVoteCastedChanged => e.type === 'poll.vote_changed'; +const isPollVoteRemovedEvent = ( + e: Event, +): e is PollVoteCastedRemoved => e.type === 'poll.vote_removed'; + +export const isVoteAnswer = ( + vote: PollVote | PollAnswer, +): vote is PollAnswer => !!(vote as PollAnswer)?.answer_text; + +export type PollAnswersQueryParams = { + filter?: QueryVotesFilters; + options?: QueryVotesOptions; + sort?: VoteSort; +}; + +export type PollOptionVotesQueryParams = { + filter: { option_id: string } & QueryVotesFilters; + options?: QueryVotesOptions; + sort?: VoteSort; +}; + +type OptionId = string; +type PollVoteId = string; + +export type PollState = SCG['pollType'] & Omit, 'own_votes' | 'id'> & { + lastActivityAt: Date; // todo: would be ideal to get this from the BE + maxVotedOptionIds: OptionId[]; + ownVotes: PollVote[]; + ownVotesByOptionId: Record; // single user can vote only once for the same option + ownAnswer?: PollAnswer; // each user can have only one answer +}; + +type PollInitOptions = { + client: StreamChat; + poll: PollResponse; +}; + +export class Poll { + public readonly state: StateStore>; + public id: string; + private client: StreamChat; + private unsubscribeFunctions: Set<() => void> = new Set(); + + constructor({ client, poll: { own_votes, id, ...pollResponseForState } }: PollInitOptions) { + this.client = client; + this.id = id; + const { ownAnswer, ownVotes } = own_votes?.reduce<{ownVotes: PollVote[], ownAnswer?: PollAnswer}>((acc, voteOrAnswer) => { + if (isVoteAnswer(voteOrAnswer)) { + acc.ownAnswer = voteOrAnswer; + } else { + acc.ownVotes.push(voteOrAnswer); + } + return acc; + }, {ownVotes: []}) ?? {ownVotes: []}; + + this.state = new StateStore>({ + ...pollResponseForState, + lastActivityAt: new Date(), + maxVotedOptionIds: getMaxVotedOptionIds(pollResponseForState.vote_counts_by_option as PollResponse['vote_counts_by_option']), + ownAnswer, + ownVotesByOptionId: getOwnVotesByOptionId(ownVotes), + ownVotes, + }); + } + + get data(): PollState { + return this.state.getLatestValue(); + } + + public registerSubscriptions = () => { + if (this.unsubscribeFunctions.size) { + // Already listening for events and changes + return; + } + + this.unsubscribeFunctions.add(this.subscribePollUpdated()); + this.unsubscribeFunctions.add(this.subscribePollClosed()); + this.unsubscribeFunctions.add(this.subscribeVoteCasted()); + this.unsubscribeFunctions.add(this.subscribeVoteChanged()); + this.unsubscribeFunctions.add(this.subscribeVoteRemoved()); + }; + + public unregisterSubscriptions = () => { + this.unsubscribeFunctions.forEach((cleanupFunction) => cleanupFunction()); + this.unsubscribeFunctions.clear(); + }; + + private subscribePollUpdated = () => { + return this.client.on('poll.updated', (event) => { + if (event.poll?.id && event.poll.id !== this.id) return; + if (!isPollUpdatedEvent(event)) return; + // @ts-ignore + this.state.partialNext({ ...extractPollData(event.poll), lastActivityAt: new Date(event.created_at) }); + }).unsubscribe; + } + + private subscribePollClosed = () => { + return this.client.on('poll.closed', (event) => { + if (event.poll?.id && event.poll.id !== this.id) return; + if (!isPollClosedEventEvent(event)) return; + // @ts-ignore + this.state.partialNext({ is_closed: true, lastActivityAt: new Date(event.created_at) }); + }).unsubscribe; + } + + private subscribeVoteCasted = () => { + return this.client.on('poll.vote_casted', (event) => { + if (event.poll?.id && event.poll.id !== this.id) return; + if (!isPollVoteCastedEvent(event)) return; + const currentState = this.data; + const isOwnVote = event.poll_vote.user_id === this.client.userID; + const ownVotes = [...(currentState?.ownVotes || [])]; + let latestAnswers = [...(currentState.latest_answers as PollAnswer[])]; + let ownAnswer = currentState.ownAnswer; + const ownVotesByOptionId = currentState.ownVotesByOptionId; + let maxVotedOptionIds = currentState.maxVotedOptionIds; + + if (isOwnVote) { + if (isVoteAnswer(event.poll_vote)) { + ownAnswer = event.poll_vote; + } else { + ownVotes.push(event.poll_vote); + if (event.poll_vote.option_id) { + ownVotesByOptionId[event.poll_vote.option_id] = event.poll_vote.id; + } + } + } + + if (isVoteAnswer(event.poll_vote)) { + latestAnswers = [event.poll_vote, ...latestAnswers]; + } else { + maxVotedOptionIds = getMaxVotedOptionIds(event.poll.vote_counts_by_option); + } + + const { latest_answers, own_votes, ...pollEnrichData } = extractPollEnrichedData(event.poll); + // @ts-ignore + this.state.partialNext( { + ...pollEnrichData, + latest_answers: latestAnswers, + lastActivityAt: new Date(event.created_at), + ownAnswer, + ownVotes, + ownVotesByOptionId, + maxVotedOptionIds, + }); + }).unsubscribe; + } + + private subscribeVoteChanged = () => { + return this.client.on('poll.vote_changed', (event) => { + // this event is triggered only when event.poll.enforce_unique_vote === true + if (event.poll?.id && event.poll.id !== this.id) return; + if (!isPollVoteChangedEvent(event)) return; + const currentState = this.data; + const isOwnVote = event.poll_vote.user_id === this.client.userID; + let ownVotes = [...(currentState?.ownVotes || [])]; + let latestAnswers = [...(currentState.latest_answers as PollAnswer[])]; + let ownAnswer = currentState.ownAnswer; + let ownVotesByOptionId = currentState.ownVotesByOptionId; + let maxVotedOptionIds = currentState.maxVotedOptionIds; + + if (isOwnVote) { + if (isVoteAnswer(event.poll_vote)) { + latestAnswers = [ + event.poll_vote, + ...latestAnswers.filter((answer) => answer.user_id !== event.poll_vote.user_id), + ]; + ownVotes = ownVotes.filter((vote) => vote.id !== event.poll_vote.id); + ownAnswer = event.poll_vote; + } else { // event.poll.enforce_unique_vote === true + ownVotes = [event.poll_vote]; + ownVotesByOptionId = {[event.poll_vote.option_id!]: event.poll_vote.id}; + + if (ownAnswer?.id === event.poll_vote.id) { + ownAnswer = undefined; + } + maxVotedOptionIds = getMaxVotedOptionIds(event.poll.vote_counts_by_option); + } + } else if (isVoteAnswer(event.poll_vote)) { + latestAnswers = [event.poll_vote, ...latestAnswers]; + } else { + maxVotedOptionIds = getMaxVotedOptionIds(event.poll.vote_counts_by_option); + } + + const { latest_answers, own_votes, ...pollEnrichData } = extractPollEnrichedData(event.poll); + // @ts-ignore + this.state.partialNext({ + ...pollEnrichData, + latest_answers: latestAnswers, + lastActivityAt: new Date(event.created_at), + ownAnswer, + ownVotes, + ownVotesByOptionId, + maxVotedOptionIds, + }); + }).unsubscribe; + } + + private subscribeVoteRemoved = () => { + return this.client.on('poll.vote_removed', (event) => { + if (event.poll?.id && event.poll.id !== this.id) return; + if (!isPollVoteRemovedEvent(event)) return; + const currentState = this.data; + const isOwnVote = event.poll_vote.user_id === this.client.userID; + let ownVotes = [...(currentState?.ownVotes || [])]; + let latestAnswers = [...(currentState.latest_answers as PollAnswer[])]; + let ownAnswer = currentState.ownAnswer; + const ownVotesByOptionId = { ...currentState.ownVotesByOptionId }; + let maxVotedOptionIds = currentState.maxVotedOptionIds; + + if (isOwnVote) { + ownVotes = ownVotes.filter((vote) => vote.id !== event.poll_vote.id); + if (event.poll_vote.option_id) { + delete ownVotesByOptionId[event.poll_vote.option_id]; + } + } + if (isVoteAnswer(event.poll_vote)) { + latestAnswers = latestAnswers.filter((answer) => answer.id !== event.poll_vote.id); + ownAnswer = undefined; + } else { + maxVotedOptionIds = getMaxVotedOptionIds(event.poll.vote_counts_by_option); + } + + const { latest_answers, own_votes, ...pollEnrichData } = extractPollEnrichedData(event.poll); + // @ts-ignore + this.state.partialNext({ + ...pollEnrichData, + latest_answers: latestAnswers, + lastActivityAt: new Date(event.created_at), + ownAnswer, + ownVotes, + ownVotesByOptionId, + maxVotedOptionIds, + }); + }).unsubscribe; + } + + query = async (id: string)=> { + const { poll } = await this.client.getPoll(id); + // @ts-ignore + this.state.partialNext({ ...poll, lastActivityAt: new Date() }); + return poll; + } + + update = async (data: Exclude, 'id'>) => { + return await this.client.updatePoll({ ...data, id: this.id }); + } + + partialUpdate = async (partialPollObject: PartialPollUpdate) => { + return await this.client.partialUpdatePoll(this.id as string, partialPollObject); + } + + close = async () => { + return await this.client.closePoll(this.id as string); + } + + delete = async () => { + return await this.client.deletePoll(this.id as string); + } + + createOption = async (option: PollOptionData) => { + return await this.client.createPollOption(this.id as string, option); + } + + updateOption = async (option: PollOptionData) => { + return await this.client.updatePollOption(this.id as string, option); + } + + deleteOption = async (optionId: string) => { + return await this.client.deletePollOption(this.id as string, optionId); + } + + castVote = async (optionId: string, messageId: string) => { + return await this.client.castPollVote(messageId, this.id as string, { option_id: optionId }); + } + + removeVote = async (voteId: string, messageId: string) => { + return await this.client.removePollVote(messageId, this.id as string, voteId); + } + + addAnswer = async (answerText: string, messageId: string) => { + return await this.client.addPollAnswer(messageId, this.id as string, answerText); + } + + removeAnswer = async (answerId: string, messageId: string) => { + return await this.client.removePollVote(messageId, this.id as string, answerId); + } + + queryAnswers = async (params: PollAnswersQueryParams) => { + return await this.client.queryPollAnswers(this.id as string, params.filter, params.sort, params.options); + } + + queryOptionVotes = async (params: PollOptionVotesQueryParams) => { + return await this.client.queryPollVotes(this.id as string, params.filter, params.sort, params.options); + } +} + +function getMaxVotedOptionIds(voteCountsByOption: PollResponse['vote_counts_by_option']) { + let maxVotes = 0; + let winningOptions: string[] = []; + for (const [id, count] of Object.entries(voteCountsByOption ?? {})) { + if (count > maxVotes) { + winningOptions = [id]; + maxVotes = count; + } else if (count === maxVotes) { + winningOptions.push(id); + } + } + return winningOptions; +} + +function getOwnVotesByOptionId(ownVotes: PollVote[]) { + return !ownVotes + ? ({} as Record) + : ownVotes.reduce>((acc, vote) => { + if (isVoteAnswer(vote) || !vote.option_id) return acc; + acc[vote.option_id] = vote.id; + return acc; + }, {}); +} + +function extractPollData (pollResponse: PollResponse): PollData { + return { + allow_answers: pollResponse.allow_answers, + allow_user_suggested_options: pollResponse.allow_user_suggested_options, + description: pollResponse.description, + enforce_unique_vote: pollResponse.enforce_unique_vote, + id: pollResponse.id, + is_closed: pollResponse.is_closed, + max_votes_allowed: pollResponse.max_votes_allowed, + name: pollResponse.name, + options: pollResponse.options, + voting_visibility: pollResponse.voting_visibility + }; +} + +function extractPollEnrichedData (pollResponse: PollResponse): PollEnrichData { + return { + answers_count: pollResponse.answers_count, + latest_answers: pollResponse.latest_answers, + latest_votes_by_option: pollResponse.latest_votes_by_option, + vote_count: pollResponse.vote_count, + vote_counts_by_option: pollResponse.vote_counts_by_option, + own_votes: pollResponse.own_votes, + }; +} diff --git a/src/poll_manager.ts b/src/poll_manager.ts new file mode 100644 index 000000000..2e056fbc8 --- /dev/null +++ b/src/poll_manager.ts @@ -0,0 +1,39 @@ +import type { StreamChat } from './client'; +import type { + CreatePollData, + DefaultGenerics, + ExtendableGenerics, + PollSort, + QueryPollsFilters, + QueryPollsOptions, +} from './types'; +import { Poll } from './poll'; + +export class PollManager { + private client: StreamChat; + + constructor({ client }: { client: StreamChat }) { + this.client = client; + } + + public createPoll = async (poll: CreatePollData) => { + const { poll: createdPoll } = await this.client.createPoll(poll); + + return new Poll({ client: this.client, poll: createdPoll }); + }; + + public getPoll = async (id: string) => { + const { poll } = await this.client.getPoll(id); + + return new Poll({ client: this.client, poll }); + }; + + public queryPolls = async (filter: QueryPollsFilters, sort: PollSort = [], options: QueryPollsOptions = {}) => { + const { polls, next } = await this.client.queryPolls(filter, sort, options); + + return { + polls: polls.map((poll) => new Poll({ client: this.client, poll })), + next, + }; + }; +} diff --git a/src/types.ts b/src/types.ts index 8e1652392..f6752ba97 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1226,7 +1226,7 @@ export type Event; - poll_vote?: PollVote; + poll_vote?: PollVote | PollAnswer; queriedChannels?: { channels: ChannelAPIResponse[]; isLatestMessageSet?: boolean; @@ -1489,6 +1489,12 @@ export type ChannelFilters; +export type QueryPollsParams = { + filter?: QueryPollsFilters; + options?: QueryPollsOptions; + sort?: PollSort; +}; + export type QueryPollsOptions = Pager; export type VotesFiltersOptions = { @@ -3004,28 +3010,20 @@ export type UpdatePollAPIResponse = StreamChatGenerics['pollType'] & { - answers_count: number; +> = StreamChatGenerics['pollType'] & PollEnrichData & { created_at: string; created_by: UserResponse | null; created_by_id: string; enforce_unique_vote: boolean; id: string; - latest_answers: PollVote[]; - latest_votes_by_option: Record[]>; max_votes_allowed: number; name: string; options: PollOption[]; updated_at: string; - vote_count: number; - vote_counts_by_option: Record; allow_answers?: boolean; allow_user_suggested_options?: boolean; - channel?: ChannelAPIResponse | null; - cid?: string; description?: string; is_closed?: boolean; - own_votes?: PollVote[]; voting_visibility?: VotingVisibility; }; @@ -3044,15 +3042,26 @@ export enum VotingVisibility { public = 'public', } +export type PollEnrichData< + StreamChatGenerics extends ExtendableGenerics = DefaultGenerics +> = { + answers_count: number; + latest_answers: PollAnswer[]; // not updated with WS events, ordered DESC by created_at, seems like updated_at cannot be different from created_at + latest_votes_by_option: Record[]>; // not updated with WS events; always null in anonymous polls + vote_count: number; + vote_counts_by_option: Record; + own_votes?: (PollVote | PollAnswer)[]; // not updated with WS events +}; + export type PollData< StreamChatGenerics extends ExtendableGenerics = DefaultGenerics > = StreamChatGenerics['pollType'] & { + id: string; name: string; allow_answers?: boolean; allow_user_suggested_options?: boolean; description?: string; enforce_unique_vote?: boolean; - id?: string; is_closed?: boolean; max_votes_allowed?: number; options?: PollOptionData[]; @@ -3060,15 +3069,16 @@ export type PollData< voting_visibility?: VotingVisibility; }; +export type CreatePollData = Partial> & Pick, 'name'> + export type PartialPollUpdate = { - // id: string; - set?: Partial>; - unset?: Array>; + set?: Partial>; + unset?: Array>; }; export type PollOptionData< StreamChatGenerics extends ExtendableGenerics = DefaultGenerics -> = StreamChatGenerics['pollType'] & { +> = StreamChatGenerics['pollOptionType'] & { text: string; id?: string; position?: number; @@ -3117,21 +3127,32 @@ export type PollOptionResponse< export type PollVote = { created_at: string; id: string; - is_answer: boolean; poll_id: string; user_id: string; - answer_text?: string; option_id?: string; user?: UserResponse; }; +export type PollAnswer = Exclude< + PollVote, + 'option_id' +> & { + answer_text: string; + is_answer: boolean; // this is absolutely redundant prop as answer_text indicates that a vote is an answer +}; + export type PollVotesAPIResponse = { - votes: PollVote[]; + votes: (PollVote | PollAnswer)[]; + next?: string; +}; + +export type PollAnswersAPIResponse = { + votes: PollAnswer[]; // todo: should be changes to answers? next?: string; }; export type CastVoteAPIResponse = { - vote: PollVote; + vote: PollVote | PollAnswer; }; export type QueryMessageHistoryFilters = QueryFilters<