Polls

A poll is a question with options that the channel's members vote on. It's created on the server first, and then its id is attached to the sent message. While the user fills in the poll form, the poll composer, messageComposer.pollComposer, holds the form and checks each field.

Info:

Example

The user has typed "One more thing" in the channel's message input but hasn't sent it. The text is in the channel composer's text composer, messageComposer.textComposer. Then they open the poll creation sheet from the attachment picker. The sheet resets the poll form when it closes, so the form starts empty:

  1. In the form, they type the question "Lunch?" and the options "Pizza" and "Sushi". Each change goes into the poll composer through PollComposer.updateFields(). The text in the message input doesn't change. Once the question and an option are filled in, pollComposer.canCreatePoll becomes true.
  2. They press Create in the sheet, and the sheet makes two calls. MessageComposer.createPoll() creates the poll on the server and stores its id in messageComposer.pollId. Then MessageComposer.send() sends a message that carries only the poll.
  3. After the send, messageComposer.pollId is empty again. "One more thing" stays in the composer, ready to send as the next message.

What it holds

The poll composer's state is in pollComposer.state. It has two fields: data (stores the form's state), and errors, the problems found in it.

data holds the poll's fields:

Field Starts as What it holds
name '' The question. A poll can't be created without one.
description '' More about the question.
options one empty option The options, each { id, text }. The last one is kept empty, for the user to type the next option into.
enforce_unique_vote true Each member votes for one option only.
max_votes_allowed '' How many options a member can vote for, as a string from '2' to '10'. '' means no limit. Used only while enforce_unique_vote is off.
allow_answers false Members can comment on the poll, one comment each, which is called an answer. Answers don't count as votes.
allow_user_suggested_options false Members can add options.
voting_visibility 'public' 'anonymous' hides who voted for what.
id a new id The id the poll gets on the server.

Each field has a getter that reads its current value, such as pollComposer.name or pollComposer.options. pollComposer.canCreatePoll says whether the form is complete: the question is set, at least one option has text, max_votes_allowed is empty or a number from 2 to 10, and pollComposer.state.errors holds no validation error.

Filling in the form

Each change passes the form through the poll middleware: a pipeline of functions that processes the changed fields and checks them, as Composer middleware explains.

What a poll form needs, and the call for each (the last column says which middleware handler a call runs):

To... Call What happens Poll middleware
change fields as the user types PollComposer.updateFields({ data }) Updates the fields in data and checks them, as below. handleFieldChange
change the text of one option PollComposer.updateFields({ data: { options: { index, text } } }) Changes the option at index. An option emptied before the last one is removed. A new empty option is added at the end when none is empty, up to 100 options. handleFieldChange
reorder the options PollComposer.updateFields({ data: { options } }) Replaces the options with the array, in its order. handleFieldChange
check a field once the user leaves it PollComposer.handleFieldBlur(field), from the field's blur handler Adds the errors that only make sense once the user is done with the field, such as an empty question, to pollComposer.state.errors. A form of your own calls it when a field loses focus. handleFieldBlur
start a new poll PollComposer.initState() Resets the form to its starting values. Sending a poll doesn't reset the form, so call it when a new poll form opens or closes. none

The SDK's creation sheet calls PollComposer.updateFields() as the user types, PollComposer.handleFieldBlur() when the user leaves the question, an option or the maximum number of votes, and PollComposer.initState() when it closes.

const { pollComposer } = channel.messageComposer;

await pollComposer.updateFields({ data: { name: "Lunch?" } });
await pollComposer.updateFields({
  data: { options: { index: 0, text: "Pizza" } },
});

While processing a change, the built-in middleware keeps the form consistent: changing enforce_unique_vote empties max_votes_allowed, and max_votes_allowed is turned into a whole number from 2 to 10, or '' when it isn't a number.

PollComposer.updateFields() also takes injectedFieldErrors, errors of your own, for example from a check performed by your own logic. They win over the errors the middleware finds.

For example, to reject questions that your own check doesn't allow:

import type { MessageComposer, PollComposerValidationCode } from "stream-chat";

// Your own check, for example a request to your server.
const isQuestionAllowed = async (name: string) => !name.includes("spam");

const changeQuestion = async (composer: MessageComposer, name: string) => {
  const allowed = await isQuestionAllowed(name);

  await composer.pollComposer.updateFields({
    data: { name },
    injectedFieldErrors: {
      name: allowed
        ? undefined
        : {
            // `code` takes only the built-in codes, so a code of your own needs a cast.
            code: "myapp:poll:name:notAllowed" as PollComposerValidationCode,
            message: "This question isn't allowed",
          },
    },
  });
};

