From 9555a3568c836a767b29664dc6c3c0482caaf1f8 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 10 Aug 2026 17:11:35 +0200 Subject: [PATCH] feat(example): channel-specific nicknames in mentions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mentions can be searched and rendered by a per-channel nickname stored on the channel member (`member.nickname`), while `mentioned_users` keeps the real user id. Covers the autocomplete dropdown, the composer textarea and the message list. Inert for members without a nickname, so stock behaviour is unchanged. Built entirely on public API — no patches to stream-chat or stream-chat-react: - NicknameMentionsSearchSource subclasses MentionsSearchSource and is injected through `createMentionsMiddleware({ searchSource })`. It matches on nickname locally and, above the 100-member local-search threshold, server-side via `$or` over `name` and `nickname`. `$autocomplete` on a custom member field is supported by the API, contrary to the base implementation's comment; note the member sort is a key/value map (`{ user_id: 1 }`), not `{ field, direction }`. The server response is flattened to `member.user` by the base, so nicknames are harvested before that to keep them available for suggestions. - Suggestion `name` is set to the nickname, which drives both the dropdown label and the inserted text. This is load-bearing: the composition middleware drops a mention whose `id`/`name` is absent from the text, silently. - Mention display text is frozen into `message.text` at send time, so a composition middleware records `mention_display_names` on the message. The renderer resolves `@nickname` from that map with no member lookup, which is what makes rendering work past the 100-member threshold. Trade-off: renaming does not rewrite mentions in existing messages. - renderText emits two entities per mentioned user (nickname and username) so both tokens highlight, satisfying "mention by nickname OR username". - `withNicknameMentions` wraps the app's existing message UI rather than replacing it, so inline editing and nickname mentions coexist. Writing a nickname for another member is a server-side operation; `updateMemberPartial` from a browser client only writes your own membership. Co-Authored-By: Claude Opus 5 --- examples/vite/src/App.tsx | 30 +++- .../NicknameMentionsSearchSource.ts | 150 ++++++++++++++++++ .../ChannelNicknames/NicknameMessageUI.tsx | 48 ++++++ examples/vite/src/ChannelNicknames/index.ts | 5 + .../vite/src/ChannelNicknames/nicknameData.ts | 38 +++++ .../nicknameMentionComposition.ts | 72 +++++++++ .../renderTextWithNicknames.tsx | 68 ++++++++ .../vite/src/stream-chat-custom-data.d.ts | 26 +++ 8 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 examples/vite/src/ChannelNicknames/NicknameMentionsSearchSource.ts create mode 100644 examples/vite/src/ChannelNicknames/NicknameMessageUI.tsx create mode 100644 examples/vite/src/ChannelNicknames/index.ts create mode 100644 examples/vite/src/ChannelNicknames/nicknameData.ts create mode 100644 examples/vite/src/ChannelNicknames/nicknameMentionComposition.ts create mode 100644 examples/vite/src/ChannelNicknames/renderTextWithNicknames.tsx create mode 100644 examples/vite/src/stream-chat-custom-data.d.ts 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; + } +}