Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion examples/vite/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
createCommandInjectionMiddleware,
createCommandStringExtractionMiddleware,
createDraftCommandInjectionMiddleware,
createMentionsMiddleware,
SearchController,
UserSearchSource,
} from 'stream-chat';
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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) => (
<Attachment {...props} AttachmentActions={CustomAttachmentActions} />
);
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -425,7 +453,7 @@ const App = () => {
HeaderStartContent: SidebarToggle,
MessageActions: ConfigurableMessageActions,
AttachmentSelector: CommandModeAttachmentSelector,
Message: InlineEditableMessage,
Message: MessageWithNicknameMentions,
...messageUiOverrides,
}}
>
Expand Down
150 changes: 150 additions & 0 deletions examples/vite/src/ChannelNicknames/NicknameMentionsSearchSource.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();

/** 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 }),
};
};
}
48 changes: 48 additions & 0 deletions examples/vite/src/ChannelNicknames/NicknameMessageUI.tsx
Original file line number Diff line number Diff line change
@@ -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<MessageUIComponentProps>,
) => {
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 <MessageUIComponent {...props} renderText={renderText} />;
};

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);
5 changes: 5 additions & 0 deletions examples/vite/src/ChannelNicknames/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './nicknameData';
export * from './NicknameMentionsSearchSource';
export * from './nicknameMentionComposition';
export * from './renderTextWithNicknames';
export * from './NicknameMessageUI';
38 changes: 38 additions & 0 deletions examples/vite/src/ChannelNicknames/nicknameData.ts
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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<MessageComposerMiddlewareState>) => {
const mentionedUsers = state.localMessage.mentioned_users ?? [];

if (!mentionedUsers.length) return forward();

const mentionedUserIds = new Set(mentionedUsers.map((user) => user.id));
const displayNames: Record<string, string> = {};

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