The error is saved to pollComposer.state.errors.name, and pollComposer.canCreatePoll stays false while it's there. Passing undefined for a field clears your error.

Validation errors

While the user fills in the form, the poll composer saves field errors in pollComposer.state.errors, for your UI to show next to the fields. While any error is there, pollComposer.canCreatePoll is false.

Subscribe to pollComposer.state to show the errors as they change:

const { pollComposer } = channel.messageComposer;

const unsubscribe = pollComposer.state.subscribeWithSelector(
  ({ errors }) => ({ errors }),
  ({ errors }) => {
    // The question's error, or undefined when the question is fine.
    if (errors.name) {
      console.log(errors.name.code, errors.name.message);
    }

    // One error per option that has a problem, keyed by the option's id.
    Object.entries(errors.options ?? {}).forEach(([optionId, error]) => {
      console.log(optionId, error.code, error.message);
    });
  },
);

For example, after the user leaves the question empty and types "Pizza" into two options, pollComposer.state.errors is:

{
  "name": {
    "code": "validation:poll:name:required",
    "message": "Question is required"
  },
  "options": {
    "<id of the second Pizza option>": {
      "code": "validation:poll:option:duplicate",
      "message": "Option already exists"
    }
  }
}

code says what's wrong: translate it. message is in English, to show when you have no translation.

The table lists every code, and where to read it:

code Read from Found when Means
validation:poll:name:required errors.name the user leaves the question The question is empty.
validation:poll:option:duplicate errors.options[option.id] an option changes, or the user leaves the options The option has the same text as an option before it.
validation:poll:option:empty errors.options[option.id] the user leaves the options An option other than the last one is empty.
validation:poll:maxVotes:uniqueVoteEnforced errors.max_votes_allowed max_votes_allowed changes A vote limit was set while enforce_unique_vote is on.
validation:poll:maxVotes:notNumeric errors.max_votes_allowed max_votes_allowed changes The limit isn't a number.
validation:poll:maxVotes:outOfRange errors.max_votes_allowed max_votes_allowed changes, or the user leaves it The limit isn't from 2 to 10.

The built-in processing already turns max_votes_allowed into a number from 2 to 10, so the last two appear only with processors of your own.

The SDK's creation sheet shows an option's error under the option. For the built-in optionDuplicate and optionEmpty codes it shows its own translated text, and for any other code the error's message. It shows no text for an error of the question or of max_votes_allowed. Any error disables the send button, because PollComposer.canCreatePoll is false while an error is set.

Validators and processors

PollComposer.updateFields() runs handleFieldChange on the poll state chain, with the fields it changes. PollComposer.handleFieldBlur(field) runs handleFieldBlur with one field.

For each field in the change, the built-in middleware runs the field's processor, then the field's validator:

  • A processor receives { data, value } and returns the fields to store, such as { name: value.trim() }. It can return more than one field.
  • A validator receives { data, value, currentError } and returns errors by field. { name: undefined } clears the question's error. It can set or clear the errors of other fields too.

data is the poll before the change, value is the field's new value after its processor, and currentError is the field's error before the change. The built-in functions are exported:

Event Processors Validators
handleFieldChange pollCompositionStateProcessors. They keep max_votes_allowed between 2 and 10, empty it when enforce_unique_vote changes, remove an option the user empties unless it's the last, and add an empty option at the end when none is empty, up to 100 options. pollStateChangeValidators and defaultPollFieldChangeEventValidators. They flag duplicate options and an invalid max_votes_allowed, and clear the question's error once the user types.
handleFieldBlur None. pollStateChangeValidators and defaultPollFieldBlurEventValidators. They flag duplicate options and an invalid max_votes_allowed, require a question, and flag an empty option other than the last.

createPollComposerStateMiddleware({ processors, validators }) takes your functions by event, then by field, as in { handleFieldBlur: { name: validateName } }. Each one replaces the built-in function for the same field and event, and the other built-in functions keep running. To keep a built-in check, call the built-in function from yours. A processor of your own for options replaces the one that removes and adds options. Its value is the whole list when the options are reordered, and { index, text } when one option changes. isTargetedOptionTextUpdate(value) tells them apart.

An error is a PollComposerValidationError, { code, message, metadata? }. stream-chat builds its own with pollComposerValidationError(code), and the type of code allows only the codes in POLL_COMPOSER_VALIDATION_CODE. For a check of your own, give the error a code of your own, cast to PollComposerValidationCode, and the text to show as message. A prefix such as my-app: keeps your codes apart from the built-in ones.

