Skip to content

Commit 5adf3af

Browse files
committed
Merge origin/master into feat/replace-emoji-mart (resolve i18n conflicts)
2 parents 9055a79 + c89366b commit 5adf3af

163 files changed

Lines changed: 11483 additions & 1530 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.cursor/skills/accessibility/SKILL.md

Lines changed: 78 additions & 5 deletions
Large diffs are not rendered by default.

.cursor/skills/dev-patterns/SKILL.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,12 @@ When importing from 'stream-chat' library, always import by library name (from '
5050
## React components
5151

5252
Try to avoid inline `style` attribute and prefer adding styles to `.scss` files.
53+
54+
## How a plugin works in stream-chat-react
55+
56+
A "plugin" is just a separate build entry point that ships as a subpath export (e.g. stream-chat-react/emojis, stream-chat-react/mp3-encoder). Concretely, each plugin is wired in 4 places:
57+
58+
- Source folder — src/plugins/<Name>/ with an index.ts re-exporting its public API (see src/plugins/Emojis/index.ts).
59+
- Vite entry — a new key in build.lib.entry in vite.config.ts:20.
60+
- package.json — an exports subpath + a typesVersions entry (package.json:18,44).
61+
- Styling (optional) — a styling/index.scss added as a separate sass target in the build-styling script (package.json:185), producing its own CSS file like dist/css/emoji-picker.css.

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## [14.7.0](https://github.com/GetStream/stream-chat-react/compare/v14.6.1...v14.7.0) (2026-07-06)
2+
3+
### Features
4+
5+
* allow to customize ChannelListHeader ([#3234](https://github.com/GetStream/stream-chat-react/issues/3234)) ([f9752d4](https://github.com/GetStream/stream-chat-react/commit/f9752d441468ab1d8c8d43e4df1638f3adc1f90e))
6+
17
## [14.6.1](https://github.com/GetStream/stream-chat-react/compare/v14.6.0...v14.6.1) (2026-07-03)
28

39
### Bug Fixes

examples/vite/src/AccessibilityNavigation/ChatSkipNavigation.tsx

Lines changed: 88 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,37 +5,85 @@ import {
55
CHANNEL_LIST_TARGET_ID,
66
CHANNEL_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID,
77
CHANNELS_SELECTOR_BUTTON_TARGET_QUERY,
8+
THREAD_LIST_TARGET_ID,
9+
THREAD_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID,
810
} from '../ChatLayout/Panels.tsx';
911

1012
export const CHAT_SKIP_NAVIGATION_TARGET_ID = 'app-chat-skip-navigation';
1113

14+
// The list skip-link should land on the FIRST item (a focusable role="option" button with a visible
15+
// focus ring), not on the listbox container — focusing a container gives no visual focus cue.
16+
// Returns whether an option was actually focused: an empty/loading list has no `role="option"`, in
17+
// which case the caller must NOT preventDefault, letting SkipNavigation fall back to focusing the
18+
// list container (otherwise the link would be a dead end).
19+
const focusFirstListOption = (listTargetId: string): boolean => {
20+
const option = document.querySelector<HTMLElement>(`#${listTargetId} [role="option"]`);
21+
option?.focus();
22+
return !!option;
23+
};
24+
25+
const isLinkFor = (target: EventTarget | null, targetId: string) =>
26+
target instanceof HTMLAnchorElement && target.getAttribute('href') === `#${targetId}`;
27+
28+
const isActivationKey = (key: string) =>
29+
key === 'Enter' || key === ' ' || key === 'Spacebar';
30+
1231
const getSkipNavigationLinkLabel = (targetId: string) => {
13-
if (targetId === CHANNEL_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID) {
32+
if (
33+
targetId === CHANNEL_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID ||
34+
targetId === THREAD_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID
35+
) {
1436
return 'Skip to message composer';
1537
}
1638
if (targetId === CHANNEL_LIST_TARGET_ID) {
1739
return 'Skip to channel list';
1840
}
41+
if (targetId === THREAD_LIST_TARGET_ID) {
42+
return 'Skip to thread list';
43+
}
1944

2045
return 'Skip to sidebar';
2146
};
2247

2348
export const ChatSkipNavigation = () => {
24-
const [channelsSelectorButtonTargetId, setChannelsSelectorButtonTargetId] = useState<
25-
string | null
26-
>(null);
49+
// Skip targets are tagged imperatively onto SDK-owned DOM and depend on the active view (the
50+
// channel list shows in the channels view, the thread list in the threads view) and the layout, so
51+
// resolve them from the live DOM and keep them in sync via a MutationObserver. Only links whose
52+
// target currently exists are rendered, so there are never dead skip links.
53+
const [composerTargetId, setComposerTargetId] = useState<string | null>(null);
54+
const [listTargetId, setListTargetId] = useState<string | null>(null);
55+
const [selectorButtonTargetId, setSelectorButtonTargetId] = useState<string | null>(
56+
null,
57+
);
2758

2859
useEffect(() => {
29-
const syncChannelsSelectorButtonTargetId = () => {
30-
const channelsSelectorButton = document.querySelector<HTMLElement>(
31-
CHANNELS_SELECTOR_BUTTON_TARGET_QUERY,
60+
const syncTargets = () => {
61+
// The channel composer (channels view) and the thread composer (threads view) carry different
62+
// ids; whichever is currently mounted is the composer skip target.
63+
setComposerTargetId(
64+
document.getElementById(CHANNEL_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID)
65+
? CHANNEL_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID
66+
: document.getElementById(THREAD_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID)
67+
? THREAD_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID
68+
: null,
69+
);
70+
// Exactly one of the two lists is mounted at a time, depending on the active view.
71+
setListTargetId(
72+
document.getElementById(THREAD_LIST_TARGET_ID)
73+
? THREAD_LIST_TARGET_ID
74+
: document.getElementById(CHANNEL_LIST_TARGET_ID)
75+
? CHANNEL_LIST_TARGET_ID
76+
: null,
77+
);
78+
setSelectorButtonTargetId(
79+
document.querySelector<HTMLElement>(CHANNELS_SELECTOR_BUTTON_TARGET_QUERY)?.id ??
80+
null,
3281
);
33-
setChannelsSelectorButtonTargetId(channelsSelectorButton?.id ?? null);
3482
};
3583

36-
syncChannelsSelectorButtonTargetId();
84+
syncTargets();
3785

38-
const observer = new MutationObserver(syncChannelsSelectorButtonTargetId);
86+
const observer = new MutationObserver(syncTargets);
3987
observer.observe(document.body, {
4088
attributes: true,
4189
attributeFilter: ['id'],
@@ -46,22 +94,39 @@ export const ChatSkipNavigation = () => {
4694
return () => observer.disconnect();
4795
}, []);
4896

49-
const targetIds = useMemo(() => {
50-
const skipTargets = [
51-
CHANNEL_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID,
52-
CHANNEL_LIST_TARGET_ID,
53-
];
54-
55-
if (channelsSelectorButtonTargetId) {
56-
skipTargets.push(channelsSelectorButtonTargetId);
57-
}
58-
59-
return skipTargets;
60-
}, [channelsSelectorButtonTargetId]);
97+
// Ordered by keyboard priority: composer, then the active list, then the sidebar selector.
98+
const targetIds = useMemo(
99+
() =>
100+
[composerTargetId, listTargetId, selectorButtonTargetId].filter(
101+
(id): id is string => !!id,
102+
),
103+
[composerTargetId, listTargetId, selectorButtonTargetId],
104+
);
61105

62106
return (
63107
<nav aria-label='Chat quick navigation' id={CHAT_SKIP_NAVIGATION_TARGET_ID}>
64-
<SkipNavigation getLinkLabel={getSkipNavigationLinkLabel} targetIds={targetIds} />
108+
<SkipNavigation
109+
getLinkLabel={getSkipNavigationLinkLabel}
110+
// Intercept the active list link (SkipNavigation's documented `onClick`/`onKeyDown` +
111+
// `preventDefault` escape hatch) to focus the first item instead of the listbox container.
112+
// Other links fall through to SkipNavigation's default focus handling.
113+
onClick={(event) => {
114+
if (!listTargetId || !isLinkFor(event.currentTarget, listTargetId)) return;
115+
// Only take over when there is an option to focus; otherwise let SkipNavigation focus the
116+
// list container so the link is never a dead end.
117+
if (focusFirstListOption(listTargetId)) event.preventDefault();
118+
}}
119+
onKeyDown={(event) => {
120+
if (
121+
!listTargetId ||
122+
!isLinkFor(event.currentTarget, listTargetId) ||
123+
!isActivationKey(event.key)
124+
)
125+
return;
126+
if (focusFirstListOption(listTargetId)) event.preventDefault();
127+
}}
128+
targetIds={targetIds}
129+
/>
65130
</nav>
66131
);
67132
};

examples/vite/src/App.tsx

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ import {
7878
} from './CustomMessageUi';
7979
import { ConfigurableMessageActions } from './CustomMessageActions';
8080
import { SidebarToggle } from './Sidebar/SidebarToggle.tsx';
81+
import { CommandModeAttachmentSelector } from './CommandModeAttachmentSelector.tsx';
8182

8283
const PUBLIC_VITE_EXAMPLE_API_KEY = 'xzwhhgtazy6h';
8384

@@ -104,6 +105,12 @@ if (!apiKey) {
104105
throw new Error('VITE_STREAM_API_KEY is not defined');
105106
}
106107

108+
const options: ChannelOptions = {
109+
presence: true,
110+
state: true,
111+
limit: 10,
112+
};
113+
107114
const sort: ChannelSort = { last_message_at: -1, updated_at: -1 };
108115

109116
// @ts-expect-error ai_generated isn't on LocalMessage's public type yet
@@ -289,18 +296,6 @@ const App = () => {
289296
() => new URLSearchParams(window.location.search),
290297
[],
291298
);
292-
const options = useMemo<ChannelOptions>(
293-
() => ({
294-
// filter_values: {
295-
// user_id: userId,
296-
// },
297-
// predefined_filter: 'livestreams_channels',
298-
presence: true,
299-
state: true,
300-
limit: 10,
301-
}),
302-
[],
303-
);
304299
const initialChannelId = useMemo(() => getSelectedChannelIdFromUrl(), []);
305300
const initialChatView = useMemo(() => getSelectedChatViewFromUrl(), []);
306301
const initialThreadId = useMemo(
@@ -489,6 +484,7 @@ const App = () => {
489484
HeaderEndContent: SidebarToggle,
490485
HeaderStartContent: SidebarToggle,
491486
MessageActions: ConfigurableMessageActions,
487+
AttachmentSelector: CommandModeAttachmentSelector,
492488
...messageUiOverrides,
493489
}}
494490
>

examples/vite/src/ChannelPreviewOverlay/ChannelPreviewOverlay.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export const ChannelPreviewOverlay = () => {
6969
{canJoin && (
7070
<Button
7171
appearance='solid'
72+
autoFocus
7273
className='app-channel-preview-overlay__join-button'
7374
disabled={joining}
7475
onClick={handleJoin}

examples/vite/src/ChatLayout/Panels.tsx

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
ChannelList,
1010
ChatView,
1111
type ChatViewSelectorEntry,
12+
MessageComposerUI as DefaultMessageComposerUI,
1213
MessageComposer,
1314
MessageList,
1415
Thread,
@@ -34,10 +35,33 @@ import { ThreadStateSync } from './Sync.tsx';
3435

3536
export const CHANNEL_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID =
3637
'app-channel-message-composer-textarea';
38+
export const THREAD_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID =
39+
'app-thread-message-composer-textarea';
3740
export const CHANNEL_LIST_TARGET_ID = 'app-channel-list';
41+
export const THREAD_LIST_TARGET_ID = 'app-thread-list';
3842
export const CHANNELS_SELECTOR_BUTTON_TARGET_QUERY =
3943
'[id^="str-chat__chat-view-"][id$="-tab-channels"]';
4044

45+
// Tag the list's `[role="listbox"]` with a stable id so the skip-navigation links can target it. The
46+
// listbox unmounts/remounts (search mode, view switches, reloads) and its view may start inactive, so
47+
// observe `document.body` and re-tag whenever it (re)appears. The selector is globally unambiguous —
48+
// only one view's sidebar is mounted at a time.
49+
const useTagSkipNavigationListbox = (listboxSelector: string, targetId: string) => {
50+
useEffect(() => {
51+
const tagListbox = () => {
52+
const listbox = document.querySelector<HTMLElement>(listboxSelector);
53+
if (listbox && listbox.id !== targetId) {
54+
listbox.id = targetId;
55+
}
56+
};
57+
58+
tagListbox();
59+
const observer = new MutationObserver(tagListbox);
60+
observer.observe(document.body, { childList: true, subtree: true });
61+
return () => observer.disconnect();
62+
}, [listboxSelector, targetId]);
63+
};
64+
4165
const ChannelThreadPanel = () => {
4266
const { thread } = useChannelStateContext('ChannelThreadPanel');
4367
const isOpen = !!thread;
@@ -92,6 +116,7 @@ const ResponsiveChannelPanels = () => {
92116
asyncMessagesMultiSendEnabled
93117
audioRecordingEnabled
94118
maxRows={10}
119+
focus
95120
/>
96121
<ChannelPreviewOverlay />
97122
</div>
@@ -127,14 +152,10 @@ export const ChannelsPanels = ({
127152
closeSidebar();
128153
}, [channel?.id, closeSidebar]);
129154

130-
useEffect(() => {
131-
const channelListElement = channelsLayoutRef.current?.querySelector<HTMLElement>(
132-
'.app-chat-sidebar-overlay > .str-chat__channel-list',
133-
);
134-
if (!channelListElement) return;
135-
136-
channelListElement.id = CHANNEL_LIST_TARGET_ID;
137-
}, [channel?.id, sidebarOpen]);
155+
useTagSkipNavigationListbox(
156+
'.app-chat-sidebar-overlay .str-chat__channel-list-inner__main',
157+
CHANNEL_LIST_TARGET_ID,
158+
);
138159

139160
return (
140161
<ChatView.Channels>
@@ -173,6 +194,16 @@ export const ChannelsPanels = ({
173194
);
174195
};
175196

197+
// Renders the "Back to quick navigation" return link above the thread's composer (the SDK renders
198+
// the thread composer internally, so we inject via the MessageComposerUI slot) — matching the
199+
// channel view, where the link sits right above <MessageComposer />.
200+
const ThreadMessageComposerUI = () => (
201+
<>
202+
<ReturnToSkipNavigation />
203+
<DefaultMessageComposerUI />
204+
</>
205+
);
206+
176207
export const ThreadsPanels = ({
177208
iconOnly,
178209
itemSet,
@@ -184,6 +215,11 @@ export const ThreadsPanels = ({
184215
const { activeThread } = useThreadsViewContext();
185216
const threadsLayoutRef = useRef<HTMLDivElement | null>(null);
186217

218+
useTagSkipNavigationListbox(
219+
'.app-chat-sidebar-overlay .str-chat__thread-list',
220+
THREAD_LIST_TARGET_ID,
221+
);
222+
187223
return (
188224
<ChatView.Threads>
189225
<ThreadStateSync />
@@ -202,12 +238,21 @@ export const ThreadsPanels = ({
202238
<div className='app-chat-view__threads-main'>
203239
<ChatView.ThreadAdapter>
204240
<WithDragAndDropUpload className='str-chat__dropzone-root--thread'>
205-
<WithComponents overrides={{ TypingIndicator }}>
206-
<ReturnToSkipNavigation />
241+
<WithComponents
242+
overrides={{
243+
MessageComposerUI: ThreadMessageComposerUI,
244+
TypingIndicator,
245+
}}
246+
>
207247
<Thread
208248
additionalMessageComposerProps={{
249+
additionalTextareaProps: {
250+
id: THREAD_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID,
251+
},
209252
audioRecordingEnabled: true,
210253
asyncMessagesMultiSendEnabled: true,
254+
// Auto-focus the thread composer on mount, matching the channels-view composer.
255+
focus: true,
211256
}}
212257
virtualized
213258
/>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { TextComposerState } from 'stream-chat';
2+
import {
3+
type AttachmentSelectorProps,
4+
AttachmentSelector as DefaultAttachmentSelector,
5+
useMessageComposerController,
6+
useStateStore,
7+
} from 'stream-chat-react';
8+
9+
const textComposerCommandSelector = ({ command }: TextComposerState) => ({ command });
10+
11+
export const CommandModeAttachmentSelector = (props: AttachmentSelectorProps) => {
12+
const messageComposer = useMessageComposerController();
13+
const { command } = useStateStore(
14+
messageComposer.textComposer.state,
15+
textComposerCommandSelector,
16+
);
17+
18+
return (
19+
<DefaultAttachmentSelector
20+
{...props}
21+
// In command mode, keyboard flow should stay in command controls + textarea.
22+
// Removing the attachment trigger from tab order avoids an irrelevant stop.
23+
// Spread any incoming buttonProps first so ARIA/data props aren't dropped when this override
24+
// is wired globally via WithComponents.
25+
buttonProps={{ ...props.buttonProps, tabIndex: command ? -1 : undefined }}
26+
/>
27+
);
28+
};

0 commit comments

Comments
 (0)