graph TD
MC[MessageComposer] --> TC[TextComposer]
MC --> PC[PollComposer]
MC --> AM[AttachmentManager]
MC --> LPM[LinkPreviewsManager]
MC --> CDM[CustomDataManager]MessageComposer API
The following sections document the MessageComposer API and its sub-managers.
Best Practices
- Read the core
MessageComposermanager first before extending sub-managers. - Keep custom manager usage consistent across channel and thread composers.
- Avoid bypassing managers when updating composer state.
- Validate custom data and attachments before composition.
- Document any custom hooks or extensions for future maintainers.
MessageComposer provides the core API for composing messages (text, attachments, polls, custom data). It coordinates specialized submanagers, each focused on one aspect of composition.
MessageComposer Class Architecture
MessageComposer follows a coordinator pattern and delegates responsibilities to submanagers:
Submanager Responsibilities
TextComposer
- Handles text input and editing
- Manages text state and validation
- Processes text-related events
PollComposer
- Manages poll creation and editing
- Handles poll state and validation
AttachmentManager
- Manages attachments other than link previews
- Handles uploads and downloads
LinkPreviewsManager
- Manages link previews
- Handles URL detection and preview generation
LocationComposer
- Collects information for sharing user location within a message
CustomDataManager
- Manages custom message data
- Manages custom composer data (bucket to store any temporary data that will not be submitted with the message)
Message Composition Scenarios
MessageComposer supports multiple composition scenarios with distinct initialization and behavior:
1. Message Editing
When editing an existing message, MessageComposer initializes with the message's current state:
const composer = new MessageComposer({
composition: existingMessage, // LocalMessage
compositionContext: channel,
client,
});Key characteristics:
- Initializes with message's current state (text, attachments, etc.)
- Tracks editing timestamps via
editingAuditState - Prevents draft creation (drafts are disabled)
- Maintains original message ID
- Updates are applied to the existing message
2. Drafts
Drafts can be enabled via the MessageComposer configuration:
const composer = new MessageComposer({
composition: draftResponse, // DraftResponse
compositionContext: channel,
client,
config: { drafts: { enabled: true } },
});
composer.updateConfig({ drafts: { enabled: false } });The composer exposes the draft management API:
await composer.createDraft();
await composer.deleteDraft();No parameters are needed; the composer extracts data from its state and draft composition middleware.
The composer takes care of local state updates based on draft.updated and draft.deleted events automatically.
3. New Message Context
When creating a new message, MessageComposer starts with an empty state:
const composer = new MessageComposer({
compositionContext: channel,
client,
});Key characteristics:
- Starts with empty state
- Generates new message ID
- No draft tracking by default
- Clean slate for all submanagers
MessageComposer API
MessageComposer Constructor
constructor({
composition,
config,
compositionContext,
client,
}: MessageComposerOptions)Where MessageComposerOptions is:
type MessageComposerOptions = {
client: StreamChat;
// Channel | Thread | LocalMessageWithLegacyThreadId
compositionContext: CompositionContext;
// state seed - state is initialized with the draft or an edited message
composition?: DraftResponse | MessageResponse | LocalMessage;
config?: DeepPartial<MessageComposerConfig>;
};MessageComposer State Management
// Initialize or reset composer state
initState({ composition }: { composition?: DraftResponse | MessageResponse | LocalMessage } = {}): void
// Init state from channel response
initStateFromChannelResponse(channelApiResponse: ChannelAPIResponse): void
// Generate a new message id
refreshId(): void
// Clear all composer state
clear(): void
// Restore state from edited message or clear
restore(): void
// Update composer configuration
updateConfig(config: DeepPartial<MessageComposerConfig>): void
// Set quoted message
setQuotedMessage(quotedMessage: LocalMessage | null): void
// Toggle the boolean flag showReplyInChannel
// to show a thread reply in a channel's message list too
toggleShowReplyInChannel(): voidMessage Composition
// Compose a message
compose(): Promise<MessageComposerMiddlewareValue['state'] | undefined>
// Compose a draft message
composeDraft(): Promise<MessageComposerMiddlewareValue['state'] | undefined>
// Create a draft message
createDraft(): Promise<void>
// Delete a draft message
deleteDraft(): Promise<void>
// Create a poll
createPoll(): Promise<void>
// Gets a draft and init the composer state
getDraft(): Promise<DraftResponse | null>Context Information Getters
// Get context type (channel, thread, legacy_thread, or message)
get contextType(): string
// Get composer tag
get tag(): string
// Get thread ID
get threadId(): string | nullMessageComposer State Getters
// Get message ID
get id(): string
// Get draft ID
get draftId(): string | null
// Get last change timestamp
get lastChange(): LastComposerChange
// Get quoted message
get quotedMessage(): LocalMessage | null
// Get poll ID
get pollId(): string | null
/**
* Get the boolean flag determining, whether the message will sent to the channel's message list.
* During the composition, the `show_in_channel: true` will be added to the thread reply's payload.
*/
get showReplyInChannel(): booleanComposition State Getters
// Check if composer has sendable data
get hasSendableData(): boolean
// Check if composition is empty
get compositionIsEmpty(): boolean
// Check if last change was local
get lastChangeOriginIsLocal(): booleanStatic Methods
// Generate a unique ID
static generateId(): stringText Composition With Suggestions
TextComposer handles text composition and tracks mentioned users. It supports autocomplete and suggestions based on typed text. Its state includes:
| Field | Description |
|---|---|
| text | Message text |
| selection | Cursor position (start/end of selection) |
| mentionedUsers | An array of user objects mentioned in the text. Before the message submission, the array is filtered for stale mentions. |
| suggestions | Active suggestions (mentions, commands, emojis) based on the trigger in the text |
Configuration
TextComposer allows adjusting individual configuration parameters:
// Enable/disable text composition
textComposer.enabled = true;
// Set default text value
textComposer.defaultValue = "Hello";
// Configure text length limits
textComposer.maxLengthOnEdit = 1000;
textComposer.maxLengthOnSend = 500;
// Toggle typing events
textComposer.publishTypingEvents = true;Text State Management
// Get current state values
const text = textComposer.text;
const selection = textComposer.selection;
const mentionedUsers = textComposer.mentionedUsers;
const suggestions = textComposer.suggestions;
const isEmpty = textComposer.textIsEmpty;
// Update text state
textComposer.setText("New message");
textComposer.setSelection({ start: 0, end: 5 });
// Insert text at selection
textComposer.insertText({
text: "Hello",
selection: { start: 0, end: 0 },
});
// Wrap selection with text
textComposer.wrapSelection({
head: "**",
tail: "**",
selection: { start: 0, end: 5 },
});
// Initialize state
textComposer.initState({ message: existingMessage });If you don’t pass a selection to insertText or wrapSelection, update selection with textComposer.setSelection(selection) so those methods use the correct textComposer.selection value.
User Mentions
For a user mention to be updated, the user object has to always contain an id.
// Add mentioned user
textComposer.upsertMentionedUser(user);
// Remove mentioned user
textComposer.removeMentionedUser(userId);
// Get mentioned user
const user = textComposer.getMentionedUser(userId);
// Set all mentioned users
textComposer.setMentionedUsers(users);Suggestions
Suggestions are set during text change processing in the TextComposer middleware chain. You can also set or clear them directly:
// Set suggestions
textComposer.setSuggestions({
trigger: "@",
items: users,
});
// Close suggestions
textComposer.closeSuggestions();Event Handling
Register text changes with TextComposer.handleChange():
// Handle text change
textComposer.handleChange({
text: "New text",
selection: { start: 0, end: 0 },
});Handle suggestion selection with TextComposer.handleSelect():
// Handle suggestion selection
textComposer.handleSelect(suggestion);Attachment Management
AttachmentManager handles file attachments during composition, including upload state.
Basic Attachment Management
// Get current attachments
const attachments = attachmentManager.attachments;
// Add or update attachments
attachmentManager.upsertAttachments([newAttachment]);
// Remove attachments
attachmentManager.removeAttachments([attachmentId]);Uploading Attachments
When using uploadAttachment, ensure the attachment object is correctly formed:
// Convert file to proper attachment object
const localAttachment =
await attachmentManager.fileToLocalUploadAttachment(file);
// Upload the attachment
const uploadedAttachment =
await attachmentManager.uploadAttachment(localAttachment);Uploaded attachments include localMetadata, which is removed before sending the message. localMetadata contains:
file- the file referenceuploadState-'uploading','finished','failed','blocked','pending'uploadPermissionCheck- contains the upload permission check result to the CDN and explains the reason if blocked
When editing a draft or existing message, uploaded attachments are marked uploadState: "finished" and have no file reference or uploadPermissionCheck.
Custom Upload Configuration
File Filtering
Use fileUploadFilter to add custom filtering logic beyond acceptedFiles:
attachmentManager.fileUploadFilter = (file) => {
// Custom filter logic
return file.size < 10 * 1024 * 1024; // Example: 10MB limit
};Custom Upload Destination
Use doUploadRequest to customize where files are uploaded:
attachmentManager.setCustomUploadFn(async (file) => {
// Upload to custom CDN
const result = await customCDN.upload(file);
return { file: result.url };
});For custom CDN uploads, you may need to override getUploadConfigCheck:
class CustomAttachmentManager extends AttachmentManager {
getUploadConfigCheck = async (file) => {
// Skip default upload checks for custom CDN
return { uploadBlocked: false };
};
}Attachment Identity Functions
AttachmentManager uses attachment identity functions internally. Use them for type safety:
import { isLocalImageAttachment } from "stream-chat";
if (isLocalImageAttachment(attachment)) {
// Type-safe access to image-specific properties
console.log(attachment.original_height);
}Attachments are considered “local” only if they have localMetadata.
| Function | Description |
|---|---|
isLocalAttachment | Checks if attachment has local metadata |
isLocalUploadAttachment | Checks if attachment has upload state metadata |
isFileAttachment | Checks if attachment is a file attachment |
isLocalFileAttachment | Checks if attachment is a local file attachment |
isImageAttachment | Checks if attachment is an image attachment |
isLocalImageAttachment | Checks if attachment is a local image attachment |
isAudioAttachment | Checks if attachment is an audio attachment |
isLocalAudioAttachment | Checks if attachment is a local audio attachment |
isVoiceRecordingAttachment | Checks if attachment is a voice recording attachment |
isLocalVoiceRecordingAttachment | Checks if attachment is a local voice recording attachment |
isVideoAttachment | Checks if attachment is a video attachment |
isLocalVideoAttachment | Checks if attachment is a local video attachment |
isUploadedAttachment | Checks if attachment is an uploaded attachment (audio, file, image, video, or voice recording) |
isScrapedContent | Checks if attachment is scraped content (has og_scrape_url or title_link) |
Link Preview Management
LinkPreviewsManager handles link preview generation during composition. It finds, enriches, and manages previews, then converts them to attachments before send.
State
State:
type LinkPreviewsManagerState = {
previews: Map<string, LinkPreview>; // URL -> Preview mapping
};Each preview has a status:
LOADING: Preview is being fetchedLOADED: Preview successfully loadedFAILED: Preview loading failedDISMISSED: Preview was dismissed by userPENDING: Preview is waiting to be processed
LinkPreviewsManager API
Finding and Enriching URLs
Enrichment is debounced by 1.5s by default.
// Find and enrich URLs in text (debounced)
linkPreviewsManager.findAndEnrichUrls(text);
// Cancel ongoing enrichment
linkPreviewsManager.cancelURLEnrichment();Managing Previews
// Clear non-dismissed previews
linkPreviewsManager.clearPreviews();
// Update a specific preview
linkPreviewsManager.updatePreview(url, {
title: "New Title",
description: "New Description",
status: LinkPreviewStatus.LOADED,
});
// Dismiss a preview
linkPreviewsManager.dismissPreview(url);Dismissed previews are not re-enriched when the same URL appears again in the text. The clearPreviews method preserves dismissed previews while removing others.
Static Helpers
LinkPreviewsManager exposes static helpers for preview state:
// Check preview status
LinkPreviewsManager.previewIsLoading(preview);
LinkPreviewsManager.previewIsLoaded(preview);
LinkPreviewsManager.previewIsDismissed(preview);
LinkPreviewsManager.previewIsFailed(preview);
LinkPreviewsManager.previewIsPending(preview);The getPreviewData static method extracts the preview data that will be converted to a scraped attachment when the message is sent to the server.
// Get preview data without status
LinkPreviewsManager.getPreviewData(preview);Location Composition
LocationComposer is a MessageComposer submanager that composes message.shared_location.
LocationComposer configuration
Config options:
- enabled - enabled by default, but can be overridden by channel config (
shared_locations) - getDeviceId - default generates UUID v4; provide your own for a stable per-device ID
messageComposer.updateConfig({
location: {
/* ... */
},
});LocationComposer state
State tracks latitude, longitude, and optional live sharing duration.
export type StaticLocationPreview = StaticLocationPayload;
export type LiveLocationPreview = Omit<LiveLocationPayload, "end_at"> & {
durationMs?: number;
};
type LocationComposerState = {
location: StaticLocationPreview | LiveLocationPreview | null;
};LocationComposer API
Getters:
- config - current config
- deviceId - cached device ID
- location - current location state
- validLocation - valid payload or
nullif incomplete/invalid
State methods:
- initState(props: { message?: DraftMessage | LocalMessage }) - resets state
- setData(data: { latitude: number; longitude: number; durationMs?: number }) - sets location state
Sending a location message
To send a location-only message, use messageComposer.sendLocation(). The API request runs only if the message is not a thread reply and LocationComposer can produce a valid location object.
If shared_location should accompany text/attachments, use messageComposer.compose() and then channel.sendMessage().
Poll Composition
PollComposer handles poll composition and creation. It builds poll data to attach to a message.
PollComposer State
Poll state includes data and validation errors:
type PollComposerState = {
data: {
id: string;
name: string;
description: string;
options: Array<{ id: string; text: string }>;
max_votes_allowed: string;
enforce_unique_vote: boolean;
allow_answers: boolean;
allow_user_suggested_options: boolean;
voting_visibility: VotingVisibility;
user_id: string;
};
errors: Record<string, string>;
};Validation prevents failed creation requests on the server.
PollComposer State Management
Initialize or reset state:
// Reset to initial state
pollComposer.initState();Poll composition updates multiple fields. You can react to field updates and blur events. updateFields accepts partial objects:
// Update poll fields
pollComposer.updateFields({
name: "Favorite Color?",
description: "Choose your favorite color",
options: [
{ id: "1", text: "Red" },
{ id: "2", text: "Blue" },
],
max_votes_allowed: "1",
enforce_unique_vote: true,
});// Handle field blur validation
pollComposer.handleFieldBlur("name");Validation runs on each update or blur. Customize via PollComposer middleware (see the PollComposer middleware guide).
Poll Creation
messageComposer.createPoll();Behind the scenes, the poll is composed and created on the server. Watch for failed creation requests by subscribing to StreamChat.notifications.state (see the client notifications service guide).
State Access
// Check if poll can be created
const canCreate = pollComposer.canCreatePoll;
// Access poll fields
const name = pollComposer.name;
const description = pollComposer.description;
const options = pollComposer.options;
const maxVotes = pollComposer.max_votes_allowed;
const enforceUniqueVote = pollComposer.enforce_unique_vote;
const allowAnswers = pollComposer.allow_answers;
const allowUserOptions = pollComposer.allow_user_suggested_options;
const votingVisibility = pollComposer.voting_visibility;Validation
canCreatePoll checks the minimum requirements for creation:
- At least one non-empty option exists
- Poll name is not empty
max_votes_allowedis either empty or a valid number between 2 and 10- No validation errors are present
Custom Data Management
CustomDataManager handles custom data for messages and the composer. It manages two types:
- Message custom data - data that will be sent with the message
- Composer custom data - data that stays in the composer and is not sent with the message
State
The custom data state consists of two separate objects:
type CustomDataManagerState = {
message: CustomMessageData; // Data sent with the message
custom: CustomMessageComposerData; // Data for custom integration needs, not sent with the message
};The custom property stores integration data that should not be sent with the message (for example, UI state or temporary data).
State Management
// Get current state values
const messageData = customDataManager.customMessageData;
const composerData = customDataManager.customComposerData;
// Update message custom data
customDataManager.setMessageData({
customField: "value",
});
// Update composer custom data
customDataManager.setCustomData({
composerField: "value",
});
// Initialize state
customDataManager.initState({ message: existingMessage });Custom Data Comparison
isMessageDataEqual determines whether message custom data changed. By default, it JSON-stringifies and compares:
isMessageDataEqual = (
nextState: CustomDataManagerState,
previousState?: CustomDataManagerState,
) =>
JSON.stringify(nextState.message) === JSON.stringify(previousState?.message);You can override this method to implement custom comparison logic. For example:
class CustomDataManager {
isMessageDataEqual = (
nextState: CustomDataManagerState,
previousState?: CustomDataManagerState,
) => {
// Custom comparison logic
return nextState.message.customField === previousState?.message.customField;
};
}This is particularly useful when:
- Only specific fields need to be compared
- Complex data structures require special comparison logic
- Certain fields should be ignored in the comparison