This setup limits each option to 40 characters, and keeps the built-in checks for duplicate and empty options:

import {
  createPollComposerStateMiddleware,
  defaultPollFieldBlurEventValidators,
  pollStateChangeValidators,
  StreamChat,
} from "stream-chat";
import type {
  PollComposerOption,
  PollComposerValidationCode,
  PollComposerValidationError,
  PollStateChangeValidator,
} from "stream-chat";

const client = StreamChat.getInstance("YOUR_API_KEY");

const MAX_OPTION_LENGTH = 40;

// The type of `code` allows only the built-in codes, so a code of your own is cast.
const ownError = (
  code: string,
  message: string,
): PollComposerValidationError => ({
  code: code as PollComposerValidationCode,
  message,
});

const withLengthCheck =
  (builtIn: PollStateChangeValidator | undefined): PollStateChangeValidator =>
  (params) => {
    const errors = { ...builtIn?.(params).options };
    const options: PollComposerOption[] = params.value;

    options.forEach((option) => {
      if (!errors[option.id] && option.text.length > MAX_OPTION_LENGTH) {
        errors[option.id] = ownError(
          "my-app:poll:option:tooLong",
          `Use at most ${MAX_OPTION_LENGTH} characters`,
        );
      }
    });

    return { options: Object.keys(errors).length > 0 ? errors : undefined };
  };

client.config.setSetupFunction("messageComposer", ({ composer }) => {
  const { stateMiddlewareExecutor } = composer.pollComposer;

  stateMiddlewareExecutor.replace([
    createPollComposerStateMiddleware({
      validators: {
        handleFieldChange: {
          options: withLengthCheck(pollStateChangeValidators.options),
        },
        handleFieldBlur: {
          options: withLengthCheck(defaultPollFieldBlurEventValidators.options),
        },
      },
    }),
  ]);

  return () =>
    stateMiddlewareExecutor.replace([createPollComposerStateMiddleware()]);
});

MiddlewareExecutor.replace() puts the new middleware in the place of the built-in one, because both have the same id, and the teardown puts a default one back.

Creating and sending the poll

Creating the poll takes two steps:

const composer = channel.messageComposer;

await composer.createPoll(); // creates the poll on the server and sets composer.pollId
await composer.send(); // sends the message that carries the poll

MessageComposer.createPoll() does nothing while pollComposer.canCreatePoll is false. Otherwise it sends the form, without its empty options, to the server and stores the new poll's id in messageComposer.pollId. When the request fails, it publishes an api:poll:create:failed notification and throws.

While messageComposer.pollId is set, the composer has something to send. In a channel composer, MessageComposer.send() then sends a message that carries only the poll: no text, attachments, mentions, quoted message or custom data. The text, attachments and quoted message stay in the composer, ready to send as the next message, as Messages with a poll explains. After the send, messageComposer.pollId is cleared.

In a thread composer, and while a message is edited, the poll is sent with the rest of the message, and the composer clears after the send.

To send the poll together with the text and attachments in a channel too, remove the poll-only middleware. Its id is exported from stream-chat as POLL_ONLY_COMPOSITION_MIDDLEWARE_ID:

import { POLL_ONLY_COMPOSITION_MIDDLEWARE_ID, StreamChat } from "stream-chat";

const client = StreamChat.getInstance("YOUR_API_KEY");

client.config.setSetupFunction("messageComposer", ({ composer }) => {
  // A poll then goes out with the text and attachments, and the composer clears after the send.
  composer.compositionMiddlewareExecutor.remove(
    POLL_ONLY_COMPOSITION_MIDDLEWARE_ID,
  );
});

A draft keeps messageComposer.pollId, so a poll that was created but not sent comes back with the draft. The form in pollComposer.state isn't saved in it.

When polls are on

Polls are on while messageComposer.config.polls.enabled is true. That's when both the polls.enabled setting in client.config and the channel type's polls setting are on. The composer doesn't check it when it creates or sends a poll: the UI reads it to decide whether to offer polls. Channel reads it to set hasCreatePoll, and the SDK's attachment picker offers polls only outside threads, when hasCreatePoll is true and the user has the send-poll capability.

Settings

Set them for every composer in client.config, under messageComposer.polls:

client.config.set({
  messageComposer: {
    polls: { enabled: false },
  },
});

The poll composer has no setters. To change a setting for one composer only, patch that composer's configuration, for example channel.messageComposer.updateConfig({ polls: { enabled: false } }).

Setting Default What it does
enabled true false turns off polls. Combined with the channel type's polls setting.

See Polls for the feature, including voting, and the custom poll flow UI cookbook for a creation screen of your own.

Used in