diff --git a/projects/stream-chat-angular/src/lib/modal/stream-modal.module.ts b/projects/stream-chat-angular/src/lib/modal/stream-modal.module.ts
new file mode 100644
index 00000000..e0168ae8
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/modal/stream-modal.module.ts
@@ -0,0 +1,11 @@
+import { NgModule } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { ModalComponent } from './modal.component';
+import { IconModule } from '../icon/icon.module';
+
+@NgModule({
+ declarations: [ModalComponent],
+ imports: [CommonModule, IconModule],
+ exports: [ModalComponent],
+})
+export class StreamModalModule {}
diff --git a/projects/stream-chat-angular/src/lib/notification-list/stream-notification.module.ts b/projects/stream-chat-angular/src/lib/notification-list/stream-notification.module.ts
new file mode 100644
index 00000000..1894a5de
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/notification-list/stream-notification.module.ts
@@ -0,0 +1,12 @@
+import { NgModule } from '@angular/core';
+import { NotificationListComponent } from './notification-list.component';
+import { NotificationComponent } from '../notification/notification.component';
+import { CommonModule } from '@angular/common';
+import { TranslateModule } from '@ngx-translate/core';
+
+@NgModule({
+ declarations: [NotificationComponent, NotificationListComponent],
+ imports: [CommonModule, TranslateModule],
+ exports: [NotificationComponent, NotificationListComponent],
+})
+export class StreamNotificationModule {}
diff --git a/projects/stream-chat-angular/src/lib/paginated-list/stream-paginated-list.module.ts b/projects/stream-chat-angular/src/lib/paginated-list/stream-paginated-list.module.ts
new file mode 100644
index 00000000..e9efa3d3
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/paginated-list/stream-paginated-list.module.ts
@@ -0,0 +1,12 @@
+import { NgModule } from '@angular/core';
+import { PaginatedListComponent } from './paginated-list.component';
+import { CommonModule } from '@angular/common';
+import { TranslateModule } from '@ngx-translate/core';
+import { IconModule } from '../icon/icon.module';
+
+@NgModule({
+ declarations: [PaginatedListComponent],
+ imports: [CommonModule, TranslateModule, IconModule],
+ exports: [PaginatedListComponent],
+})
+export class StreamPaginatedListModule {}
diff --git a/projects/stream-chat-angular/src/lib/polls/base-poll.component.ts b/projects/stream-chat-angular/src/lib/polls/base-poll.component.ts
new file mode 100644
index 00000000..7ddaa214
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/base-poll.component.ts
@@ -0,0 +1,123 @@
+import {
+ AfterViewInit,
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ Input,
+ OnChanges,
+ OnDestroy,
+ SimpleChanges,
+} from '@angular/core';
+import { ChatClientService } from '../chat-client.service';
+import { Poll, User } from 'stream-chat';
+import { ChannelService } from '../channel.service';
+import { Subscription } from 'rxjs';
+import { CustomTemplatesService } from '../custom-templates.service';
+import { NotificationService } from '../notification.service';
+
+/**
+ * @internal
+ */
+@Component({
+ selector: 'stream-base-poll',
+ template: '',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export abstract class BasePollComponent
+ implements OnChanges, AfterViewInit, OnDestroy
+{
+ /**
+ * The poll id to display
+ */
+ @Input() pollId: string | undefined;
+ /**
+ * The message id the poll is attached to
+ */
+ @Input() messageId: string | undefined;
+ canVote = false;
+ canQueryVotes = false;
+ private pollStateUnsubscribe?: () => void;
+ private isViewInited = false;
+ private capabilitySubscription?: Subscription;
+ protected dismissNotificationFn: (() => void) | undefined;
+
+ constructor(
+ public customTemplatesService: CustomTemplatesService,
+ private chatClientService: ChatClientService,
+ private cdRef: ChangeDetectorRef,
+ private channelService: ChannelService,
+ protected notificationService: NotificationService
+ ) {
+ this.capabilitySubscription = this.channelService.activeChannel$.subscribe(
+ (channel) => {
+ if (channel) {
+ const capabilities = channel.data?.own_capabilities as string[];
+ this.canVote = capabilities.indexOf('cast-poll-vote') !== -1;
+ this.canQueryVotes = capabilities.indexOf('query-poll-votes') !== -1;
+ }
+ }
+ );
+ }
+
+ ngOnChanges(changes: SimpleChanges): void {
+ if (changes['pollId']) {
+ if (this.pollId) {
+ this.setupStateStoreSelector();
+ } else {
+ this.pollStateUnsubscribe?.();
+ }
+ this.dismissNotificationFn?.();
+ }
+ }
+
+ ngAfterViewInit(): void {
+ this.isViewInited = true;
+ }
+
+ ngOnDestroy(): void {
+ this.pollStateUnsubscribe?.();
+ this.dismissNotificationFn?.();
+ this.capabilitySubscription?.unsubscribe();
+ }
+
+ protected get poll(): Poll | undefined {
+ return this.chatClientService.chatClient.polls.fromState(this.pollId ?? '');
+ }
+
+ protected setupStateStoreSelector(): void {
+ this.pollStateUnsubscribe?.();
+ const poll = this.chatClientService.chatClient.polls.fromState(
+ this.pollId ?? ''
+ );
+ if (poll) {
+ this.pollStateUnsubscribe = this.stateStoreSelector(poll, () => {
+ this.markForCheck();
+ });
+ }
+ }
+
+ protected addNotification(
+ ...args: Parameters<
+ typeof this.notificationService.addTemporaryNotification
+ >
+ ) {
+ this.dismissNotificationFn?.();
+ this.dismissNotificationFn =
+ this.notificationService.addTemporaryNotification(...args);
+ }
+
+ protected abstract stateStoreSelector(
+ poll: Poll,
+ markForCheck: () => void
+ ): () => void;
+
+ protected get currentUser(): User | undefined {
+ return this.chatClientService.chatClient.user;
+ }
+
+ protected markForCheck(): void {
+ if (this.isViewInited) {
+ this.cdRef.detectChanges();
+ }
+ }
+}
diff --git a/projects/stream-chat-angular/src/lib/polls/poll-actions/add-option/add-option.component.html b/projects/stream-chat-angular/src/lib/polls/poll-actions/add-option/add-option.component.html
new file mode 100644
index 00000000..fc13dfdd
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll-actions/add-option/add-option.component.html
@@ -0,0 +1,40 @@
+
diff --git a/projects/stream-chat-angular/src/lib/polls/poll-actions/add-option/add-option.component.ts b/projects/stream-chat-angular/src/lib/polls/poll-actions/add-option/add-option.component.ts
new file mode 100644
index 00000000..19e76944
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll-actions/add-option/add-option.component.ts
@@ -0,0 +1,78 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ HostBinding,
+ Input,
+} from '@angular/core';
+import { FormControl, FormGroup, Validators } from '@angular/forms';
+import { BasePollComponent } from '../../base-poll.component';
+import { Poll, PollOption } from 'stream-chat';
+import { createUniqueValidator } from '../../unique.validator';
+
+/**
+ *
+ */
+@Component({
+ selector: 'stream-add-option',
+ templateUrl: './add-option.component.html',
+ styles: [],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AddOptionComponent extends BasePollComponent {
+ @HostBinding('class') class = 'str-chat__dialog';
+ /**
+ * The callback to close the modal the component is displayed in
+ */
+ @Input() closeModal: () => void = () => {};
+ formGroup = new FormGroup({
+ text: new FormControl('', [
+ Validators.required,
+ createUniqueValidator((value) => {
+ return !this.options.some(
+ (option) =>
+ option.text.trim().toLowerCase() === value.trim().toLowerCase()
+ );
+ }),
+ ]),
+ });
+ options: PollOption[] = [];
+
+ async addOption() {
+ if (this.formGroup.invalid || !this.messageId) {
+ return;
+ }
+ try {
+ await this.poll?.createOption({
+ text: this.formGroup.value.text!,
+ });
+ this.closeModal();
+ this.markForCheck();
+ } catch (error) {
+ this.notificationService.addTemporaryNotification(
+ 'streamChat.Failed to add option ({{ message }})',
+ 'error',
+ undefined,
+ { message: error }
+ );
+ this.markForCheck();
+ throw error;
+ }
+ }
+
+ protected stateStoreSelector(
+ poll: Poll,
+ markForCheck: () => void
+ ): () => void {
+ const unsubscribe = poll.state.subscribeWithSelector(
+ (state) => ({
+ options: state.options,
+ }),
+ (state) => {
+ this.options = state.options;
+ markForCheck();
+ }
+ );
+
+ return unsubscribe;
+ }
+}
diff --git a/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-actions.component.html b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-actions.component.html
new file mode 100644
index 00000000..ba5bef51
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-actions.component.html
@@ -0,0 +1,155 @@
+
+ maxOptionsDisplayed"
+ >
+
+
+
+
+
+
+
+
+ 0 && canQueryVotes">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
streamChat.End vote
+
+ streamChat.After a poll is closed, no more votes can be cast
+
+
+
+
+
+
+
+
+
diff --git a/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-actions.component.ts b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-actions.component.ts
new file mode 100644
index 00000000..f1bb9c65
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-actions.component.ts
@@ -0,0 +1,154 @@
+import {
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ Input,
+ TemplateRef,
+ ViewChild,
+} from '@angular/core';
+import { BasePollComponent } from '../base-poll.component';
+import { Poll, PollAnswer, PollOption } from 'stream-chat';
+import { ModalContext } from '../../types';
+import { CustomTemplatesService } from '../../custom-templates.service';
+import { ChatClientService } from '../../chat-client.service';
+import { ChannelService } from '../../channel.service';
+import { MessageActionsService } from '../../message-actions.service';
+import { NotificationService } from '../../notification.service';
+
+type Action =
+ | 'allOptions'
+ | 'suggestOption'
+ | 'addAnswer'
+ | 'viewComments'
+ | 'viewResults'
+ | 'endVote';
+
+/**
+ *
+ */
+@Component({
+ selector: 'stream-poll-actions',
+ templateUrl: './poll-actions.component.html',
+ styles: [],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class PollActionsComponent extends BasePollComponent {
+ /**
+ * If there are more options than this number, the "See all options" button will be displayed
+ */
+ @Input() maxOptionsDisplayed: number | undefined = 10;
+ /**
+ * The maximum number of options allowed for the poll, this is defined by Stream API
+ */
+ @Input() maxPollOptions = 100;
+ name = '';
+ options: PollOption[] = [];
+ isClosed = false;
+ allowUserSuggestions = false;
+ allowAnswers = false;
+ answerCount = 0;
+ isOwnPoll = false;
+ ownAnwer: PollAnswer | undefined;
+ selectedAction: Action | undefined = undefined;
+ isModalOpen = false;
+ @ViewChild('allOptions') allOptions!: TemplateRef
;
+ @ViewChild('suggestOption') suggestOption!: TemplateRef;
+ @ViewChild('addAnswer') addAnswer!: TemplateRef;
+ @ViewChild('viewComments') viewComments!: TemplateRef;
+ @ViewChild('viewResults') viewResults!: TemplateRef;
+ @ViewChild('endVote') endVote!: TemplateRef;
+
+ constructor(
+ customTemplatesService: CustomTemplatesService,
+ chatClientService: ChatClientService,
+ cdRef: ChangeDetectorRef,
+ channelService: ChannelService,
+ notificationService: NotificationService,
+ private messageService: MessageActionsService
+ ) {
+ super(
+ customTemplatesService,
+ chatClientService,
+ cdRef,
+ channelService,
+ notificationService
+ );
+ }
+
+ protected stateStoreSelector(
+ poll: Poll,
+ markForCheck: () => void
+ ): () => void {
+ const unsubscribe = poll.state.subscribeWithSelector(
+ (state) => ({
+ options: state.options,
+ is_closed: state.is_closed,
+ allow_user_suggested_options: state.allow_user_suggested_options,
+ allow_answers: state.allow_answers,
+ answer_count: state.answers_count,
+ created_by: state.created_by,
+ name: state.name,
+ own_answer: state.ownAnswer,
+ max_votes_allowed: state.max_votes_allowed,
+ own_votes_by_option_id: state.ownVotesByOptionId,
+ }),
+ (state) => {
+ this.options = state.options;
+ this.isClosed = state.is_closed ?? false;
+ this.allowUserSuggestions = state.allow_user_suggested_options ?? false;
+ this.allowAnswers = state.allow_answers ?? false;
+ this.answerCount = state.answer_count ?? 0;
+ this.isOwnPoll = state.created_by?.id === this.currentUser?.id;
+ this.name = state.name;
+ this.ownAnwer = state.own_answer ?? undefined;
+ markForCheck();
+ }
+ );
+
+ return unsubscribe;
+ }
+
+ modalOpened = (action: Action) => {
+ this.selectedAction = action;
+ if (!this.isModalOpen) {
+ this.isModalOpen = true;
+ this.messageService.modalOpenedForMessage.next(this.messageId);
+ }
+ };
+
+ modalClosed = () => {
+ this.selectedAction = undefined;
+ if (this.isModalOpen) {
+ this.isModalOpen = false;
+ this.messageService.modalOpenedForMessage.next(undefined);
+ this.markForCheck();
+ }
+ };
+
+ async closePoll() {
+ try {
+ await this.poll?.close();
+ this.modalClosed();
+ this.markForCheck();
+ } catch (error) {
+ this.notificationService.addTemporaryNotification(
+ 'streamChat.Failed to end vote'
+ );
+ throw error;
+ }
+ }
+
+ getModalContext(): ModalContext {
+ return {
+ isOpen: this.isModalOpen,
+ isOpenChangeHandler: (isOpen: boolean) => {
+ if (isOpen) {
+ this.modalOpened(this.selectedAction!);
+ } else {
+ this.modalClosed();
+ }
+ },
+ content: this[this.selectedAction!],
+ };
+ }
+}
diff --git a/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-answers-list/poll-answers-list.component.html b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-answers-list/poll-answers-list.component.html
new file mode 100644
index 00000000..3b06069d
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-answers-list/poll-answers-list.component.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+ {{ answer.answer_text }}
+
+
+
+
+
+
+
+
+
diff --git a/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-answers-list/poll-answers-list.component.ts b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-answers-list/poll-answers-list.component.ts
new file mode 100644
index 00000000..b69ea266
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-answers-list/poll-answers-list.component.ts
@@ -0,0 +1,95 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ EventEmitter,
+ HostBinding,
+ OnChanges,
+ Output,
+ SimpleChanges,
+} from '@angular/core';
+import { BasePollComponent } from '../../base-poll.component';
+import { Poll, PollAnswer } from 'stream-chat';
+
+/**
+ *
+ */
+@Component({
+ selector: 'stream-poll-answers-list',
+ templateUrl: './poll-answers-list.component.html',
+ styles: [],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class PollAnswersListComponent
+ extends BasePollComponent
+ implements OnChanges
+{
+ @HostBinding('class') class = 'str-chat__modal__poll-answer-list';
+ /**
+ * The even that's emitted when the update/add comment button is clicked
+ */
+ @Output() upsertOwnAnswer = new EventEmitter();
+ isLoading = false;
+ next?: string | undefined;
+ answers: PollAnswer[] = [];
+ isClosed = false;
+ ownAnswer: PollAnswer | undefined;
+
+ ngOnChanges(changes: SimpleChanges): void {
+ super.ngOnChanges(changes);
+ if (changes['pollId']) {
+ void this.queryAnswers();
+ }
+ }
+
+ async queryAnswers() {
+ if (!this.poll) {
+ return;
+ }
+ try {
+ this.isLoading = true;
+ const response = await this.poll.queryAnswers({
+ filter: {},
+ sort: { created_at: -1 },
+ options: {
+ next: this.next,
+ },
+ });
+
+ this.next = response.next;
+ this.answers = [...this.answers, ...response.votes];
+ this.markForCheck();
+ } catch (error) {
+ this.notificationService.addTemporaryNotification(
+ 'streamChat.Error loading answers'
+ );
+ this.markForCheck();
+ throw error;
+ } finally {
+ this.isLoading = false;
+ this.markForCheck();
+ }
+ }
+
+ trackByAnswerId(_: number, answer: PollAnswer) {
+ return answer.id;
+ }
+
+ protected stateStoreSelector(
+ poll: Poll,
+ markForCheck: () => void
+ ): () => void {
+ const unsubscribe = poll.state.subscribeWithSelector(
+ (state) => ({
+ is_closed: state.is_closed,
+ own_answer: state.ownAnswer,
+ }),
+ (state) => {
+ this.isClosed = state.is_closed ?? false;
+ this.ownAnswer = state.own_answer ?? undefined;
+ markForCheck();
+ }
+ );
+
+ return unsubscribe;
+ }
+}
diff --git a/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-results-list/poll-results-list.component.html b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-results-list/poll-results-list.component.html
new file mode 100644
index 00000000..2ea49f22
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-results-list/poll-results-list.component.html
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
{{ name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-results-list/poll-results-list.component.ts b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-results-list/poll-results-list.component.ts
new file mode 100644
index 00000000..223a2cbf
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-results-list/poll-results-list.component.ts
@@ -0,0 +1,69 @@
+import { ChangeDetectionStrategy, Component, HostBinding } from '@angular/core';
+import { BasePollComponent } from '../../../base-poll.component';
+import { Poll, PollOption, PollVote } from 'stream-chat';
+
+/**
+ *
+ */
+@Component({
+ selector: 'stream-poll-results-list',
+ templateUrl: './poll-results-list.component.html',
+ styles: [],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class PollResultsListComponent extends BasePollComponent {
+ @HostBinding('class') class = 'str-chat__modal__poll-results';
+ name = '';
+ options: PollOption[] = [];
+ optionToView: PollOption | undefined;
+ votePreviewCount = 5;
+ maxVotedOptionIds: string[] = [];
+ voteCountsByOption: Record = {};
+ latestVotesByOption: Record = {};
+
+ protected stateStoreSelector(
+ poll: Poll,
+ markForCheck: () => void
+ ): () => void {
+ const unsubscribe = poll.state.subscribeWithSelector(
+ (state) => ({
+ name: state.name,
+ options: state.options,
+ maxVotedOptionIds: state.maxVotedOptionIds,
+ vote_counts_by_option: state.vote_counts_by_option,
+ latest_votes_by_option: state.latest_votes_by_option,
+ }),
+ (state) => {
+ this.name = state.name;
+ this.options = [...state.options];
+ this.options.sort((a, b) => {
+ return (
+ (state.vote_counts_by_option[b.id] ?? 0) -
+ (state.vote_counts_by_option[a.id] ?? 0)
+ );
+ });
+ this.maxVotedOptionIds = state.maxVotedOptionIds;
+ this.voteCountsByOption = state.vote_counts_by_option;
+ this.latestVotesByOption = state.latest_votes_by_option;
+ markForCheck();
+ }
+ );
+
+ return unsubscribe;
+ }
+
+ isWinner(optionId: string) {
+ return (
+ this.maxVotedOptionIds.length === 1 &&
+ this.maxVotedOptionIds[0] === optionId
+ );
+ }
+
+ trackByOptionId(_: number, option: PollOption) {
+ return option.id;
+ }
+
+ trackByVoteId(_: number, vote: PollVote) {
+ return vote.id;
+ }
+}
diff --git a/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-vote-results-list/poll-vote-results-list.component.html b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-vote-results-list/poll-vote-results-list.component.html
new file mode 100644
index 00000000..19786a58
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-vote-results-list/poll-vote-results-list.component.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
diff --git a/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-vote-results-list/poll-vote-results-list.component.ts b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-vote-results-list/poll-vote-results-list.component.ts
new file mode 100644
index 00000000..bf7a2340
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-vote-results-list/poll-vote-results-list.component.ts
@@ -0,0 +1,99 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ Input,
+ OnChanges,
+ SimpleChanges,
+} from '@angular/core';
+import { BasePollComponent } from '../../../base-poll.component';
+import { Poll, PollOption, PollVote } from 'stream-chat';
+
+/**
+ *
+ */
+@Component({
+ selector: 'stream-poll-vote-results-list',
+ templateUrl: './poll-vote-results-list.component.html',
+ styles: [],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class PollVoteResultsListComponent
+ extends BasePollComponent
+ implements OnChanges
+{
+ /**
+ * The poll option to display the votes for
+ */
+ @Input() option: PollOption | undefined;
+ isWinner = false;
+ voteCount = 0;
+ isLoading = false;
+ next?: string | undefined;
+ votes: PollVote[] = [];
+
+ ngOnChanges(changes: SimpleChanges): void {
+ super.ngOnChanges(changes);
+ if (changes['option']) {
+ this.setupStateStoreSelector();
+ this.next = undefined;
+ this.votes = [];
+ void this.loadVotes();
+ }
+ }
+
+ async loadVotes() {
+ if (!this.poll) {
+ return;
+ }
+ try {
+ this.isLoading = true;
+ const response = await this.poll.queryOptionVotes({
+ filter: {
+ option_id: this.option?.id ?? '',
+ },
+ sort: { created_at: -1 },
+ options: {
+ next: this.next,
+ },
+ });
+
+ this.next = response.next;
+ this.votes = [...this.votes, ...response.votes];
+ this.markForCheck();
+ } catch (error) {
+ this.notificationService.addTemporaryNotification(
+ 'streamChat.Error loading votes'
+ );
+ throw error;
+ this.markForCheck();
+ } finally {
+ this.isLoading = false;
+ this.markForCheck();
+ }
+ }
+
+ trackByVoteId = (_: number, vote: PollVote) => {
+ return vote.id;
+ };
+
+ protected stateStoreSelector(
+ poll: Poll,
+ markForCheck: () => void
+ ): () => void {
+ const subscribe = poll.state.subscribeWithSelector(
+ (state) => ({
+ voteCount: state.vote_counts_by_option[this.option?.id ?? ''],
+ maxVotedOptionIds: state.maxVotedOptionIds,
+ }),
+ (state) => {
+ this.isWinner =
+ state.maxVotedOptionIds?.includes(this.option?.id ?? '') &&
+ state.maxVotedOptionIds.length === 1;
+ this.voteCount = state.voteCount ?? 0;
+ markForCheck();
+ }
+ );
+
+ return subscribe;
+ }
+}
diff --git a/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-vote/poll-vote.component.html b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-vote/poll-vote.component.html
new file mode 100644
index 00000000..bc9a9c7c
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-vote/poll-vote.component.html
@@ -0,0 +1,17 @@
+
+
+
+
+ {{ vote?.user?.name ?? anonymousTranslation }}
+
+
+
+ {{ parseDate(vote!.created_at) }}
+
+
diff --git a/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-vote/poll-vote.component.ts b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-vote/poll-vote.component.ts
new file mode 100644
index 00000000..99bf977d
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-vote/poll-vote.component.ts
@@ -0,0 +1,36 @@
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+import { TranslateService } from '@ngx-translate/core';
+import { PollAnswer, PollVote } from 'stream-chat';
+import { DateParserService } from '../../../../date-parser.service';
+
+/**
+ *
+ */
+@Component({
+ selector: 'stream-poll-vote',
+ templateUrl: './poll-vote.component.html',
+ styles: [],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class PollVoteComponent {
+ /**
+ * The poll vote or answer to display
+ */
+ @Input() vote: PollVote | PollAnswer | undefined;
+ anonymousTranslation = 'Anonymous';
+
+ constructor(
+ private translateService: TranslateService,
+ private dateParser: DateParserService
+ ) {
+ this.translateService
+ .get('streamChat.Anonymous')
+ .subscribe((translation: string) => {
+ this.anonymousTranslation = translation;
+ });
+ }
+
+ parseDate(date: string) {
+ return this.dateParser.parseDateTime(new Date(date));
+ }
+}
diff --git a/projects/stream-chat-angular/src/lib/polls/poll-actions/upsert-answer/upsert-answer.component.html b/projects/stream-chat-angular/src/lib/polls/poll-actions/upsert-answer/upsert-answer.component.html
new file mode 100644
index 00000000..e1c97008
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll-actions/upsert-answer/upsert-answer.component.html
@@ -0,0 +1,34 @@
+
diff --git a/projects/stream-chat-angular/src/lib/polls/poll-actions/upsert-answer/upsert-answer.component.ts b/projects/stream-chat-angular/src/lib/polls/poll-actions/upsert-answer/upsert-answer.component.ts
new file mode 100644
index 00000000..f680b0e3
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll-actions/upsert-answer/upsert-answer.component.ts
@@ -0,0 +1,66 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ HostBinding,
+ Input,
+ OnChanges,
+ SimpleChanges,
+} from '@angular/core';
+import { FormControl, FormGroup, Validators } from '@angular/forms';
+import { Poll, PollAnswer } from 'stream-chat';
+import { BasePollComponent } from '../../base-poll.component';
+
+/**
+ *
+ */
+@Component({
+ selector: 'stream-upsert-answer',
+ templateUrl: './upsert-answer.component.html',
+ styles: [],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class UpsertAnswerComponent
+ extends BasePollComponent
+ implements OnChanges
+{
+ @HostBinding('class') class = 'str-chat__dialog';
+ /**
+ * The poll comment to edit, when in edit mode
+ */
+ @Input() answer: PollAnswer | undefined;
+ /**
+ * The callback to close the modal the component is displayed in
+ */
+ @Input() closeModal: () => void = () => {};
+ formGroup = new FormGroup({
+ comment: new FormControl('', [Validators.required]),
+ });
+
+ ngOnChanges(changes: SimpleChanges): void {
+ super.ngOnChanges(changes);
+ if (changes['answer']) {
+ this.formGroup.get('comment')?.setValue(this.answer?.answer_text ?? '');
+ }
+ }
+
+ async addComment() {
+ if (this.formGroup.invalid || !this.messageId) {
+ return;
+ }
+ try {
+ await this.poll?.addAnswer(this.formGroup.value.comment!, this.messageId);
+ this.closeModal();
+ this.markForCheck();
+ } catch (error) {
+ this.notificationService.addTemporaryNotification(
+ 'streamChat.Failed to add comment'
+ );
+ this.markForCheck();
+ throw error;
+ }
+ }
+
+ protected stateStoreSelector(_: Poll, __: () => void): () => void {
+ return () => {};
+ }
+}
diff --git a/projects/stream-chat-angular/src/lib/polls/poll-composer/poll-composer.component.html b/projects/stream-chat-angular/src/lib/polls/poll-composer/poll-composer.component.html
new file mode 100644
index 00000000..887bb351
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll-composer/poll-composer.component.html
@@ -0,0 +1,221 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/projects/stream-chat-angular/src/lib/polls/poll-composer/poll-composer.component.ts b/projects/stream-chat-angular/src/lib/polls/poll-composer/poll-composer.component.ts
new file mode 100644
index 00000000..c608a471
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll-composer/poll-composer.component.ts
@@ -0,0 +1,150 @@
+import {
+ Component,
+ EventEmitter,
+ Output,
+ TemplateRef,
+ ViewChild,
+} from '@angular/core';
+import { FormArray, FormControl, FormGroup, Validators } from '@angular/forms';
+import { VotingVisibility } from 'stream-chat';
+import { CustomTemplatesService } from '../../custom-templates.service';
+import { ModalContext } from '../../types';
+import { ChatClientService } from '../../chat-client.service';
+import { NotificationService } from '../../notification.service';
+import { atLeastOneOption, maximumNumberOfVotes } from './validators';
+
+/**
+ *
+ */
+@Component({
+ selector: 'stream-poll-composer',
+ templateUrl: './poll-composer.component.html',
+ styles: [],
+})
+export class PollComposerComponent {
+ /**
+ * Emitted when a poll is created, the poll id is emitted
+ */
+ @Output() pollCompose = new EventEmitter();
+ /**
+ * Emitted when the poll composing is cancelled
+ */
+ @Output() cancel = new EventEmitter();
+ formGroup = new FormGroup({
+ name: new FormControl('', [Validators.required]),
+ options: new FormArray>(
+ [new FormControl('')],
+ [atLeastOneOption()]
+ ),
+ multiple_answers: new FormControl(false),
+ maximum_number_of_votes: new FormControl(null, [
+ Validators.min(2),
+ Validators.max(10),
+ ]),
+ is_anonymous: new FormControl(false),
+ allow_user_suggested_options: new FormControl(false),
+ allow_answers: new FormControl(false),
+ });
+ isModalOpen = true;
+ @ViewChild('formContent') private formContent!: TemplateRef;
+
+ constructor(
+ readonly customTemplatesService: CustomTemplatesService,
+ private chatService: ChatClientService,
+ private notificationService: NotificationService
+ ) {
+ this.formGroup
+ .get('maximum_number_of_votes')
+ ?.addValidators([
+ maximumNumberOfVotes(
+ this.formGroup.get('multiple_answers') as FormControl
+ ),
+ ]);
+ this.formGroup.get('maximum_number_of_votes')?.disable();
+ this.formGroup.valueChanges.subscribe((value) => {
+ if (
+ value.multiple_answers &&
+ this.formGroup.get('maximum_number_of_votes')?.disabled
+ ) {
+ this.formGroup.get('maximum_number_of_votes')?.enable();
+ } else if (
+ !value.multiple_answers &&
+ this.formGroup.get('maximum_number_of_votes')?.enabled
+ ) {
+ this.formGroup.get('maximum_number_of_votes')?.disable();
+ }
+ });
+ }
+
+ optionChanged(index: number) {
+ const control = this.options.at(index);
+ const penultimateIndex = this.options.length - 2;
+ if (index === this.options.length - 1 && control.value?.length === 1) {
+ this.addOption();
+ } else if (
+ index === penultimateIndex &&
+ control.value?.length === 0 &&
+ this.options.at(this.options.length - 1).value?.length === 0
+ ) {
+ this.removeLastOption();
+ }
+ }
+
+ get options() {
+ return this.formGroup.get('options') as FormArray<
+ FormControl
+ >;
+ }
+
+ addOption() {
+ const control = new FormControl('', []);
+ this.options.push(control);
+ }
+
+ removeLastOption() {
+ this.options.removeAt(this.options.length - 1);
+ }
+
+ getModalContext(): ModalContext {
+ return {
+ isOpen: this.isModalOpen,
+ isOpenChangeHandler: (isOpen: boolean) => {
+ if (!isOpen) {
+ this.cancel.emit();
+ }
+ this.isModalOpen = isOpen;
+ },
+ content: this.formContent,
+ };
+ }
+
+ async createPoll() {
+ try {
+ const maxVotesControl = this.formGroup.get('maximum_number_of_votes');
+ const response = await this.chatService.chatClient.polls.createPoll({
+ name: this.formGroup.get('name')!.value!,
+ options: this.formGroup
+ .get('options')!
+ .value.filter((v) => !!v)
+ .map((v) => ({ text: v! })),
+ enforce_unique_vote: !this.formGroup.get('multiple_answers')?.value,
+ max_votes_allowed: maxVotesControl?.value
+ ? +maxVotesControl.value
+ : undefined,
+ voting_visibility: this.formGroup.get('is_anonymous')?.value
+ ? VotingVisibility.anonymous
+ : VotingVisibility.public,
+ allow_user_suggested_options: !!this.formGroup.get(
+ 'allow_user_suggested_options'
+ )?.value,
+ allow_answers: !!this.formGroup.get('allow_answers')?.value,
+ });
+ this.pollCompose.emit(response?.id);
+ } catch (error) {
+ this.notificationService.addTemporaryNotification(
+ 'streamChat.Failed to create poll'
+ );
+ throw error;
+ }
+ }
+}
diff --git a/projects/stream-chat-angular/src/lib/polls/poll-composer/validators.ts b/projects/stream-chat-angular/src/lib/polls/poll-composer/validators.ts
new file mode 100644
index 00000000..2669f320
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll-composer/validators.ts
@@ -0,0 +1,27 @@
+import {
+ AbstractControl,
+ FormArray,
+ FormControl,
+ ValidatorFn,
+} from '@angular/forms';
+
+export function atLeastOneOption(): ValidatorFn {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return (control: AbstractControl) => {
+ const formArray = control as FormArray>;
+ const hasAtLeastOne = formArray.value.some((item) => !!item);
+ return hasAtLeastOne ? null : { atLeastOne: true };
+ };
+}
+
+export function maximumNumberOfVotes(
+ canHaveMultipleVotes: FormControl
+): ValidatorFn {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return (control: AbstractControl) => {
+ const formControl = control as FormControl;
+ return canHaveMultipleVotes.value && !formControl.value
+ ? { maximumNumberOfVotes: true }
+ : null;
+ };
+}
diff --git a/projects/stream-chat-angular/src/lib/polls/poll-header/poll-header.component.html b/projects/stream-chat-angular/src/lib/polls/poll-header/poll-header.component.html
new file mode 100644
index 00000000..c1f2196c
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll-header/poll-header.component.html
@@ -0,0 +1,6 @@
+
diff --git a/projects/stream-chat-angular/src/lib/polls/poll-header/poll-header.component.ts b/projects/stream-chat-angular/src/lib/polls/poll-header/poll-header.component.ts
new file mode 100644
index 00000000..0982b830
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll-header/poll-header.component.ts
@@ -0,0 +1,100 @@
+import { Component, ChangeDetectionStrategy } from '@angular/core';
+import { Poll, PollState } from 'stream-chat';
+import { BasePollComponent } from '../base-poll.component';
+
+type SelectionInstructions = {
+ text: string;
+ count: number | undefined;
+};
+
+/**
+ *
+ */
+@Component({
+ selector: 'stream-poll-header',
+ templateUrl: './poll-header.component.html',
+ styles: [],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class PollHeaderComponent extends BasePollComponent {
+ name = '';
+ selectionInstructions: SelectionInstructions = {
+ text: '',
+ count: undefined,
+ };
+
+ protected stateStoreSelector(
+ poll: Poll,
+ markForCheck: () => void
+ ): () => void {
+ const unsubscribe = poll.state.subscribeWithSelector(
+ (state) => ({
+ name: state.name,
+ is_closed: state.is_closed,
+ enforce_unique_vote: state.enforce_unique_vote,
+ max_votes_allowed: state.max_votes_allowed,
+ options: state.options,
+ }),
+ (state) => {
+ const name = state.name;
+ const selectionInstructions = this.getSelectionInstructions(state);
+
+ let changed = false;
+ if (name !== this.name) {
+ this.name = name;
+ changed = true;
+ }
+ if (
+ selectionInstructions.text !== this.selectionInstructions.text ||
+ selectionInstructions.count !== this.selectionInstructions.count
+ ) {
+ this.selectionInstructions = selectionInstructions;
+ changed = true;
+ }
+
+ if (changed) {
+ markForCheck();
+ }
+ }
+ );
+
+ return unsubscribe;
+ }
+
+ getSelectionInstructions(
+ state: Pick<
+ PollState,
+ 'is_closed' | 'enforce_unique_vote' | 'max_votes_allowed' | 'options'
+ >
+ ): SelectionInstructions {
+ if (state.is_closed)
+ return {
+ text: 'streamChat.Vote ended',
+ count: undefined,
+ };
+ if (state.enforce_unique_vote || state.options.length === 1) {
+ return {
+ text: 'streamChat.Select one',
+ count: undefined,
+ };
+ }
+ if (state.max_votes_allowed)
+ return {
+ text: 'streamChat.Select up to {{count}}',
+ count:
+ state.max_votes_allowed > state.options.length
+ ? state.options.length
+ : state.max_votes_allowed,
+ };
+ if (state.options.length > 1) {
+ return {
+ text: 'streamChat.Select one or more',
+ count: undefined,
+ };
+ }
+ return {
+ text: '',
+ count: undefined,
+ };
+ }
+}
diff --git a/projects/stream-chat-angular/src/lib/polls/poll-option-selector/poll-option-selector.component.html b/projects/stream-chat-angular/src/lib/polls/poll-option-selector/poll-option-selector.component.html
new file mode 100644
index 00000000..02bee898
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll-option-selector/poll-option-selector.component.html
@@ -0,0 +1,61 @@
+
+
+
+
+
+
{{ option?.text }}
+
+
+
+
+
+
+
+ {{ 'streamChat.{{ count }} votes' | translate:{count: voteCount} }}
+
+
+ {{ voteCount }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/projects/stream-chat-angular/src/lib/polls/poll-option-selector/poll-option-selector.component.ts b/projects/stream-chat-angular/src/lib/polls/poll-option-selector/poll-option-selector.component.ts
new file mode 100644
index 00000000..44f9cb23
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll-option-selector/poll-option-selector.component.ts
@@ -0,0 +1,168 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ Input,
+ OnChanges,
+ SimpleChanges,
+} from '@angular/core';
+import { BasePollComponent } from '../base-poll.component';
+import {
+ isVoteAnswer,
+ Poll,
+ PollOption,
+ PollVote,
+ VotingVisibility,
+} from 'stream-chat';
+
+/**
+ *
+ */
+@Component({
+ selector: 'stream-poll-option-selector',
+ templateUrl: './poll-option-selector.component.html',
+ styles: [],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class PollOptionSelectorComponent
+ extends BasePollComponent
+ implements OnChanges
+{
+ @Input() option: PollOption | undefined;
+ @Input() displayAvatarCount = 3;
+ @Input() voteCountVerbose = false;
+ isClosed = false;
+ latestVotes: PollVote[] = [];
+ isWinner = false;
+ ownVote: PollVote | undefined;
+ voteCount = 0;
+ votingVisibility: VotingVisibility | undefined;
+ winningOptionCount = 0;
+ maxVoteAllowedCount = 0;
+ ownVoteCount = 0;
+
+ ngOnChanges(changes: SimpleChanges): void {
+ super.ngOnChanges(changes);
+ if (changes['option']) {
+ this.setupStateStoreSelector();
+ }
+ }
+
+ async toggleVote() {
+ if (!this.canVote || !this.option?.id || !this.messageId || this.isClosed)
+ return;
+ const haveVotedForTheOption = !!this.ownVote;
+ if (
+ !haveVotedForTheOption &&
+ this.maxVoteAllowedCount > 0 &&
+ this.ownVoteCount >= this.maxVoteAllowedCount
+ ) {
+ this.addNotification(
+ 'streamChat.You have reached the maximum number of votes allowed'
+ );
+ return;
+ }
+ try {
+ await (haveVotedForTheOption
+ ? this.poll?.removeVote(this.ownVote!.id, this.messageId)
+ : this.poll?.castVote(this.option?.id, this.messageId));
+ } catch (error) {
+ this.notificationService.addTemporaryNotification(
+ 'streamChat.Failed to cast vote'
+ );
+ throw error;
+ }
+ }
+
+ trackByVoteId(_: number, vote: PollVote) {
+ return vote.id;
+ }
+
+ protected stateStoreSelector(
+ poll: Poll,
+ markForCheck: () => void
+ ): () => void {
+ const unsubscribe = poll.state.subscribeWithSelector(
+ (nextValue) => {
+ return {
+ is_closed: nextValue.is_closed,
+ latest_votes_by_option: nextValue.latest_votes_by_option,
+ maxVotedOptionIds: nextValue.maxVotedOptionIds,
+ ownVotesByOptionId: nextValue.ownVotesByOptionId,
+ vote_counts_by_option: nextValue.vote_counts_by_option,
+ voting_visibility: nextValue.voting_visibility,
+ max_votes_allowed: nextValue.max_votes_allowed,
+ };
+ },
+ (state) => {
+ const isClosed = state.is_closed;
+ const latestVotes =
+ state.latest_votes_by_option[this.option?.id ?? '']?.filter(
+ (vote) => !!vote.user && !isVoteAnswer(vote)
+ ) ?? [];
+ const isWinner =
+ state.maxVotedOptionIds.includes(this.option?.id ?? '') &&
+ state.maxVotedOptionIds.length === 1;
+ const ownVote = state.ownVotesByOptionId[this.option?.id ?? ''];
+ const voteCount = state.vote_counts_by_option[this.option?.id ?? ''];
+ const votingVisibility = state.voting_visibility;
+ const winningOptionCount =
+ state.vote_counts_by_option[state.maxVotedOptionIds?.[0] ?? ''];
+ const maxVoteAllowedCount = state.max_votes_allowed;
+ const ownVoteCount = Object.keys(state.ownVotesByOptionId).length;
+
+ let changed = false;
+ if (isClosed !== this.isClosed) {
+ this.isClosed = isClosed ?? false;
+ changed = true;
+ }
+ if (latestVotes !== this.latestVotes) {
+ this.latestVotes = latestVotes ?? [];
+ changed = true;
+ }
+ if (isWinner !== this.isWinner) {
+ this.isWinner = isWinner ?? false;
+ changed = true;
+ }
+ if (ownVote !== this.ownVote) {
+ this.ownVote = ownVote ?? undefined;
+ changed = true;
+ }
+ if (voteCount !== this.voteCount) {
+ this.voteCount = voteCount ?? 0;
+ changed = true;
+ }
+ if (votingVisibility !== this.votingVisibility) {
+ this.votingVisibility = votingVisibility ?? undefined;
+ changed = true;
+ }
+ if (winningOptionCount !== this.winningOptionCount) {
+ this.winningOptionCount = winningOptionCount ?? 0;
+ changed = true;
+ }
+ if (maxVoteAllowedCount !== this.maxVoteAllowedCount) {
+ this.maxVoteAllowedCount = maxVoteAllowedCount ?? 0;
+ changed = true;
+ }
+ if (ownVoteCount !== this.ownVoteCount) {
+ this.ownVoteCount = ownVoteCount ?? 0;
+ changed = true;
+ }
+
+ if (
+ this.dismissNotificationFn &&
+ this.maxVoteAllowedCount > 0 &&
+ this.ownVoteCount <= this.maxVoteAllowedCount
+ ) {
+ this.dismissNotificationFn();
+ changed = true;
+ }
+
+ if (changed) {
+ markForCheck();
+ }
+ }
+ );
+
+ return unsubscribe;
+ }
+}
diff --git a/projects/stream-chat-angular/src/lib/polls/poll-options-list/poll-options-list.component.html b/projects/stream-chat-angular/src/lib/polls/poll-options-list/poll-options-list.component.html
new file mode 100644
index 00000000..aa5786af
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll-options-list/poll-options-list.component.html
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/projects/stream-chat-angular/src/lib/polls/poll-options-list/poll-options-list.component.ts b/projects/stream-chat-angular/src/lib/polls/poll-options-list/poll-options-list.component.ts
new file mode 100644
index 00000000..055b3f5e
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll-options-list/poll-options-list.component.ts
@@ -0,0 +1,41 @@
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+import { BasePollComponent } from '../base-poll.component';
+import { Poll, PollOption } from 'stream-chat';
+
+/**
+ *
+ */
+@Component({
+ selector: 'stream-poll-options-list',
+ templateUrl: './poll-options-list.component.html',
+ styles: [],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class PollOptionsListComponent extends BasePollComponent {
+ /**
+ * How many options should be displayed. If there are more options than this number, use the poll actions to display all options
+ */
+ @Input() maxOptionsDisplayed: number | undefined = 10;
+ options: PollOption[] = [];
+
+ protected stateStoreSelector(
+ poll: Poll,
+ markForCheck: () => void
+ ): () => void {
+ const unsubscribe = poll.state.subscribeWithSelector(
+ (state) => ({
+ options: state.options,
+ }),
+ (state) => {
+ this.options = state.options;
+ markForCheck();
+ }
+ );
+
+ return unsubscribe;
+ }
+
+ trackByOptionId(_: number, option: PollOption) {
+ return option.id;
+ }
+}
diff --git a/projects/stream-chat-angular/src/lib/polls/poll-preview/poll-preview.component.html b/projects/stream-chat-angular/src/lib/polls/poll-preview/poll-preview.component.html
new file mode 100644
index 00000000..1e0b6a82
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll-preview/poll-preview.component.html
@@ -0,0 +1,7 @@
+
diff --git a/projects/stream-chat-angular/src/lib/polls/poll-preview/poll-preview.component.ts b/projects/stream-chat-angular/src/lib/polls/poll-preview/poll-preview.component.ts
new file mode 100644
index 00000000..f080eec2
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll-preview/poll-preview.component.ts
@@ -0,0 +1,36 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { BasePollComponent } from '../base-poll.component';
+import { Poll } from 'stream-chat';
+
+/**
+ *
+ */
+@Component({
+ selector: 'stream-poll-preview',
+ templateUrl: './poll-preview.component.html',
+ styles: [],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class PollPreviewComponent extends BasePollComponent {
+ name = '';
+ isClosed = false;
+
+ protected stateStoreSelector(
+ poll: Poll,
+ markForCheck: () => void
+ ): () => void {
+ const unsubscribe = poll.state.subscribeWithSelector(
+ (state) => ({
+ name: state.name,
+ is_closed: state.is_closed,
+ }),
+ (state) => {
+ this.name = state.name;
+ this.isClosed = state.is_closed ?? false;
+ markForCheck();
+ }
+ );
+
+ return unsubscribe;
+ }
+}
diff --git a/projects/stream-chat-angular/src/lib/polls/poll/poll.component.html b/projects/stream-chat-angular/src/lib/polls/poll/poll.component.html
new file mode 100644
index 00000000..23137ce2
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll/poll.component.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/projects/stream-chat-angular/src/lib/polls/poll/poll.component.ts b/projects/stream-chat-angular/src/lib/polls/poll/poll.component.ts
new file mode 100644
index 00000000..71b5cf56
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/poll/poll.component.ts
@@ -0,0 +1,35 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { BasePollComponent } from '../base-poll.component';
+import { Poll } from 'stream-chat';
+
+/**
+ *
+ */
+@Component({
+ selector: 'stream-poll',
+ templateUrl: './poll.component.html',
+ styles: [],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class PollComponent extends BasePollComponent {
+ isClosed = false;
+
+ protected stateStoreSelector(
+ poll: Poll,
+ markForCheck: () => void
+ ): () => void {
+ const unsubscribe = poll.state.subscribeWithSelector(
+ (state) => {
+ return {
+ is_closed: state.is_closed,
+ };
+ },
+ (state) => {
+ this.isClosed = state.is_closed ?? false;
+ markForCheck();
+ }
+ );
+
+ return unsubscribe;
+ }
+}
diff --git a/projects/stream-chat-angular/src/lib/polls/stream-polls.module.ts b/projects/stream-chat-angular/src/lib/polls/stream-polls.module.ts
new file mode 100644
index 00000000..9c627169
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/stream-polls.module.ts
@@ -0,0 +1,64 @@
+import { NgModule } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { PollComposerComponent } from './poll-composer/poll-composer.component';
+import { PollComponent } from './poll/poll.component';
+import { PollHeaderComponent } from './poll-header/poll-header.component';
+import { TranslateModule } from '@ngx-translate/core';
+import { PollOptionsListComponent } from './poll-options-list/poll-options-list.component';
+import { PollOptionSelectorComponent } from './poll-option-selector/poll-option-selector.component';
+import { StreamAvatarModule } from '../stream-avatar.module';
+import { PollActionsComponent } from './poll-actions/poll-actions.component';
+import { StreamModalModule } from '../modal/stream-modal.module';
+import { StreamNotificationModule } from '../notification-list/stream-notification.module';
+import { StreamPaginatedListModule } from '../paginated-list/stream-paginated-list.module';
+import { PollVoteComponent } from './poll-actions/poll-results/poll-vote/poll-vote.component';
+import { PollAnswersListComponent } from './poll-actions/poll-answers-list/poll-answers-list.component';
+import { PollVoteResultsListComponent } from './poll-actions/poll-results/poll-vote-results-list/poll-vote-results-list.component';
+import { PollResultsListComponent } from './poll-actions/poll-results/poll-results-list/poll-results-list.component';
+import { UpsertAnswerComponent } from './poll-actions/upsert-answer/upsert-answer.component';
+import { AddOptionComponent } from './poll-actions/add-option/add-option.component';
+import { ReactiveFormsModule } from '@angular/forms';
+import { PollPreviewComponent } from './poll-preview/poll-preview.component';
+
+@NgModule({
+ declarations: [
+ PollComposerComponent,
+ PollComponent,
+ PollHeaderComponent,
+ PollOptionsListComponent,
+ PollOptionSelectorComponent,
+ PollActionsComponent,
+ PollResultsListComponent,
+ PollVoteResultsListComponent,
+ PollVoteComponent,
+ PollAnswersListComponent,
+ UpsertAnswerComponent,
+ AddOptionComponent,
+ PollPreviewComponent,
+ ],
+ imports: [
+ CommonModule,
+ TranslateModule,
+ StreamAvatarModule,
+ StreamModalModule,
+ StreamNotificationModule,
+ StreamPaginatedListModule,
+ ReactiveFormsModule,
+ ],
+ exports: [
+ PollComposerComponent,
+ PollComponent,
+ PollHeaderComponent,
+ PollOptionsListComponent,
+ PollOptionSelectorComponent,
+ PollActionsComponent,
+ PollResultsListComponent,
+ PollVoteResultsListComponent,
+ PollVoteComponent,
+ PollAnswersListComponent,
+ UpsertAnswerComponent,
+ AddOptionComponent,
+ PollPreviewComponent,
+ ],
+})
+export class StreamPollsModule {}
diff --git a/projects/stream-chat-angular/src/lib/polls/unique.validator.ts b/projects/stream-chat-angular/src/lib/polls/unique.validator.ts
new file mode 100644
index 00000000..735eb984
--- /dev/null
+++ b/projects/stream-chat-angular/src/lib/polls/unique.validator.ts
@@ -0,0 +1,19 @@
+import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
+
+export function createUniqueValidator(
+ isUnique: (v: string) => boolean
+): ValidatorFn {
+ return (control: AbstractControl): ValidationErrors | null => {
+ const value = control.value;
+
+ if (!value) {
+ return null;
+ }
+
+ if (!isUnique(value)) {
+ return { duplicate: true };
+ }
+
+ return null;
+ };
+}
diff --git a/projects/stream-chat-angular/src/lib/stream-chat.module.ts b/projects/stream-chat-angular/src/lib/stream-chat.module.ts
index 79baa6bb..305612ae 100644
--- a/projects/stream-chat-angular/src/lib/stream-chat.module.ts
+++ b/projects/stream-chat-angular/src/lib/stream-chat.module.ts
@@ -13,7 +13,6 @@ import { MessageReactionsComponent } from './message-reactions/message-reactions
import { NotificationComponent } from './notification/notification.component';
import { NotificationListComponent } from './notification-list/notification-list.component';
import { AttachmentPreviewListComponent } from './attachment-preview-list/attachment-preview-list.component';
-import { ModalComponent } from './modal/modal.component';
import { TextareaDirective } from './message-input/textarea.directive';
import { StreamAvatarModule } from './stream-avatar.module';
import { ThreadComponent } from './thread/thread.component';
@@ -21,13 +20,15 @@ import { MessageBouncePromptComponent } from './message-bounce-prompt/message-bo
import { NgxFloatUiModule } from 'ngx-float-ui';
import { TranslateModule } from '@ngx-translate/core';
import { MessageReactionsSelectorComponent } from './message-reactions-selector/message-reactions-selector.component';
-import { PaginatedListComponent } from './paginated-list/paginated-list.component';
import { UserListComponent } from './user-list/user-list.component';
import { VoiceRecordingModule } from './voice-recording/voice-recording.module';
import { IconModule } from './icon/icon.module';
import { VoiceRecorderService } from './message-input/voice-recorder.service';
import { MessageTextComponent } from './message-text/message-text.component';
import { MessageBlockedComponent } from './message-blocked/message-blocked.component';
+import { StreamModalModule } from './modal/stream-modal.module';
+import { StreamNotificationModule } from './notification-list/stream-notification.module';
+import { StreamPaginatedListModule } from './paginated-list/stream-paginated-list.module';
@NgModule({
declarations: [
@@ -41,16 +42,12 @@ import { MessageBlockedComponent } from './message-blocked/message-blocked.compo
MessageActionsBoxComponent,
AttachmentListComponent,
MessageReactionsComponent,
- NotificationComponent,
- NotificationListComponent,
AttachmentPreviewListComponent,
- ModalComponent,
TextareaDirective,
ThreadComponent,
MessageBouncePromptComponent,
MessageReactionsSelectorComponent,
UserListComponent,
- PaginatedListComponent,
MessageTextComponent,
MessageBlockedComponent,
],
@@ -61,6 +58,9 @@ import { MessageBlockedComponent } from './message-blocked/message-blocked.compo
TranslateModule,
VoiceRecordingModule,
IconModule,
+ StreamModalModule,
+ StreamNotificationModule,
+ StreamPaginatedListModule,
],
exports: [
ChannelComponent,
@@ -76,17 +76,18 @@ import { MessageBlockedComponent } from './message-blocked/message-blocked.compo
NotificationComponent,
NotificationListComponent,
AttachmentPreviewListComponent,
- ModalComponent,
+ StreamModalModule,
StreamAvatarModule,
ThreadComponent,
MessageBouncePromptComponent,
VoiceRecordingModule,
MessageReactionsSelectorComponent,
UserListComponent,
- PaginatedListComponent,
+ StreamPaginatedListModule,
IconModule,
MessageTextComponent,
MessageBlockedComponent,
+ StreamNotificationModule,
],
providers: [VoiceRecorderService],
})
diff --git a/projects/stream-chat-angular/src/lib/types.ts b/projects/stream-chat-angular/src/lib/types.ts
index a5f49810..f7d8e94d 100644
--- a/projects/stream-chat-angular/src/lib/types.ts
+++ b/projects/stream-chat-angular/src/lib/types.ts
@@ -152,6 +152,7 @@ export type AvatarLocation =
| 'quoted-message-sender'
| 'autocomplete-item'
| 'typing-indicator'
+ | 'poll-voter'
/**
* @deprecated this will be renamed to user-list in the next major release
*/
@@ -353,6 +354,7 @@ export type MessageInput = {
parentId: string | undefined;
quotedMessageId: string | undefined;
customData: undefined | CustomMessageData;
+ pollId: string | undefined;
};
export type OffsetNextPageConfiguration = {
diff --git a/projects/stream-chat-angular/src/public-api.ts b/projects/stream-chat-angular/src/public-api.ts
index 120b5ed1..8aaa1f7c 100644
--- a/projects/stream-chat-angular/src/public-api.ts
+++ b/projects/stream-chat-angular/src/public-api.ts
@@ -84,3 +84,20 @@ export * from './lib/format-duration';
export * from './lib/message-input/voice-recorder.service';
export * from './lib/voice-recorder/mp3-transcoder';
export * from './lib/message-text/message-text.component';
+export * from './lib/polls/stream-polls.module';
+export * from './lib/polls/poll-composer/poll-composer.component';
+export * from './lib/polls/poll/poll.component';
+export * from './lib/polls/poll-header/poll-header.component';
+export * from './lib/polls/poll-options-list/poll-options-list.component';
+export * from './lib/polls/poll-option-selector/poll-option-selector.component';
+export * from './lib/polls/poll-actions/poll-actions.component';
+export * from './lib/modal/stream-modal.module';
+export * from './lib/notification-list/stream-notification.module';
+export * from './lib/polls/poll-actions/poll-results/poll-results-list/poll-results-list.component';
+export * from './lib/polls/poll-actions/poll-results/poll-vote-results-list/poll-vote-results-list.component';
+export * from './lib/paginated-list/stream-paginated-list.module';
+export * from './lib/polls/poll-actions/poll-results/poll-vote/poll-vote.component';
+export * from './lib/polls/poll-actions/poll-answers-list/poll-answers-list.component';
+export * from './lib/polls/poll-actions/upsert-answer/upsert-answer.component';
+export * from './lib/polls/poll-actions/add-option/add-option.component';
+export * from './lib/polls/poll-preview/poll-preview.component';