UNPKG

stream-chat

Version:

JS SDK for the Stream Chat API

441 lines (383 loc) 13.9 kB
import { StateStore } from './store'; import type { StreamChat } from './client'; import type { Event, 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; export type PollState = Omit<PollResponse, 'own_votes' | 'id'> & { lastActivityAt: Date; // todo: would be ideal to get this from the BE maxVotedOptionIds: OptionId[]; ownVotesByOptionId: Record<OptionId, PollVote>; ownAnswer?: PollAnswer; // each user can have only one answer }; type PollInitOptions = { client: StreamChat; poll: PollResponse; }; export class Poll { public readonly state: StateStore<PollState>; public id: string; private client: StreamChat; constructor({ client, poll }: PollInitOptions) { this.client = client; this.id = poll.id; this.state = new StateStore<PollState>(this.getInitialStateFromPollResponse(poll)); } private getInitialStateFromPollResponse = (poll: PollInitOptions['poll']) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { own_votes, id, ...pollResponseForState } = poll; 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: [] }; return { ...pollResponseForState, lastActivityAt: new Date(), maxVotedOptionIds: getMaxVotedOptionIds( pollResponseForState.vote_counts_by_option as PollResponse['vote_counts_by_option'], ), ownAnswer, ownVotesByOptionId: getOwnVotesByOptionId(ownVotes), }; }; private upsertOfflineDb = () => { this.client.offlineDb?.executeQuerySafely( (db) => db.upsertPoll({ poll: mapPollStateToResponse(this) }), { method: 'upsertPoll' }, ); }; public reinitializeState = (poll: PollInitOptions['poll']) => { this.state.partialNext(this.getInitialStateFromPollResponse(poll)); }; get data(): PollState { return this.state.getLatestValue(); } public handlePollUpdated = (event: Event) => { if (event.poll?.id && event.poll.id !== this.id) return; if (!isPollUpdatedEvent(event)) return; // eslint-disable-next-line @typescript-eslint/no-unused-vars const { id, ...pollData } = extractPollData(event.poll); // @ts-expect-error type mismatch this.state.partialNext({ ...pollData, lastActivityAt: new Date(event.created_at) }); this.upsertOfflineDb(); }; public handlePollClosed = (event: Event) => { if (event.poll?.id && event.poll.id !== this.id) return; if (!isPollClosedEventEvent(event)) return; this.state.partialNext({ is_closed: true, lastActivityAt: new Date(event.created_at), }); this.upsertOfflineDb(); }; public handleVoteCasted = (event: 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; 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 if (event.poll_vote.option_id) { ownVotesByOptionId[event.poll_vote.option_id] = event.poll_vote; } } if (isVoteAnswer(event.poll_vote)) { latestAnswers = [event.poll_vote, ...latestAnswers]; } else { maxVotedOptionIds = getMaxVotedOptionIds(event.poll.vote_counts_by_option); } const pollEnrichData = extractPollEnrichedData(event.poll); this.state.partialNext({ ...pollEnrichData, latest_answers: latestAnswers, lastActivityAt: new Date(event.created_at), ownAnswer, ownVotesByOptionId, maxVotedOptionIds, }); this.upsertOfflineDb(); }; public handleVoteChanged = (event: 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 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.id !== event.poll_vote.id), ]; ownAnswer = event.poll_vote; } else if (event.poll_vote.option_id) { if (event.poll.enforce_unique_vote) { ownVotesByOptionId = { [event.poll_vote.option_id]: event.poll_vote }; } else { ownVotesByOptionId = Object.entries(ownVotesByOptionId).reduce< Record<OptionId, PollVote> >((acc, [optionId, vote]) => { if ( optionId !== event.poll_vote.option_id && vote.id === event.poll_vote.id ) { return acc; } acc[optionId] = vote; return acc; }, {}); ownVotesByOptionId[event.poll_vote.option_id] = event.poll_vote; } 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 pollEnrichData = extractPollEnrichedData(event.poll); this.state.partialNext({ ...pollEnrichData, latest_answers: latestAnswers, lastActivityAt: new Date(event.created_at), ownAnswer, ownVotesByOptionId, maxVotedOptionIds, }); this.upsertOfflineDb(); }; public handleVoteRemoved = (event: 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 latestAnswers = [...(currentState.latest_answers as PollAnswer[])]; let ownAnswer = currentState.ownAnswer; const ownVotesByOptionId = { ...currentState.ownVotesByOptionId }; let maxVotedOptionIds = currentState.maxVotedOptionIds; if (isVoteAnswer(event.poll_vote)) { latestAnswers = latestAnswers.filter((answer) => answer.id !== event.poll_vote.id); if (isOwnVote) { ownAnswer = undefined; } } else { maxVotedOptionIds = getMaxVotedOptionIds(event.poll.vote_counts_by_option); if (isOwnVote && event.poll_vote.option_id) { delete ownVotesByOptionId[event.poll_vote.option_id]; } } const pollEnrichData = extractPollEnrichedData(event.poll); this.state.partialNext({ ...pollEnrichData, latest_answers: latestAnswers, lastActivityAt: new Date(event.created_at), ownAnswer, ownVotesByOptionId, maxVotedOptionIds, }); this.upsertOfflineDb(); }; query = async (id: string) => { const { poll } = await this.client.getPoll(id); this.state.partialNext({ ...poll, lastActivityAt: new Date() }); return poll; }; update = async (data: Exclude<PollData, 'id'>) => await this.client.updatePoll({ ...data, id: this.id }); partialUpdate = async (partialPollObject: PartialPollUpdate) => await this.client.partialUpdatePoll(this.id as string, partialPollObject); close = async () => await this.client.closePoll(this.id as string); delete = async () => await this.client.deletePoll(this.id as string); createOption = async (option: PollOptionData) => await this.client.createPollOption(this.id as string, option); updateOption = async (option: PollOptionData) => await this.client.updatePollOption(this.id as string, option); deleteOption = async (optionId: string) => await this.client.deletePollOption(this.id as string, optionId); castVote = async (optionId: string, messageId: string) => { const { max_votes_allowed, ownVotesByOptionId } = this.data; const reachedVoteLimit = max_votes_allowed && max_votes_allowed === Object.keys(ownVotesByOptionId).length; if (reachedVoteLimit) { this.client.notifications.addInfo({ message: 'Reached the vote limit. Remove an existing vote first.', origin: { emitter: 'Poll', context: { messageId, optionId }, }, options: { type: 'validation:poll:castVote:limit', }, }); return; } return await this.client.castPollVote(messageId, this.id as string, { option_id: optionId, }); }; removeVote = async (voteId: string, messageId: string) => await this.client.removePollVote(messageId, this.id as string, voteId); addAnswer = async (answerText: string, messageId: string) => await this.client.addPollAnswer(messageId, this.id as string, answerText); removeAnswer = async (answerId: string, messageId: string) => await this.client.removePollVote(messageId, this.id as string, answerId); queryAnswers = async (params: PollAnswersQueryParams) => await this.client.queryPollAnswers( this.id as string, params.filter, params.sort, params.options, ); queryOptionVotes = async (params: PollOptionVotesQueryParams) => 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<OptionId, PollVote>) : ownVotes.reduce<Record<OptionId, PollVote>>((acc, vote) => { if (isVoteAnswer(vote) || !vote.option_id) return acc; acc[vote.option_id] = vote; return acc; }, {}); } export 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, }; } export function mapPollStateToResponse(poll: Poll): PollResponse { const { // eslint-disable-next-line @typescript-eslint/no-unused-vars lastActivityAt, // eslint-disable-next-line @typescript-eslint/no-unused-vars maxVotedOptionIds, ownVotesByOptionId, ownAnswer, ...restState } = poll.data; const ownVotes = [ ...Object.values(ownVotesByOptionId), ...(ownAnswer ? [ownAnswer] : []), ].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at)); return { ...restState, own_votes: ownVotes, id: poll.id, }; } export function extractPollEnrichedData( pollResponse: PollResponse, ): Omit<PollEnrichData, 'own_votes' | 'latest_answers'> { return { answers_count: pollResponse.answers_count, latest_votes_by_option: pollResponse.latest_votes_by_option, vote_count: pollResponse.vote_count, vote_counts_by_option: pollResponse.vote_counts_by_option, }; }