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 form, which sits apart from the message input, for example in a dialog. Opening it calls PollComposer.initState(), 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 form, and the UI layer 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 message input, 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. none
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 { PollComposerValidationCode } from "stream-chat";

const { pollComposer } = channel.messageComposer;

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

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

  await 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. Polls shows how the React SDK displays them.

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.

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.

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

import { POLL_ONLY_COMPOSITION_MIDDLEWARE_ID } from "stream-chat";

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,
  );
});

The setup function runs for every composer, existing and new, so this removes the poll-only middleware from all of them.

Poll with text and attachments builds the UI for it.

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.

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.

Used in