diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx
index 7abfd4087..c37924e83 100644
--- a/examples/vite/src/App.tsx
+++ b/examples/vite/src/App.tsx
@@ -12,6 +12,7 @@ import {
createCommandInjectionMiddleware,
createCommandStringExtractionMiddleware,
createDraftCommandInjectionMiddleware,
+ createMentionsMiddleware,
SearchController,
UserSearchSource,
} from 'stream-chat';
@@ -69,6 +70,11 @@ import {
} from './CustomMessageUi';
import { ConfigurableMessageActions } from './CustomMessageActions';
import { InlineEditableMessage } from './InlineEditMessage';
+import {
+ createNicknameMentionCompositionMiddleware,
+ NicknameMentionsSearchSource,
+ withNicknameMentions,
+} from './ChannelNicknames';
import { SidebarToggle } from './Sidebar/SidebarToggle.tsx';
import { CommandModeAttachmentSelector } from './CommandModeAttachmentSelector.tsx';
@@ -221,6 +227,10 @@ const reactionsVariant = getReactionsVariant();
const attachmentActionsVariant = getAttachmentActionsVariant();
const globalDialogManager = 'globalDialogManager';
+// Composed at module scope so the slot component identity stays stable across renders. Wraps the
+// app's own message UI rather than replacing it, so inline editing and nickname mentions coexist.
+const MessageWithNicknameMentions = withNicknameMentions(InlineEditableMessage);
+
const CustomAttachmentWithActions = (props: AttachmentProps) => (
);
@@ -365,6 +375,24 @@ const App = () => {
unique: true,
});
+ // --- Channel nicknames in mentions (see src/ChannelNicknames) -------------------------
+ // `replace` matches on middleware id, so swapping in a mentions middleware backed by our
+ // own search source keeps the SDK's ordering intact. The search source is a documented
+ // injection point on `createMentionsMiddleware` — no fork, no patch.
+ composer.textComposer.middlewareExecutor.replace([
+ createMentionsMiddleware(composer.channel, {
+ searchSource: new NicknameMentionsSearchSource(composer.channel),
+ }) as TextComposerMiddleware,
+ ]);
+
+ // Records which display text each mention was written with. Must run after the SDK's
+ // text-composition middleware, which is what fills `mentioned_users`.
+ composer.compositionMiddlewareExecutor.insert({
+ middleware: [createNicknameMentionCompositionMiddleware(composer)],
+ position: { after: 'stream-io/message-composer-middleware/text-composition' },
+ unique: true,
+ });
+
composer.updateConfig({
linkPreviews: { enabled: true },
location: { enabled: true },
@@ -425,7 +453,7 @@ const App = () => {
HeaderStartContent: SidebarToggle,
MessageActions: ConfigurableMessageActions,
AttachmentSelector: CommandModeAttachmentSelector,
- Message: InlineEditableMessage,
+ Message: MessageWithNicknameMentions,
...messageUiOverrides,
}}
>
diff --git a/examples/vite/src/ChannelNicknames/NicknameMentionsSearchSource.ts b/examples/vite/src/ChannelNicknames/NicknameMentionsSearchSource.ts
new file mode 100644
index 000000000..4e0160455
--- /dev/null
+++ b/examples/vite/src/ChannelNicknames/NicknameMentionsSearchSource.ts
@@ -0,0 +1,150 @@
+import {
+ getTokenizedSuggestionDisplayName,
+ type MemberFilters,
+ type MemberSort,
+ MentionsSearchSource,
+ type UserResponse,
+ type UserSuggestion,
+} from 'stream-chat';
+
+import { getMemberNickname } from './nicknameData';
+
+const normalize = (value: string | undefined) => (value ?? '').toLowerCase();
+
+/**
+ * Mention autocomplete that matches on the channel nickname as well as the username, and shows
+ * the nickname in the dropdown — on both the local and the server-side search paths.
+ *
+ * Every override here replaces a public arrow-function field on `MentionsSearchSource`; a subclass
+ * field of the same name wins, because subclass field initializers run after `super()` and the base
+ * constructor never calls these (so there is no ordering hazard). `resetState` is the exception —
+ * it is a prototype method, overridden normally and chained with `super`.
+ *
+ * `getUserSuggestionsPage` dispatches through `this.searchMembersLocally` / `this.queryMembers`,
+ * which is why that caller needs no changes.
+ */
+export class NicknameMentionsSearchSource extends MentionsSearchSource {
+ /**
+ * Nicknames harvested from server-side `queryMembers` responses, keyed by user id.
+ *
+ * Needed because the base `queryMembers` maps each member down to `member.user`, dropping the
+ * member-level custom fields — so for a member the client has not loaded locally, the nickname
+ * we just matched on would otherwise be unavailable when building the suggestion.
+ */
+ private nicknamesByUserId = new Map();
+
+ /** Local member state first (always current), then whatever the last query returned. */
+ private resolveNickname = (userId: string) =>
+ getMemberNickname(this.channel, userId) ?? this.nicknamesByUserId.get(userId);
+
+ private resolveDisplayName = (user: UserResponse) =>
+ this.resolveNickname(user.id) ?? user.name ?? user.id;
+
+ /**
+ * Local (in-memory) member search — the path taken while the channel has fewer than 100 members,
+ * i.e. while `channel.state.members` is known to hold everyone.
+ *
+ * The base implementation matches `user.name` and `user.id` (plus a Levenshtein fallback). This
+ * one adds the member's `nickname`.
+ */
+ searchMembersLocally = (searchQuery: string) => {
+ const query = normalize(searchQuery);
+ const ownUserId = this.client.userID;
+
+ return this.getMembersAndWatchers()
+ .filter((user) => {
+ if (user.id === ownUserId) return false;
+ if (!query) return true;
+
+ return (
+ normalize(getMemberNickname(this.channel, user.id)).includes(query) ||
+ normalize(user.name).includes(query) ||
+ normalize(user.id).includes(query)
+ );
+ })
+ .sort((left, right) =>
+ this.resolveDisplayName(left).localeCompare(this.resolveDisplayName(right)),
+ );
+ };
+
+ /**
+ * Server-side member search — the path taken once the channel has 100+ members and
+ * `channel.state.members` can no longer be trusted to hold everyone.
+ *
+ * The API *does* support `$autocomplete` on a custom member field: `{ nickname: { $autocomplete } }`
+ * and an `$or` combining it with `name` both work (verified against the live endpoint). The
+ * base implementation's `// autocomplete possible only for name` comment is wrong.
+ *
+ * Two details this has to get right:
+ * - the base reads a *static* `memberFilters` field, which cannot embed the per-keystroke
+ * query — overriding the method is the only way to get a dynamic filter;
+ * - `sort` here is a key/value map (`{ user_id: 1 }`), not `{ field, direction }`. Passing the
+ * latter yields `sort must contain at maximum 1 item`, because it counts object keys.
+ *
+ * An integrator-supplied `memberFilters` still wins, matching base behaviour.
+ */
+ prepareQueryMembersParams = (searchQuery: string, offset = 0) => ({
+ filters:
+ this.memberFilters ??
+ ({
+ $or: [
+ { name: { $autocomplete: searchQuery } },
+ { nickname: { $autocomplete: searchQuery } },
+ ],
+ } as unknown as MemberFilters),
+ options: { ...this.searchOptions, limit: this.pageSize, offset },
+ sort: [{ user_id: 1 }] as unknown as MemberSort,
+ });
+
+ /**
+ * Same request the base makes, but the member-level `nickname` is captured on the way through
+ * before the response is flattened to plain users.
+ */
+ queryMembers = async (searchQuery: string, offset = 0) => {
+ const { filters, options, sort } = this.prepareQueryMembersParams(
+ searchQuery,
+ offset,
+ );
+ const response = await this.channel.queryMembers(filters, sort, options);
+
+ response.members.forEach((member) => {
+ const userId = member.user_id ?? member.user?.id;
+ const nickname = typeof member.nickname === 'string' ? member.nickname.trim() : '';
+
+ if (userId && nickname) this.nicknamesByUserId.set(userId, nickname);
+ });
+
+ return response.members.map((member) => member.user) as UserResponse[];
+ };
+
+ resetState() {
+ // Guarded: the base constructor may reach this before the field initializer has run.
+ this.nicknamesByUserId?.clear();
+ super.resetState();
+ }
+
+ /**
+ * Turns a matched user into the suggestion the dropdown renders.
+ *
+ * Setting `name` to the nickname does double duty: it is what the dropdown displays, and the
+ * composer inserts `@${suggestion.name || suggestion.id}` — so the textarea gets `@nickname`
+ * too.
+ *
+ * It is also load-bearing for correctness: the composition middleware only keeps a mention in
+ * `mentioned_users` when `entity.id` or `entity.name` actually appears in the text. Leave `name`
+ * as the username here and the mention is silently dropped — no error, no notification.
+ */
+ toUserSuggestion = (
+ user: UserResponse,
+ searchToken = this.searchQuery,
+ ): UserSuggestion => {
+ const displayName = this.resolveDisplayName(user);
+
+ return {
+ ...user,
+ mentionType: 'user',
+ name: displayName,
+ ...getTokenizedSuggestionDisplayName({ displayName, searchToken }),
+ };
+ };
+}
diff --git a/examples/vite/src/ChannelNicknames/NicknameMessageUI.tsx b/examples/vite/src/ChannelNicknames/NicknameMessageUI.tsx
new file mode 100644
index 000000000..a564df468
--- /dev/null
+++ b/examples/vite/src/ChannelNicknames/NicknameMessageUI.tsx
@@ -0,0 +1,48 @@
+import { useMemo } from 'react';
+import type { ComponentType } from 'react';
+import {
+ MessageUI as DefaultMessageUI,
+ type MessageUIComponentProps,
+ useChannelStateContext,
+ useMessageContext,
+} from 'stream-chat-react';
+
+import { createNicknameRenderText } from './renderTextWithNicknames';
+
+/**
+ * Wraps a message-UI component so its text renders channel nicknames in mentions.
+ *
+ * Why a wrapper and not just a `renderText` prop on `MessageList`: `renderText`'s signature is
+ * `(text, mentionedUsers, options)` — it never sees the message, so it cannot read
+ * `message.custom.mention_display_names`. Resolving that has to happen one level up, per message.
+ *
+ * A HOC rather than a fixed slot component because the demo already overrides the message UI
+ * (`InlineEditableMessage`). Composing keeps both features instead of one clobbering the other,
+ * and works because that component spreads `{...props}` into the default UI, so the injected
+ * `renderText` reaches `MessageText`.
+ */
+export const withNicknameMentions = (
+ MessageUIComponent: ComponentType,
+) => {
+ const MessageUIWithNicknameMentions = (props: MessageUIComponentProps) => {
+ const { channel } = useChannelStateContext('withNicknameMentions');
+ const { message: contextMessage } = useMessageContext('withNicknameMentions');
+ const message = props.message ?? contextMessage;
+
+ const renderText = useMemo(
+ () => createNicknameRenderText({ channel, message }),
+ [channel, message],
+ );
+
+ return ;
+ };
+
+ MessageUIWithNicknameMentions.displayName = `withNicknameMentions(${
+ MessageUIComponent.displayName || MessageUIComponent.name || 'MessageUI'
+ })`;
+
+ return MessageUIWithNicknameMentions;
+};
+
+/** Convenience for apps that do not otherwise override the message UI. */
+export const NicknameMessageUI = withNicknameMentions(DefaultMessageUI);
diff --git a/examples/vite/src/ChannelNicknames/index.ts b/examples/vite/src/ChannelNicknames/index.ts
new file mode 100644
index 000000000..335218194
--- /dev/null
+++ b/examples/vite/src/ChannelNicknames/index.ts
@@ -0,0 +1,5 @@
+export * from './nicknameData';
+export * from './NicknameMentionsSearchSource';
+export * from './nicknameMentionComposition';
+export * from './renderTextWithNicknames';
+export * from './NicknameMessageUI';
diff --git a/examples/vite/src/ChannelNicknames/nicknameData.ts b/examples/vite/src/ChannelNicknames/nicknameData.ts
new file mode 100644
index 000000000..a86b0aa31
--- /dev/null
+++ b/examples/vite/src/ChannelNicknames/nicknameData.ts
@@ -0,0 +1,38 @@
+import type { Channel, UserResponse } from 'stream-chat';
+
+/**
+ * Channel-specific nicknames.
+ *
+ * The nickname lives on the **channel member** (`member.nickname`), not on the user — that is what
+ * scopes it to a single channel.
+ *
+ * Writing it is out of scope here. A browser client can only write its own membership
+ * (`updateMemberPartial` takes no `user_id`), so nicknames for other people are set server-side —
+ * however the integrating app already manages its own data.
+ *
+ * Everything in this folder only reads that field, and degrades to the plain username when it is
+ * absent — so it is inert for members without a nickname.
+ *
+ * Nothing here patches `stream-chat` or `stream-chat-react`; every hook used is public API.
+ */
+
+/** Message custom-data key holding `{ [userId]: displayTextUsedInThisMessage }`. */
+export const MENTION_DISPLAY_NAMES_KEY = 'mention_display_names';
+
+export const getMemberNickname = (
+ channel: Channel,
+ userId: string,
+): string | undefined => {
+ // Custom member fields sit at the top level of the member object in this SDK version —
+ // `ChannelMemberResponse` is `CustomMemberData & { … }`, not a `custom` bag.
+ const nickname = channel.state.members?.[userId]?.nickname;
+
+ return typeof nickname === 'string' && nickname.trim() ? nickname.trim() : undefined;
+};
+
+/**
+ * What a mention of `user` should read as in this channel. This is the string the composer
+ * inserts into the message text, so it is also the string the renderer has to match on.
+ */
+export const getMentionDisplayName = (channel: Channel, user: UserResponse): string =>
+ getMemberNickname(channel, user.id) ?? user.name ?? user.id;
diff --git a/examples/vite/src/ChannelNicknames/nicknameMentionComposition.ts b/examples/vite/src/ChannelNicknames/nicknameMentionComposition.ts
new file mode 100644
index 000000000..9b84140c7
--- /dev/null
+++ b/examples/vite/src/ChannelNicknames/nicknameMentionComposition.ts
@@ -0,0 +1,72 @@
+import type {
+ CustomMessageData,
+ MessageComposer,
+ MessageComposerMiddlewareState,
+ MessageCompositionMiddleware,
+ MiddlewareHandlerParams,
+} from 'stream-chat';
+
+import { MENTION_DISPLAY_NAMES_KEY } from './nicknameData';
+
+/**
+ * Records, on the message itself, which display text each mention was written with.
+ *
+ * Why bother, when the renderer could just look the nickname up on the channel member?
+ *
+ * Because mention display text is **frozen into `message.text`** at send time — the composer
+ * inserts `@${name}` and the renderer matches that literal substring. So the renderer does not
+ * need the *current* nickname; it needs to know which token maps to which user. Reading that back
+ * off the message means:
+ *
+ * - no dependency on `channel.state.members` holding the mentioned user (breaks past 100 members)
+ * - the rendered mention stays consistent with the frozen text after a rename
+ *
+ * The trade-off is the flip side of that last point: renaming somebody does **not** retroactively
+ * rewrite mentions in old messages. Live resolution would mean storing `@user_id` in the text and
+ * resolving at render time — a different product, and a much larger change.
+ *
+ * Must run after the SDK's text-composition middleware, which is what populates `mentioned_users`.
+ */
+export const createNicknameMentionCompositionMiddleware = (
+ composer: MessageComposer,
+): MessageCompositionMiddleware => ({
+ id: 'demo/message-composer-middleware/nickname-mention-display-names',
+ handlers: {
+ compose: ({
+ state,
+ next,
+ forward,
+ }: MiddlewareHandlerParams) => {
+ const mentionedUsers = state.localMessage.mentioned_users ?? [];
+
+ if (!mentionedUsers.length) return forward();
+
+ const mentionedUserIds = new Set(mentionedUsers.map((user) => user.id));
+ const displayNames: Record = {};
+
+ // `textComposer.mentions` holds the entities the user actually picked from the dropdown,
+ // with `name` already set to the nickname by NicknameMentionsSearchSource#toUserSuggestion.
+ composer.textComposer.mentions.forEach((entity) => {
+ if (entity.mentionType !== 'user' || !entity.name) return;
+ if (!mentionedUserIds.has(entity.id)) return;
+
+ displayNames[entity.id] = entity.name;
+ });
+
+ if (!Object.keys(displayNames).length) return forward();
+
+ // Custom message fields go at the **top level** of the payload in this SDK version —
+ // `LocalMessage` / `MessageRequest` are `CustomMessageData & { … }`, and the SDK's own
+ // `custom-data` composition middleware spreads them the same way.
+ const customData = {
+ [MENTION_DISPLAY_NAMES_KEY]: displayNames,
+ } as CustomMessageData;
+
+ return next({
+ ...state,
+ localMessage: { ...state.localMessage, ...customData },
+ message: { ...state.message, ...customData },
+ });
+ },
+ },
+});
diff --git a/examples/vite/src/ChannelNicknames/renderTextWithNicknames.tsx b/examples/vite/src/ChannelNicknames/renderTextWithNicknames.tsx
new file mode 100644
index 000000000..da1b5523f
--- /dev/null
+++ b/examples/vite/src/ChannelNicknames/renderTextWithNicknames.tsx
@@ -0,0 +1,68 @@
+import type { Channel, LocalMessage } from 'stream-chat';
+import {
+ renderText as defaultRenderText,
+ getRenderTextMentionEntities,
+ type RenderTextFunction,
+ type RenderTextMentionEntity,
+} from 'stream-chat-react';
+
+import { getMemberNickname, MENTION_DISPLAY_NAMES_KEY } from './nicknameData';
+
+type CreateNicknameRenderTextParams = {
+ channel: Channel;
+ message: LocalMessage;
+};
+
+/**
+ * Builds a `renderText` that highlights a mention written as either `@nickname` or `@username`.
+ *
+ * The default renderer derives one display text per entity — `entity.name || entity.id` — and
+ * matches it literally against the message text. `mentioned_users` comes back from the server with
+ * the *real* username, so `@nickname` in the text never matches by default.
+ *
+ * The fix is to emit **two entities for the same user**: one named with the nickname, one with the
+ * username. `createMentionLookup` keys its replacement map by display text, so both tokens resolve
+ * to the same user and both get wrapped in a `` node. That is what makes "mention them by
+ * their nickname OR their username" work, with no change to the SDK.
+ *
+ * Nickname sources, in priority order:
+ * 1. `message.custom.mention_display_names` — what the sender actually typed, written by
+ * `createNicknameMentionCompositionMiddleware`. Works at any channel size.
+ * 2. `channel.state.members[id].nickname` — live lookup, and the only option for messages
+ * sent before this feature existed. Only available while the member is loaded.
+ */
+export const createNicknameRenderText =
+ ({ channel, message }: CreateNicknameRenderTextParams): RenderTextFunction =>
+ (text, mentionedUsers, options) => {
+ const persistedDisplayNames = message[MENTION_DISPLAY_NAMES_KEY];
+ const baseEntities =
+ options?.messageMentionEntities ??
+ getRenderTextMentionEntities({ mentioned_users: mentionedUsers });
+
+ const entities = baseEntities.reduce((acc, entity) => {
+ if (entity.mentionType !== 'user') {
+ acc.push(entity);
+ return acc;
+ }
+
+ const nickname =
+ persistedDisplayNames?.[entity.id] ?? getMemberNickname(channel, entity.id);
+
+ // No nickname, or the mention was written with the username anyway — nothing to add.
+ if (!nickname || nickname === entity.name) {
+ acc.push(entity);
+ return acc;
+ }
+
+ // Nickname first: `createMentionLookup` sorts by display-text length and first-wins on
+ // collisions, so listing it up front keeps the longer/more specific token in play.
+ acc.push({ ...entity, name: nickname }, entity);
+
+ return acc;
+ }, []);
+
+ return defaultRenderText(text, mentionedUsers, {
+ ...options,
+ messageMentionEntities: entities,
+ });
+ };
diff --git a/examples/vite/src/stream-chat-custom-data.d.ts b/examples/vite/src/stream-chat-custom-data.d.ts
new file mode 100644
index 000000000..bedb3212e
--- /dev/null
+++ b/examples/vite/src/stream-chat-custom-data.d.ts
@@ -0,0 +1,26 @@
+import 'stream-chat';
+
+/**
+ * `stream-chat` types app-specific fields through these interfaces; integrators declare their own
+ * shape via module augmentation. This file is the demo app doing exactly that.
+ */
+declare module 'stream-chat' {
+ interface CustomMemberData {
+ /**
+ * Channel-specific nickname. Lives on the *member*, not the user — that is what scopes it to a
+ * single channel. Read by `src/ChannelNicknames`; written server-side by the integrating app.
+ */
+ nickname?: string | null;
+ }
+
+ interface CustomMessageData {
+ /**
+ * `{ [userId]: displayTextUsedForThisMention }`, written at send time.
+ *
+ * Mention display text is frozen into `message.text`, so recording the mapping here lets the
+ * renderer resolve `@nickname` → user without a member lookup — which matters once a channel
+ * outgrows the 100-member threshold where members stop being held in local channel state.
+ */
+ mention_display_names?: Record;
+ }
+}