diff --git a/src/channel.ts b/src/channel.ts index 63b64ccddc..ca9cffab33 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -53,6 +53,7 @@ import { UserFilters, UserResponse, QueryChannelAPIResponse, + PollVoteData, SendMessageOptions, } from './types'; import { Role } from './permissions'; @@ -1158,6 +1159,20 @@ export class Channel(this._channelURL() + '/call', options); } + /** + * Cast or cancel one or more votes on a poll + * @param pollId string The poll id + * @param votes PollVoteData[] The votes that will be casted (or canceled in case of an empty array) + * @returns {APIResponse & PollVoteResponse} The poll votes + */ + async vote(messageId: string, pollId: string, vote: PollVoteData) { + return await this.getClient().castPollVote(messageId, pollId, vote); + } + + async removeVote(messageId: string, pollId: string, voteId: string) { + return await this.getClient().removePollVote(messageId, pollId, voteId); + } + /** * on - Listen to events on this channel. * @@ -1401,6 +1416,31 @@ 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 c194298c33..67e5a72188 100644 --- a/src/client.ts +++ b/src/client.ts @@ -117,9 +117,14 @@ import { OGAttachment, OwnUserResponse, PartialMessageUpdate, + PartialPollUpdate, PartialUserUpdate, PermissionAPIResponse, PermissionsAPIResponse, + PollData, + PollOptionData, + PollVoteData, + PollVotesAPIResponse, PushProvider, PushProviderConfig, PushProviderID, @@ -127,6 +132,7 @@ import { PushProviderUpsertResponse, QueryChannelsAPIResponse, QuerySegmentsOptions, + QueryPollsResponse, ReactionResponse, ReactivateUserOptions, ReactivateUsersOptions, @@ -173,6 +179,20 @@ import { QuerySegmentTargetsFilter, SortParam, GetMessageOptions, + QueryVotesFilters, + VoteSort, + CreatePollAPIResponse, + GetPollAPIResponse, + UpdatePollAPIResponse, + CreatePollOptionAPIResponse, + GetPollOptionAPIResponse, + UpdatePollOptionAPIResponse, + PollVote, + CastVoteAPIResponse, + QueryPollsFilters, + PollSort, + QueryPollsOptions, + QueryVotesOptions, } from './types'; import { InsightMetrics, postInsights } from './insights'; import { Thread } from './thread'; @@ -3406,4 +3426,190 @@ export class StreamChat(this.baseURL + `/messages/${id}/commit`); } + + /** + * Creates a poll + * @param params PollData The poll that will be created + * @returns {APIResponse & CreatePollAPIResponse} The poll + */ + async createPoll(poll: PollData) { + return await this.post(this.baseURL + `/polls`, poll); + } + + /** + * Retrieves a poll + * @param id string The poll id + * @returns {APIResponse & GetPollAPIResponse} The poll + */ + async getPoll(id: string, userId?: string): Promise { + return await this.get(this.baseURL + `/polls/${id}`, { + ...(userId ? { user_id: userId } : {}), + }); + } + + /** + * Updates a poll + * @param poll PollData The poll that will be updated + * @returns {APIResponse & PollResponse} The poll + */ + async updatePoll(poll: PollData) { + return await this.put(this.baseURL + `/polls`, poll); + } + + /** + * Partially updates a poll + * @param id string The poll id + * @param {PartialPollUpdate} partialPollObject which should contain id and any of "set" or "unset" params; + * example: {id: "44f26af5-f2be-4fa7-9dac-71cf893781de", set:{field: value}, unset:["field2"]} + * @returns {APIResponse & UpdatePollAPIResponse} The poll + */ + async partialUpdatePoll( + id: string, + partialPollObject: PartialPollUpdate, + ): Promise { + return await this.patch(this.baseURL + `/polls/${id}`, partialPollObject); + } + + /** + * Delete a poll + * @param id string The poll id + * @param userId string The user id (only serverside) + * @returns + */ + async deletePoll(id: string, userId?: string): Promise { + return await this.delete(this.baseURL + `/polls/${id}`, { + ...(userId ? { user_id: userId } : {}), + }); + } + + /** + * Close a poll + * @param id string The poll id + * @returns {APIResponse & UpdatePollAPIResponse} The poll + */ + async closePoll(id: string): Promise { + return this.partialUpdatePoll(id, { + set: { + is_closed: true, + }, + }); + } + + /** + * Creates a poll option + * @param pollId string The poll id + * @param option PollOptionData The poll option that will be created + * @returns {APIResponse & PollOptionResponse} The poll option + */ + async createPollOption(pollId: string, option: PollOptionData) { + return await this.post( + this.baseURL + `/polls/${pollId}/options`, + option, + ); + } + + /** + * Retrieves a poll option + * @param pollId string The poll id + * @param optionId string The poll option id + * @returns {APIResponse & PollOptionResponse} The poll option + */ + async getPollOption(pollId: string, optionId: string) { + return await this.get( + this.baseURL + `/polls/${pollId}/options/${optionId}`, + ); + } + + /** + * Updates a poll option + * @param pollId string The poll id + * @param option PollOptionData The poll option that will be updated + * @returns + */ + async updatePollOption(pollId: string, option: PollOptionData) { + return await this.put(this.baseURL + `/polls/${pollId}/options`, option); + } + + /** + * Delete a poll option + * @param pollId string The poll id + * @param optionId string The poll option id + * @returns {APIResponse} The poll option + */ + async deletePollOption(pollId: string, optionId: string) { + return await this.delete(this.baseURL + `/polls/${pollId}/options/${optionId}`); + } + + /** + * Cast vote on a poll + * @param messageId string The message id + * @param pollId string The poll id + * @param vote PollVoteData The vote that will be casted + * @returns {APIResponse & CastVoteAPIResponse} The poll vote + */ + async castPollVote(messageId: string, pollId: string, vote: PollVoteData, options = {}) { + return await this.post( + this.baseURL + `/messages/${messageId}/polls/${pollId}/vote`, + { vote, ...options }, + ); + } + + /** + * Add a poll answer + * @param messageId string The message id + * @param pollId string The poll id + * @param answerText string The answer text + */ + async addPollAnswer(messageId: string, pollId: string, answerText: string) { + return this.castPollVote(messageId, pollId, { + answer_text: answerText, + }); + } + + async removePollVote(messageId: string, pollId: string, voteId: string) { + return await this.delete( + this.baseURL + `/messages/${messageId}/polls/${pollId}/vote/${voteId}`, + ); + } + + /** + * Queries polls + * @param filter + * @param sort + * @param options Option object, {limit: 10, offset:0} + * @returns {APIResponse & QueryPollsResponse} The polls + */ + async queryPolls( + filter: QueryPollsFilters = {}, + sort: PollSort = [], + options: QueryPollsOptions = {}, + ): Promise { + return await this.post(this.baseURL + '/polls/query', { + filter, + sort: normalizeQuerySort(sort), + ...options, + }); + } + + /** + * Queries poll votes + * @param pollId + * @param filter + * @param sort + * @param options Option object, {limit: 10, offset:0} + + * @returns {APIResponse & PollVotesAPIResponse} The poll votes + */ + async queryPollVotes( + pollId: string, + filter: QueryVotesFilters = {}, + sort: VoteSort = [], + options: QueryVotesOptions = {}, + ): Promise { + return await this.post(this.baseURL + `/polls/${pollId}/votes`, { + filter, + sort: normalizeQuerySort(sort), + ...options, + }); + } } diff --git a/src/events.ts b/src/events.ts index c0a762deef..2bb1f54824 100644 --- a/src/events.ts +++ b/src/events.ts @@ -30,6 +30,11 @@ export const EVENT_MAP = { 'notification.mutes_updated': true, 'notification.removed_from_channel': true, 'notification.thread_message_new': true, + 'poll.closed': true, + 'poll.updated': true, + 'poll.vote_casted': true, + 'poll.vote_changed': true, + 'poll.vote_removed': true, 'reaction.deleted': true, 'reaction.new': true, 'reaction.updated': true, diff --git a/src/thread.ts b/src/thread.ts index 788651357b..1cc402093d 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -31,24 +31,38 @@ export class Thread; read: ThreadReadStatus = {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: Record = {}; constructor(client: StreamChat, t: ThreadResponse) { - this.id = t.parent_message.id; - this.message = formatMessage(t.parent_message); - this.latestReplies = t.latest_replies.map(formatMessage); - this.participants = t.thread_participants; - this.replyCount = t.reply_count; - this.channel = t.channel; + const { + parent_message_id, + parent_message, + latest_replies, + thread_participants, + reply_count, + channel, + read, + ...data + } = t; + + this.id = parent_message_id; + this.message = formatMessage(parent_message); + this.latestReplies = latest_replies.map(formatMessage); + this.participants = thread_participants; + this.replyCount = reply_count; + this.channel = channel; this._channel = client.channel(t.channel.type, t.channel.id); this._client = client; - if (t.read) { - for (const r of t.read) { + if (read) { + for (const r of read) { this.read[r.user.id] = { ...r, last_read: new Date(r.last_read), }; } } + this.data = data; } getClient(): StreamChat { diff --git a/src/types.ts b/src/types.ts index 8ad93215e6..f391fcd967 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,6 +41,8 @@ export type DefaultGenerics = { commandType: LiteralStringForUnion; eventType: UR; messageType: UR; + pollOptionType: UR; + pollType: UR; reactionType: UR; userType: UR; }; @@ -51,6 +53,8 @@ export type ExtendableGenerics = { commandType: string; eventType: UR; messageType: UR; + pollOptionType: UR; + pollType: UR; reactionType: UR; userType: UR; }; @@ -93,6 +97,7 @@ export type AppSettingsAPIResponse, @@ -614,6 +621,7 @@ export type MessageResponse< export type MessageResponseBase< StreamChatGenerics extends ExtendableGenerics = DefaultGenerics > = MessageBase & { + poll: PollResponse; type: MessageLabel; args?: string; before_message_send_failed?: boolean; @@ -890,6 +898,7 @@ export type CreateChannelOptions; + poll_vote?: PollVote; queriedChannels?: { channels: ChannelAPIResponse[]; isLatestMessageSet?: boolean; @@ -1365,6 +1376,8 @@ export type ChannelFilters, @@ -1378,6 +1391,8 @@ export type ChannelFilters[Key] @@ -1390,6 +1405,8 @@ export type ChannelFilters[Key] @@ -1397,6 +1414,94 @@ export type ChannelFilters; +export type QueryPollsOptions = Pager; + +export type VotesFiltersOptions = { + is_answer?: boolean; + option_id?: string; + user_id?: string; +}; + +export type QueryVotesOptions = Pager; + +export type QueryPollsFilters = QueryFilters< + { + id?: RequireOnlyOne, '$eq' | '$in'>> | PrimitiveFilter; + } & { + user_id?: + | RequireOnlyOne, '$eq' | '$in'>> + | PrimitiveFilter; + } & { + is_closed?: + | RequireOnlyOne, '$eq'>> + | PrimitiveFilter; + } & { + max_votes_allowed?: + | RequireOnlyOne< + Pick, '$eq' | '$ne' | '$gt' | '$lt' | '$gte' | '$lte'> + > + | PrimitiveFilter; + } & { + allow_answers?: + | RequireOnlyOne, '$eq'>> + | PrimitiveFilter; + } & { + allow_user_suggested_options?: + | RequireOnlyOne, '$eq'>> + | PrimitiveFilter; + } & { + voting_visibility?: + | RequireOnlyOne, '$eq'>> + | PrimitiveFilter; + } & { + created_at?: + | RequireOnlyOne, '$eq' | '$gt' | '$lt' | '$gte' | '$lte'>> + | PrimitiveFilter; + } & { + created_by_id?: + | RequireOnlyOne, '$eq' | '$in'>> + | PrimitiveFilter; + } & { + updated_at?: + | RequireOnlyOne, '$eq' | '$gt' | '$lt' | '$gte' | '$lte'>> + | PrimitiveFilter; + } & { + name?: + | RequireOnlyOne, '$eq' | '$in'>> + | PrimitiveFilter; + } +>; + +export type QueryVotesFilters = QueryFilters< + { + id?: RequireOnlyOne, '$eq' | '$in'>> | PrimitiveFilter; + } & { + option_id?: + | RequireOnlyOne, '$eq' | '$in'>> + | PrimitiveFilter; + } & { + is_answer?: + | RequireOnlyOne, '$eq'>> + | PrimitiveFilter; + } & { + user_id?: + | RequireOnlyOne, '$eq' | '$in'>> + | PrimitiveFilter; + } & { + created_at?: + | RequireOnlyOne, '$eq' | '$gt' | '$lt' | '$gte' | '$lte'>> + | PrimitiveFilter; + } & { + created_by_id?: + | RequireOnlyOne, '$eq' | '$in'>> + | PrimitiveFilter; + } & { + updated_at?: + | RequireOnlyOne, '$eq' | '$gt' | '$lt' | '$gte' | '$lte'>> + | PrimitiveFilter; + } +>; + export type ContainsOperator = { [Key in keyof CustomType]?: CustomType[Key] extends (infer ContainType)[] ? @@ -1429,6 +1534,8 @@ export type MessageFilters, @@ -1442,6 +1549,8 @@ export type MessageFilters[Key] @@ -1454,6 +1563,8 @@ export type MessageFilters[Key] @@ -1532,6 +1643,8 @@ export type UserFilters, @@ -1545,6 +1658,8 @@ export type UserFilters[Key] @@ -1557,6 +1672,8 @@ export type UserFilters[Key] @@ -1631,6 +1748,26 @@ export type QuerySort | UserSort; +export type PollSort = PollSortBase | Array; + +export type PollSortBase = { + created_at?: AscDesc; + id?: AscDesc; + is_closed?: AscDesc; + name?: AscDesc; + updated_at?: AscDesc; +}; + +export type VoteSort = VoteSortBase | Array; + +export type VoteSortBase = { + created_at?: AscDesc; + id?: AscDesc; + is_closed?: AscDesc; + name?: AscDesc; + updated_at?: AscDesc; +}; + /** * Base Types */ @@ -1820,6 +1957,7 @@ export type ChannelConfigFields = { message_retention?: string; mutes?: boolean; name?: string; + polls?: boolean; push_notifications?: boolean; quotes?: boolean; reactions?: boolean; @@ -2103,7 +2241,8 @@ export type EndpointName = | 'ListImports' | 'UpsertPushProvider' | 'DeletePushProvider' - | 'ListPushProviders'; + | 'ListPushProviders' + | 'CreatePoll'; export type ExportChannelRequest = { id: string; @@ -2203,6 +2342,7 @@ export type MessageBase< pin_expires?: string | null; pinned?: boolean; pinned_at?: string | null; + poll_id?: string; quoted_message_id?: string; show_in_channel?: boolean; silent?: boolean; @@ -2479,8 +2619,8 @@ export type DeleteType = 'soft' | 'hard' | 'pruning'; DeleteUserOptions specifies a collection of one or more `user_ids` to be deleted. `user`: - - soft: marks user as deleted and retains all user data - - pruning: marks user as deleted and nullifies user information + - soft: marks user as deleted and retains all user data + - pruning: marks user as deleted and nullifies user information - hard: deletes user completely - this requires hard option for messages and conversation as well `conversations`: - soft: marks all conversation channels as deleted (same effect as Delete Channels with 'hard' option disabled) @@ -2743,3 +2883,152 @@ export class ErrorFromResponse extends Error { response?: AxiosResponse; status?: number; } + +export type QueryPollsResponse = { + polls: PollResponse[]; + next?: string; +}; + +export type CreatePollAPIResponse = { + poll: PollResponse; +}; + +export type GetPollAPIResponse = { + poll: PollResponse; +}; + +export type UpdatePollAPIResponse = { + poll: PollResponse; +}; + +export type PollResponse< + StreamChatGenerics extends ExtendableGenerics = DefaultGenerics +> = StreamChatGenerics['pollType'] & { + answers_count: number; + 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; +}; + +export type PollOption = { + created_at: string; + id: string; + poll_id: string; + text: string; + updated_at: string; + vote_count: number; + votes?: PollVote[]; +}; + +export enum VotingVisibility { + anonymous = 'anonymous', + public = 'public', +} + +export type PollData< + StreamChatGenerics extends ExtendableGenerics = DefaultGenerics +> = StreamChatGenerics['pollType'] & { + 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[]; + user_id?: string; + voting_visibility?: VotingVisibility; +}; + +export type PartialPollUpdate = { + // id: string; + set?: Partial>; + unset?: Array>; +}; + +export type PollOptionData< + StreamChatGenerics extends ExtendableGenerics = DefaultGenerics +> = StreamChatGenerics['pollType'] & { + text: string; + id?: string; + position?: number; +}; + +export type PartialPollOptionUpdate = { + set?: Partial>; + unset?: Array>; +}; + +export type PollVoteData = { + answer_text?: string; + is_answer?: boolean; + option_id?: string; +}; + +export type PollPaginationOptions = { + limit?: number; + next?: string; +}; + +export type CreatePollOptionAPIResponse = { + poll_option: PollOptionResponse; +}; + +export type GetPollOptionAPIResponse< + StreamChatGenerics extends ExtendableGenerics = DefaultGenerics +> = CreatePollOptionAPIResponse; +export type UpdatePollOptionAPIResponse< + StreamChatGenerics extends ExtendableGenerics = DefaultGenerics +> = CreatePollOptionAPIResponse; + +export type PollOptionResponse< + StreamChatGenerics extends ExtendableGenerics = DefaultGenerics +> = StreamChatGenerics['pollType'] & { + created_at: string; + id: string; + poll_id: string; + position: number; + text: string; + updated_at: string; + vote_count: number; + votes?: PollVote[]; +}; + +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 PollVotesAPIResponse = { + votes: PollVote[]; + next?: string; +}; + +export type CastVoteAPIResponse = { + vote: PollVote; +};