From 280b47d3a104041666984a4c6d452666d4fa7c7b Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 12 Jan 2026 08:02:25 +0100 Subject: [PATCH 01/32] feat: use LLC message pagination and message list state optimistic updates --- src/components/Channel/Channel.tsx | 1155 ++++++++--------- src/components/Channel/channelState.ts | 7 + .../hooks/useCreateChannelStateContext.ts | 231 ++-- .../ChannelHeader/ChannelHeader.tsx | 5 +- src/components/ChatView/ChatView.tsx | 3 +- .../InfiniteScrollPaginator.tsx | 70 +- .../MediaRecorder/hooks/useMediaRecorder.ts | 24 +- src/components/Message/Message.tsx | 21 +- src/components/Message/MessageSimple.tsx | 6 +- src/components/Message/QuotedMessage.tsx | 9 +- .../Message/hooks/useRetryHandler.ts | 29 +- src/components/Message/types.ts | 9 +- .../MessageInput/EditMessageForm.tsx | 25 +- .../MessageInput/MessageInputFlat.tsx | 10 +- src/components/MessageInput/hooks/index.ts | 2 + .../MessageInput/hooks/useCooldownTimer.tsx | 13 +- .../hooks/useCreateMessageInputContext.ts | 5 - .../hooks/useMessageInputControls.ts | 12 +- .../MessageInput/hooks/useSendMessageFn.ts | 94 ++ .../MessageInput/hooks/useSubmitHandler.ts | 96 -- .../MessageInput/hooks/useUpdateMessage.ts | 0 .../MessageInput/hooks/useUpdateMessageFn.ts | 51 + src/components/MessageList/MessageList.tsx | 195 +-- .../MessageList/MessageListNotifications.tsx | 18 +- .../MessageList/ScrollToBottomButton.tsx | 19 +- .../UnreadMessagesNotification.tsx | 37 +- .../MessageList/VirtualizedMessageList.tsx | 183 +-- .../VirtualizedMessageListComponents.tsx | 4 +- .../MessageList/useMessageListElements.tsx | 15 +- .../MessageList/useScrollLocationLogic.tsx | 6 +- .../useUnreadMessagesNotification.ts | 26 +- .../useNewMessageNotification.ts | 18 +- ...seUnreadMessagesNotificationVirtualized.ts | 29 +- .../MessageList/hooks/useMarkRead.ts | 57 +- src/components/MessageList/renderMessages.tsx | 25 +- src/components/MessageList/utils.ts | 18 +- .../PollCreationDialogControls.tsx | 7 +- .../hooks/usePollOptionVotesPagination.ts | 2 +- .../TextareaComposer/TextareaComposer.tsx | 7 +- src/components/Thread/Thread.tsx | 324 +++-- src/components/Thread/ThreadStart.tsx | 31 +- .../TypingIndicator/TypingIndicator.tsx | 35 +- src/components/Window/Window.tsx | 7 +- src/context/ChannelActionContext.tsx | 73 +- src/context/ChannelStateContext.tsx | 8 +- src/context/MessageBounceContext.tsx | 11 +- src/context/MessageContext.tsx | 6 +- src/context/MessageListContext.tsx | 2 +- src/hooks/index.ts | 1 + src/hooks/useMessagePaginator.ts | 13 + src/types/index.ts | 2 +- src/types/types.ts | 4 +- src/utils/useStableCallback.ts | 2 +- 53 files changed, 1626 insertions(+), 1436 deletions(-) create mode 100644 src/components/MessageInput/hooks/useSendMessageFn.ts delete mode 100644 src/components/MessageInput/hooks/useSubmitHandler.ts create mode 100644 src/components/MessageInput/hooks/useUpdateMessage.ts create mode 100644 src/components/MessageInput/hooks/useUpdateMessageFn.ts create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/useMessagePaginator.ts diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 32fc8fe0fb..026811844c 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -81,7 +81,7 @@ import { findInMsgSetByDate, findInMsgSetById, makeAddNotifications } from './ut import { useThreadContext } from '../Threads'; import { getChannel } from '../../utils'; import type { - ChannelUnreadUiState, + // ChannelUnreadUiState, GiphyVersions, ImageAttachmentSizeHandler, VideoAttachmentSizeHandler, @@ -187,7 +187,7 @@ export type ChannelProps = ChannelPropsForwardedToComponentContext & { /** Custom action handler to override the default `channel.markRead` request function (advanced usage only) */ doMarkReadRequest?: ( channel: StreamChannel, - setChannelUnreadUiState?: (state: ChannelUnreadUiState) => void, + // setChannelUnreadUiState?: (state: ChannelUnreadUiState) => void, ) => Promise | void; /** Custom action handler to override the default `channel.sendMessage` request function (advanced usage only) */ doSendMessageRequest?: ( @@ -329,22 +329,22 @@ const ChannelInner = ( const [notifications, setNotifications] = useState([]); const notificationTimeouts = useRef>([]); - const [channelUnreadUiState, _setChannelUnreadUiState] = - useState(); - - const channelReducer = useMemo(() => makeChannelReducer(), []); - - const [state, dispatch] = useReducer( - channelReducer, - // channel.initialized === false if client.channel().query() was not called, e.g. ChannelList is not used - // => Channel will call channel.watch() in useLayoutEffect => state.loading is used to signal the watch() call state - { - ...initialState, - hasMore: channel.state.messagePagination.hasPrev, - loading: !channel.initialized, - messages: channel.state.messages, - }, - ); + // const [channelUnreadUiState, _setChannelUnreadUiState] = + // useState(); + + // const channelReducer = useMemo(() => makeChannelReducer(), []); + + // const [state, dispatch] = useReducer( + // channelReducer, + // // channel.initialized === false if client.channel().query() was not called, e.g. ChannelList is not used + // // => Channel will call channel.watch() in useLayoutEffect => state.loading is used to signal the watch() call state + // { + // ...initialState, + // hasMore: channel.state.messagePagination.hasPrev, + // loading: !channel.initialized, + // messages: channel.state.messages, + // }, + // ); const jumpToMessageFromSearch = useSearchFocusedMessage(); const isMounted = useIsMounted(); @@ -358,23 +358,23 @@ const ChannelInner = ( const channelCapabilitiesArray = channel.data?.own_capabilities as string[]; - const throttledCopyStateFromChannel = throttle( - () => dispatch({ channel, type: 'copyStateFromChannelOnEvent' }), - 500, - { - leading: true, - trailing: true, - }, - ); - - const setChannelUnreadUiState = useMemo( - () => - throttle(_setChannelUnreadUiState, 200, { - leading: true, - trailing: false, - }), - [], - ); + // const throttledCopyStateFromChannel = throttle( + // () => dispatch({ channel, type: 'copyStateFromChannelOnEvent' }), + // 500, + // { + // leading: true, + // trailing: true, + // }, + // ); + + // const setChannelUnreadUiState = useMemo( + // () => + // throttle(_setChannelUnreadUiState, 200, { + // leading: true, + // trailing: false, + // }), + // [], + // ); const markRead = useMemo( () => @@ -391,17 +391,18 @@ const ChannelInner = ( if (doMarkReadRequest) { doMarkReadRequest( channel, - updateChannelUiUnreadState ? setChannelUnreadUiState : undefined, + // updateChannelUiUnreadState ? setChannelUnreadUiState : undefined, ); } else { const markReadResponse = await channel.markRead(); // markReadResponse.event can be null in case of a user that is not a member of a channel being marked read // in that case event is null and we should not set unread UI if (updateChannelUiUnreadState && markReadResponse?.event) { - _setChannelUnreadUiState({ - last_read: lastRead.current, - last_read_message_id: markReadResponse.event.last_read_message_id, - unread_messages: 0, + channel.messagePaginator.unreadStateSnapshot.next({ + firstUnreadMessageId: null, + lastReadAt: lastRead.current, + lastReadMessageId: markReadResponse.event.last_read_message_id ?? null, + unreadCount: 0, }); } } @@ -423,18 +424,18 @@ const ChannelInner = ( channel, channelConfig, doMarkReadRequest, - setChannelUnreadUiState, + // setChannelUnreadUiState, t, ], ); const handleEvent = async (event: Event) => { if (event.message) { - dispatch({ - channel, - message: event.message, - type: 'updateThreadOnEvent', - }); + // dispatch({ + // channel, + // message: event.message, + // type: 'updateThreadOnEvent', + // }); } // ignore the event if it is not targeted at the current channel. @@ -445,9 +446,9 @@ const ChannelInner = ( if (event.type === 'user.watching.start' || event.type === 'user.watching.stop') return; - if (event.type === 'typing.start' || event.type === 'typing.stop') { - return dispatch({ channel, type: 'setTyping' }); - } + // if (event.type === 'typing.start' || event.type === 'typing.stop') { + // return dispatch({ channel, type: 'setTyping' }); + // } if (event.type === 'connection.changed' && typeof event.online === 'boolean') { online.current = event.online; @@ -503,22 +504,22 @@ const ChannelInner = ( }); } - if (event.type === 'notification.mark_unread') - _setChannelUnreadUiState((prev) => { - if (!(event.last_read_at && event.user)) return prev; - return { - first_unread_message_id: event.first_unread_message_id, - last_read: new Date(event.last_read_at), - last_read_message_id: event.last_read_message_id, - unread_messages: event.unread_messages ?? 0, - }; - }); - - if (event.type === 'channel.truncated' && event.cid === channel.cid) { - _setChannelUnreadUiState(undefined); - } - - throttledCopyStateFromChannel(); + // if (event.type === 'notification.mark_unread') + // _setChannelUnreadUiState((prev) => { + // if (!(event.last_read_at && event.user)) return prev; + // return { + // first_unread_message_id: event.first_unread_message_id, + // last_read: new Date(event.last_read_at), + // last_read_message_id: event.last_read_message_id, + // unread_messages: event.unread_messages ?? 0, + // }; + // }); + + // if (event.type === 'channel.truncated' && event.cid === channel.cid) { + // _setChannelUnreadUiState(undefined); + // } + + // throttledCopyStateFromChannel(); }; // useLayoutEffect here to prevent spinner. Use Suspense when it is available in stable release @@ -551,7 +552,7 @@ const ChannelInner = ( const config = channel.getConfig(); setChannelConfig(config); } catch (e) { - dispatch({ error: e as Error, type: 'setError' }); + // dispatch({ error: e as Error, type: 'setError' }); errored = true; } } @@ -560,17 +561,17 @@ const ChannelInner = ( originalTitle.current = document.title; if (!errored) { - dispatch({ - channel, - hasMore: channel.state.messagePagination.hasPrev, - type: 'initStateFromChannel', - }); - - if (client.user?.id && channel.state.read[client.user.id]) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { user, ...ownReadState } = channel.state.read[client.user.id]; - _setChannelUnreadUiState(ownReadState); - } + // dispatch({ + // channel, + // hasMore: channel.state.messagePagination.hasPrev, + // type: 'initStateFromChannel', + // }); + + // if (client.user?.id && channel.state.read[client.user.id]) { + // // eslint-disable-next-line @typescript-eslint/no-unused-vars + // const { user, ...ownReadState } = channel.state.read[client.user.id]; + // _setChannelUnreadUiState(ownReadState); + // } /** * TODO: maybe pass last_read to the countUnread method to get proper value * combined with channel.countUnread adjustment (_countMessageAsUnread) @@ -608,13 +609,13 @@ const ChannelInner = ( initializeOnMount, ]); - useEffect(() => { - if (!state.thread) return; - - const message = state.messages?.find((m) => m.id === state.thread?.id); - - if (message) dispatch({ message, type: 'setThread' }); - }, [state.messages, state.thread]); + // useEffect(() => { + // if (!state.thread) return; + // + // const message = state.messages?.find((m) => m.id === state.thread?.id); + // + // if (message) dispatch({ message, type: 'setThread' }); + // }, [state.messages, state.thread]); const handleHighlightedMessageChange = useCallback( ({ @@ -624,11 +625,11 @@ const ChannelInner = ( highlightedMessageId: string; highlightDuration?: number; }) => { - dispatch({ - channel, - highlightedMessageId, - type: 'jumpToMessageFinished', - }); + // dispatch({ + // channel, + // highlightedMessageId, + // type: 'jumpToMessageFinished', + // }); if (clearHighlightedMessageTimeoutId.current) { clearTimeout(clearHighlightedMessageTimeoutId.current); } @@ -637,10 +638,10 @@ const ChannelInner = ( searchController._internalState.partialNext({ focusedMessage: undefined }); } clearHighlightedMessageTimeoutId.current = null; - dispatch({ type: 'clearHighlightedMessage' }); + // dispatch({ type: 'clearHighlightedMessage' }); }, highlightDuration ?? DEFAULT_HIGHLIGHT_DURATION); }, - [channel, searchController], + [searchController._internalState], ); useEffect(() => { @@ -656,266 +657,265 @@ const ChannelInner = ( [], ); - // eslint-disable-next-line react-hooks/exhaustive-deps - const loadMoreFinished = useCallback( - debounce( - (hasMore: boolean, messages: ChannelState['messages']) => { - if (!isMounted.current) return; - dispatch({ hasMore, messages, type: 'loadMoreFinished' }); - }, - 2000, - { leading: true, trailing: true }, - ), - [], - ); - - const loadMore = async (limit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE) => { - if ( - !online.current || - !window.navigator.onLine || - !channel.state.messagePagination.hasPrev - ) - return 0; - - // prevent duplicate loading events... - const oldestMessage = state?.messages?.[0]; - - if ( - state.loadingMore || - state.loadingMoreNewer || - oldestMessage?.status !== 'received' - ) { - return 0; - } - - dispatch({ loadingMore: true, type: 'setLoadingMore' }); - - const oldestID = oldestMessage?.id; - const perPage = limit; - let queryResponse: ChannelAPIResponse; - - try { - queryResponse = await channel.query({ - messages: { id_lt: oldestID, limit: perPage }, - watchers: { limit: perPage }, - }); - } catch (e) { - console.warn('message pagination request failed with error', e); - dispatch({ loadingMore: false, type: 'setLoadingMore' }); - return 0; - } - - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); - - return queryResponse.messages.length; - }; - - const loadMoreNewer = async (limit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE) => { - if ( - !online.current || - !window.navigator.onLine || - !channel.state.messagePagination.hasNext - ) - return 0; - - const newestMessage = state?.messages?.[state?.messages?.length - 1]; - if (state.loadingMore || state.loadingMoreNewer) return 0; - - dispatch({ loadingMoreNewer: true, type: 'setLoadingMoreNewer' }); - - const newestId = newestMessage?.id; - const perPage = limit; - let queryResponse: ChannelAPIResponse; - - try { - queryResponse = await channel.query({ - messages: { id_gt: newestId, limit: perPage }, - watchers: { limit: perPage }, - }); - } catch (e) { - console.warn('message pagination request failed with error', e); - dispatch({ loadingMoreNewer: false, type: 'setLoadingMoreNewer' }); - return 0; - } - - dispatch({ - hasMoreNewer: channel.state.messagePagination.hasNext, - messages: channel.state.messages, - type: 'loadMoreNewerFinished', - }); - return queryResponse.messages.length; - }; - - const jumpToMessage: ChannelActionContextValue['jumpToMessage'] = useCallback( - async ( - messageId, - messageLimit = DEFAULT_JUMP_TO_PAGE_SIZE, - highlightDuration = DEFAULT_HIGHLIGHT_DURATION, - ) => { - dispatch({ loadingMore: true, type: 'setLoadingMore' }); - await channel.state.loadMessageIntoState(messageId, undefined, messageLimit); - - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); - handleHighlightedMessageChange({ - highlightDuration, - highlightedMessageId: messageId, - }); - }, - [channel, handleHighlightedMessageChange, loadMoreFinished], - ); - - const jumpToLatestMessage: ChannelActionContextValue['jumpToLatestMessage'] = - useCallback(async () => { - await channel.state.loadMessageIntoState('latest'); - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); - dispatch({ - type: 'jumpToLatestMessage', - }); - }, [channel, loadMoreFinished]); - - const jumpToFirstUnreadMessage: ChannelActionContextValue['jumpToFirstUnreadMessage'] = - useCallback( - async ( - queryMessageLimit = DEFAULT_JUMP_TO_PAGE_SIZE, - highlightDuration = DEFAULT_HIGHLIGHT_DURATION, - ) => { - if (!channelUnreadUiState?.unread_messages) return; - let lastReadMessageId = channelUnreadUiState?.last_read_message_id; - let firstUnreadMessageId = channelUnreadUiState?.first_unread_message_id; - let isInCurrentMessageSet = false; - - if (firstUnreadMessageId) { - const result = findInMsgSetById(firstUnreadMessageId, channel.state.messages); - isInCurrentMessageSet = result.index !== -1; - } else if (lastReadMessageId) { - const result = findInMsgSetById(lastReadMessageId, channel.state.messages); - isInCurrentMessageSet = !!result.target; - firstUnreadMessageId = - result.index > -1 ? channel.state.messages[result.index + 1]?.id : undefined; - } else { - const lastReadTimestamp = channelUnreadUiState.last_read.getTime(); - const { index: lastReadMessageIndex, target: lastReadMessage } = - findInMsgSetByDate( - channelUnreadUiState.last_read, - channel.state.messages, - true, - ); - - if (lastReadMessage) { - firstUnreadMessageId = channel.state.messages[lastReadMessageIndex + 1]?.id; - isInCurrentMessageSet = !!firstUnreadMessageId; - lastReadMessageId = lastReadMessage.id; - } else { - dispatch({ loadingMore: true, type: 'setLoadingMore' }); - let messages; - try { - messages = ( - await channel.query( - { - messages: { - created_at_around: channelUnreadUiState.last_read.toISOString(), - limit: queryMessageLimit, - }, - }, - 'new', - ) - ).messages; - } catch (e) { - addNotification(t('Failed to jump to the first unread message'), 'error'); - loadMoreFinished( - channel.state.messagePagination.hasPrev, - channel.state.messages, - ); - return; - } - - const firstMessageWithCreationDate = messages.find((msg) => msg.created_at); - if (!firstMessageWithCreationDate) { - addNotification(t('Failed to jump to the first unread message'), 'error'); - loadMoreFinished( - channel.state.messagePagination.hasPrev, - channel.state.messages, - ); - return; - } - const firstMessageTimestamp = new Date( - firstMessageWithCreationDate.created_at as string, - ).getTime(); - if (lastReadTimestamp < firstMessageTimestamp) { - // whole channel is unread - firstUnreadMessageId = firstMessageWithCreationDate.id; - } else { - const result = findInMsgSetByDate(channelUnreadUiState.last_read, messages); - lastReadMessageId = result.target?.id; - } - loadMoreFinished( - channel.state.messagePagination.hasPrev, - channel.state.messages, - ); - } - } - - if (!firstUnreadMessageId && !lastReadMessageId) { - addNotification(t('Failed to jump to the first unread message'), 'error'); - return; - } - - if (!isInCurrentMessageSet) { - dispatch({ loadingMore: true, type: 'setLoadingMore' }); - try { - const targetId = (firstUnreadMessageId ?? lastReadMessageId) as string; - await channel.state.loadMessageIntoState( - targetId, - undefined, - queryMessageLimit, - ); - /** - * if the index of the last read message on the page is beyond the half of the page, - * we have arrived to the oldest page of the channel - */ - const indexOfTarget = channel.state.messages.findIndex( - (message) => message.id === targetId, - ) as number; - loadMoreFinished( - channel.state.messagePagination.hasPrev, - channel.state.messages, - ); - firstUnreadMessageId = - firstUnreadMessageId ?? channel.state.messages[indexOfTarget + 1]?.id; - } catch (e) { - addNotification(t('Failed to jump to the first unread message'), 'error'); - loadMoreFinished( - channel.state.messagePagination.hasPrev, - channel.state.messages, - ); - return; - } - } - - if (!firstUnreadMessageId) { - addNotification(t('Failed to jump to the first unread message'), 'error'); - return; - } - if (!channelUnreadUiState.first_unread_message_id) - _setChannelUnreadUiState({ - ...channelUnreadUiState, - first_unread_message_id: firstUnreadMessageId, - last_read_message_id: lastReadMessageId, - }); - handleHighlightedMessageChange({ - highlightDuration, - highlightedMessageId: firstUnreadMessageId, - }); - }, - [ - addNotification, - channel, - handleHighlightedMessageChange, - loadMoreFinished, - t, - channelUnreadUiState, - ], - ); + // const loadMoreFinished = useCallback( + // debounce( + // (hasMore: boolean, messages: ChannelState['messages']) => { + // if (!isMounted.current) return; + // dispatch({ hasMore, messages, type: 'loadMoreFinished' }); + // }, + // 2000, + // { leading: true, trailing: true }, + // ), + // [], + // ); + + // const loadMore = async (limit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE) => { + // if ( + // !online.current || + // !window.navigator.onLine || + // !channel.state.messagePagination.hasPrev + // ) + // return 0; + // + // // prevent duplicate loading events... + // const oldestMessage = state?.messages?.[0]; + // + // if ( + // state.loadingMore || + // state.loadingMoreNewer || + // oldestMessage?.status !== 'received' + // ) { + // return 0; + // } + // + // dispatch({ loadingMore: true, type: 'setLoadingMore' }); + // + // const oldestID = oldestMessage?.id; + // const perPage = limit; + // let queryResponse: ChannelAPIResponse; + // + // try { + // queryResponse = await channel.query({ + // messages: { id_lt: oldestID, limit: perPage }, + // watchers: { limit: perPage }, + // }); + // } catch (e) { + // console.warn('message pagination request failed with error', e); + // dispatch({ loadingMore: false, type: 'setLoadingMore' }); + // return 0; + // } + // + // loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); + // + // return queryResponse.messages.length; + // }; + + // const loadMoreNewer = async (limit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE) => { + // if ( + // !online.current || + // !window.navigator.onLine || + // !channel.state.messagePagination.hasNext + // ) + // return 0; + // + // const newestMessage = state?.messages?.[state?.messages?.length - 1]; + // if (state.loadingMore || state.loadingMoreNewer) return 0; + // + // dispatch({ loadingMoreNewer: true, type: 'setLoadingMoreNewer' }); + // + // const newestId = newestMessage?.id; + // const perPage = limit; + // let queryResponse: ChannelAPIResponse; + // + // try { + // queryResponse = await channel.query({ + // messages: { id_gt: newestId, limit: perPage }, + // watchers: { limit: perPage }, + // }); + // } catch (e) { + // console.warn('message pagination request failed with error', e); + // dispatch({ loadingMoreNewer: false, type: 'setLoadingMoreNewer' }); + // return 0; + // } + // + // dispatch({ + // hasMoreNewer: channel.state.messagePagination.hasNext, + // messages: channel.state.messages, + // type: 'loadMoreNewerFinished', + // }); + // return queryResponse.messages.length; + // }; + + // const jumpToMessage: ChannelActionContextValue['jumpToMessage'] = useCallback( + // async ( + // messageId, + // messageLimit = DEFAULT_JUMP_TO_PAGE_SIZE, + // highlightDuration = DEFAULT_HIGHLIGHT_DURATION, + // ) => { + // dispatch({ loadingMore: true, type: 'setLoadingMore' }); + // await channel.state.loadMessageIntoState(messageId, undefined, messageLimit); + // + // loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); + // handleHighlightedMessageChange({ + // highlightDuration, + // highlightedMessageId: messageId, + // }); + // }, + // [channel, handleHighlightedMessageChange, loadMoreFinished], + // ); + + // const jumpToLatestMessage: ChannelActionContextValue['jumpToLatestMessage'] = + // useCallback(async () => { + // await channel.state.loadMessageIntoState('latest'); + // loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); + // dispatch({ + // type: 'jumpToLatestMessage', + // }); + // }, [channel, loadMoreFinished]); + // + // const jumpToFirstUnreadMessage: ChannelActionContextValue['jumpToFirstUnreadMessage'] = + // useCallback( + // async ( + // queryMessageLimit = DEFAULT_JUMP_TO_PAGE_SIZE, + // highlightDuration = DEFAULT_HIGHLIGHT_DURATION, + // ) => { + // if (!channelUnreadUiState?.unread_messages) return; + // let lastReadMessageId = channelUnreadUiState?.last_read_message_id; + // let firstUnreadMessageId = channelUnreadUiState?.first_unread_message_id; + // let isInCurrentMessageSet = false; + // + // if (firstUnreadMessageId) { + // const result = findInMsgSetById(firstUnreadMessageId, channel.state.messages); + // isInCurrentMessageSet = result.index !== -1; + // } else if (lastReadMessageId) { + // const result = findInMsgSetById(lastReadMessageId, channel.state.messages); + // isInCurrentMessageSet = !!result.target; + // firstUnreadMessageId = + // result.index > -1 ? channel.state.messages[result.index + 1]?.id : undefined; + // } else { + // const lastReadTimestamp = channelUnreadUiState.last_read.getTime(); + // const { index: lastReadMessageIndex, target: lastReadMessage } = + // findInMsgSetByDate( + // channelUnreadUiState.last_read, + // channel.state.messages, + // true, + // ); + // + // if (lastReadMessage) { + // firstUnreadMessageId = channel.state.messages[lastReadMessageIndex + 1]?.id; + // isInCurrentMessageSet = !!firstUnreadMessageId; + // lastReadMessageId = lastReadMessage.id; + // } else { + // dispatch({ loadingMore: true, type: 'setLoadingMore' }); + // let messages; + // try { + // messages = ( + // await channel.query( + // { + // messages: { + // created_at_around: channelUnreadUiState.last_read.toISOString(), + // limit: queryMessageLimit, + // }, + // }, + // 'new', + // ) + // ).messages; + // } catch (e) { + // addNotification(t('Failed to jump to the first unread message'), 'error'); + // loadMoreFinished( + // channel.state.messagePagination.hasPrev, + // channel.state.messages, + // ); + // return; + // } + // + // const firstMessageWithCreationDate = messages.find((msg) => msg.created_at); + // if (!firstMessageWithCreationDate) { + // addNotification(t('Failed to jump to the first unread message'), 'error'); + // loadMoreFinished( + // channel.state.messagePagination.hasPrev, + // channel.state.messages, + // ); + // return; + // } + // const firstMessageTimestamp = new Date( + // firstMessageWithCreationDate.created_at as string, + // ).getTime(); + // if (lastReadTimestamp < firstMessageTimestamp) { + // // whole channel is unread + // firstUnreadMessageId = firstMessageWithCreationDate.id; + // } else { + // const result = findInMsgSetByDate(channelUnreadUiState.last_read, messages); + // lastReadMessageId = result.target?.id; + // } + // loadMoreFinished( + // channel.state.messagePagination.hasPrev, + // channel.state.messages, + // ); + // } + // } + // + // if (!firstUnreadMessageId && !lastReadMessageId) { + // addNotification(t('Failed to jump to the first unread message'), 'error'); + // return; + // } + // + // if (!isInCurrentMessageSet) { + // dispatch({ loadingMore: true, type: 'setLoadingMore' }); + // try { + // const targetId = (firstUnreadMessageId ?? lastReadMessageId) as string; + // await channel.state.loadMessageIntoState( + // targetId, + // undefined, + // queryMessageLimit, + // ); + // /** + // * if the index of the last read message on the page is beyond the half of the page, + // * we have arrived to the oldest page of the channel + // */ + // const indexOfTarget = channel.state.messages.findIndex( + // (message) => message.id === targetId, + // ) as number; + // loadMoreFinished( + // channel.state.messagePagination.hasPrev, + // channel.state.messages, + // ); + // firstUnreadMessageId = + // firstUnreadMessageId ?? channel.state.messages[indexOfTarget + 1]?.id; + // } catch (e) { + // addNotification(t('Failed to jump to the first unread message'), 'error'); + // loadMoreFinished( + // channel.state.messagePagination.hasPrev, + // channel.state.messages, + // ); + // return; + // } + // } + // + // if (!firstUnreadMessageId) { + // addNotification(t('Failed to jump to the first unread message'), 'error'); + // return; + // } + // if (!channelUnreadUiState.first_unread_message_id) + // _setChannelUnreadUiState({ + // ...channelUnreadUiState, + // first_unread_message_id: firstUnreadMessageId, + // last_read_message_id: lastReadMessageId, + // }); + // handleHighlightedMessageChange({ + // highlightDuration, + // highlightedMessageId: firstUnreadMessageId, + // }); + // }, + // [ + // addNotification, + // channel, + // handleHighlightedMessageChange, + // loadMoreFinished, + // t, + // channelUnreadUiState, + // ], + // ); const deleteMessage = useCallback( async ( @@ -942,218 +942,217 @@ const ChannelInner = ( // add the message to the local channel state channel.state.addMessageSorted(updatedMessage, true); - dispatch({ - channel, - parentId: state.thread && updatedMessage.parent_id, - type: 'copyMessagesFromChannel', - }); + // dispatch({ + // channel, + // parentId: state.thread && updatedMessage.parent_id, + // type: 'copyMessagesFromChannel', + // }); }; - const doSendMessage = async ({ - localMessage, - message, - options, - }: { - localMessage: LocalMessage; - message: Message; - options?: SendMessageOptions; - }) => { - try { - let messageResponse: void | SendMessageAPIResponse; - - if (doSendMessageRequest) { - messageResponse = await doSendMessageRequest(channel, message, options); - } else { - messageResponse = await channel.sendMessage(message, options); - } - - let existingMessage: LocalMessage | undefined = undefined; - for (let i = channel.state.messages.length - 1; i >= 0; i--) { - const msg = channel.state.messages[i]; - if (msg.id && msg.id === message.id) { - existingMessage = msg; - break; - } - } - - const responseTimestamp = new Date( - messageResponse?.message?.updated_at || 0, - ).getTime(); - const existingMessageTimestamp = existingMessage?.updated_at?.getTime() || 0; - const responseIsTheNewest = responseTimestamp > existingMessageTimestamp; - - // Replace the message payload after send is completed - // We need to check for the newest message payload, because on slow network, the response can arrive later than WS events message.new, message.updated. - // Always override existing message in status "sending" - if ( - messageResponse?.message && - (responseIsTheNewest || existingMessage?.status === 'sending') - ) { - updateMessage({ - ...messageResponse.message, - status: 'received', - }); - } - } catch (error) { - // error response isn't usable so needs to be stringified then parsed - const stringError = JSON.stringify(error); - const parsedError = ( - stringError ? JSON.parse(stringError) : {} - ) as ErrorFromResponse; - - // Handle the case where the message already exists - // (typically, when retrying to send a message). - // If the message already exists, we can assume it was sent successfully, - // so we update the message status to "received". - // Right now, the only way to check this error is by checking - // the combination of the error code and the error description, - // since there is no special error code for duplicate messages. - if ( - parsedError.code === 4 && - error instanceof Error && - error.message.includes('already exists') - ) { - updateMessage({ - ...localMessage, - status: 'received', - }); - } else { - updateMessage({ - ...localMessage, - error: parsedError, - status: 'failed', - }); - - thread?.upsertReplyLocally({ - message: { - ...localMessage, - error: parsedError, - status: 'failed', - }, - }); - } - } - }; - - const sendMessage = async ({ - localMessage, - message, - options, - }: { - localMessage: LocalMessage; - message: Message; - options?: SendMessageOptions; - }) => { - channel.state.filterErrorMessages(); - - thread?.upsertReplyLocally({ - message: localMessage, - }); - - updateMessage(localMessage); - - await doSendMessage({ localMessage, message, options }); - }; - - const retrySendMessage = async (localMessage: LocalMessage) => { - updateMessage({ - ...localMessage, - error: undefined, - status: 'sending', - }); - - await doSendMessage({ - localMessage, - message: localMessageToNewMessagePayload(localMessage), - }); - }; + // const doSendMessage = async ({ + // localMessage, + // message, + // options, + // }: { + // localMessage: LocalMessage; + // message: Message; + // options?: SendMessageOptions; + // }) => { + // try { + // let messageResponse: void | SendMessageAPIResponse; + // + // if (doSendMessageRequest) { + // messageResponse = await doSendMessageRequest(channel, message, options); + // } else { + // messageResponse = await channel.sendMessage(message, options); + // } + // + // let existingMessage: LocalMessage | undefined = undefined; + // for (let i = channel.state.messages.length - 1; i >= 0; i--) { + // const msg = channel.state.messages[i]; + // if (msg.id && msg.id === message.id) { + // existingMessage = msg; + // break; + // } + // } + // + // const responseTimestamp = new Date( + // messageResponse?.message?.updated_at || 0, + // ).getTime(); + // const existingMessageTimestamp = existingMessage?.updated_at?.getTime() || 0; + // const responseIsTheNewest = responseTimestamp > existingMessageTimestamp; + // + // // Replace the message payload after send is completed + // // We need to check for the newest message payload, because on slow network, the response can arrive later than WS events message.new, message.updated. + // // Always override existing message in status "sending" + // if ( + // messageResponse?.message && + // (responseIsTheNewest || existingMessage?.status === 'sending') + // ) { + // updateMessage({ + // ...messageResponse.message, + // status: 'received', + // }); + // } + // } catch (error) { + // // error response isn't usable so needs to be stringified then parsed + // const stringError = JSON.stringify(error); + // const parsedError = ( + // stringError ? JSON.parse(stringError) : {} + // ) as ErrorFromResponse; + // + // // Handle the case where the message already exists + // // (typically, when retrying to send a message). + // // If the message already exists, we can assume it was sent successfully, + // // so we update the message status to "received". + // // Right now, the only way to check this error is by checking + // // the combination of the error code and the error description, + // // since there is no special error code for duplicate messages. + // if ( + // parsedError.code === 4 && + // error instanceof Error && + // error.message.includes('already exists') + // ) { + // updateMessage({ + // ...localMessage, + // status: 'received', + // }); + // } else { + // updateMessage({ + // ...localMessage, + // error: parsedError, + // status: 'failed', + // }); + // + // thread?.upsertReplyLocally({ + // message: { + // ...localMessage, + // error: parsedError, + // status: 'failed', + // }, + // }); + // } + // } + // }; + + // const sendMessage = async ({ + // localMessage, + // message, + // options, + // }: { + // localMessage: LocalMessage; + // message: Message; + // options?: SendMessageOptions; + // }) => { + // channel.state.filterErrorMessages(); + // + // thread?.upsertReplyLocally({ + // message: localMessage, + // }); + // + // updateMessage(localMessage); + // + // await doSendMessage({ localMessage, message, options }); + // }; + + // const retrySendMessage = async (localMessage: LocalMessage) => { + // updateMessage({ + // ...localMessage, + // error: undefined, + // status: 'sending', + // }); + // + // await doSendMessage({ + // localMessage, + // message: localMessageToNewMessagePayload(localMessage), + // }); + // }; const removeMessage = (message: LocalMessage) => { channel.state.removeMessage(message); - dispatch({ - channel, - parentId: state.thread && message.parent_id, - type: 'copyMessagesFromChannel', - }); + // dispatch({ + // channel, + // parentId: state.thread && message.parent_id, + // type: 'copyMessagesFromChannel', + // }); }; /** THREAD */ const openThread = (message: LocalMessage, event?: React.BaseSyntheticEvent) => { event?.preventDefault(); - dispatch({ channel, message, type: 'openThread' }); + // dispatch({ channel, message, type: 'openThread' }); }; const closeThread = (event?: React.BaseSyntheticEvent) => { event?.preventDefault(); - dispatch({ type: 'closeThread' }); + // dispatch({ type: 'closeThread' }); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - const loadMoreThreadFinished = useCallback( - debounce( - ( - threadHasMore: boolean, - threadMessages: Array>, - ) => { - dispatch({ - threadHasMore, - threadMessages, - type: 'loadMoreThreadFinished', - }); - }, - 2000, - { leading: true, trailing: true }, - ), - [], - ); - - const loadMoreThread = async (limit: number = DEFAULT_THREAD_PAGE_SIZE) => { - // FIXME: should prevent loading more, if state.thread.reply_count === channel.state.threads[parentID].length - if (state.threadLoadingMore || !state.thread || !state.threadHasMore) return; - - dispatch({ type: 'startLoadingThread' }); - const parentId = state.thread.id; - - if (!parentId) { - return dispatch({ type: 'closeThread' }); - } - - const oldMessages = channel.state.threads[parentId] || []; - const oldestMessageId = oldMessages[0]?.id; - - try { - const queryResponse = await channel.getReplies(parentId, { - id_lt: oldestMessageId, - limit, - }); - - const threadHasMoreMessages = hasMoreMessagesProbably( - queryResponse.messages.length, - limit, - ); - const newThreadMessages = channel.state.threads[parentId] || []; - - // next set loadingMore to false so we can start asking for more data - loadMoreThreadFinished(threadHasMoreMessages, newThreadMessages); - } catch (e) { - loadMoreThreadFinished(false, oldMessages); - } - }; + // const loadMoreThreadFinished = useCallback( + // debounce( + // ( + // threadHasMore: boolean, + // threadMessages: Array>, + // ) => { + // dispatch({ + // threadHasMore, + // threadMessages, + // type: 'loadMoreThreadFinished', + // }); + // }, + // 2000, + // { leading: true, trailing: true }, + // ), + // [], + // ); + + // const loadMoreThread = async (limit: number = DEFAULT_THREAD_PAGE_SIZE) => { + // // FIXME: should prevent loading more, if state.thread.reply_count === channel.state.threads[parentID].length + // if (state.threadLoadingMore || !state.thread || !state.threadHasMore) return; + // + // dispatch({ type: 'startLoadingThread' }); + // const parentId = state.thread.id; + // + // if (!parentId) { + // return dispatch({ type: 'closeThread' }); + // } + // + // const oldMessages = channel.state.threads[parentId] || []; + // const oldestMessageId = oldMessages[0]?.id; + // + // try { + // const queryResponse = await channel.getReplies(parentId, { + // id_lt: oldestMessageId, + // limit, + // }); + // + // const threadHasMoreMessages = hasMoreMessagesProbably( + // queryResponse.messages.length, + // limit, + // ); + // const newThreadMessages = channel.state.threads[parentId] || []; + // + // // next set loadingMore to false so we can start asking for more data + // loadMoreThreadFinished(threadHasMoreMessages, newThreadMessages); + // } catch (e) { + // loadMoreThreadFinished(false, oldMessages); + // } + // }; const onMentionsHoverOrClick = useMentionsHandlers(onMentionsHover, onMentionsClick); - const editMessage = useEditMessageHandler(doUpdateMessageRequest); + // const editMessage = useEditMessageHandler(doUpdateMessageRequest); - const { typing, ...restState } = state; + // const { typing, ...restState } = state; const channelStateContextValue = useCreateChannelStateContext({ - ...restState, + // ...restState, channel, channelCapabilitiesArray, channelConfig, - channelUnreadUiState, + // channelUnreadUiState, giphyVersion: props.giphyVersion || 'fixed_height', imageAttachmentSizeHandler: props.imageAttachmentSizeHandler || getImageAttachmentConfiguration, @@ -1162,7 +1161,7 @@ const ChannelInner = ( shouldGenerateVideoThumbnail: props.shouldGenerateVideoThumbnail || true, videoAttachmentSizeHandler: props.videoAttachmentSizeHandler || getVideoAttachmentConfiguration, - watcher_count: state.watcherCount, + // watcher_count: state.watcherCount, }); const channelActionContextValue: ChannelActionContextValue = useMemo( @@ -1170,22 +1169,22 @@ const ChannelInner = ( addNotification, closeThread, deleteMessage, - dispatch, - editMessage, - jumpToFirstUnreadMessage, - jumpToLatestMessage, - jumpToMessage, - loadMore, - loadMoreNewer, - loadMoreThread, + // dispatch, + // editMessage, + // jumpToFirstUnreadMessage, + // jumpToLatestMessage, + // jumpToMessage, + // loadMore, + // loadMoreNewer, + // loadMoreThread, markRead, onMentionsClick: onMentionsHoverOrClick, onMentionsHover: onMentionsHoverOrClick, openThread, removeMessage, - retrySendMessage, - sendMessage, - setChannelUnreadUiState, + // retrySendMessage, + // sendMessage, + // setChannelUnreadUiState, skipMessageDataMemoization, updateMessage, }), @@ -1193,13 +1192,13 @@ const ChannelInner = ( [ channel.cid, deleteMessage, - loadMore, - loadMoreNewer, + // loadMore, + // loadMoreNewer, markRead, - jumpToFirstUnreadMessage, - jumpToMessage, - jumpToLatestMessage, - setChannelUnreadUiState, + // jumpToFirstUnreadMessage, + // jumpToMessage, + // jumpToLatestMessage, + // setChannelUnreadUiState, ], ); @@ -1346,25 +1345,25 @@ const ChannelInner = ( ], ); - const typingContextValue = useCreateTypingContext({ - typing, - }); - - if (state.error) { - return ( - - - - ); - } - - if (state.loading) { - return ( - - - - ); - } + // const typingContextValue = useCreateTypingContext({ + // typing, + // }); + + // if (state.error) { + // return ( + // + // + // + // ); + // } + + // if (state.loading) { + // return ( + // + // + // + // ); + // } if (!channel.watch) { return ( @@ -1379,11 +1378,11 @@ const ChannelInner = ( - - -
{children}
-
-
+ {/**/} + +
{children}
+
+ {/*
*/}
diff --git a/src/components/Channel/channelState.ts b/src/components/Channel/channelState.ts index 597e1ca36b..2bccac8310 100644 --- a/src/components/Channel/channelState.ts +++ b/src/components/Channel/channelState.ts @@ -272,17 +272,24 @@ export const initialState = { hasMoreNewer: false, loading: true, loadingMore: false, + // todo: add reactive state to Channel class members: {}, messages: [], + // todo: add reactive state to Channel class pinnedMessages: [], + // todo: add reactive state to Channel class read: {}, + // todo: could be moved as a prop to MessageList / VML suppressAutoscroll: false, thread: null, threadHasMore: true, threadLoadingMore: false, threadMessages: [], + // todo: could be moved as a prop to MessageList / VML threadSuppressAutoscroll: false, + // todo: add reactive state to Channel class typing: {}, + // todo: add reactive state to Channel class watcherCount: 0, watchers: {}, }; diff --git a/src/components/Channel/hooks/useCreateChannelStateContext.ts b/src/components/Channel/hooks/useCreateChannelStateContext.ts index 3986592791..e08b316976 100644 --- a/src/components/Channel/hooks/useCreateChannelStateContext.ts +++ b/src/components/Channel/hooks/useCreateChannelStateContext.ts @@ -14,49 +14,49 @@ export const useCreateChannelStateContext = ( channel, channelCapabilitiesArray = [], channelConfig, - channelUnreadUiState, - error, + // channelUnreadUiState, + // error, giphyVersion, - hasMore, - hasMoreNewer, - highlightedMessageId, + // hasMore, + // hasMoreNewer, + // highlightedMessageId, imageAttachmentSizeHandler, - loading, - loadingMore, - members, - messages = [], + // loading, + // loadingMore, + // members, + // messages = [], mutes, notifications, - pinnedMessages, - read = {}, + // pinnedMessages, + // read = {}, shouldGenerateVideoThumbnail, - skipMessageDataMemoization, - suppressAutoscroll, - thread, - threadHasMore, - threadLoadingMore, - threadMessages = [], + // skipMessageDataMemoization, + // suppressAutoscroll, + // thread, + // threadHasMore, + // threadLoadingMore, + // threadMessages = [], videoAttachmentSizeHandler, - watcher_count, - watcherCount, - watchers, + // watcher_count, + // watcherCount, + // watchers, } = value; - const channelId = channel.cid; - const lastRead = channel.initialized && channel.lastRead()?.getTime(); - const membersLength = Object.keys(members || []).length; - const notificationsLength = notifications.length; - const readUsers = Object.values(read); - const readUsersLength = readUsers.length; - const readUsersLastReadDateStrings: string[] = []; - for (const { last_read } of readUsers) { - if (!lastRead) continue; - readUsersLastReadDateStrings.push(last_read?.toISOString()); - } - const readUsersLastReads = readUsersLastReadDateStrings.join(); - const threadMessagesLength = threadMessages?.length; + // const channelId = channel.cid; + // const lastRead = channel.initialized && channel.lastRead()?.getTime(); + // const membersLength = Object.keys(members || []).length; + // const notificationsLength = notifications.length; + // const readUsers = Object.values(read); + // const readUsersLength = readUsers.length; + // const readUsersLastReadDateStrings: string[] = []; + // for (const { last_read } of readUsers) { + // if (!lastRead) continue; + // readUsersLastReadDateStrings.push(last_read?.toISOString()); + // } + // const readUsersLastReads = readUsersLastReadDateStrings.join(); + // const threadMessagesLength = threadMessages?.length; - const channelCapabilities: Record = {}; + const channelCapabilities: Record = useMemo(() => ({}), []); channelCapabilitiesArray.forEach((capability) => { channelCapabilities[capability] = true; @@ -64,100 +64,107 @@ export const useCreateChannelStateContext = ( // FIXME: this is crazy - I could not find out why the messages were not getting updated when only message properties that are not part // of this serialization has been changed. A great example of memoization gone wrong. - const memoizedMessageData = skipMessageDataMemoization - ? messages - : messages - .map( - ({ - deleted_at, - latest_reactions, - pinned, - reply_count, - status, - type, - updated_at, - user, - }) => - `${type}${deleted_at}${ - latest_reactions ? latest_reactions.map(({ type }) => type).join() : '' - }${pinned}${reply_count}${status}${ - updated_at && (isDayOrMoment(updated_at) || isDate(updated_at)) - ? updated_at.toISOString() - : updated_at || '' - }${user?.updated_at}`, - ) - .join(); + // const memoizedMessageData = skipMessageDataMemoization + // ? messages + // : messages + // .map( + // ({ + // deleted_at, + // latest_reactions, + // pinned, + // reply_count, + // status, + // type, + // updated_at, + // user, + // }) => + // `${type}${deleted_at}${ + // latest_reactions ? latest_reactions.map(({ type }) => type).join() : '' + // }${pinned}${reply_count}${status}${ + // updated_at && (isDayOrMoment(updated_at) || isDate(updated_at)) + // ? updated_at.toISOString() + // : updated_at || '' + // }${user?.updated_at}`, + // ) + // .join(); - const memoizedThreadMessageData = threadMessages - .map( - ({ deleted_at, latest_reactions, pinned, status, updated_at, user }) => - `${deleted_at}${ - latest_reactions ? latest_reactions.map(({ type }) => type).join() : '' - }${pinned}${status}${ - updated_at && (isDayOrMoment(updated_at) || isDate(updated_at)) - ? updated_at.toISOString() - : updated_at || '' - }${user?.updated_at}`, - ) - .join(); + // const memoizedThreadMessageData = threadMessages + // .map( + // ({ deleted_at, latest_reactions, pinned, status, updated_at, user }) => + // `${deleted_at}${ + // latest_reactions ? latest_reactions.map(({ type }) => type).join() : '' + // }${pinned}${status}${ + // updated_at && (isDayOrMoment(updated_at) || isDate(updated_at)) + // ? updated_at.toISOString() + // : updated_at || '' + // }${user?.updated_at}`, + // ) + // .join(); const channelStateContext: ChannelStateContextValue = useMemo( () => ({ channel, channelCapabilities, channelConfig, - channelUnreadUiState, - error, + // channelUnreadUiState, + // error, giphyVersion, - hasMore, - hasMoreNewer, - highlightedMessageId, + // hasMore, + // hasMoreNewer, + // highlightedMessageId, imageAttachmentSizeHandler, - loading, - loadingMore, - members, - messages, + // loading, + // loadingMore, + // members, + // messages, mutes, notifications, - pinnedMessages, - read, + // pinnedMessages, + // read, shouldGenerateVideoThumbnail, - suppressAutoscroll, - thread, - threadHasMore, - threadLoadingMore, - threadMessages, + // suppressAutoscroll, + // thread, + // threadHasMore, + // threadLoadingMore, + // threadMessages, videoAttachmentSizeHandler, - watcher_count, - watcherCount, - watchers, + // watcher_count, + // watcherCount, + // watchers, }), - // eslint-disable-next-line react-hooks/exhaustive-deps [ - channel.data?.name, // otherwise ChannelHeader will not be updated - channelId, - channelUnreadUiState, - error, - hasMore, - hasMoreNewer, - highlightedMessageId, - lastRead, - loading, - loadingMore, - membersLength, - memoizedMessageData, - memoizedThreadMessageData, - notificationsLength, - readUsersLength, - readUsersLastReads, + channel, + // channel.data?.name, // otherwise ChannelHeader will not be updated + channelCapabilities, + channelConfig, + // channelId, + // channelUnreadUiState, + giphyVersion, + // error, + // hasMore, + // hasMoreNewer, + // highlightedMessageId, + imageAttachmentSizeHandler, + // lastRead, + // loading, + // loadingMore, + // membersLength, + // memoizedMessageData, + // memoizedThreadMessageData, + mutes, + notifications, + // notificationsLength, + // readUsersLength, + // readUsersLastReads, shouldGenerateVideoThumbnail, - skipMessageDataMemoization, - suppressAutoscroll, - thread, - threadHasMore, - threadLoadingMore, - threadMessagesLength, - watcherCount, + // skipMessageDataMemoization, + // suppressAutoscroll, + // thread, + // threadHasMore, + // threadLoadingMore, + // threadMessagesLength, + videoAttachmentSizeHandler, + // watcherCount, ], ); diff --git a/src/components/ChannelHeader/ChannelHeader.tsx b/src/components/ChannelHeader/ChannelHeader.tsx index 802478352e..ceb829aebf 100644 --- a/src/components/ChannelHeader/ChannelHeader.tsx +++ b/src/components/ChannelHeader/ChannelHeader.tsx @@ -33,7 +33,7 @@ export const ChannelHeader = (props: ChannelHeaderProps) => { title: overrideTitle, } = props; - const { channel, watcher_count } = useChannelStateContext('ChannelHeader'); + const { channel } = useChannelStateContext('ChannelHeader'); const { openMobileNav } = useChatContext('ChannelHeader'); const { t } = useTranslationContext('ChannelHeader'); const { displayImage, displayTitle, groupChannelDisplayInfo } = useChannelPreviewInfo({ @@ -76,7 +76,8 @@ export const ChannelHeader = (props: ChannelHeaderProps) => { ,{' '} )} - {t('{{ watcherCount }} online', { watcherCount: watcher_count })} + {/*todo: get the watcher count from LLC reactive state */} + {/*{t('{{ watcherCount }} online', { watcherCount: watcher_count })}*/}

diff --git a/src/components/ChatView/ChatView.tsx b/src/components/ChatView/ChatView.tsx index 014731c87c..e080c30d17 100644 --- a/src/components/ChatView/ChatView.tsx +++ b/src/components/ChatView/ChatView.tsx @@ -10,7 +10,7 @@ import type { PropsWithChildren } from 'react'; import type { Thread, ThreadManagerState } from 'stream-chat'; import clsx from 'clsx'; -type ChatView = 'channels' | 'threads'; +type ChatView = 'channels' | 'threads' | (string & {}); type ChatViewContextValue = { activeChatView: ChatView; @@ -37,6 +37,7 @@ export const ChatView = ({ children }: PropsWithChildren) => { ); }; +// todo: move channel list orchestrator here const ChannelsView = ({ children }: PropsWithChildren) => { const { activeChatView } = useContext(ChatViewContext); diff --git a/src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx b/src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx index 7457a1b69c..4118d5469f 100644 --- a/src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx +++ b/src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx @@ -1,6 +1,7 @@ import clsx from 'clsx'; import debounce from 'lodash.debounce'; import type { PropsWithChildren } from 'react'; +import { forwardRef } from 'react'; import React, { useEffect, useMemo, useRef } from 'react'; import { DEFAULT_LOAD_PAGE_SCROLL_THRESHOLD } from '../../constants/limits'; @@ -14,7 +15,8 @@ const mousewheelListener = (event: Event) => { } }; -export type InfiniteScrollPaginatorProps = React.ComponentProps<'div'> & { +type InfiniteScrollPaginatorOwnProps = { + element?: React.ElementType; listenToScroll?: ( distanceFromBottom: number, distanceFromTop: number, @@ -28,12 +30,39 @@ export type InfiniteScrollPaginatorProps = React.ComponentProps<'div'> & { useCapture?: boolean; }; -export const InfiniteScrollPaginator = ( - props: PropsWithChildren, -) => { +// helper: get the right ref type for a given element/component +type PolymorphicRef = React.ComponentPropsWithRef['ref']; + +// polymorphic props, defaulting to 'div' +export type InfiniteScrollPaginatorProps = + PropsWithChildren< + InfiniteScrollPaginatorOwnProps & { + element?: C; + } & Omit< + React.ComponentPropsWithRef, + keyof InfiniteScrollPaginatorOwnProps | 'element' + > + >; + +type InfiniteScrollPaginatorComponent = ( + props: InfiniteScrollPaginatorProps & { + ref?: PolymorphicRef; + }, +) => React.ReactNode; + +const renderPolymorphic = ( + Comp: C, + props: React.ComponentPropsWithRef & { ref?: PolymorphicRef }, + children?: React.ReactNode, +) => React.createElement(Comp, props, children); + +export const InfiniteScrollPaginator = forwardRef(function InfiniteScrollPaginator< + E extends React.ElementType = 'div', +>(props: InfiniteScrollPaginatorProps, ref: React.ForwardedRef) { const { children, className, + element: Component = 'div' as E, listenToScroll, loadNextDebounceMs = 500, loadNextOnScrollToBottom, @@ -43,7 +72,7 @@ export const InfiniteScrollPaginator = ( ...componentProps } = props; - const rootRef = useRef(null); + const rootRef = useRef(null); const childRef = useRef(null); const scrollListener = useMemo( @@ -114,15 +143,24 @@ export const InfiniteScrollPaginator = ( }; }, [useCapture]); - return ( -
-
- {children} -
-
+ return renderPolymorphic( + Component as E, + { + ...(componentProps as React.ComponentPropsWithRef), + className: clsx('str-chat__infinite-scroll-paginator', className), + ref: (node: React.ComponentRef | null) => { + if (typeof ref === 'function') { + ref(node); + } else if (ref && 'current' in ref) { + (ref as React.RefObject | null>).current = node; + } + rootRef.current = node && node instanceof HTMLElement ? node : null; + }, + }, + React.createElement( + 'div', + { className: 'str-chat__infinite-scroll-paginator__content', ref: childRef }, + children, + ), ); -}; +}) as InfiniteScrollPaginatorComponent; diff --git a/src/components/MediaRecorder/hooks/useMediaRecorder.ts b/src/components/MediaRecorder/hooks/useMediaRecorder.ts index 6438dc2b35..fd85714b78 100644 --- a/src/components/MediaRecorder/hooks/useMediaRecorder.ts +++ b/src/components/MediaRecorder/hooks/useMediaRecorder.ts @@ -6,6 +6,7 @@ import { useMessageComposer } from '../../MessageInput'; import type { LocalVoiceRecordingAttachment } from 'stream-chat'; import type { CustomAudioRecordingConfig, MediaRecordingState } from '../classes'; import type { MessageInputContextValue } from '../../../context'; +import { useSendMessageFn } from '../../MessageInput/hooks/useSendMessageFn'; export type RecordingController = { completeRecording: () => void; @@ -17,7 +18,7 @@ export type RecordingController = { type UseMediaRecorderParams = Pick< MessageInputContextValue, - 'asyncMessagesMultiSendEnabled' | 'handleSubmit' + 'asyncMessagesMultiSendEnabled' > & { enabled: boolean; generateRecordingTitle?: (mimeType: string) => string; @@ -28,15 +29,15 @@ export const useMediaRecorder = ({ asyncMessagesMultiSendEnabled, enabled, generateRecordingTitle, - handleSubmit, recordingConfig, }: UseMediaRecorderParams): RecordingController => { const { t } = useTranslationContext('useMediaRecorder'); const messageComposer = useMessageComposer(); + const sendMessageFn = useSendMessageFn(); const [recording, setRecording] = useState(); const [recordingState, setRecordingState] = useState(); const [permissionState, setPermissionState] = useState(); - const [isScheduledForSubmit, scheduleForSubmit] = useState(false); + // const [isScheduledForSubmit, scheduleForSubmit] = useState(false); const recorder = useMemo( () => @@ -56,17 +57,18 @@ export const useMediaRecorder = ({ if (!recording) return; await messageComposer.attachmentManager.uploadAttachment(recording); if (!asyncMessagesMultiSendEnabled) { - // FIXME: cannot call handleSubmit() directly as the function has stale reference to attachments - scheduleForSubmit(true); + await sendMessageFn(); + // // FIXME: cannot call handleSubmit() directly as the function has stale reference to attachments + // scheduleForSubmit(true); } recorder.cleanUp(); - }, [asyncMessagesMultiSendEnabled, messageComposer, recorder]); + }, [asyncMessagesMultiSendEnabled, messageComposer, recorder, sendMessageFn]); - useEffect(() => { - if (!isScheduledForSubmit) return; - handleSubmit(); - scheduleForSubmit(false); - }, [handleSubmit, isScheduledForSubmit]); + // useEffect(() => { + // if (!isScheduledForSubmit) return; + // handleSubmit(); + // scheduleForSubmit(false); + // }, [handleSubmit, isScheduledForSubmit]); useEffect(() => { if (!recorder) return; diff --git a/src/components/Message/Message.tsx b/src/components/Message/Message.tsx index 39b0e3f039..1fcb184907 100644 --- a/src/components/Message/Message.tsx +++ b/src/components/Message/Message.tsx @@ -12,7 +12,6 @@ import { usePinHandler, useReactionHandler, useReactionsFetcher, - useRetryHandler, useUserHandler, useUserRole, } from './hooks'; @@ -47,7 +46,6 @@ type MessageContextPropsToPick = | 'handleOpenThread' | 'handlePin' | 'handleReaction' - | 'handleRetry' | 'mutes' | 'onMentionsClickMessage' | 'onMentionsHoverMessage' @@ -74,7 +72,7 @@ const MessageWithContext = (props: MessageWithContextProps) => { } = props; const { client, isMessageAIGenerated } = useChatContext('Message'); - const { channelConfig, read } = useChannelStateContext('Message'); + const { channel, channelConfig } = useChannelStateContext('Message'); const { Message: contextMessage } = useComponentContext('Message'); const actionsEnabled = message.type === 'regular' && message.status === 'received'; @@ -104,13 +102,13 @@ const MessageWithContext = (props: MessageWithContextProps) => { !!( !isMyMessage && client.user?.id && - read && - (!read[client.user.id] || + channel.state.read && + (!channel.state.read[client.user.id] || (message?.created_at && new Date(message.created_at).getTime() > - read[client.user.id].last_read.getTime())) + channel.state.read[client.user.id].last_read.getTime())) ), - [client, isMyMessage, message.created_at, read], + [client, isMyMessage, message.created_at, channel], ); const messageActionsHandler = useCallback( @@ -208,18 +206,16 @@ export const Message = (props: MessageProps) => { openThread: propOpenThread, pinPermissions, reactionDetailsSort, - retrySendMessage: propRetrySendMessage, sortReactionDetails, sortReactions, } = props; const { addNotification } = useChannelActionContext('Message'); - const { highlightedMessageId, mutes } = useChannelStateContext('Message'); + const { mutes } = useChannelStateContext('Message'); const handleAction = useActionHandler(message); const handleOpenThread = useOpenThreadHandler(message, propOpenThread); const handleReaction = useReactionHandler(message); - const handleRetry = useRetryHandler(propRetrySendMessage); const userRoles = useUserRole(message, onlySenderCanEdit, disableQuotedMessages); const handleFetchReactions = useReactionsFetcher(message, { @@ -260,7 +256,7 @@ export const Message = (props: MessageProps) => { notify: addNotification, }); - const highlighted = highlightedMessageId === message.id; + // const highlighted = highlightedMessageId === message.id; return ( { handleOpenThread={handleOpenThread} handlePin={handlePin} handleReaction={handleReaction} - handleRetry={handleRetry} - highlighted={highlighted} + // highlighted={highlighted} initialMessage={props.initialMessage} lastOwnMessage={props.lastOwnMessage} lastReceivedId={props.lastReceivedId} diff --git a/src/components/Message/MessageSimple.tsx b/src/components/Message/MessageSimple.tsx index 9bf01d1666..106dcb8b16 100644 --- a/src/components/Message/MessageSimple.tsx +++ b/src/components/Message/MessageSimple.tsx @@ -14,7 +14,7 @@ import { StreamedMessageText as DefaultStreamedMessageText } from './StreamedMes import { isDateSeparatorMessage } from '../MessageList'; import { MessageIsThreadReplyInChannelButtonIndicator as DefaultMessageIsThreadReplyInChannelButtonIndicator } from './MessageIsThreadReplyInChannelButtonIndicator'; import { ReminderNotification as DefaultReminderNotification } from './ReminderNotification'; -import { useMessageReminder } from './hooks'; +import { useMessageReminder, useRetryHandler } from './hooks'; import { areMessageUIPropsEqual, isMessageBlocked, @@ -50,7 +50,6 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => { groupedByUser, handleAction, handleOpenThread, - handleRetry, highlighted, isMessageAIGenerated, isMyMessage, @@ -65,6 +64,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => { const [isBounceDialogOpen, setIsBounceDialogOpen] = useState(false); const [isEditedTimestampOpen, setEditedTimestampOpen] = useState(false); const reminder = useMessageReminder(message.id); + const handleRetry = useRetryHandler(); const { Attachment = DefaultAttachment, @@ -125,7 +125,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => { let handleClick: (() => void) | undefined = undefined; if (allowRetry) { - handleClick = () => handleRetry(message); + handleClick = () => handleRetry({ localMessage: message }); } else if (isBounced) { handleClick = () => setIsBounceDialogOpen(true); } else if (isEdited) { diff --git a/src/components/Message/QuotedMessage.tsx b/src/components/Message/QuotedMessage.tsx index db2cf19087..96575de738 100644 --- a/src/components/Message/QuotedMessage.tsx +++ b/src/components/Message/QuotedMessage.tsx @@ -7,12 +7,12 @@ import { Avatar as DefaultAvatar } from '../Avatar'; import { Poll } from '../Poll'; import { useChatContext } from '../../context/ChatContext'; import { useComponentContext } from '../../context/ComponentContext'; +import type { MessageContextValue } from '../../context/MessageContext'; import { useMessageContext } from '../../context/MessageContext'; import { useTranslationContext } from '../../context/TranslationContext'; -import { useChannelActionContext } from '../../context/ChannelActionContext'; import { renderText as defaultRenderText } from './renderText'; -import type { MessageContextValue } from '../../context/MessageContext'; import { useActionHandler } from './'; +import { useMessagePaginator } from '../../hooks'; export type QuotedMessageProps = Pick; @@ -26,9 +26,8 @@ export const QuotedMessage = ({ renderText: propsRenderText }: QuotedMessageProp renderText: contextRenderText, } = useMessageContext('QuotedMessage'); const { t, userLanguage } = useTranslationContext('QuotedMessage'); - const { jumpToMessage } = useChannelActionContext('QuotedMessage'); const actionHandler = useActionHandler(message); - + const messagePaginator = useMessagePaginator(); const renderText = propsRenderText ?? contextRenderText ?? defaultRenderText; const Avatar = ContextAvatar || DefaultAvatar; @@ -65,7 +64,7 @@ export const QuotedMessage = ({ renderText: propsRenderText }: QuotedMessageProp onClickCapture={(e) => { e.stopPropagation(); e.preventDefault(); - jumpToMessage(quoted_message.id); + messagePaginator.jumpToMessage(quoted_message.id); }} > {quoted_message.user && ( diff --git a/src/components/Message/hooks/useRetryHandler.ts b/src/components/Message/hooks/useRetryHandler.ts index 45d7c33d36..71468ccee0 100644 --- a/src/components/Message/hooks/useRetryHandler.ts +++ b/src/components/Message/hooks/useRetryHandler.ts @@ -1,17 +1,20 @@ -import type { RetrySendMessage } from '../../../context/ChannelActionContext'; -import { useChannelActionContext } from '../../../context/ChannelActionContext'; +import { useThreadContext } from '../../Threads'; +import { useChannelStateContext } from '../../../context'; +import type { RetrySendMessageWithLocalUpdateParams } from 'stream-chat'; +import { useCallback } from 'react'; -export const useRetryHandler = ( - customRetrySendMessage?: RetrySendMessage, -): RetrySendMessage => { - const { retrySendMessage: contextRetrySendMessage } = - useChannelActionContext('useRetryHandler'); +export type RetryHandler = ( + params: RetrySendMessageWithLocalUpdateParams, +) => Promise; - const retrySendMessage = customRetrySendMessage || contextRetrySendMessage; +export const useRetryHandler = (): RetryHandler => { + const { channel } = useChannelStateContext(); + const thread = useThreadContext(); - return async (message) => { - if (message) { - await retrySendMessage(message); - } - }; + return useCallback( + async (params: RetrySendMessageWithLocalUpdateParams) => { + await (thread ?? channel).retrySendMessageWithLocalUpdate(params); + }, + [channel, thread], + ); }; diff --git a/src/components/Message/types.ts b/src/components/Message/types.ts index c32669cb60..b14731f438 100644 --- a/src/components/Message/types.ts +++ b/src/components/Message/types.ts @@ -1,11 +1,9 @@ import type { TFunction } from 'i18next'; import type { ReactNode } from 'react'; -import type { ReactionSort, UserResponse } from 'stream-chat'; +import type { LocalMessage, ReactionSort, UserResponse } from 'stream-chat'; import type { PinPermissions, UserEventHandler } from './hooks'; import type { MessageActionsArray } from './utils'; - -import type { LocalMessage } from 'stream-chat'; import type { GroupStyle } from '../MessageList/utils'; import type { MessageInputProps } from '../MessageInput/MessageInput'; import type { ReactionDetailsComparator, ReactionsComparator } from '../Reactions/types'; @@ -99,8 +97,9 @@ export type MessageProps = { mentioned_users?: UserResponse[], options?: RenderTextOptions, ) => ReactNode; - /** Custom retry send message handler to override default in [ChannelActionContext](https://getstream.io/chat/docs/sdk/react/contexts/channel_action_context/) */ - retrySendMessage?: ChannelActionContextValue['retrySendMessage']; + // todo: document how to register custom CustomSendMessageRequestFn with Channel and Thread through StreamChat + // /** Custom retry send message handler to override default in [ChannelActionContext](https://getstream.io/chat/docs/sdk/react/contexts/channel_action_context/) */ + // retrySendMessage?: CustomSendMessageRequestFn; /** Keep track of read receipts for each message sent by the user. When disabled, only the last own message delivery / read status is rendered. */ returnAllReadData?: boolean; /** Comparator function to sort the list of reacted users diff --git a/src/components/MessageInput/EditMessageForm.tsx b/src/components/MessageInput/EditMessageForm.tsx index 215b154d48..9d1416ff7d 100644 --- a/src/components/MessageInput/EditMessageForm.tsx +++ b/src/components/MessageInput/EditMessageForm.tsx @@ -5,10 +5,13 @@ import { Modal as DefaultModal } from '../Modal'; import { useComponentContext, useMessageContext, - useMessageInputContext, useTranslationContext, } from '../../context'; -import { useMessageComposer, useMessageComposerHasSendableData } from './hooks'; +import { + useMessageComposer, + useMessageComposerHasSendableData, + useUpdateMessageFn, +} from './hooks'; import type { MessageUIComponentProps } from '../Message'; @@ -30,34 +33,32 @@ const EditMessageFormSendButton = () => { export const EditMessageForm = () => { const { t } = useTranslationContext('EditMessageForm'); const messageComposer = useMessageComposer(); - const { clearEditingState, handleSubmit } = useMessageInputContext('EditMessageForm'); - - const cancel = useCallback(() => { - clearEditingState?.(); - messageComposer.restore(); - }, [clearEditingState, messageComposer]); + const updateMessage = useUpdateMessageFn(); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') cancel(); + if (event.key === 'Escape') messageComposer.restore(); }; document.addEventListener('keydown', onKeyDown); return () => document.removeEventListener('keydown', onKeyDown); - }, [cancel]); + }, [messageComposer]); return (
{ + e.preventDefault(); + updateMessage(); + }} >
+ ); +}; +``` + +### 5) User action flow: thread click -> open in next slot + +```tsx +const ThreadListItem = ({ thread }: { thread: StreamThread }) => { + const { layoutController } = useChatViewContext(); + return ( + + ); +}; +``` + +### 6) View switch flow: from `threads` view to opening an arbitrary channel + +Scenario: + +- User is currently in `activeView='threads'`. +- User clicks a message-annotation action in a thread item + (for example: "View reply in channel" / "This reply was also sent to channel"). +- Expected outcome: + - channel opens in a workspace slot (via resolver chain), + - `activeView` switches to `'channels'`, + - entity list pane shows ChannelList when open. + +```tsx +const ViewReplyInChannelAction = ({ channel }: { channel: StreamChannel }) => { + const { layoutController } = useChatViewContext(); + + const onViewInChannel = () => { + layoutController.setActiveView('channels'); + layoutController.openChannel(channel, { activate: true }); + }; + + return ; +}; +``` + +If `resolveTargetSlotChannelDefault` is used, slot decision still follows the same chain: + +1. honor `targetSlot` if provided +2. else use free slot +3. else replace thread slot (for channel entity) +4. else replace earliest occupied +5. else fallback to active/last slot + +### 7) Persistence note + +Motivation: + +- `slotBindings` store in-memory instances (`source`), which cannot be safely persisted to URL/localStorage/session storage. +- After refresh/navigation/app restart, those instances do not exist anymore. +- To restore user workspace reliably, persist only serializable identifiers (`entity.key`) and rebuild instances on restore. + +What this solves: + +- Users return to the same multi-pane layout after refresh. +- Deep links can reproduce slot arrangement. +- Persistence stays stable across runtime boundaries (tabs/sessions/process restarts). + +Recommended flow: + +1. Persist a compact snapshot: slot -> `entity.key` (+ optionally `kind`/`activeView`). +2. On restore, resolve each key back to a live instance (channel/thread/etc.). +3. Rebind resolved instances into slots using `layoutController.bind(...)`. +4. Skip entries that cannot be resolved (deleted channel, revoked access, etc.). + +Example: + +```ts +// persist +const serialized = Object.fromEntries( + Object.entries(layoutController.state.getLatestValue().slotBindings).map(([slot, entity]) => [ + slot, + entity ? { key: entity.key, kind: entity.kind } : null, + ]), +); + +// restore +const slot1 = serialized.slot1; +if (slot1?.kind === 'channel') { + const channel = resolveChannel(slot1.key); + if (channel) { + layoutController.bind('slot1', { kind: 'channel', source: channel, key: slot1.key }); + } +} +``` + +## Suggested migration path + +1. Add controller API to `ChatView` context backed by `StateStore` (non-breaking, with default controller). +2. Change `ChannelHeader` toggle to consume `ChatView` controller, keep `onSidebarToggle` prop override. +3. Deprecate and remove `openMobileNav` from `ChatContext`. +4. Add simplified dynamic slot mode (`maxSlots`, placement policy, `open(entity)` with `source`), then migrate examples/e2e. +5. Add optional serializer/deserializer helpers for persistence. +6. Introduce typed adapters as optional advanced API. + +## Summary + +- Recommended design: pass a generic `LayoutController` (or use default one) through `ChatView` and expose it in `ChatView` context. +- Bindings should carry source instances so outlets render directly without extra lookup. +- Optional `key` handles deduplication and persistence. +- Simple path: developers set `maxSlots` and open entities; slots can host any type combination, including multiple channels. +- DX path: developers usually call high-level helpers (`openChannel`, `openThread`, ...) and avoid manual `kind` authoring. diff --git a/src/components/ChatView/specs/layoutController/state.json b/src/components/ChatView/specs/layoutController/state.json new file mode 100644 index 0000000000..c3255027d1 --- /dev/null +++ b/src/components/ChatView/specs/layoutController/state.json @@ -0,0 +1,20 @@ +{ + "tasks": { + "task-1-core-types-and-controller-engine": "done", + "task-2-resolver-registry-and-built-in-strategies": "done", + "task-3-chatview-integration-context-and-props": "pending", + "task-4-header-toggle-wiring-for-entity-list-pane": "pending", + "task-5-built-in-two-step-dx-layout-api": "pending", + "task-6-tests-for-controller-resolvers-and-integration": "pending", + "task-7-docs-and-spec-alignment": "pending" + }, + "flags": { + "blocked": false, + "needs-review": false + }, + "meta": { + "last_updated": "2026-02-26", + "worktree": "../stream-chat-react-worktrees/chatview-layout-controller", + "branch": "feat/chatview-layout-controller" + } +} From 64ff386016abf744becaba84235ac352d0e8365b Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 26 Feb 2026 18:25:19 +0100 Subject: [PATCH 03/32] feat(LayoutController): implement ChatView Integration (Context and Props) and Header Toggle Wiring for Entity List Pane --- .../ChannelHeader/ChannelHeader.tsx | 21 ++- src/components/ChatView/ChatView.tsx | 122 ++++++++++++++++-- src/components/ChatView/index.tsx | 3 + .../specs/layoutController/decisions.md | 59 +++++++++ .../ChatView/specs/layoutController/plan.md | 18 +-- .../specs/layoutController/state.json | 4 +- 6 files changed, 199 insertions(+), 28 deletions(-) diff --git a/src/components/ChannelHeader/ChannelHeader.tsx b/src/components/ChannelHeader/ChannelHeader.tsx index 4bfb2e3211..f261cc74f9 100644 --- a/src/components/ChannelHeader/ChannelHeader.tsx +++ b/src/components/ChannelHeader/ChannelHeader.tsx @@ -2,11 +2,12 @@ import React from 'react'; import { IconLayoutAlignLeft } from '../Icons/icons'; import { Avatar as DefaultAvatar } from '../Avatar'; +import { useChatViewContext } from '../ChatView'; import { useChannelHeaderOnlineStatus } from './hooks/useChannelHeaderOnlineStatus'; import { useChannelPreviewInfo } from '../ChannelPreview/hooks/useChannelPreviewInfo'; import { useChannelStateContext } from '../../context/ChannelStateContext'; -import { useChatContext } from '../../context/ChatContext'; import { useTranslationContext } from '../../context/TranslationContext'; +import { useStateStore } from '../../store'; import type { ChannelAvatarProps } from '../Avatar'; import { Button } from '../Button'; import clsx from 'clsx'; @@ -18,12 +19,18 @@ export type ChannelHeaderProps = { image?: string; /** UI component to display menu icon, defaults to [MenuIcon](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ChannelHeader/ChannelHeader.tsx)*/ MenuIcon?: React.ComponentType; + /** Optional external toggle override for sidebar/entity list pane behavior */ + onSidebarToggle?: () => void; /** When true, shows IconLayoutAlignLeft instead of MenuIcon for sidebar expansion */ sidebarCollapsed?: boolean; /** Set title manually */ title?: string; }; +const entityListPaneOpenSelector = ({ entityListPaneOpen }: { entityListPaneOpen: boolean }) => ({ + entityListPaneOpen, +}); + /** * The ChannelHeader component renders some basic information about a Channel. */ @@ -32,12 +39,13 @@ export const ChannelHeader = (props: ChannelHeaderProps) => { Avatar = DefaultAvatar, image: overrideImage, MenuIcon = IconLayoutAlignLeft, - sidebarCollapsed = true, + onSidebarToggle, + sidebarCollapsed: sidebarCollapsedProp, title: overrideTitle, } = props; const { channel } = useChannelStateContext(); - const { openMobileNav } = useChatContext('ChannelHeader'); + const { layoutController } = useChatViewContext(); const { t } = useTranslationContext('ChannelHeader'); const { displayImage, displayTitle, groupChannelDisplayInfo } = useChannelPreviewInfo({ channel, @@ -45,6 +53,11 @@ export const ChannelHeader = (props: ChannelHeaderProps) => { overrideTitle, }); const onlineStatusText = useChannelHeaderOnlineStatus(); + const { entityListPaneOpen } = + useStateStore(layoutController.state, entityListPaneOpenSelector) ?? + entityListPaneOpenSelector(layoutController.state.getLatestValue()); + const sidebarCollapsed = sidebarCollapsedProp ?? !entityListPaneOpen; + const handleSidebarToggle = onSidebarToggle ?? layoutController.toggleEntityListPane; return (
{ aria-label={sidebarCollapsed ? t('aria/Expand sidebar') : t('aria/Menu')} circular className='str-chat__header-sidebar-toggle' - onClick={openMobileNav} + onClick={handleSidebarToggle} size='md' variant='secondary' > diff --git a/src/components/ChatView/ChatView.tsx b/src/components/ChatView/ChatView.tsx index 7e46393cc9..e40b00b32a 100644 --- a/src/components/ChatView/ChatView.tsx +++ b/src/components/ChatView/ChatView.tsx @@ -2,6 +2,7 @@ import clsx from 'clsx'; import React, { type ComponentType, createContext, + useCallback, useContext, useEffect, useMemo, @@ -14,30 +15,125 @@ import { Icon } from '../Threads/icons'; import { UnreadCountBadge } from '../Threads/UnreadCountBadge'; import { useChatContext, useTranslationContext } from '../../context'; import { useStateStore } from '../../store'; +import { createLayoutController } from './layoutController/LayoutController'; +import { resolveTargetSlotChannelDefault } from './layoutSlotResolvers'; import type { PropsWithChildren } from 'react'; import type { Thread, ThreadManagerState } from 'stream-chat'; +import type { + ChatViewLayoutState, + DuplicateEntityPolicy, + LayoutController, + LayoutEntityBinding, + ResolveDuplicateEntity, + ResolveTargetSlot, +} from './layoutController/layoutControllerTypes'; export type ChatView = 'channels' | 'threads'; +export type ChatViewEntityInferer = { + kind: LayoutEntityBinding['kind']; + match: (source: unknown) => boolean; + toBinding: (source: unknown) => LayoutEntityBinding; +}; + +export type ChatViewProps = PropsWithChildren<{ + duplicateEntityPolicy?: DuplicateEntityPolicy; + entityInferers?: ChatViewEntityInferer[]; + layoutController?: LayoutController; + maxSlots?: number; + resolveDuplicateEntity?: ResolveDuplicateEntity; + resolveTargetSlot?: ResolveTargetSlot; +}>; + type ChatViewContextValue = { activeChatView: ChatView; + activeView: ChatView; + entityInferers: ChatViewEntityInferer[]; + layoutController: LayoutController; setActiveChatView: (cv: ChatView) => void; + setActiveView: (cv: ChatView) => void; }; +const DEFAULT_MAX_SLOTS = 1; + +const createVisibleSlots = (maxSlots: number) => + Array.from({ length: Math.max(0, maxSlots) }, (_, index) => `slot${index + 1}`); + +const defaultLayoutController = createLayoutController({ + initialState: { + activeView: 'channels', + visibleSlots: createVisibleSlots(DEFAULT_MAX_SLOTS), + }, +}); + const ChatViewContext = createContext({ activeChatView: 'channels', + activeView: 'channels', + entityInferers: [], + layoutController: defaultLayoutController, setActiveChatView: () => undefined, + setActiveView: () => undefined, }); export const useChatViewContext = () => useContext(ChatViewContext); -export const ChatView = ({ children }: PropsWithChildren) => { - const [activeChatView, setActiveChatView] = useState('channels'); +const activeViewSelector = ({ activeView }: ChatViewLayoutState) => ({ activeView }); +export const ChatView = ({ + children, + duplicateEntityPolicy, + entityInferers = [], + layoutController, + maxSlots = DEFAULT_MAX_SLOTS, + resolveDuplicateEntity, + resolveTargetSlot, +}: ChatViewProps) => { const { theme } = useChatContext(); - const value = useMemo(() => ({ activeChatView, setActiveChatView }), [activeChatView]); + const internalLayoutController = useMemo( + () => + createLayoutController({ + duplicateEntityPolicy, + initialState: { + activeView: 'channels', + visibleSlots: createVisibleSlots(maxSlots), + }, + resolveDuplicateEntity, + resolveTargetSlot: resolveTargetSlot ?? resolveTargetSlotChannelDefault, + }), + [ + duplicateEntityPolicy, + maxSlots, + resolveDuplicateEntity, + resolveTargetSlot, + ], + ); + + const effectiveLayoutController = layoutController ?? internalLayoutController; + + const { activeView } = + useStateStore(effectiveLayoutController.state, activeViewSelector) ?? + activeViewSelector(effectiveLayoutController.state.getLatestValue()); + + const setActiveView = useCallback( + (cv: ChatView) => { + effectiveLayoutController.setActiveView(cv); + }, + [effectiveLayoutController], + ); + + const value = useMemo( + () => ({ + activeChatView: activeView, + activeView, + entityInferers, + layoutController: effectiveLayoutController, + setActiveChatView: setActiveView, + setActiveView, + }), + [activeView, effectiveLayoutController, entityInferers, setActiveView], + ); return ( @@ -47,9 +143,9 @@ export const ChatView = ({ children }: PropsWithChildren) => { }; const ChannelsView = ({ children }: PropsWithChildren) => { - const { activeChatView } = useChatViewContext(); + const { activeView } = useChatViewContext(); - if (activeChatView !== 'channels') return null; + if (activeView !== 'channels') return null; return
{children}
; }; @@ -67,13 +163,13 @@ const ThreadsViewContext = createContext({ export const useThreadsViewContext = () => useContext(ThreadsViewContext); const ThreadsView = ({ children }: PropsWithChildren) => { - const { activeChatView } = useChatViewContext(); + const { activeView } = useChatViewContext(); const [activeThread, setActiveThread] = useState(undefined); const value = useMemo(() => ({ activeThread, setActiveThread }), [activeThread]); - if (activeChatView !== 'threads') return null; + if (activeView !== 'threads') return null; return ( @@ -164,14 +260,14 @@ const selector = ({ unreadThreadCount }: ThreadManagerState) => ({ }); export const ChatViewChannelsSelectorButton = () => { - const { activeChatView, setActiveChatView } = useChatViewContext(); + const { activeView, setActiveView } = useChatViewContext(); const { t } = useTranslationContext(); return ( setActiveChatView('channels')} + onPointerDown={() => setActiveView('channels')} text={t('Channels')} /> ); @@ -182,13 +278,13 @@ export const ChatViewThreadsSelectorButton = () => { const { unreadThreadCount } = useStateStore(client.threads.state, selector) ?? { unreadThreadCount: 0, }; - const { activeChatView, setActiveChatView } = useChatViewContext(); + const { activeView, setActiveView } = useChatViewContext(); const { t } = useTranslationContext(); return ( setActiveChatView('threads')} + aria-selected={activeView === 'threads'} + onPointerDown={() => setActiveView('threads')} > diff --git a/src/components/ChatView/index.tsx b/src/components/ChatView/index.tsx index c9582c20c1..52f50b1f82 100644 --- a/src/components/ChatView/index.tsx +++ b/src/components/ChatView/index.tsx @@ -1 +1,4 @@ export * from './ChatView'; +export * from './layoutController/LayoutController'; +export * from './layoutController/layoutControllerTypes'; +export * from './layoutSlotResolvers'; diff --git a/src/components/ChatView/specs/layoutController/decisions.md b/src/components/ChatView/specs/layoutController/decisions.md index 75579218af..cc3312de54 100644 --- a/src/components/ChatView/specs/layoutController/decisions.md +++ b/src/components/ChatView/specs/layoutController/decisions.md @@ -60,3 +60,62 @@ Pure resolver functions are independently testable and reusable by integrators. **Tradeoffs / Consequences:** `replaceActive` and `activeOrLast` currently resolve identically by design; keeping both exported names improves API clarity for different integration intents. + +## Decision: Keep activeChatView as a compatibility alias over controller activeView + +**Date:** 2026-02-26 +**Context:** +Task 3 introduces `layoutController` as the source of truth in ChatView context, but existing consumers and selectors read `activeChatView` and call `setActiveChatView`. + +**Decision:** +Expose both `activeView`/`setActiveView` and compatibility aliases `activeChatView`/`setActiveChatView` from `useChatViewContext()`, all mapped to `layoutController.state.activeView` and `layoutController.setActiveView`. + +**Reasoning:** +This keeps existing ChatView usage stable while enabling the new controller-first API without forcing immediate downstream migration. + +**Alternatives considered:** + +- Remove old names and migrate all call sites at once — rejected because it would be a broad breaking change outside Task 3 scope. + +**Tradeoffs / Consequences:** +Context temporarily carries duplicate field names until follow-up cleanup/migration tasks. + +## Decision: Use default channel resolver fallback for internally created controllers + +**Date:** 2026-02-26 +**Context:** +Task 3 requires ChatView to wire a default resolver fallback when `resolveTargetSlot` is absent. + +**Decision:** +When ChatView creates its internal controller, default `resolveTargetSlot` to `resolveTargetSlotChannelDefault`; external `layoutController` instances are left untouched. + +**Reasoning:** +This gives predictable out-of-the-box replacement behavior for the built-in path while respecting externally managed controller policy. + +**Alternatives considered:** + +- Leave resolver undefined and rely on controller fallback only — rejected because it does not satisfy Task 3 acceptance and weakens default DX. +- Force `maxSlots` and resolver onto external controllers — rejected because external controllers should remain authoritative. + +**Tradeoffs / Consequences:** +Internal and external controller paths may differ by integrator design, which is intentional for flexibility. + +## Decision: ChannelHeader toggle now defaults to ChatView layout controller + +**Date:** 2026-02-26 +**Context:** +Task 4 requires ChannelHeader's sidebar toggle to be driven by ChatView layout state, while still allowing external override handlers. + +**Decision:** +Update `ChannelHeader` so the toggle button uses `layoutController.toggleEntityListPane()` by default, add an optional `onSidebarToggle` prop that takes precedence when provided, and derive `sidebarCollapsed` from `!entityListPaneOpen` when `sidebarCollapsed` is not controlled by props. + +**Reasoning:** +This aligns header behavior with the new ChatView layout-controller source of truth and preserves integrator escape hatches for custom sidebar behavior. + +**Alternatives considered:** + +- Keep using `ChatContext.openMobileNav` as default toggle path — rejected because layout responsibilities are being moved to ChatView. +- Require `sidebarCollapsed` to always be controlled by the parent — rejected because default controller-driven behavior should work out of the box. + +**Tradeoffs / Consequences:** +When `ChannelHeader` is rendered outside a ChatView provider, it falls back to the default ChatView context controller state rather than `openMobileNav`; follow-up integration tests in Task 6 should validate expected host usage patterns. diff --git a/src/components/ChatView/specs/layoutController/plan.md b/src/components/ChatView/specs/layoutController/plan.md index 816b28f75e..fd1e86a3bb 100644 --- a/src/components/ChatView/specs/layoutController/plan.md +++ b/src/components/ChatView/specs/layoutController/plan.md @@ -71,9 +71,9 @@ Primary spec for this plan: **Dependencies:** Task 1, Task 2 -**Status:** pending +**Status:** done -**Owner:** unassigned +**Owner:** codex **Scope:** @@ -85,9 +85,9 @@ Primary spec for this plan: **Acceptance Criteria:** -- [ ] Existing ChatView usage does not break at runtime. -- [ ] New props and context are typed/exported. -- [ ] `str-chat__chat-view` behavior remains stable for existing layouts. +- [x] Existing ChatView usage does not break at runtime. +- [x] New props and context are typed/exported. +- [x] `str-chat__chat-view` behavior remains stable for existing layouts. ## Task 4: Header Toggle Wiring for Entity List Pane @@ -95,9 +95,9 @@ Primary spec for this plan: **Dependencies:** Task 3 -**Status:** pending +**Status:** done -**Owner:** unassigned +**Owner:** codex **Scope:** @@ -107,8 +107,8 @@ Primary spec for this plan: **Acceptance Criteria:** -- [ ] Header toggle hides/shows entity list pane via ChatView state. -- [ ] Override prop still takes precedence when provided. +- [x] Header toggle hides/shows entity list pane via ChatView state. +- [x] Override prop still takes precedence when provided. ## Task 5: Built-in Two-Step DX Layout API diff --git a/src/components/ChatView/specs/layoutController/state.json b/src/components/ChatView/specs/layoutController/state.json index c3255027d1..492b5ce1fb 100644 --- a/src/components/ChatView/specs/layoutController/state.json +++ b/src/components/ChatView/specs/layoutController/state.json @@ -2,8 +2,8 @@ "tasks": { "task-1-core-types-and-controller-engine": "done", "task-2-resolver-registry-and-built-in-strategies": "done", - "task-3-chatview-integration-context-and-props": "pending", - "task-4-header-toggle-wiring-for-entity-list-pane": "pending", + "task-3-chatview-integration-context-and-props": "done", + "task-4-header-toggle-wiring-for-entity-list-pane": "done", "task-5-built-in-two-step-dx-layout-api": "pending", "task-6-tests-for-controller-resolvers-and-integration": "pending", "task-7-docs-and-spec-alignment": "pending" From 1bf06f68dc0e3f1028cbf04d7932deffd827d05f Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 27 Feb 2026 09:44:41 +0100 Subject: [PATCH 04/32] feat(LayoutController): implement Built-in Two-Step DX Layout API --- .cursor/skills/ralph-protocol/SKILL.md | 2 +- .../ChannelHeader/ChannelHeader.tsx | 6 +- src/components/ChatView/ChatView.tsx | 97 +++++++++++++++++-- .../ChatView/layout/WorkspaceLayout.tsx | 45 +++++++++ .../layoutController/LayoutController.ts | 12 ++- .../layoutController/layoutControllerTypes.ts | 2 +- .../ChatView/layoutSlotResolvers.ts | 8 +- .../specs/layoutController/decisions.md | 26 +++++ .../ChatView/specs/layoutController/plan.md | 30 +++--- .../ChatView/specs/layoutController/spec.md | 69 ++++++++----- .../specs/layoutController/state.json | 2 +- 11 files changed, 247 insertions(+), 52 deletions(-) create mode 100644 src/components/ChatView/layout/WorkspaceLayout.tsx diff --git a/.cursor/skills/ralph-protocol/SKILL.md b/.cursor/skills/ralph-protocol/SKILL.md index 06b89d9380..40dbdef1ff 100644 --- a/.cursor/skills/ralph-protocol/SKILL.md +++ b/.cursor/skills/ralph-protocol/SKILL.md @@ -11,7 +11,7 @@ Files are the source of truth. All agents share memory via files. No silent deci | File | Purpose | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **spec.md** | What we achieve; success criteria; constraints; non-goals. Read first. Only change if scope or constraints actually change. No implementation details. | +| **spec.md** | What we achieve; success criteria; constraints; non-goals. Read first. Only change if scope or constraints actually change. No implementation details. | | **plan.md** | How we achieve it. Ordered tasks, ownership, dependencies, status (`pending \| in-progress \| done \| blocked`). Propose changes before big deviations; don't rewrite completed sections. | | **state.json** | Current memory. Task statuses, flags (`blocked`, `needs-review`, etc.). Update immediately after acting. Read before assuming anything. | | **decisions.md** | Log of non-trivial decisions (what + why). Append only; never delete. Prevents reopening or contradicting past choices. | diff --git a/src/components/ChannelHeader/ChannelHeader.tsx b/src/components/ChannelHeader/ChannelHeader.tsx index f261cc74f9..72a352c09b 100644 --- a/src/components/ChannelHeader/ChannelHeader.tsx +++ b/src/components/ChannelHeader/ChannelHeader.tsx @@ -27,7 +27,11 @@ export type ChannelHeaderProps = { title?: string; }; -const entityListPaneOpenSelector = ({ entityListPaneOpen }: { entityListPaneOpen: boolean }) => ({ +const entityListPaneOpenSelector = ({ + entityListPaneOpen, +}: { + entityListPaneOpen: boolean; +}) => ({ entityListPaneOpen, }); diff --git a/src/components/ChatView/ChatView.tsx b/src/components/ChatView/ChatView.tsx index e40b00b32a..9faca352c2 100644 --- a/src/components/ChatView/ChatView.tsx +++ b/src/components/ChatView/ChatView.tsx @@ -10,15 +10,18 @@ import React, { } from 'react'; import { Button, type ButtonProps } from '../Button'; +import { ChannelList } from '../ChannelList'; import { ThreadProvider } from '../Threads'; +import { ThreadList } from '../Threads/ThreadList'; import { Icon } from '../Threads/icons'; import { UnreadCountBadge } from '../Threads/UnreadCountBadge'; import { useChatContext, useTranslationContext } from '../../context'; import { useStateStore } from '../../store'; +import { WorkspaceLayout } from './layout/WorkspaceLayout'; import { createLayoutController } from './layoutController/LayoutController'; import { resolveTargetSlotChannelDefault } from './layoutSlotResolvers'; -import type { PropsWithChildren } from 'react'; +import type { PropsWithChildren, ReactNode } from 'react'; import type { Thread, ThreadManagerState } from 'stream-chat'; import type { ChatViewLayoutState, @@ -37,13 +40,34 @@ export type ChatViewEntityInferer = { toBinding: (source: unknown) => LayoutEntityBinding; }; +export type ChatViewBuiltinLayout = 'nav-rail-entity-list-workspace'; + +type LayoutEntityByKind = Extract< + LayoutEntityBinding, + { kind: TKind } +>; + +export type ChatViewSlotRendererProps = { + entity: LayoutEntityByKind; + slot: string; + source: LayoutEntityByKind['source']; +}; + +export type ChatViewSlotRenderers = Partial<{ + [TKind in LayoutEntityBinding['kind']]: ( + props: ChatViewSlotRendererProps, + ) => ReactNode; +}>; + export type ChatViewProps = PropsWithChildren<{ duplicateEntityPolicy?: DuplicateEntityPolicy; entityInferers?: ChatViewEntityInferer[]; + layout?: ChatViewBuiltinLayout; layoutController?: LayoutController; maxSlots?: number; resolveDuplicateEntity?: ResolveDuplicateEntity; resolveTargetSlot?: ResolveTargetSlot; + slotRenderers?: ChatViewSlotRenderers; }>; type ChatViewContextValue = { @@ -79,15 +103,52 @@ const ChatViewContext = createContext({ export const useChatViewContext = () => useContext(ChatViewContext); const activeViewSelector = ({ activeView }: ChatViewLayoutState) => ({ activeView }); +const workspaceLayoutStateSelector = ({ + entityListPaneOpen, + slotBindings, + visibleSlots, +}: ChatViewLayoutState) => ({ + entityListPaneOpen, + slotBindings, + visibleSlots, +}); + +const renderSlotBinding = ( + entity: LayoutEntityBinding | undefined, + slot: string, + slotRenderers: ChatViewSlotRenderers | undefined, +) => { + if (!entity || !slotRenderers) return null; + + switch (entity.kind) { + case 'channel': + return slotRenderers.channel?.({ entity, slot, source: entity.source }) ?? null; + case 'thread': + return slotRenderers.thread?.({ entity, slot, source: entity.source }) ?? null; + case 'memberList': + return slotRenderers.memberList?.({ entity, slot, source: entity.source }) ?? null; + case 'userList': + return slotRenderers.userList?.({ entity, slot, source: entity.source }) ?? null; + case 'pinnedMessagesList': + return ( + slotRenderers.pinnedMessagesList?.({ entity, slot, source: entity.source }) ?? + null + ); + default: + return null; + } +}; export const ChatView = ({ children, duplicateEntityPolicy, entityInferers = [], + layout, layoutController, maxSlots = DEFAULT_MAX_SLOTS, resolveDuplicateEntity, resolveTargetSlot, + slotRenderers, }: ChatViewProps) => { const { theme } = useChatContext(); @@ -102,12 +163,7 @@ export const ChatView = ({ resolveDuplicateEntity, resolveTargetSlot: resolveTargetSlot ?? resolveTargetSlotChannelDefault, }), - [ - duplicateEntityPolicy, - maxSlots, - resolveDuplicateEntity, - resolveTargetSlot, - ], + [duplicateEntityPolicy, maxSlots, resolveDuplicateEntity, resolveTargetSlot], ); const effectiveLayoutController = layoutController ?? internalLayoutController; @@ -135,9 +191,34 @@ export const ChatView = ({ [activeView, effectiveLayoutController, entityInferers, setActiveView], ); + const workspaceLayoutState = + useStateStore(effectiveLayoutController.state, workspaceLayoutStateSelector) ?? + workspaceLayoutStateSelector(effectiveLayoutController.state.getLatestValue()); + + const content = + layout === 'nav-rail-entity-list-workspace' ? ( + : + } + entityListPaneOpen={workspaceLayoutState.entityListPaneOpen} + navRail={} + slots={workspaceLayoutState.visibleSlots.map((slot) => ({ + content: renderSlotBinding( + workspaceLayoutState.slotBindings[slot], + slot, + slotRenderers, + ), + slot, + }))} + /> + ) : ( + children + ); + return ( -
{children}
+
{content}
); }; diff --git a/src/components/ChatView/layout/WorkspaceLayout.tsx b/src/components/ChatView/layout/WorkspaceLayout.tsx new file mode 100644 index 0000000000..f92aa46854 --- /dev/null +++ b/src/components/ChatView/layout/WorkspaceLayout.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import clsx from 'clsx'; + +import type { ReactNode } from 'react'; + +export type WorkspaceLayoutSlot = { + content?: ReactNode; + slot: string; +}; + +export type WorkspaceLayoutProps = { + entityListPane?: ReactNode; + entityListPaneOpen?: boolean; + navRail?: ReactNode; + slots: WorkspaceLayoutSlot[]; +}; + +export const WorkspaceLayout = ({ + entityListPane, + entityListPaneOpen = true, + navRail, + slots, +}: WorkspaceLayoutProps) => ( +
+ {navRail ? ( +
{navRail}
+ ) : null} + {entityListPaneOpen ? ( +
+ {entityListPane} +
+ ) : null} +
+ {slots.map(({ content, slot }) => ( +
+ {content} +
+ ))} +
+
+); diff --git a/src/components/ChatView/layoutController/LayoutController.ts b/src/components/ChatView/layoutController/LayoutController.ts index bb24dcdae9..e69d52322c 100644 --- a/src/components/ChatView/layoutController/LayoutController.ts +++ b/src/components/ChatView/layoutController/LayoutController.ts @@ -87,7 +87,10 @@ const upsertSlotBinding = ( }; }; -const clearSlotBinding = (current: ChatViewLayoutState, slot: LayoutSlot): ChatViewLayoutState => { +const clearSlotBinding = ( + current: ChatViewLayoutState, + slot: LayoutSlot, +): ChatViewLayoutState => { if (!current.slotBindings[slot] && !current.slotMeta[slot]) return current; const nextSlotBindings = { ...current.slotBindings }; @@ -187,7 +190,12 @@ export const createLayoutController = ( const open: LayoutController['open'] = (entity, openOptions) => { const current = state.getLatestValue(); - const targetSlot = resolveOpenTargetSlot(current, entity, openOptions, resolveTargetSlot); + const targetSlot = resolveOpenTargetSlot( + current, + entity, + openOptions, + resolveTargetSlot, + ); if (!targetSlot) { return { diff --git a/src/components/ChatView/layoutController/layoutControllerTypes.ts b/src/components/ChatView/layoutController/layoutControllerTypes.ts index ace37a988c..805a4f22f2 100644 --- a/src/components/ChatView/layoutController/layoutControllerTypes.ts +++ b/src/components/ChatView/layoutController/layoutControllerTypes.ts @@ -1,6 +1,6 @@ import type { - Channel as StreamChannel, StateStore, + Channel as StreamChannel, Thread as StreamThread, } from 'stream-chat'; diff --git a/src/components/ChatView/layoutSlotResolvers.ts b/src/components/ChatView/layoutSlotResolvers.ts index 746fc397fe..14d1ace99d 100644 --- a/src/components/ChatView/layoutSlotResolvers.ts +++ b/src/components/ChatView/layoutSlotResolvers.ts @@ -15,13 +15,17 @@ export const firstFree: SlotResolver = ({ state }) => export const existingThreadSlotForThread: SlotResolver = ({ entity, state }) => { if (entity.kind !== 'thread') return null; - return state.visibleSlots.find((slot) => state.slotBindings[slot]?.kind === 'thread') ?? null; + return ( + state.visibleSlots.find((slot) => state.slotBindings[slot]?.kind === 'thread') ?? null + ); }; export const existingThreadSlotForChannel: SlotResolver = ({ entity, state }) => { if (entity.kind !== 'channel') return null; - return state.visibleSlots.find((slot) => state.slotBindings[slot]?.kind === 'thread') ?? null; + return ( + state.visibleSlots.find((slot) => state.slotBindings[slot]?.kind === 'thread') ?? null + ); }; export const earliestOccupied: SlotResolver = ({ state }) => { diff --git a/src/components/ChatView/specs/layoutController/decisions.md b/src/components/ChatView/specs/layoutController/decisions.md index cc3312de54..6e2f0c2e00 100644 --- a/src/components/ChatView/specs/layoutController/decisions.md +++ b/src/components/ChatView/specs/layoutController/decisions.md @@ -119,3 +119,29 @@ This aligns header behavior with the new ChatView layout-controller source of tr **Tradeoffs / Consequences:** When `ChannelHeader` is rendered outside a ChatView provider, it falls back to the default ChatView context controller state rather than `openMobileNav`; follow-up integration tests in Task 6 should validate expected host usage patterns. + +## Decision: Add opt-in built-in ChatView workspace layout with kind-based slot renderers + +**Date:** 2026-02-26 +**Context:** +Task 5 requires a two-step DX path so integrators can render a nav-rail/entity-list/workspace shell without building custom `DynamicSlotsLayout` and `SlotOutlet` components. + +**Decision:** +Extend `ChatView` with optional `layout='nav-rail-entity-list-workspace'` and `slotRenderers` props. In this mode, `ChatView` renders: + +- nav rail (`ChatViewSelector`) +- entity list pane (`ChannelList` when `activeView='channels'`, `ThreadList` when `activeView='threads'`) controlled by `entityListPaneOpen` +- workspace slots from `visibleSlots`, where each bound entity is rendered by `slotRenderers[entity.kind]`. + +The layout container is implemented in `src/components/ChatView/layout/WorkspaceLayout.tsx`, while existing custom-children behavior remains the default when `layout` is not provided. + +**Reasoning:** +This provides the requested low-friction two-step integration while preserving the advanced/custom layout escape hatch and existing usage patterns. + +**Alternatives considered:** + +- Replace current `children` composition model entirely — rejected because it would break advanced/custom integrations. +- Hardcode slot rendering for built-in entity kinds — rejected because it would reduce extensibility and conflict with the spec’s renderer-by-kind design. + +**Tradeoffs / Consequences:** +Built-in mode uses default `ChannelList`/`ThreadList` props; deeper pane customization remains available through custom layout mode until dedicated built-in pane configuration is introduced. diff --git a/src/components/ChatView/specs/layoutController/plan.md b/src/components/ChatView/specs/layoutController/plan.md index fd1e86a3bb..5b0df9c229 100644 --- a/src/components/ChatView/specs/layoutController/plan.md +++ b/src/components/ChatView/specs/layoutController/plan.md @@ -116,9 +116,9 @@ Primary spec for this plan: **Dependencies:** Task 3 -**Status:** pending +**Status:** done -**Owner:** unassigned +**Owner:** codex **Scope:** @@ -128,8 +128,8 @@ Primary spec for this plan: **Acceptance Criteria:** -- [ ] Integrator can render multi-slot workspace in two steps (`ChatView` + `slotRenderers`). -- [ ] Existing custom-layout usage still works. +- [x] Integrator can render multi-slot workspace in two steps (`ChatView` + `slotRenderers`). +- [x] Existing custom-layout usage still works. ## Task 6: Tests for Controller, Resolvers, and Integration @@ -178,32 +178,38 @@ Primary spec for this plan: ## Execution order Phase 1 (Parallel): + - Task 1: Core Types and Controller Engine Phase 2 (After Task 1): + - Task 2: Resolver Registry and Built-in Strategies Phase 3 (After Tasks 1, 2): + - Task 3: ChatView Integration (Context and Props) Phase 4 (After Task 3): + - Task 4: Header Toggle Wiring for Entity List Pane - Task 5: Built-in Two-Step DX Layout API Phase 5 (After Tasks 2, 3, 4, 5): + - Task 6: Tests for Controller, Resolvers, and Integration Phase 6 (After Tasks 5, 6): + - Task 7: Docs and Spec Alignment ## File Ownership Summary -| Task | Creates/Modifies | -| ---- | ---------------- | -| 1 | `src/components/ChatView/layoutController/LayoutController.ts`, `src/components/ChatView/layoutController/layoutControllerTypes.ts` | -| 2 | `layoutSlotResolvers.ts` | -| 3 | `ChatView.tsx`, `index.tsx` | -| 4 | `ChannelHeader.tsx` | -| 5 | `ChatView.tsx`, `layout/WorkspaceLayout.tsx` | +| Task | Creates/Modifies | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `src/components/ChatView/layoutController/LayoutController.ts`, `src/components/ChatView/layoutController/layoutControllerTypes.ts` | +| 2 | `layoutSlotResolvers.ts` | +| 3 | `ChatView.tsx`, `index.tsx` | +| 4 | `ChannelHeader.tsx` | +| 5 | `ChatView.tsx`, `layout/WorkspaceLayout.tsx` | | 6 | `ChatView/__tests__/layoutController.test.ts`, `ChatView/__tests__/ChatView.test.tsx`, `ChannelHeader/__tests__/ChannelHeader.test.js` | -| 7 | `src/components/ChatView/specs/layoutController/spec.md`, `src/components/ChatView/specs/layoutController/plan.md` | +| 7 | `src/components/ChatView/specs/layoutController/spec.md`, `src/components/ChatView/specs/layoutController/plan.md` | diff --git a/src/components/ChatView/specs/layoutController/spec.md b/src/components/ChatView/specs/layoutController/spec.md index 0c407f149c..bb966a4d03 100644 --- a/src/components/ChatView/specs/layoutController/spec.md +++ b/src/components/ChatView/specs/layoutController/spec.md @@ -245,7 +245,8 @@ const resolveTargetSlotChannelDefault = ({ if (requestedSlot) return requestedSlot; const freeSlot = state.visibleSlots.find((slot) => !state.slotBindings[slot]); - const fallback = activeSlot ?? state.visibleSlots[state.visibleSlots.length - 1] ?? null; + const fallback = + activeSlot ?? state.visibleSlots[state.visibleSlots.length - 1] ?? null; const threadOccupiedSlot = state.visibleSlots.find( (slot) => state.slotBindings[slot]?.kind === 'thread', ); @@ -283,12 +284,16 @@ const firstFree: SlotResolver = ({ state }) => const existingThreadSlotForThread: SlotResolver = ({ state, entity }) => { if (entity.kind !== 'thread') return null; - return state.visibleSlots.find((slot) => state.slotBindings[slot]?.kind === 'thread') ?? null; + return ( + state.visibleSlots.find((slot) => state.slotBindings[slot]?.kind === 'thread') ?? null + ); }; const existingThreadSlotForChannel: SlotResolver = ({ state, entity }) => { if (entity.kind !== 'channel') return null; - return state.visibleSlots.find((slot) => state.slotBindings[slot]?.kind === 'thread') ?? null; + return ( + state.visibleSlots.find((slot) => state.slotBindings[slot]?.kind === 'thread') ?? null + ); }; const activeOrLast: SlotResolver = ({ state, activeSlot }) => @@ -399,7 +404,10 @@ Example (advanced mode): render `DynamicSlotsLayout` as `ChatView` children. ```tsx const ChatShell = () => ( - +
@@ -479,9 +487,7 @@ Example: - entity.kind === 'thread' ? 'move' : 'reject' - } + resolveDuplicateEntity={({ entity }) => (entity.kind === 'thread' ? 'move' : 'reject')} /> ``` @@ -490,7 +496,13 @@ const identity = entity.key; const existing = identity ? findSlotByKey(identity) : undefined; const policy = existing && resolveDuplicateEntity - ? resolveDuplicateEntity({ state, entity, existingSlot: existing, requestedSlot, activeSlot }) + ? resolveDuplicateEntity({ + state, + entity, + existingSlot: existing, + requestedSlot, + activeSlot, + }) : duplicateEntityPolicy; if (policy === 'move' && existing) clear(existing); if (policy === 'reject' && existing) return existing; @@ -641,15 +653,31 @@ Example snapshot: ### 2) Multiple channels side-by-side ```ts -layoutController.bind('slot1', { kind: 'channel', source: channelGeneral, key: channelGeneral.cid }); -layoutController.bind('slot2', { kind: 'channel', source: channelSupport, key: channelSupport.cid }); -layoutController.bind('slot3', { kind: 'channel', source: channelRandom, key: channelRandom.cid }); +layoutController.bind('slot1', { + kind: 'channel', + source: channelGeneral, + key: channelGeneral.cid, +}); +layoutController.bind('slot2', { + kind: 'channel', + source: channelSupport, + key: channelSupport.cid, +}); +layoutController.bind('slot3', { + kind: 'channel', + source: channelRandom, + key: channelRandom.cid, +}); ``` ### 3) Mixed layout: channel + thread + pinned list ```ts -layoutController.bind('slot1', { kind: 'channel', source: channelGeneral, key: channelGeneral.cid }); +layoutController.bind('slot1', { + kind: 'channel', + source: channelGeneral, + key: channelGeneral.cid, +}); layoutController.bind('slot2', { kind: 'thread', source: thread101, key: thread101.id }); layoutController.bind('slot3', { kind: 'pinnedMessagesList', @@ -664,9 +692,7 @@ layoutController.bind('slot3', { const ChannelListItem = ({ channel }: { channel: StreamChannel }) => { const { layoutController } = useChatViewContext(); return ( - + ); }; ``` @@ -676,11 +702,7 @@ const ChannelListItem = ({ channel }: { channel: StreamChannel }) => { ```tsx const ThreadListItem = ({ thread }: { thread: StreamThread }) => { const { layoutController } = useChatViewContext(); - return ( - - ); + return ; }; ``` @@ -743,10 +765,9 @@ Example: ```ts // persist const serialized = Object.fromEntries( - Object.entries(layoutController.state.getLatestValue().slotBindings).map(([slot, entity]) => [ - slot, - entity ? { key: entity.key, kind: entity.kind } : null, - ]), + Object.entries(layoutController.state.getLatestValue().slotBindings).map( + ([slot, entity]) => [slot, entity ? { key: entity.key, kind: entity.kind } : null], + ), ); // restore diff --git a/src/components/ChatView/specs/layoutController/state.json b/src/components/ChatView/specs/layoutController/state.json index 492b5ce1fb..2e3ee7a4dd 100644 --- a/src/components/ChatView/specs/layoutController/state.json +++ b/src/components/ChatView/specs/layoutController/state.json @@ -4,7 +4,7 @@ "task-2-resolver-registry-and-built-in-strategies": "done", "task-3-chatview-integration-context-and-props": "done", "task-4-header-toggle-wiring-for-entity-list-pane": "done", - "task-5-built-in-two-step-dx-layout-api": "pending", + "task-5-built-in-two-step-dx-layout-api": "done", "task-6-tests-for-controller-resolvers-and-integration": "pending", "task-7-docs-and-spec-alignment": "pending" }, From 4c72159d2d978f6db93b411e5e2c30305e9025a9 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 27 Feb 2026 10:11:56 +0100 Subject: [PATCH 05/32] feat(LayoutController): implement Docs and Spec Alignment --- .../__tests__/ChannelHeader.test.js | 73 ++++++++- .../ChatView/__tests__/ChatView.test.tsx | 147 ++++++++++++++++++ .../__tests__/layoutController.test.ts | 140 +++++++++++++++++ .../specs/layoutController/decisions.md | 24 +++ .../ChatView/specs/layoutController/plan.md | 8 +- .../specs/layoutController/state.json | 2 +- 6 files changed, 385 insertions(+), 9 deletions(-) create mode 100644 src/components/ChatView/__tests__/ChatView.test.tsx create mode 100644 src/components/ChatView/__tests__/layoutController.test.ts diff --git a/src/components/ChannelHeader/__tests__/ChannelHeader.test.js b/src/components/ChannelHeader/__tests__/ChannelHeader.test.js index bda9b074bb..645a7fa94e 100644 --- a/src/components/ChannelHeader/__tests__/ChannelHeader.test.js +++ b/src/components/ChannelHeader/__tests__/ChannelHeader.test.js @@ -21,6 +21,7 @@ import { import { toHaveNoViolations } from 'jest-axe'; import { axe } from '../../../../axe-helper'; import { ChannelAvatar } from '../../Avatar'; +import { ChatView, createLayoutController } from '../../ChatView'; expect.extend(toHaveNoViolations); @@ -38,18 +39,29 @@ const defaultChannelState = { const t = jest.fn((key) => key); -const renderComponentBase = ({ channel, client, props }) => +const renderComponentBase = ({ channel, chatViewProps, client, props }) => render( - + {chatViewProps ? ( + + + + ) : ( + + )} , ); -async function renderComponent({ channelData, channelType = 'messaging', props } = {}) { +async function renderComponent({ + channelData, + channelType = 'messaging', + chatViewProps, + props, +} = {}) { client = await getTestClientWithUser(user1); testChannel1 = generateChannel({ ...defaultChannelState, channel: channelData }); /* eslint-disable-next-line react-hooks/rules-of-hooks */ @@ -57,7 +69,7 @@ async function renderComponent({ channelData, channelType = 'messaging', props } const channel = client.channel(channelType, testChannel1.id, channelData); await channel.query(); - return renderComponentBase({ channel, client, props }); + return renderComponentBase({ channel, chatViewProps, client, props }); } afterEach(cleanup); @@ -429,4 +441,57 @@ describe('ChannelHeader', () => { }); }); }); + + it('should toggle entity list pane via ChatView controller when sidebarCollapsed is uncontrolled', async () => { + const layoutController = createLayoutController({ + initialState: { + entityListPaneOpen: true, + visibleSlots: ['slot1'], + }, + }); + + await renderComponent({ + chatViewProps: { + layoutController, + }, + }); + + expect(screen.getByRole('button', { name: 'aria/Menu' })).toBeInTheDocument(); + act(() => { + screen.getByRole('button', { name: 'aria/Menu' }).click(); + }); + + await waitFor(() => + expect(layoutController.state.getLatestValue().entityListPaneOpen).toBe(false), + ); + expect( + screen.getByRole('button', { name: 'aria/Expand sidebar' }), + ).toBeInTheDocument(); + }); + + it('should prioritize onSidebarToggle over ChatView controller toggle', async () => { + const onSidebarToggle = jest.fn(); + const layoutController = createLayoutController({ + initialState: { + entityListPaneOpen: true, + visibleSlots: ['slot1'], + }, + }); + + await renderComponent({ + chatViewProps: { + layoutController, + }, + props: { + onSidebarToggle, + }, + }); + + act(() => { + screen.getByRole('button', { name: 'aria/Menu' }).click(); + }); + + expect(onSidebarToggle).toHaveBeenCalledTimes(1); + expect(layoutController.state.getLatestValue().entityListPaneOpen).toBe(true); + }); }); diff --git a/src/components/ChatView/__tests__/ChatView.test.tsx b/src/components/ChatView/__tests__/ChatView.test.tsx new file mode 100644 index 0000000000..ac4bc4b339 --- /dev/null +++ b/src/components/ChatView/__tests__/ChatView.test.tsx @@ -0,0 +1,147 @@ +import React, { useEffect } from 'react'; +import { StateStore } from 'stream-chat'; +import { fireEvent, render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +jest.mock('../../ChannelList', () => ({ + ChannelList: () =>
ChannelList
, +})); + +jest.mock('../../Threads/ThreadList', () => ({ + ThreadList: () =>
ThreadList
, +})); + +import { ChatView, useChatViewContext } from '../ChatView'; + +import { ChatProvider } from '../../../context/ChatContext'; +import { TranslationProvider } from '../../../context/TranslationContext'; +import { createLayoutController } from '../layoutController/LayoutController'; + +import type { Channel as StreamChannel } from 'stream-chat'; +import type { ChatContextValue } from '../../../context/ChatContext'; +import type { LayoutController } from '../layoutController/layoutControllerTypes'; + +const makeChannel = (cid: string) => ({ cid }) as unknown as StreamChannel; + +const createChatContextValue = (): ChatContextValue => + ({ + channelsQueryState: { + error: null, + queryInProgress: null, + setError: jest.fn(), + setQueryInProgress: jest.fn(), + }, + client: { + threads: { + state: new StateStore({ + unreadThreadCount: 0, + }), + }, + }, + closeMobileNav: jest.fn(), + getAppSettings: jest.fn(() => null), + latestMessageDatesByChannels: {}, + mutes: [], + openMobileNav: jest.fn(), + searchController: {}, + setActiveChannel: jest.fn(), + theme: 'str-chat__theme-light', + useImageFlagEmojisOnWindows: false, + }) as unknown as ChatContextValue; + +const renderWithProviders = (ui: React.ReactNode) => + render( + + key, userLanguage: 'en' }}> + {ui} + + , + ); + +describe('ChatView', () => { + it('switches from threads to channels and opens channel in slot via layoutController', () => { + const channel = makeChannel('messaging:target'); + let capturedController: LayoutController | undefined; + + const ViewReplyInChannelAction = () => { + const { activeView, layoutController } = useChatViewContext(); + capturedController = layoutController; + + return ( + <> +
{activeView}
+ + + + ); + }; + + renderWithProviders( + + + , + ); + + fireEvent.click(screen.getByText('enter-threads')); + expect(screen.getByTestId('active-view')).toHaveTextContent('threads'); + + fireEvent.click(screen.getByText('view-in-channel')); + expect(screen.getByTestId('active-view')).toHaveTextContent('channels'); + expect(capturedController?.state.getLatestValue().slotBindings.slot1?.kind).toBe( + 'channel', + ); + }); + + it('renders built-in workspace layout with slotRenderers', () => { + const channel = makeChannel('messaging:workspace'); + const layoutController = createLayoutController({ + initialState: { + visibleSlots: ['slot1'], + }, + }); + layoutController.openChannel(channel); + + renderWithProviders( +
{source.cid}
, + }} + />, + ); + + expect(screen.getByTestId('channel-list')).toBeInTheDocument(); + expect(screen.getByTestId('channel-slot')).toHaveTextContent('messaging:workspace'); + }); + + it('preserves custom children layout when built-in layout is not set', () => { + const Child = () => { + const { layoutController } = useChatViewContext(); + + useEffect(() => { + layoutController.openChannel(makeChannel('messaging:custom')); + }, [layoutController]); + + return
custom-layout
; + }; + + renderWithProviders( + + + , + ); + + expect(screen.getByTestId('custom-layout')).toBeInTheDocument(); + }); +}); diff --git a/src/components/ChatView/__tests__/layoutController.test.ts b/src/components/ChatView/__tests__/layoutController.test.ts new file mode 100644 index 0000000000..1d1f7bf005 --- /dev/null +++ b/src/components/ChatView/__tests__/layoutController.test.ts @@ -0,0 +1,140 @@ +import { createLayoutController } from '../layoutController/LayoutController'; +import { resolveTargetSlotChannelDefault } from '../layoutSlotResolvers'; + +import type { Channel as StreamChannel, Thread as StreamThread } from 'stream-chat'; +import type { ChatViewLayoutState } from '../layoutController/layoutControllerTypes'; + +const makeChannel = (cid: string) => ({ cid }) as unknown as StreamChannel; +const makeThread = (id: string) => ({ id }) as unknown as StreamThread; + +describe('layoutController', () => { + it('returns opened, replaced, and rejected outcomes from open()', () => { + const controller = createLayoutController({ + initialState: { + visibleSlots: ['slot1'], + }, + resolveTargetSlot: () => 'slot1', + }); + + const firstOpen = controller.openChannel(makeChannel('messaging:one')); + const secondOpen = controller.openChannel(makeChannel('messaging:two')); + controller.clear('slot1'); + const rejectedOpen = controller.openChannel(makeChannel('messaging:three')); + + expect(firstOpen).toMatchObject({ slot: 'slot1', status: 'opened' }); + expect(secondOpen).toMatchObject({ slot: 'slot1', status: 'replaced' }); + expect(rejectedOpen).toMatchObject({ + reason: 'no-available-slot', + status: 'rejected', + }); + }); + + it('tracks occupiedAt when slot becomes occupied and clears it on clear()', () => { + const controller = createLayoutController({ + initialState: { + visibleSlots: ['slot1'], + }, + }); + + controller.openChannel(makeChannel('messaging:one')); + const occupiedAt = controller.state.getLatestValue().slotMeta.slot1?.occupiedAt; + controller.clear('slot1'); + + expect(typeof occupiedAt).toBe('number'); + expect(controller.state.getLatestValue().slotMeta.slot1).toBeUndefined(); + }); + + it('supports duplicateEntityPolicy reject and move', () => { + const rejectController = createLayoutController({ + duplicateEntityPolicy: 'reject', + initialState: { visibleSlots: ['slot1', 'slot2'] }, + resolveTargetSlot: () => 'slot2', + }); + const duplicateChannel = makeChannel('messaging:duplicate'); + + rejectController.openChannel(duplicateChannel, { targetSlot: 'slot1' }); + const rejectResult = rejectController.openChannel(duplicateChannel, { + targetSlot: 'slot2', + }); + + expect(rejectResult).toMatchObject({ + reason: 'duplicate-entity', + status: 'rejected', + }); + + const moveController = createLayoutController({ + duplicateEntityPolicy: 'move', + initialState: { visibleSlots: ['slot1', 'slot2'] }, + }); + + moveController.openChannel(makeChannel('messaging:one'), { targetSlot: 'slot1' }); + moveController.openChannel(makeChannel('messaging:two'), { targetSlot: 'slot2' }); + moveController.openChannel(makeChannel('messaging:one'), { targetSlot: 'slot2' }); + + const movedState = moveController.state.getLatestValue(); + expect(movedState.slotBindings.slot1).toBeUndefined(); + expect(movedState.slotBindings.slot2?.kind).toBe('channel'); + expect((movedState.slotBindings.slot2?.source as StreamChannel).cid).toBe( + 'messaging:one', + ); + }); +}); + +describe('resolveTargetSlotChannelDefault', () => { + const makeState = (overrides: Partial): ChatViewLayoutState => ({ + activeSlot: undefined, + activeView: 'channels', + entityListPaneOpen: true, + mode: 'default', + slotBindings: {}, + slotMeta: {}, + visibleSlots: ['slot1', 'slot2'], + ...overrides, + }); + + it('prefers requestedSlot when provided', () => { + const slot = resolveTargetSlotChannelDefault({ + entity: { kind: 'channel', source: makeChannel('messaging:one') }, + requestedSlot: 'slot2', + state: makeState({}), + }); + + expect(slot).toBe('slot2'); + }); + + it('replaces thread slot first when opening a channel into a full workspace', () => { + const state = makeState({ + slotBindings: { + slot1: { kind: 'channel', source: makeChannel('messaging:one') }, + slot2: { kind: 'thread', source: makeThread('thread-1') }, + }, + }); + + const slot = resolveTargetSlotChannelDefault({ + entity: { kind: 'channel', source: makeChannel('messaging:two') }, + state, + }); + + expect(slot).toBe('slot2'); + }); + + it('falls back to earliest occupied slot when only channels are present', () => { + const state = makeState({ + slotBindings: { + slot1: { kind: 'channel', source: makeChannel('messaging:one') }, + slot2: { kind: 'channel', source: makeChannel('messaging:two') }, + }, + slotMeta: { + slot1: { occupiedAt: 10 }, + slot2: { occupiedAt: 20 }, + }, + }); + + const slot = resolveTargetSlotChannelDefault({ + entity: { kind: 'channel', source: makeChannel('messaging:three') }, + state, + }); + + expect(slot).toBe('slot1'); + }); +}); diff --git a/src/components/ChatView/specs/layoutController/decisions.md b/src/components/ChatView/specs/layoutController/decisions.md index 6e2f0c2e00..e8e1b3198f 100644 --- a/src/components/ChatView/specs/layoutController/decisions.md +++ b/src/components/ChatView/specs/layoutController/decisions.md @@ -145,3 +145,27 @@ This provides the requested low-friction two-step integration while preserving t **Tradeoffs / Consequences:** Built-in mode uses default `ChannelList`/`ThreadList` props; deeper pane customization remains available through custom layout mode until dedicated built-in pane configuration is introduced. + +## Decision: Add Task 6 coverage across controller, resolver, ChatView integration, and ChannelHeader toggle behavior + +**Date:** 2026-02-26 +**Context:** +Task 6 requires tests for resolver behavior, controller `open` outcomes/`occupiedAt`, thread-to-channel integration flow, and ChannelHeader entity list pane toggling. + +**Decision:** +Add: + +- `src/components/ChatView/__tests__/layoutController.test.ts` for controller open statuses (`opened`/`replaced`/`rejected`), `occupiedAt` lifecycle, duplicate policies (`reject`/`move`), and `resolveTargetSlotChannelDefault` replacement fallbacks. +- `src/components/ChatView/__tests__/ChatView.test.tsx` for integration coverage of switching `activeView` from threads to channels while opening a channel, plus built-in workspace mode rendering with `slotRenderers` and custom children mode preservation. +- new ChannelHeader tests in `src/components/ChannelHeader/__tests__/ChannelHeader.test.js` to assert default ChatView-driven entity pane toggle and `onSidebarToggle` precedence. + +**Reasoning:** +This directly maps to Task 6 acceptance criteria while keeping tests in module-local `__tests__` folders and reusing existing repository test patterns. + +**Alternatives considered:** + +- Add only controller unit tests and defer integration/header coverage — rejected because Task 6 explicitly requires both integration and toggle behavior checks. +- Add integration tests only to story-level/e2e suites — rejected because Task 6 scope is unit/integration tests in component modules. + +**Tradeoffs / Consequences:** +In this local environment, executing Jest is blocked by missing runtime dependency (`@babel/runtime/helpers/interopRequireDefault`) from linked `stream-chat-js`; typecheck passes, and full Jest verification should be rerun once dependency linkage is fixed. diff --git a/src/components/ChatView/specs/layoutController/plan.md b/src/components/ChatView/specs/layoutController/plan.md index 5b0df9c229..df4a318f38 100644 --- a/src/components/ChatView/specs/layoutController/plan.md +++ b/src/components/ChatView/specs/layoutController/plan.md @@ -137,9 +137,9 @@ Primary spec for this plan: **Dependencies:** Task 2, Task 3, Task 4, Task 5 -**Status:** pending +**Status:** done -**Owner:** unassigned +**Owner:** codex **Scope:** @@ -150,8 +150,8 @@ Primary spec for this plan: **Acceptance Criteria:** -- [ ] New tests cover resolver defaults and replacement scenarios. -- [ ] Tests verify thread/channel switching and entity list pane toggling. +- [x] New tests cover resolver defaults and replacement scenarios. +- [x] Tests verify thread/channel switching and entity list pane toggling. - [ ] No regression in existing ChatView/ChannelHeader tests. ## Task 7: Docs and Spec Alignment diff --git a/src/components/ChatView/specs/layoutController/state.json b/src/components/ChatView/specs/layoutController/state.json index 2e3ee7a4dd..ea7789691b 100644 --- a/src/components/ChatView/specs/layoutController/state.json +++ b/src/components/ChatView/specs/layoutController/state.json @@ -5,7 +5,7 @@ "task-3-chatview-integration-context-and-props": "done", "task-4-header-toggle-wiring-for-entity-list-pane": "done", "task-5-built-in-two-step-dx-layout-api": "done", - "task-6-tests-for-controller-resolvers-and-integration": "pending", + "task-6-tests-for-controller-resolvers-and-integration": "done", "task-7-docs-and-spec-alignment": "pending" }, "flags": { From 28f224adea30a1f93194ca3c49c851ae130830ec Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 27 Feb 2026 10:44:27 +0100 Subject: [PATCH 06/32] chore(LayoutController): add new requirements to layout control API --- .../specs/layoutController/decisions.md | 27 ++ .../ChatView/specs/layoutController/plan.md | 210 +++++++++++- .../ChatView/specs/layoutController/spec.md | 313 +++++++++--------- .../specs/layoutController/state.json | 11 +- 4 files changed, 393 insertions(+), 168 deletions(-) diff --git a/src/components/ChatView/specs/layoutController/decisions.md b/src/components/ChatView/specs/layoutController/decisions.md index e8e1b3198f..c25476729a 100644 --- a/src/components/ChatView/specs/layoutController/decisions.md +++ b/src/components/ChatView/specs/layoutController/decisions.md @@ -40,6 +40,33 @@ This keeps the plan memory synchronized with actual repository state and prevent **Tradeoffs / Consequences:** Follow-up tasks should treat Task 1 APIs as the baseline and only refine via explicit plan updates. +## Decision: Adopt unified slot navigation model for next implementation phase + +**Date:** 2026-02-27 +**Context:** +New requirements were introduced after the initial controller implementation to support one-slot mobile back navigation, unified slot treatment for channel list/search, min-slot fallbacks, mount-preserving hide/unhide, deep-link restore, and a less intimidating DX API. + +**Decision:** +Evolve the spec and plan with a unified slot model: + +- add per-slot parent stacks, +- model `channelList` as an entity slot (no dedicated entity-list-pane state), +- support `minSlots` with fallback content, +- introduce a mount-preserving `Slot` primitive for hide/unhide, +- add `openView` and serializer/restore contract, +- move high-level domain methods (`openChannel`, `openThread`, etc.) into `useChatViewNavigation()` and keep `LayoutController` low-level. + +**Reasoning:** +This design cleanly handles mobile one-slot navigation, avoids divergent list-pane semantics, improves deep-link behavior, and makes common integration paths easier without removing advanced low-level control. + +**Alternatives considered:** + +- Keep current entity-list-pane as a special layout region and only patch back behavior — rejected because it still blocks list replacement in the same slot and creates split semantics. +- Keep all high-level methods on `LayoutController` — rejected because it keeps DX intimidating and mixes domain-level workflow into low-level layout primitives. + +**Tradeoffs / Consequences:** +The implementation requires a second phase touching controller types, ChatView contexts, ChannelHeader behavior, and tests. Existing APIs remain usable during migration but should converge on the new navigation hook model. + ## Decision: Implement resolver composition as pure slot resolvers **Date:** 2026-02-26 diff --git a/src/components/ChatView/specs/layoutController/plan.md b/src/components/ChatView/specs/layoutController/plan.md index df4a318f38..015f8ad871 100644 --- a/src/components/ChatView/specs/layoutController/plan.md +++ b/src/components/ChatView/specs/layoutController/plan.md @@ -33,7 +33,7 @@ Primary spec for this plan: - Define `LayoutEntityBinding`, `ChatViewLayoutState`, `ResolveTargetSlotArgs`, `OpenResult`. - Implement `createLayoutController` with `state: StateStore`. -- Implement commands: `setActiveView`, `setMode`, `bind`, `clear`, `open`, `openChannel`, `openThread`, `openMemberList`, `openUserList`, `openPinnedMessagesList`, `toggleEntityListPane`, `setEntityListPaneOpen`. +- Implement commands: `setActiveView`, `setMode`, `bind`, `clear`, `open`, and initial high-level helpers. - Enforce `occupiedAt` invariant when occupying/clearing slots. - Implement duplicate entity handling (`duplicateEntityPolicy`, `resolveDuplicateEntity`) and result semantics. @@ -89,7 +89,7 @@ Primary spec for this plan: - [x] New props and context are typed/exported. - [x] `str-chat__chat-view` behavior remains stable for existing layouts. -## Task 4: Header Toggle Wiring for Entity List Pane +## Task 4: Header Toggle Wiring **File(s) to create/modify:** `src/components/ChannelHeader/ChannelHeader.tsx` @@ -101,13 +101,13 @@ Primary spec for this plan: **Scope:** -- Update header toggle button behavior to call ChatView controller (`toggleEntityListPane`) by default. +- Update header toggle button behavior to call ChatView layout actions by default. - Keep external override behavior (`onSidebarToggle`) intact. -- Ensure `sidebarCollapsed` derives from `entityListPaneOpen` when not controlled. +- Ensure collapsed state derives from ChatView layout state when not controlled. **Acceptance Criteria:** -- [x] Header toggle hides/shows entity list pane via ChatView state. +- [x] Header toggle hides/shows list area via ChatView state. - [x] Override prop still takes precedence when provided. ## Task 5: Built-in Two-Step DX Layout API @@ -146,12 +146,12 @@ Primary spec for this plan: - Add unit tests for resolver chain and duplicate policies. - Add controller tests for `open` outcomes and `occupiedAt`. - Add integration tests for switching from threads view to channel via annotation action path. -- Add header toggle tests for `entityListPaneOpen`. +- Add header toggle tests for list visibility state. **Acceptance Criteria:** - [x] New tests cover resolver defaults and replacement scenarios. -- [x] Tests verify thread/channel switching and entity list pane toggling. +- [x] Tests verify thread/channel switching and list visibility toggling. - [ ] No regression in existing ChatView/ChannelHeader tests. ## Task 7: Docs and Spec Alignment @@ -175,6 +175,155 @@ Primary spec for this plan: - [ ] Spec reflects implemented API exactly. - [ ] Examples compile logically against final exported types. +## Task 8: Slot Parent Stack and Back Navigation + +**File(s) to create/modify:** `src/components/ChatView/layoutController/LayoutController.ts`, `src/components/ChatView/layoutController/layoutControllerTypes.ts`, `src/components/ChannelHeader/ChannelHeader.tsx` + +**Dependencies:** Task 3 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Add per-slot parent stack (`slotHistory`) to support back navigation within a single slot. +- Add low-level controller commands for stack management (`pushParent`, `popParent`) and back-aware close behavior. +- Update header affordance logic to prefer back arrow when current slot has parents. + +**Acceptance Criteria:** + +- [ ] One-slot flow `channelList -> channel -> thread` can pop back deterministically. +- [ ] Header icon/action switches between back and list-toggle semantics using slot history. + +## Task 9: Unify ChannelList into Slot Model + +**File(s) to create/modify:** `src/components/ChatView/layoutController/layoutControllerTypes.ts`, `src/components/ChatView/ChatView.tsx`, `src/components/ChatView/layout/WorkspaceLayout.tsx`, `src/components/ChannelHeader/ChannelHeader.tsx` + +**Dependencies:** Task 8 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Add `channelList` entity kind and treat list panes as regular slots. +- Remove dedicated `entityListPane` state/commands from ChatView layout model. +- Enable replacing `channelList` with alternative entities (e.g. search results) in the same slot. + +**Acceptance Criteria:** + +- [ ] Channel list can be opened/closed/replaced via slot binding APIs. +- [ ] No layout code path depends on legacy `entityListPaneOpen`. + +## Task 10: Min Slots and Fallback Workspace States + +**File(s) to create/modify:** `src/components/ChatView/ChatView.tsx`, `src/components/ChatView/layout/WorkspaceLayout.tsx`, `src/components/ChatView/layoutController/layoutControllerTypes.ts` + +**Dependencies:** Task 9 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Add `minSlots` support in ChatView layout initialization and rendering. +- Add per-slot fallback rendering for unbound slots (e.g. empty channel workspace prompt). +- Keep `maxSlots` behavior for upper bound slot availability. + +**Acceptance Criteria:** + +- [ ] `minSlots={2}` can render `channelList + empty workspace` before channel selection. +- [ ] Fallback content disappears when slot receives entity binding and reappears when cleared. + +## Task 11: Generic Slot Component with Mount-Preserving Hide/Unhide + +**File(s) to create/modify:** `src/components/ChatView/layout/Slot.tsx` (new), `src/components/ChatView/styling/` (SCSS updates), `src/components/ChatView/layout/WorkspaceLayout.tsx` + +**Dependencies:** Task 10 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Introduce generic `Slot` component that applies hidden/visible classes at root level. +- Hide slots with CSS while keeping subtree mounted. +- Wire slot visibility state into controller (`hiddenSlots` / `setSlotHidden`). + +**Acceptance Criteria:** + +- [ ] Hidden slots remain mounted (no pagination re-initialization). +- [ ] Slot visibility is controllable via layout state and reflected in CSS class contract. + +## Task 12: Deep-Linking, Serialization, and openView + +**File(s) to create/modify:** `src/components/ChatView/layoutController/LayoutController.ts`, `src/components/ChatView/layoutController/layoutControllerTypes.ts`, `src/components/ChatView/layoutController/serialization.ts` (new) + +**Dependencies:** Task 10 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Add `openView` command to controller/navigation flow. +- Define serializable layout snapshot format including active view, slot bindings, hidden slots, and parent stacks. +- Add restore helpers that rebind entities safely and skip unresolved keys. + +**Acceptance Criteria:** + +- [ ] View-first deep links (`openView` then entity opens) are supported. +- [ ] Layout snapshot round-trip preserves slot stack and visibility semantics. + +## Task 13: High-Level Navigation Hook and Context Split + +**File(s) to create/modify:** `src/components/ChatView/ChatView.tsx`, `src/components/ChatView/ChatViewNavigationContext.tsx` (new), `src/components/ChatView/index.tsx`, `src/components/ChannelHeader/ChannelHeader.tsx` + +**Dependencies:** Task 12 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Create `useChatViewNavigation()` with domain actions (`openChannel`, `closeChannel`, `openThread`, `closeThread`, `hideChannelList`, `unhideChannelList`, `openView`). +- Remove high-level domain methods from `LayoutController` API surface. +- Keep low-level `LayoutController` available for advanced/custom workflows. + +**Acceptance Criteria:** + +- [ ] Consumer DX path uses `useChatViewNavigation()` without direct low-level controller usage. +- [ ] Existing advanced integrations can still use low-level controller methods (`open`, `bind`, `clear`, etc.). + +## Task 14: Tests for Slot Stack, Unified Slots, and Navigation DX + +**File(s) to create/modify:** `src/components/ChatView/__tests__/layoutController.test.ts`, `src/components/ChatView/__tests__/ChatView.test.tsx`, `src/components/ChannelHeader/__tests__/ChannelHeader.test.js`, `src/components/ChatView/__tests__/ChatViewNavigation.test.tsx` (new) + +**Dependencies:** Task 8, Task 9, Task 10, Task 11, Task 12, Task 13 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Add tests for per-slot back stack behavior and header icon switching. +- Add tests for `channelList` as slot, min-slot fallback rendering, and mount-preserving hide/unhide. +- Add tests for `openView` and serialization restore flows. +- Add tests for high-level navigation hook behavior and compatibility. + +**Acceptance Criteria:** + +- [ ] One-slot back-stack scenarios are covered. +- [ ] Deep-link serialization/deserialization and `openView` are covered. +- [ ] No regression in ChatView/ChannelHeader behavior with new slot model. + ## Execution order Phase 1 (Parallel): @@ -202,14 +351,43 @@ Phase 6 (After Tasks 5, 6): - Task 7: Docs and Spec Alignment +Phase 7 (After Task 3): + +- Task 8: Slot Parent Stack and Back Navigation + +Phase 8 (After Task 8): + +- Task 9: Unify ChannelList into Slot Model +- Task 10: Min Slots and Fallback Workspace States + +Phase 9 (After Task 10): + +- Task 11: Generic Slot Component with Mount-Preserving Hide/Unhide +- Task 12: Deep-Linking, Serialization, and openView + +Phase 10 (After Task 12): + +- Task 13: High-Level Navigation Hook and Context Split + +Phase 11 (After Tasks 8-13): + +- Task 14: Tests for Slot Stack, Unified Slots, and Navigation DX + ## File Ownership Summary -| Task | Creates/Modifies | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | `src/components/ChatView/layoutController/LayoutController.ts`, `src/components/ChatView/layoutController/layoutControllerTypes.ts` | -| 2 | `layoutSlotResolvers.ts` | -| 3 | `ChatView.tsx`, `index.tsx` | -| 4 | `ChannelHeader.tsx` | -| 5 | `ChatView.tsx`, `layout/WorkspaceLayout.tsx` | -| 6 | `ChatView/__tests__/layoutController.test.ts`, `ChatView/__tests__/ChatView.test.tsx`, `ChannelHeader/__tests__/ChannelHeader.test.js` | -| 7 | `src/components/ChatView/specs/layoutController/spec.md`, `src/components/ChatView/specs/layoutController/plan.md` | +| Task | Creates/Modifies | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `src/components/ChatView/layoutController/LayoutController.ts`, `src/components/ChatView/layoutController/layoutControllerTypes.ts` | +| 2 | `layoutSlotResolvers.ts` | +| 3 | `ChatView.tsx`, `index.tsx` | +| 4 | `ChannelHeader.tsx` | +| 5 | `ChatView.tsx`, `layout/WorkspaceLayout.tsx` | +| 6 | `ChatView/__tests__/layoutController.test.ts`, `ChatView/__tests__/ChatView.test.tsx`, `ChannelHeader/__tests__/ChannelHeader.test.js` | +| 7 | `src/components/ChatView/specs/layoutController/spec.md`, `src/components/ChatView/specs/layoutController/plan.md` | +| 8 | `layoutController/LayoutController.ts`, `layoutController/layoutControllerTypes.ts`, `ChannelHeader.tsx` | +| 9 | `layoutController/layoutControllerTypes.ts`, `ChatView.tsx`, `layout/WorkspaceLayout.tsx`, `ChannelHeader.tsx` | +| 10 | `ChatView.tsx`, `layout/WorkspaceLayout.tsx`, `layoutController/layoutControllerTypes.ts` | +| 11 | `layout/Slot.tsx`, `ChatView/styling/*`, `layout/WorkspaceLayout.tsx` | +| 12 | `layoutController/LayoutController.ts`, `layoutController/layoutControllerTypes.ts`, `layoutController/serialization.ts` | +| 13 | `ChatView.tsx`, `ChatViewNavigationContext.tsx`, `index.tsx`, `ChannelHeader.tsx` | +| 14 | `ChatView/__tests__/layoutController.test.ts`, `ChatView/__tests__/ChatView.test.tsx`, `ChannelHeader/__tests__/ChannelHeader.test.js`, `ChatView/__tests__/ChatViewNavigation.test.tsx` | diff --git a/src/components/ChatView/specs/layoutController/spec.md b/src/components/ChatView/specs/layoutController/spec.md index bb966a4d03..40eb07b46d 100644 --- a/src/components/ChatView/specs/layoutController/spec.md +++ b/src/components/ChatView/specs/layoutController/spec.md @@ -13,36 +13,44 @@ We should move all layout orchestration to `ChatView` and expose it through a ty ## Proposal A: Callback-only API (minimal) -- Add `entityListPaneOpen`, `onEntityListPaneToggle`, `onViewChange`, and slot binding callbacks to `ChatView`. +- Add slot-level open/close/hide callbacks to `ChatView`. - Keep state controlled/uncontrolled with `default*` variants. - Pros: simple. -- Cons: scales poorly when adding more layout modes and slot types. +- Cons: scales poorly for parent-stack back navigation and deep-link restore. ## Proposal B: StateStore + commands API (reactive style) -- `ChatView` keeps internal `StateStore` state and exposes command functions through context. -- Commands stay generic: `toggleEntityListPane`, `setActiveView`, `setMode`, `bind`, `clear`, `open`. -- Pros: predictable transitions with reactive subscriptions. +- `ChatView` keeps internal `StateStore` state and exposes typed commands through context. +- Commands stay generic: `setActiveView`, `setMode`, `bind`, `clear`, `open`, `close`, `openView`. +- Pros: predictable transitions with reactive subscriptions and clear slot history transitions. - Cons: requires strict state shape discipline to avoid ad-hoc store growth. -## Proposal C (recommended): Generic LayoutController instance passed via ChatView props +## Proposal C (recommended): LayoutController + separate DX navigation API -- Add a new prop on `ChatView`, e.g. `layoutController?: LayoutController`. +- `ChatView` accepts `layoutController?: LayoutController`. - If omitted, `ChatView` creates a default controller internally. -- `ChatView` exposes the controller and derived layout state through context. +- `ChatView` also exposes a separate high-level navigation surface (`useChatViewNavigation`) for common actions: + - `openChannel`, `closeChannel` + - `openThread`, `closeThread` + - `hideChannelList`, `unhideChannelList` + - `openView` +- `LayoutController` remains low-level and domain-agnostic. Example API shape: ```ts type LayoutSlot = string; type LayoutMode = string; +type LayoutView = string; type LayoutEntityBinding = + | { kind: 'channelList'; source: ChannelListSource; key?: string } | { kind: 'channel'; source: StreamChannel; key?: string } | { kind: 'thread'; source: StreamThread; key?: string } | { kind: 'memberList'; source: StreamChannel; key?: string } | { kind: 'userList'; source: { query: string }; key?: string } - | { kind: 'pinnedMessagesList'; source: StreamChannel; key?: string }; + | { kind: 'pinnedMessagesList'; source: StreamChannel; key?: string } + | { kind: 'searchResults'; source: SearchResultsSource; key?: string }; type ResolveTargetSlotArgs = { state: ChatViewLayoutState; @@ -56,123 +64,66 @@ type OpenResult = | { status: 'replaced'; slot: LayoutSlot; replaced: LayoutEntityBinding } | { status: 'rejected'; reason: 'no-available-slot' }; +type SlotStackItem = { + entity: LayoutEntityBinding; + view?: LayoutView; +}; + type ChatViewLayoutState = { mode: LayoutMode; - activeView: 'channels' | 'threads'; - entityListPaneOpen: boolean; + activeView: LayoutView; + activeSlot?: LayoutSlot; visibleSlots: LayoutSlot[]; slotBindings: Record; slotMeta?: Record; + slotHistory: Record; + hiddenSlots: Record; + minSlots: number; + maxSlots: number; }; interface LayoutController { state: StateStore; - - toggleEntityListPane(): void; - setEntityListPaneOpen(next: boolean): void; - - setActiveView(next: 'channels' | 'threads'): void; + setActiveView(next: LayoutView): void; setMode(next: LayoutMode): void; - + openView( + view: LayoutView, + options?: { slot?: LayoutSlot; pushToHistory?: boolean }, + ): void; bind(slot: LayoutSlot, entity?: LayoutEntityBinding): void; clear(slot: LayoutSlot): void; - - // DX helper: app defines maxSlots, controller decides placement. - // `targetSlot`: explicit slot requested by caller (for example "open in right pane"). - // `activate`: when true, the chosen slot becomes activeSlot/focused pane after open. - // This matters for follow-up actions and replace-active resolver strategies. open( entity: LayoutEntityBinding, options?: { targetSlot?: LayoutSlot; activate?: boolean }, ): OpenResult; - - // High-level convenience API (preferred for most integrators). - openChannel( - channel: StreamChannel, - options?: { targetSlot?: LayoutSlot; activate?: boolean }, - ): OpenResult; - openThread( - thread: StreamThread, - options?: { targetSlot?: LayoutSlot; activate?: boolean }, - ): OpenResult; - openMemberList( - channel: StreamChannel, - options?: { targetSlot?: LayoutSlot; activate?: boolean }, - ): OpenResult; - openUserList( - source: { query: string }, - options?: { targetSlot?: LayoutSlot; activate?: boolean }, - ): OpenResult; - openPinnedMessagesList( - channel: StreamChannel, - options?: { targetSlot?: LayoutSlot; activate?: boolean }, - ): OpenResult; - - // Optional inferred API. Uses registered inferers to derive kind/key. - openEntity( - source: unknown, - options?: { targetSlot?: LayoutSlot; activate?: boolean }, - ): OpenResult; + close(slot: LayoutSlot, options?: { restoreFromHistory?: boolean }): void; + pushParent(slot: LayoutSlot, item: SlotStackItem): void; + popParent(slot: LayoutSlot): SlotStackItem | undefined; + setSlotHidden(slot: LayoutSlot, hidden: boolean): void; } -``` - -Notes: - -- `source` is the render-ready instance/object used by outlets. -- `key` is optional stable identity for deduplication and persistence. -- If app needs URL/session restore, it should provide serializer/deserializer outside the controller. -- When no slot is free, controller calls `resolveTargetSlot(args)` (if provided by integrator). -- Low-level `open(binding)` remains for advanced/custom entity types. -- Most integrators should use high-level helpers (`openChannel`, `openThread`, ...). -- `occupiedAt` invariant: whenever a slot transitions from empty -> occupied, controller must write - `slotMeta[slot].occupiedAt = Date.now()` (or equivalent monotonic timestamp). When a slot is - cleared, `slotMeta[slot]` should be removed/reset. This keeps earliest-slot strategies deterministic. -Suggested ChatView config for full-capacity behavior: - -```ts -type ChatViewProps = { - maxSlots: number; - resolveTargetSlot?: (args: ResolveTargetSlotArgs) => LayoutSlot | null; - duplicateEntityPolicy?: 'allow' | 'move' | 'reject'; - resolveDuplicateEntity?: (args: { - state: ChatViewLayoutState; - entity: LayoutEntityBinding; - existingSlot: LayoutSlot; - requestedSlot?: LayoutSlot; - activeSlot?: LayoutSlot; - }) => 'allow' | 'move' | 'reject'; - entityInferers?: Array<{ - kind: LayoutEntityBinding['kind']; - match: (source: unknown) => boolean; - toBinding: (source: unknown) => LayoutEntityBinding; - }>; +type ChatViewNavigation = { + openChannel(channel: StreamChannel, options?: { slot?: LayoutSlot }): OpenResult; + closeChannel(options?: { slot?: LayoutSlot }): void; + openThread(thread: StreamThread, options?: { slot?: LayoutSlot }): OpenResult; + closeThread(options?: { slot?: LayoutSlot }): void; + hideChannelList(options?: { slot?: LayoutSlot }): void; + unhideChannelList(options?: { slot?: LayoutSlot }): void; + openView(view: LayoutView, options?: { slot?: LayoutSlot }): void; }; ``` -Default behavior can be equivalent to `replace-active`. - -High-level method mapping: - -```ts -openChannel(channel, options) { - return open({ kind: 'channel', source: channel, key: channel.cid }, options); -} - -openThread(thread, options) { - return open({ kind: 'thread', source: thread, key: thread.id }, options); -} -``` - -Optional inferred API: +Notes: -```ts -openEntity(source, options) { - const inferer = entityInferers.find((i) => i.match(source)); - if (!inferer) throw new Error('No entity inferer matched source'); - return open(inferer.toBinding(source), options); -} -``` +- `ChannelList` is a first-class slot entity (`kind: 'channelList'`), not a separate pane concept. +- Parent stack is per-slot (`slotHistory`), enabling back navigation in one-slot mobile layouts. +- Header action semantics are state-driven: + - show back arrow when slot has parent history to pop + - show list hide/unhide toggle when history is empty +- `minSlots` guarantees placeholders/fallback panes can remain visible before entity selection. +- Hidden slots stay mounted (`hiddenSlots`) so pagination/stateful lists are not re-initialized. +- `openView` is part of controller and navigation APIs to support URL/deep-link and cross-view transitions. +- Deep-link serialization should include view + slot history metadata, not just current bindings. ## Resolver strategies and selection flow @@ -353,14 +304,15 @@ export const layoutSlotResolvers = { Most developers should only configure: -- `maxSlots` (for example `3`) +- `minSlots` and `maxSlots` - placement policy (`fill-empty-then-replace-last`, `replace-active`, etc.) +- initial entities (commonly `channelList` + fallback workspace slot) Example: ```tsx - - + + ``` @@ -372,14 +324,16 @@ To avoid requiring custom `DynamicSlotsLayout` + `SlotOutlet`, provide a built-i Step 1: declare top-level ChatView layout shell. -Step 2: provide slot binding render config (by `kind`). +Step 2: provide slot binding render config (by `kind`) and optional per-slot fallbacks. ```tsx , channel: ({ source }) => , thread: ({ source }) => ( @@ -389,6 +343,10 @@ Step 2: provide slot binding render config (by `kind`). memberList: ({ source }) => , userList: ({ source }) => , pinnedMessagesList: ({ source }) => , + searchResults: ({ source }) => , + }} + slotFallbacks={{ + workspace: , }} /> ``` @@ -405,12 +363,12 @@ Example (advanced mode): render `DynamicSlotsLayout` as `ChatView` children. ```tsx const ChatShell = () => (
-
@@ -512,41 +470,96 @@ bind(targetSlot, entity); ## ChatView context contract (with controller) - `useChatViewContext()` returns: - - `layoutController` - - convenience wrappers (`toggleEntityListPane`, `bind`, `clear`, `open`) for DX (optional) + - `layoutController` (low-level API) + - `navigation` from `useChatViewNavigation()` (high-level API) - Layout state is consumed through `layoutController.state` and `useStateStore(...)`. - `str-chat__header-sidebar-toggle` click behavior: - - default: `layoutController.toggleEntityListPane()` + - default: if current slot has parent history, pop parent (back) + - otherwise hide/unhide channel-list slot - optional override in `ChannelHeader` via `onSidebarToggle` -- `ChannelHeader` can derive collapsed state from context when prop is not provided: - - `sidebarCollapsed = !useStateStore(layoutController.state).entityListPaneOpen` +- `ChannelHeader` icon is derived from layout state: + - show back arrow when `slotHistory[currentSlot]?.length > 0` + - show sidebar/list icon when history is empty ## ChannelList and slot counting Recommended default: -- Use a composite left region: - - fixed `navRail` (view switcher: Channels/Threads/Settings) - - toggleable `entityListPane` (ChannelList/ThreadList based on `activeView`) -- Keep both outside dynamic content slots. -- `maxSlots` counts only dynamic content panes to the right. +- Treat `ChannelList` as a regular slot entity. +- Keep `navRail` outside slots (if present), but all list/content panes are slots. +- `minSlots` controls guaranteed visible slot count (including fallback-only slots). +- `maxSlots` controls upper bound of concurrently active slots. + +Example layout with `minSlots={2}`, `maxSlots={3}`: + +- `navRail | slot1(channelList) | slot2(channel-or-fallback) | slot3(optional)` + +One-slot mobile layout: + +- `minSlots={1}`, `maxSlots={1}` +- active slot transitions: `channelList -> channel -> thread` +- back action pops slot parent stack: `thread -> channel -> channelList` + +## Slot visibility and mount lifecycle + +To preserve pagination and long-lived list state, hiding a slot must not unmount it. + +Recommended primitive: + +```tsx +type SlotProps = { + slot: LayoutSlot; + keepMounted?: boolean; +}; + +const Slot = ({ slot, keepMounted = true }: SlotProps) => { + const { layoutController } = useChatViewContext(); + const { hiddenSlots } = useStateStore(layoutController.state); + const isHidden = !!hiddenSlots[slot]; -Example layout with `maxSlots={3}`: + return ( +
+ +
+ ); +}; +``` + +`str-chat__slot--hidden` should be CSS-hidden while still mounted. -- `navRail | entityListPane | slot1 | slot2 | slot3` +## Deep-linking and serialization -Optional advanced mode: +Navigation should be serializable with an SDK-level helper interface. -- Include `ChannelList` as a slot if it must be movable/replaceable/closable. -- In that mode, integrator may configure `maxSlots={4}` and reserve one slot for list UI. +```ts +type SerializedLayout = { + activeView: LayoutView; + activeSlot?: LayoutSlot; + slots: Array<{ + slot: LayoutSlot; + entity?: { key?: string; kind: LayoutEntityBinding['kind'] }; + hidden?: boolean; + parents?: Array<{ + key?: string; + kind: LayoutEntityBinding['kind']; + view?: LayoutView; + }>; + }>; +}; -`ChannelHeader` toggle behavior in this model: +interface LayoutSerializationAdapter { + serialize(state: ChatViewLayoutState): SerializedLayout; + restore(serialized: SerializedLayout): Promise; +} +``` -- Header button should toggle `entityListPaneOpen` (not `navRail`). -- `navRail` stays visible to preserve navigation. -- Reopened `entityListPane` renders list by current `activeView`: - - `activeView='channels'` -> ChannelList - - `activeView='threads'` -> ThreadList +`openView` is the canonical entry point for applying route/view changes from deep links before opening concrete entities. ## TSX example: dynamic 3-slot layout (any combination) @@ -690,10 +703,8 @@ layoutController.bind('slot3', { ```tsx const ChannelListItem = ({ channel }: { channel: StreamChannel }) => { - const { layoutController } = useChatViewContext(); - return ( - - ); + const { openChannel } = useChatViewNavigation(); + return ; }; ``` @@ -701,8 +712,8 @@ const ChannelListItem = ({ channel }: { channel: StreamChannel }) => { ```tsx const ThreadListItem = ({ thread }: { thread: StreamThread }) => { - const { layoutController } = useChatViewContext(); - return ; + const { openThread } = useChatViewNavigation(); + return ; }; ``` @@ -716,15 +727,15 @@ Scenario: - Expected outcome: - channel opens in a workspace slot (via resolver chain), - `activeView` switches to `'channels'`, - - entity list pane shows ChannelList when open. + - channel-list slot is restored/shown when required by view policy. ```tsx const ViewReplyInChannelAction = ({ channel }: { channel: StreamChannel }) => { - const { layoutController } = useChatViewContext(); + const { openChannel, openView } = useChatViewNavigation(); const onViewInChannel = () => { - layoutController.setActiveView('channels'); - layoutController.openChannel(channel, { activate: true }); + openView('channels'); + openChannel(channel, { slot: 'workspace' }); }; return ; @@ -757,7 +768,7 @@ Recommended flow: 1. Persist a compact snapshot: slot -> `entity.key` (+ optionally `kind`/`activeView`). 2. On restore, resolve each key back to a live instance (channel/thread/etc.). -3. Rebind resolved instances into slots using `layoutController.bind(...)`. +3. Rebind resolved instances into slots using `layoutController.bind(...)` and restore `slotHistory`. 4. Skip entries that cannot be resolved (deleted channel, revoked access, etc.). Example: @@ -782,17 +793,19 @@ if (slot1?.kind === 'channel') { ## Suggested migration path -1. Add controller API to `ChatView` context backed by `StateStore` (non-breaking, with default controller). -2. Change `ChannelHeader` toggle to consume `ChatView` controller, keep `onSidebarToggle` prop override. -3. Deprecate and remove `openMobileNav` from `ChatContext`. -4. Add simplified dynamic slot mode (`maxSlots`, placement policy, `open(entity)` with `source`), then migrate examples/e2e. -5. Add optional serializer/deserializer helpers for persistence. -6. Introduce typed adapters as optional advanced API. +1. Keep low-level `LayoutController` in `ChatView` context backed by `StateStore`. +2. Add per-slot parent stack and back-pop transitions (`pushParent`/`popParent`) for one-slot/mobile workflows. +3. Move `ChannelList` into slot model (`kind: 'channelList'`) and remove dedicated entity-list-pane semantics. +4. Add `minSlots` + fallback slot rendering for pre-selection states. +5. Add mount-preserving slot hiding (`hiddenSlots` + `Slot` CSS class contract). +6. Add `openView` and layout serialization adapter for deep-link/navigation restore. +7. Add `useChatViewNavigation()` and move high-level domain methods there. ## Summary - Recommended design: pass a generic `LayoutController` (or use default one) through `ChatView` and expose it in `ChatView` context. +- Keep `LayoutController` low-level and domain-agnostic; expose domain navigation via `useChatViewNavigation()`. - Bindings should carry source instances so outlets render directly without extra lookup. - Optional `key` handles deduplication and persistence. -- Simple path: developers set `maxSlots` and open entities; slots can host any type combination, including multiple channels. -- DX path: developers usually call high-level helpers (`openChannel`, `openThread`, ...) and avoid manual `kind` authoring. +- `ChannelList` is a slot entity, enabling one-slot mobile flows and list replacement (e.g. search results) in the same slot. +- `minSlots` and slot fallbacks provide stable empty-state layouts before entity selection. diff --git a/src/components/ChatView/specs/layoutController/state.json b/src/components/ChatView/specs/layoutController/state.json index ea7789691b..046ee5b509 100644 --- a/src/components/ChatView/specs/layoutController/state.json +++ b/src/components/ChatView/specs/layoutController/state.json @@ -6,14 +6,21 @@ "task-4-header-toggle-wiring-for-entity-list-pane": "done", "task-5-built-in-two-step-dx-layout-api": "done", "task-6-tests-for-controller-resolvers-and-integration": "done", - "task-7-docs-and-spec-alignment": "pending" + "task-7-docs-and-spec-alignment": "pending", + "task-8-slot-parent-stack-and-back-navigation": "pending", + "task-9-unify-channellist-into-slot-model": "pending", + "task-10-min-slots-and-fallback-workspace-states": "pending", + "task-11-generic-slot-component-with-mount-preserving-hide-unhide": "pending", + "task-12-deep-linking-serialization-and-openview": "pending", + "task-13-high-level-navigation-hook-and-context-split": "pending", + "task-14-tests-for-slot-stack-unified-slots-and-navigation-dx": "pending" }, "flags": { "blocked": false, "needs-review": false }, "meta": { - "last_updated": "2026-02-26", + "last_updated": "2026-02-27", "worktree": "../stream-chat-react-worktrees/chatview-layout-controller", "branch": "feat/chatview-layout-controller" } From 3c3d0cf09521b3a11c8d370cf77ff8f19b70b079 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 27 Feb 2026 11:01:00 +0100 Subject: [PATCH 07/32] feat(LayoutController): implement Slot Parent Stack and Back Navigation --- .../ChannelHeader/ChannelHeader.tsx | 37 +- .../layoutController/LayoutController.ts | 117 ++- .../layoutController/layoutControllerTypes.ts | 8 + .../specs/layoutController/decisions.md | 47 + .../ChatView/specs/layoutController/plan.md | 16 +- .../ChatView/specs/layoutController/spec.md | 866 +++--------------- .../specs/layoutController/state.json | 4 +- 7 files changed, 331 insertions(+), 764 deletions(-) diff --git a/src/components/ChannelHeader/ChannelHeader.tsx b/src/components/ChannelHeader/ChannelHeader.tsx index 72a352c09b..cb92f9a258 100644 --- a/src/components/ChannelHeader/ChannelHeader.tsx +++ b/src/components/ChannelHeader/ChannelHeader.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { IconLayoutAlignLeft } from '../Icons/icons'; +import { IconChevronLeft, IconLayoutAlignLeft } from '../Icons/icons'; import { Avatar as DefaultAvatar } from '../Avatar'; import { useChatViewContext } from '../ChatView'; import { useChannelHeaderOnlineStatus } from './hooks/useChannelHeaderOnlineStatus'; @@ -9,6 +9,7 @@ import { useChannelStateContext } from '../../context/ChannelStateContext'; import { useTranslationContext } from '../../context/TranslationContext'; import { useStateStore } from '../../store'; import type { ChannelAvatarProps } from '../Avatar'; +import type { ChatViewLayoutState } from '../ChatView/layoutController/layoutControllerTypes'; import { Button } from '../Button'; import clsx from 'clsx'; @@ -27,12 +28,14 @@ export type ChannelHeaderProps = { title?: string; }; -const entityListPaneOpenSelector = ({ +const channelHeaderLayoutSelector = ({ + activeSlot, entityListPaneOpen, -}: { - entityListPaneOpen: boolean; -}) => ({ + slotHistory, +}: ChatViewLayoutState) => ({ + activeSlot, entityListPaneOpen, + slotHistory, }); /** @@ -57,11 +60,23 @@ export const ChannelHeader = (props: ChannelHeaderProps) => { overrideTitle, }); const onlineStatusText = useChannelHeaderOnlineStatus(); - const { entityListPaneOpen } = - useStateStore(layoutController.state, entityListPaneOpenSelector) ?? - entityListPaneOpenSelector(layoutController.state.getLatestValue()); + const { activeSlot, entityListPaneOpen, slotHistory } = + useStateStore(layoutController.state, channelHeaderLayoutSelector) ?? + channelHeaderLayoutSelector(layoutController.state.getLatestValue()); + const hasParentHistory = !!(activeSlot && slotHistory?.[activeSlot]?.length); const sidebarCollapsed = sidebarCollapsedProp ?? !entityListPaneOpen; const handleSidebarToggle = onSidebarToggle ?? layoutController.toggleEntityListPane; + const handleHeaderAction = hasParentHistory + ? () => { + if (!activeSlot) return; + layoutController.close(activeSlot); + } + : handleSidebarToggle; + const actionAriaLabel = hasParentHistory + ? t('aria/Go back') + : sidebarCollapsed + ? t('aria/Expand sidebar') + : t('aria/Menu'); return (
{ >
{displayTitle}
diff --git a/src/components/ChatView/layoutController/LayoutController.ts b/src/components/ChatView/layoutController/LayoutController.ts index e69d52322c..81a268d350 100644 --- a/src/components/ChatView/layoutController/LayoutController.ts +++ b/src/components/ChatView/layoutController/LayoutController.ts @@ -18,6 +18,7 @@ const DEFAULT_LAYOUT_STATE: ChatViewLayoutState = { entityListPaneOpen: true, mode: 'default', slotBindings: {}, + slotHistory: {}, slotMeta: {}, visibleSlots: [], }; @@ -50,6 +51,10 @@ const buildInitialState = ( ...DEFAULT_LAYOUT_STATE.slotBindings, ...(partialState?.slotBindings ?? {}), }, + slotHistory: { + ...DEFAULT_LAYOUT_STATE.slotHistory, + ...(partialState?.slotHistory ?? {}), + }, slotMeta: { ...DEFAULT_LAYOUT_STATE.slotMeta, ...(partialState?.slotMeta ?? {}), @@ -57,6 +62,60 @@ const buildInitialState = ( visibleSlots: partialState?.visibleSlots ?? DEFAULT_LAYOUT_STATE.visibleSlots, }); +const isSameEntityBinding = ( + first: LayoutEntityBinding, + second: LayoutEntityBinding, +): boolean => { + const firstKey = resolveEntityKey(first); + const secondKey = resolveEntityKey(second); + + if (firstKey && secondKey) return firstKey === secondKey; + + return first === second; +}; + +const pushSlotHistory = ( + current: ChatViewLayoutState, + slot: LayoutSlot, + entity: LayoutEntityBinding, +): ChatViewLayoutState => { + const slotHistory = current.slotHistory?.[slot] ?? []; + + return { + ...current, + slotHistory: { + ...(current.slotHistory ?? {}), + [slot]: [...slotHistory, entity], + }, + }; +}; + +const popSlotHistory = ( + current: ChatViewLayoutState, + slot: LayoutSlot, +): { popped?: LayoutEntityBinding; state: ChatViewLayoutState } => { + const slotHistory = current.slotHistory?.[slot]; + if (!slotHistory?.length) return { state: current }; + + const popped = slotHistory[slotHistory.length - 1]; + const nextSlotHistory = { ...(current.slotHistory ?? {}) }; + const remainingHistory = slotHistory.slice(0, -1); + + if (remainingHistory.length) { + nextSlotHistory[slot] = remainingHistory; + } else { + delete nextSlotHistory[slot]; + } + + return { + popped, + state: { + ...current, + slotHistory: nextSlotHistory, + }, + }; +}; + const upsertSlotBinding = ( current: ChatViewLayoutState, slot: LayoutSlot, @@ -91,7 +150,13 @@ const clearSlotBinding = ( current: ChatViewLayoutState, slot: LayoutSlot, ): ChatViewLayoutState => { - if (!current.slotBindings[slot] && !current.slotMeta[slot]) return current; + if ( + !current.slotBindings[slot] && + !current.slotMeta[slot] && + !current.slotHistory?.[slot] + ) { + return current; + } const nextSlotBindings = { ...current.slotBindings }; delete nextSlotBindings[slot]; @@ -99,12 +164,16 @@ const clearSlotBinding = ( const nextSlotMeta = { ...current.slotMeta }; delete nextSlotMeta[slot]; + const nextSlotHistory = { ...(current.slotHistory ?? {}) }; + delete nextSlotHistory[slot]; + const nextActiveSlot = current.activeSlot === slot ? undefined : current.activeSlot; return { ...current, activeSlot: nextActiveSlot, slotBindings: nextSlotBindings, + slotHistory: nextSlotHistory, slotMeta: nextSlotMeta, }; }; @@ -188,6 +257,42 @@ export const createLayoutController = ( state.next((current) => clearSlotBinding(current, slot)); }; + const pushParent: LayoutController['pushParent'] = (slot, entity) => { + state.next((current) => pushSlotHistory(current, slot, entity)); + }; + + const popParent: LayoutController['popParent'] = (slot) => { + let popped: LayoutEntityBinding | undefined; + + state.next((current) => { + const result = popSlotHistory(current, slot); + popped = result.popped; + return result.state; + }); + + return popped; + }; + + const close: LayoutController['close'] = (slot, options) => { + const restoreFromHistory = options?.restoreFromHistory ?? true; + if (!restoreFromHistory) { + clear(slot); + return; + } + + state.next((current) => { + const { popped, state: nextState } = popSlotHistory(current, slot); + if (!popped) return clearSlotBinding(nextState, slot); + + const restored = upsertSlotBinding(nextState, slot, popped); + + return { + ...restored, + activeSlot: slot, + }; + }); + }; + const open: LayoutController['open'] = (entity, openOptions) => { const current = state.getLatestValue(); const targetSlot = resolveOpenTargetSlot( @@ -232,7 +337,12 @@ export const createLayoutController = ( const shouldActivateSlot = openOptions?.activate ?? true; state.next((nextState) => { - const next = upsertSlotBinding(nextState, targetSlot, entity); + const currentSlotBinding = nextState.slotBindings[targetSlot]; + const withHistory = + currentSlotBinding && !isSameEntityBinding(currentSlotBinding, entity) + ? pushSlotHistory(nextState, targetSlot, currentSlotBinding) + : nextState; + const next = upsertSlotBinding(withHistory, targetSlot, entity); if (!shouldActivateSlot) return next; @@ -312,12 +422,15 @@ export const createLayoutController = ( return { bind, clear, + close, open, openChannel, openMemberList, openPinnedMessagesList, openThread, openUserList, + popParent, + pushParent, setActiveView, setEntityListPaneOpen, setMode, diff --git a/src/components/ChatView/layoutController/layoutControllerTypes.ts b/src/components/ChatView/layoutController/layoutControllerTypes.ts index 805a4f22f2..02eae6b167 100644 --- a/src/components/ChatView/layoutController/layoutControllerTypes.ts +++ b/src/components/ChatView/layoutController/layoutControllerTypes.ts @@ -30,6 +30,7 @@ export type ChatViewLayoutState = { entityListPaneOpen: boolean; mode: LayoutMode; slotBindings: Record; + slotHistory?: Record; slotMeta: Record; visibleSlots: LayoutSlot[]; }; @@ -67,6 +68,10 @@ export type OpenOptions = { targetSlot?: LayoutSlot; }; +export type CloseOptions = { + restoreFromHistory?: boolean; +}; + export type CreateLayoutControllerOptions = { duplicateEntityPolicy?: DuplicateEntityPolicy; initialState?: Partial; @@ -78,12 +83,15 @@ export type CreateLayoutControllerOptions = { export type LayoutController = { bind: (slot: LayoutSlot, entity?: LayoutEntityBinding) => void; clear: (slot: LayoutSlot) => void; + close: (slot: LayoutSlot, options?: CloseOptions) => void; open: (entity: LayoutEntityBinding, options?: OpenOptions) => OpenResult; openChannel: (channel: StreamChannel, options?: OpenOptions) => OpenResult; openMemberList: (channel: StreamChannel, options?: OpenOptions) => OpenResult; openPinnedMessagesList: (channel: StreamChannel, options?: OpenOptions) => OpenResult; openThread: (thread: StreamThread, options?: OpenOptions) => OpenResult; openUserList: (source: UserListEntitySource, options?: OpenOptions) => OpenResult; + popParent: (slot: LayoutSlot) => LayoutEntityBinding | undefined; + pushParent: (slot: LayoutSlot, entity: LayoutEntityBinding) => void; setActiveView: (next: ChatView) => void; setEntityListPaneOpen: (next: boolean) => void; setMode: (next: LayoutMode) => void; diff --git a/src/components/ChatView/specs/layoutController/decisions.md b/src/components/ChatView/specs/layoutController/decisions.md index c25476729a..2bf10f601a 100644 --- a/src/components/ChatView/specs/layoutController/decisions.md +++ b/src/components/ChatView/specs/layoutController/decisions.md @@ -196,3 +196,50 @@ This directly maps to Task 6 acceptance criteria while keeping tests in module-l **Tradeoffs / Consequences:** In this local environment, executing Jest is blocked by missing runtime dependency (`@babel/runtime/helpers/interopRequireDefault`) from linked `stream-chat-js`; typecheck passes, and full Jest verification should be rerun once dependency linkage is fixed. + +## Decision: Align spec with currently implemented Task 1-6 API surface + +**Date:** 2026-02-27 +**Context:** +`spec.md` had drifted toward planned future tasks (`openView`, slot history, unified `channelList` slot entity, and `useChatViewNavigation`) that are not implemented yet. Task 7 requires spec-to-code alignment and migration guidance based on current exports. + +**Decision:** +Rewrite `spec.md` as an implementation snapshot for completed tasks only: + +- document current `LayoutController` contract (`bind`, `clear`, `open`, domain open helpers, `setActiveView`, `setMode`, `setEntityListPaneOpen`, `toggleEntityListPane`), +- document current state shape (`entityListPaneOpen`, `slotBindings`, `slotMeta`, `visibleSlots`), +- document current resolver registry and default chain, +- document built-in ChatView layout mode (`layout='nav-rail-entity-list-workspace'` + `slotRenderers`), +- add migration notes and low-level vs high-level usage examples, +- explicitly list deferred/future APIs as non-goals for this iteration. + +**Reasoning:** +Keeping the spec strictly aligned with shipped code avoids false integration assumptions while still preserving roadmap context. + +**Alternatives considered:** + +- Keep future API proposals inline as if implemented — rejected because it contradicts Task 7 acceptance criteria. +- Remove future references entirely — rejected because briefly flagging non-goals clarifies why some planned items are still pending. + +**Tradeoffs / Consequences:** +Spec consumers now get accurate implementation guidance, while future tasks (8+) remain documented as pending in `plan.md`. + +## Decision: Add per-slot parent history and header back-priority behavior in Task 8 + +**Date:** 2026-02-27 +**Context:** +Task 8 requires deterministic back navigation inside a slot and header behavior that prefers back when slot history exists. + +**Decision:** +Extend layout controller state with per-slot `slotHistory`, add low-level commands `pushParent`, `popParent`, and `close`, and make `open(...)` push replaced entities onto slot history before rebinding. Update `ChannelHeader` so the leading action uses `close(activeSlot)` (with back icon/label) whenever the active slot has parent history; otherwise it keeps existing list-toggle behavior (`onSidebarToggle` override first, then `toggleEntityListPane`). + +**Reasoning:** +Controller-managed history keeps navigation deterministic and domain-agnostic, while header logic can remain presentation-first and state-driven. + +**Alternatives considered:** + +- Track history only in ChannelHeader local/UI state — rejected because navigation state must survive outside header lifecycles. +- Add back logic only for threads in header — rejected because Task 8 requires generic per-slot stack behavior. + +**Tradeoffs / Consequences:** +`slotHistory` is optional at the type level for compatibility with existing typed state fixtures, but initialized and maintained by controller internals. Additional slot-model unification (`channelList` as slot entity) is deferred to Task 9. diff --git a/src/components/ChatView/specs/layoutController/plan.md b/src/components/ChatView/specs/layoutController/plan.md index 015f8ad871..4558947e0a 100644 --- a/src/components/ChatView/specs/layoutController/plan.md +++ b/src/components/ChatView/specs/layoutController/plan.md @@ -160,9 +160,9 @@ Primary spec for this plan: **Dependencies:** Task 5, Task 6 -**Status:** pending +**Status:** done -**Owner:** unassigned +**Owner:** codex **Scope:** @@ -172,8 +172,8 @@ Primary spec for this plan: **Acceptance Criteria:** -- [ ] Spec reflects implemented API exactly. -- [ ] Examples compile logically against final exported types. +- [x] Spec reflects implemented API exactly. +- [x] Examples compile logically against final exported types. ## Task 8: Slot Parent Stack and Back Navigation @@ -181,9 +181,9 @@ Primary spec for this plan: **Dependencies:** Task 3 -**Status:** pending +**Status:** done -**Owner:** unassigned +**Owner:** codex **Scope:** @@ -193,8 +193,8 @@ Primary spec for this plan: **Acceptance Criteria:** -- [ ] One-slot flow `channelList -> channel -> thread` can pop back deterministically. -- [ ] Header icon/action switches between back and list-toggle semantics using slot history. +- [x] One-slot flow `channelList -> channel -> thread` can pop back deterministically. +- [x] Header icon/action switches between back and list-toggle semantics using slot history. ## Task 9: Unify ChannelList into Slot Model diff --git a/src/components/ChatView/specs/layoutController/spec.md b/src/components/ChatView/specs/layoutController/spec.md index 40eb07b46d..a0d8ebeb8f 100644 --- a/src/components/ChatView/specs/layoutController/spec.md +++ b/src/components/ChatView/specs/layoutController/spec.md @@ -1,811 +1,195 @@ -# ChatView Layout Controller Spec (proposed) +# ChatView Layout Controller Spec -We should move all layout orchestration to `ChatView` and expose it through a typed controller-style API. `ChatContext` should no longer carry view-layout responsibilities like `openMobileNav`. +This document describes the currently implemented ChatView layout-controller API. -## Core principles +## Goals -- `ChatView` is the single source of truth for layout/view state. -- `ChannelHeader` is presentation-first; it triggers layout actions but does not own layout state. -- External apps can choose low-level control (controller API) or high-level defaults (built-in layout policy). -- The layout controller is domain-agnostic. It should not know channel/thread/list business rules. -- Default developer experience should be simple: define `maxSlots` and let slots host any entity combination. -- Bindings should carry the data source instance (not only `id`) so outlets can render directly. +- Keep ChatView as the source of truth for layout state. +- Provide a low-level controller API for advanced integrations. +- Provide an easier built-in layout path for common multi-slot usage. +- Keep existing `activeChatView` usage working during migration. -## Proposal A: Callback-only API (minimal) +## Exported API surface -- Add slot-level open/close/hide callbacks to `ChatView`. -- Keep state controlled/uncontrolled with `default*` variants. -- Pros: simple. -- Cons: scales poorly for parent-stack back navigation and deep-link restore. +`src/components/ChatView/index.tsx` exports: -## Proposal B: StateStore + commands API (reactive style) +- `ChatView` components/hooks/types +- `createLayoutController` +- layout controller types +- slot resolver registry and helpers -- `ChatView` keeps internal `StateStore` state and exposes typed commands through context. -- Commands stay generic: `setActiveView`, `setMode`, `bind`, `clear`, `open`, `close`, `openView`. -- Pros: predictable transitions with reactive subscriptions and clear slot history transitions. -- Cons: requires strict state shape discipline to avoid ad-hoc store growth. +## Core types -## Proposal C (recommended): LayoutController + separate DX navigation API - -- `ChatView` accepts `layoutController?: LayoutController`. -- If omitted, `ChatView` creates a default controller internally. -- `ChatView` also exposes a separate high-level navigation surface (`useChatViewNavigation`) for common actions: - - `openChannel`, `closeChannel` - - `openThread`, `closeThread` - - `hideChannelList`, `unhideChannelList` - - `openView` -- `LayoutController` remains low-level and domain-agnostic. - -Example API shape: +Implemented in `layoutControllerTypes.ts`. ```ts type LayoutSlot = string; type LayoutMode = string; -type LayoutView = string; type LayoutEntityBinding = - | { kind: 'channelList'; source: ChannelListSource; key?: string } - | { kind: 'channel'; source: StreamChannel; key?: string } - | { kind: 'thread'; source: StreamThread; key?: string } - | { kind: 'memberList'; source: StreamChannel; key?: string } - | { kind: 'userList'; source: { query: string }; key?: string } - | { kind: 'pinnedMessagesList'; source: StreamChannel; key?: string } - | { kind: 'searchResults'; source: SearchResultsSource; key?: string }; - -type ResolveTargetSlotArgs = { - state: ChatViewLayoutState; - entity: LayoutEntityBinding; - requestedSlot?: LayoutSlot; - activeSlot?: LayoutSlot; -}; - -type OpenResult = - | { status: 'opened'; slot: LayoutSlot } - | { status: 'replaced'; slot: LayoutSlot; replaced: LayoutEntityBinding } - | { status: 'rejected'; reason: 'no-available-slot' }; - -type SlotStackItem = { - entity: LayoutEntityBinding; - view?: LayoutView; -}; + | { key?: string; kind: 'channel'; source: StreamChannel } + | { key?: string; kind: 'thread'; source: StreamThread } + | { key?: string; kind: 'memberList'; source: StreamChannel } + | { key?: string; kind: 'userList'; source: { query: string } } + | { key?: string; kind: 'pinnedMessagesList'; source: StreamChannel }; type ChatViewLayoutState = { - mode: LayoutMode; - activeView: LayoutView; activeSlot?: LayoutSlot; - visibleSlots: LayoutSlot[]; + activeView: ChatView; + entityListPaneOpen: boolean; + mode: LayoutMode; slotBindings: Record; - slotMeta?: Record; - slotHistory: Record; - hiddenSlots: Record; - minSlots: number; - maxSlots: number; -}; - -interface LayoutController { - state: StateStore; - setActiveView(next: LayoutView): void; - setMode(next: LayoutMode): void; - openView( - view: LayoutView, - options?: { slot?: LayoutSlot; pushToHistory?: boolean }, - ): void; - bind(slot: LayoutSlot, entity?: LayoutEntityBinding): void; - clear(slot: LayoutSlot): void; - open( - entity: LayoutEntityBinding, - options?: { targetSlot?: LayoutSlot; activate?: boolean }, - ): OpenResult; - close(slot: LayoutSlot, options?: { restoreFromHistory?: boolean }): void; - pushParent(slot: LayoutSlot, item: SlotStackItem): void; - popParent(slot: LayoutSlot): SlotStackItem | undefined; - setSlotHidden(slot: LayoutSlot, hidden: boolean): void; -} - -type ChatViewNavigation = { - openChannel(channel: StreamChannel, options?: { slot?: LayoutSlot }): OpenResult; - closeChannel(options?: { slot?: LayoutSlot }): void; - openThread(thread: StreamThread, options?: { slot?: LayoutSlot }): OpenResult; - closeThread(options?: { slot?: LayoutSlot }): void; - hideChannelList(options?: { slot?: LayoutSlot }): void; - unhideChannelList(options?: { slot?: LayoutSlot }): void; - openView(view: LayoutView, options?: { slot?: LayoutSlot }): void; + slotMeta: Record; + visibleSlots: LayoutSlot[]; }; -``` - -Notes: - -- `ChannelList` is a first-class slot entity (`kind: 'channelList'`), not a separate pane concept. -- Parent stack is per-slot (`slotHistory`), enabling back navigation in one-slot mobile layouts. -- Header action semantics are state-driven: - - show back arrow when slot has parent history to pop - - show list hide/unhide toggle when history is empty -- `minSlots` guarantees placeholders/fallback panes can remain visible before entity selection. -- Hidden slots stay mounted (`hiddenSlots`) so pagination/stateful lists are not re-initialized. -- `openView` is part of controller and navigation APIs to support URL/deep-link and cross-view transitions. -- Deep-link serialization should include view + slot history metadata, not just current bindings. -## Resolver strategies and selection flow - -`layoutController.open(entity, options)` selection flow: - -1. If caller provided `targetSlot`, it is forwarded as `requestedSlot`. -2. If there is a free slot, use it. -3. If no free slot, call `resolveTargetSlot({ state, entity, requestedSlot, activeSlot })`. -4. If resolver returns a slot, replace there. -5. If resolver returns `null`, return `status: 'rejected'`. - -Resolver arg semantics: - -- `requestedSlot`: explicit target requested by caller (`open(..., { targetSlot })`). -- `activeSlot`: currently focused pane; maintained by controller and updated on pane focus/click or `activate: true`. - -Recommended built-in resolver registry: - -```ts -export const layoutSlotResolvers = { - replaceActive, - replaceLast, - rejectWhenFull, - resolveTargetSlotChannelDefault, -} as const; -``` - -What this means: - -- SDK exposes a central object with reusable slot-selection strategies. -- Integrators do not need to reimplement resolver logic for common cases. -- They can pick a predefined strategy directly, for example: - -```tsx - +type OpenResult = + | { slot: LayoutSlot; status: 'opened' } + | { replaced: LayoutEntityBinding; slot: LayoutSlot; status: 'replaced' } + | { reason: 'duplicate-entity' | 'no-available-slot'; status: 'rejected' }; ``` -Each function in this registry decides which slot to use when opening content and no free slot is available (or whenever resolver logic is needed). +## LayoutController contract -Core resolver examples: +Implemented by `createLayoutController(...)`. ```ts -const replaceActive = ({ activeSlot, state }: ResolveTargetSlotArgs) => - activeSlot ?? state.visibleSlots[state.visibleSlots.length - 1] ?? null; - -const replaceLast = ({ state }: ResolveTargetSlotArgs) => - state.visibleSlots[state.visibleSlots.length - 1] ?? null; - -const rejectWhenFull = () => null; -``` - -`resolveTargetSlotChannelDefault` strategy (recommended for channel-centric layouts): +type LayoutController = { + bind(slot: LayoutSlot, entity?: LayoutEntityBinding): void; + clear(slot: LayoutSlot): void; + open(entity: LayoutEntityBinding, options?: OpenOptions): OpenResult; -0. If `requestedSlot` is provided, return it and skip the resolution chain. -1. If there is a free slot, use it. -2. If opening a `thread` and no free slot, replace a slot currently occupied by a `thread`. -3. If opening a `channel` and no free slot, replace a slot currently occupied by a `thread`. -4. If no suitable thread slot exists and all occupied slots are channels, replace the earliest occupied slot. -5. Final fallback is `activeSlot` (then last visible slot). + openChannel(channel: StreamChannel, options?: OpenOptions): OpenResult; + openThread(thread: StreamThread, options?: OpenOptions): OpenResult; + openMemberList(channel: StreamChannel, options?: OpenOptions): OpenResult; + openUserList(source: { query: string }, options?: OpenOptions): OpenResult; + openPinnedMessagesList(channel: StreamChannel, options?: OpenOptions): OpenResult; -Example: + setActiveView(next: ChatView): void; + setMode(next: LayoutMode): void; + setEntityListPaneOpen(next: boolean): void; + toggleEntityListPane(): void; -```ts -const resolveTargetSlotChannelDefault = ({ - state, - entity, - requestedSlot, - activeSlot, -}: ResolveTargetSlotArgs): LayoutSlot | null => { - if (requestedSlot) return requestedSlot; - - const freeSlot = state.visibleSlots.find((slot) => !state.slotBindings[slot]); - const fallback = - activeSlot ?? state.visibleSlots[state.visibleSlots.length - 1] ?? null; - const threadOccupiedSlot = state.visibleSlots.find( - (slot) => state.slotBindings[slot]?.kind === 'thread', - ); - const earliest = state.visibleSlots - .map((slot) => ({ - occupiedAt: state.slotMeta?.[slot]?.occupiedAt ?? Number.POSITIVE_INFINITY, - slot, - })) - .filter(({ occupiedAt }) => occupiedAt !== Number.POSITIVE_INFINITY) - .sort((a, b) => a.occupiedAt - b.occupiedAt)[0]?.slot; - - if (freeSlot) return freeSlot; - - if (entity.kind === 'thread') { - return threadOccupiedSlot ?? earliest ?? fallback; - } - - if (entity.kind === 'channel') { - return threadOccupiedSlot ?? earliest ?? fallback; - } - - return earliest ?? fallback; + state: StateStore; }; ``` -Same behavior can be expressed as composition of prebuilt resolver functions: +`open(...)` behavior: -```ts -type SlotResolver = (args: ResolveTargetSlotArgs) => LayoutSlot | null; +1. Uses requested slot when valid (`options.targetSlot`). +2. Otherwise uses first free visible slot. +3. Otherwise delegates to `resolveTargetSlot` when provided. +4. Returns `rejected` when no slot is available. +5. Applies duplicate policy (`allow`/`move`/`reject`) using `key` identity. +6. Maintains `slotMeta[slot].occupiedAt` on occupy and clears it on clear. -const requestedSlotResolver: SlotResolver = ({ requestedSlot }) => requestedSlot ?? null; +## Resolver registry -const firstFree: SlotResolver = ({ state }) => - state.visibleSlots.find((slot) => !state.slotBindings[slot]) ?? null; +Implemented in `layoutSlotResolvers.ts`. -const existingThreadSlotForThread: SlotResolver = ({ state, entity }) => { - if (entity.kind !== 'thread') return null; - return ( - state.visibleSlots.find((slot) => state.slotBindings[slot]?.kind === 'thread') ?? null - ); -}; +- `requestedSlotResolver` +- `firstFree` +- `existingThreadSlotForThread` +- `existingThreadSlotForChannel` +- `earliestOccupied` +- `activeOrLast` +- `replaceActive` +- `replaceLast` +- `rejectWhenFull` +- `composeResolvers` +- `resolveTargetSlotChannelDefault` +- `layoutSlotResolvers` (central object) -const existingThreadSlotForChannel: SlotResolver = ({ state, entity }) => { - if (entity.kind !== 'channel') return null; - return ( - state.visibleSlots.find((slot) => state.slotBindings[slot]?.kind === 'thread') ?? null - ); -}; +Default chain for channel-centric behavior: -const activeOrLast: SlotResolver = ({ state, activeSlot }) => - activeSlot ?? state.visibleSlots[state.visibleSlots.length - 1] ?? null; - -// Requires slot metadata (e.g. occupiedAt timestamp) tracked by controller state. -const earliestOccupied: SlotResolver = ({ state }) => { - const occupied = state.visibleSlots - .map((slot) => ({ - occupiedAt: state.slotMeta?.[slot]?.occupiedAt ?? Number.POSITIVE_INFINITY, - slot, - })) - .filter(({ occupiedAt }) => occupiedAt !== Number.POSITIVE_INFINITY) - .sort((a, b) => a.occupiedAt - b.occupiedAt); - return occupied[0]?.slot ?? null; -}; +`requestedSlotResolver -> firstFree -> existingThreadSlotForThread -> existingThreadSlotForChannel -> earliestOccupied -> activeOrLast` -const composeResolvers = - (...resolvers: SlotResolver[]): SlotResolver => - (args) => { - for (const resolve of resolvers) { - const slot = resolve(args); - if (slot) return slot; - } - return null; - }; - -export const resolveTargetSlotChannelDefault = composeResolvers( - requestedSlotResolver, - firstFree, - existingThreadSlotForThread, - existingThreadSlotForChannel, - earliestOccupied, - activeOrLast, -); -``` +## ChatView integration -Recommended central export: +`ChatView` accepts: -```ts -export const layoutSlotResolvers = { - requestedSlotResolver, - firstFree, - existingThreadSlotForThread, - existingThreadSlotForChannel, - earliestOccupied, - activeOrLast, - replaceActive, - replaceLast, - rejectWhenFull, - resolveTargetSlotChannelDefault, - composeResolvers, -} as const; -``` +- `maxSlots?: number` +- `resolveTargetSlot?: ResolveTargetSlot` +- `duplicateEntityPolicy?: 'allow' | 'move' | 'reject'` +- `resolveDuplicateEntity?: ResolveDuplicateEntity` +- `entityInferers?: ChatViewEntityInferer[]` +- `layoutController?: LayoutController` +- `layout?: 'nav-rail-entity-list-workspace'` +- `slotRenderers?: ChatViewSlotRenderers` -## Simplified DX mode (recommended default) +When `layoutController` is not provided, ChatView creates one internally and defaults `resolveTargetSlot` to `resolveTargetSlotChannelDefault`. -Most developers should only configure: +Context (`useChatViewContext`) exposes both: -- `minSlots` and `maxSlots` -- placement policy (`fill-empty-then-replace-last`, `replace-active`, etc.) -- initial entities (commonly `channelList` + fallback workspace slot) +- `activeView` / `setActiveView` (new) +- `activeChatView` / `setActiveChatView` (compatibility alias) +- `layoutController` -Example: +## Built-in two-step layout mode -```tsx - - - -``` +`layout='nav-rail-entity-list-workspace'` renders: -`placement` can be treated as shorthand that internally maps to `resolveTargetSlot`. +- nav rail (`ChatViewSelector`) +- entity list pane (`ChannelList` for channels, `ThreadList` for threads) +- workspace slots, rendered via `slotRenderers[kind]` -## Two-step DX mode (preferred integrator flow) +Custom-children mode remains unchanged when `layout` is omitted. -To avoid requiring custom `DynamicSlotsLayout` + `SlotOutlet`, provide a built-in workspace renderer. +## ChannelHeader behavior -Step 1: declare top-level ChatView layout shell. +`ChannelHeader` now defaults sidebar toggle behavior to ChatView layout state: -Step 2: provide slot binding render config (by `kind`) and optional per-slot fallbacks. +- reads `entityListPaneOpen` from `layoutController.state` +- default toggle uses `layoutController.toggleEntityListPane` +- `onSidebarToggle` still overrides default behavior when passed -```tsx - , - channel: ({ source }) => , - thread: ({ source }) => ( - - - - ), - memberList: ({ source }) => , - userList: ({ source }) => , - pinnedMessagesList: ({ source }) => , - searchResults: ({ source }) => , - }} - slotFallbacks={{ - workspace: , - }} -/> -``` +## Migration notes -This keeps integration to two steps while preserving advanced escape hatches. +From legacy `activeChatView`-only usage: -Advanced mode remains available: +1. Existing code can continue using `activeChatView`/`setActiveChatView`. +2. New integrations should prefer `activeView`/`setActiveView`. +3. For layout control (slot binding/open/clear), use `layoutController` from `useChatViewContext`. -- Provide custom children/layout render function when app needs full control. -- In that case integrator may still implement custom `DynamicSlotsLayout` and `SlotOutlet`. +From custom layout orchestration: -Example (advanced mode): render `DynamicSlotsLayout` as `ChatView` children. +1. Keep current custom children if full control is needed. +2. Optionally migrate to built-in two-step layout: + - set `layout='nav-rail-entity-list-workspace'` + - provide `slotRenderers` keyed by entity `kind` -```tsx -const ChatShell = () => ( - -
- - -
-
-); -``` +## Usage examples -Advanced mode: `DynamicSlotsLayout` renders generic slot outlets: +### Low-level (controller-first) ```tsx -import { useStateStore } from '../../store'; - -const DynamicSlotsLayout = () => { - const { layoutController } = useChatViewContext(); - const { visibleSlots } = useStateStore(layoutController.state); - return ( -
- {visibleSlots.map((slot) => ( - - ))} -
- ); -}; -``` - -And each `SlotOutlet` switches by `entity.kind` and uses `entity.source` directly: +const { layoutController } = useChatViewContext(); -```tsx -const SlotOutlet = ({ slot }: { slot: string }) => { - const { layoutController } = useChatViewContext(); - const { slotBindings } = useStateStore(layoutController.state); - const entity = slotBindings[slot]; - if (!entity) return null; - - switch (entity.kind) { - case 'channel': - return ; - case 'thread': - return ( - - - - ); - case 'memberList': - return ; - case 'userList': - return ; - case 'pinnedMessagesList': - return ; - default: - return null; - } -}; +layoutController.openChannel(channel, { activate: true }); +layoutController.openThread(thread, { targetSlot: 'slot2' }); +layoutController.setEntityListPaneOpen(false); ``` -## Viewing duplicate entities - -This section defines how layout should behave when a user opens an entity that is already visible in one of the slots. -Without explicit rules, repeat-open actions can feel inconsistent (sometimes duplicate, sometimes move, sometimes no-op). - -Controller can support one of these policies using `entity.key`: - -- `allow`: same entity may appear in multiple slots. -- `move`: if entity already exists, move it to target slot. -- `reject`: ignore/throw when trying to duplicate. - -Configuration: - -- `duplicateEntityPolicy`: global default (`'allow' | 'move' | 'reject'`). -- `resolveDuplicateEntity`: optional per-open resolver override (receives state + context). - -Example: +### High-level (built-in layout mode) ```tsx (entity.kind === 'thread' ? 'move' : 'reject')} + layout='nav-rail-entity-list-workspace' + maxSlots={2} + slotRenderers={{ + channel: ({ source }) => , + thread: ({ source }) => , + }} /> ``` -```ts -const identity = entity.key; -const existing = identity ? findSlotByKey(identity) : undefined; -const policy = - existing && resolveDuplicateEntity - ? resolveDuplicateEntity({ - state, - entity, - existingSlot: existing, - requestedSlot, - activeSlot, - }) - : duplicateEntityPolicy; -if (policy === 'move' && existing) clear(existing); -if (policy === 'reject' && existing) return existing; -bind(targetSlot, entity); -``` - -## ChatView context contract (with controller) - -- `useChatViewContext()` returns: - - `layoutController` (low-level API) - - `navigation` from `useChatViewNavigation()` (high-level API) -- Layout state is consumed through `layoutController.state` and `useStateStore(...)`. -- `str-chat__header-sidebar-toggle` click behavior: - - default: if current slot has parent history, pop parent (back) - - otherwise hide/unhide channel-list slot - - optional override in `ChannelHeader` via `onSidebarToggle` -- `ChannelHeader` icon is derived from layout state: - - show back arrow when `slotHistory[currentSlot]?.length > 0` - - show sidebar/list icon when history is empty - -## ChannelList and slot counting - -Recommended default: - -- Treat `ChannelList` as a regular slot entity. -- Keep `navRail` outside slots (if present), but all list/content panes are slots. -- `minSlots` controls guaranteed visible slot count (including fallback-only slots). -- `maxSlots` controls upper bound of concurrently active slots. - -Example layout with `minSlots={2}`, `maxSlots={3}`: - -- `navRail | slot1(channelList) | slot2(channel-or-fallback) | slot3(optional)` - -One-slot mobile layout: - -- `minSlots={1}`, `maxSlots={1}` -- active slot transitions: `channelList -> channel -> thread` -- back action pops slot parent stack: `thread -> channel -> channelList` - -## Slot visibility and mount lifecycle - -To preserve pagination and long-lived list state, hiding a slot must not unmount it. - -Recommended primitive: - -```tsx -type SlotProps = { - slot: LayoutSlot; - keepMounted?: boolean; -}; - -const Slot = ({ slot, keepMounted = true }: SlotProps) => { - const { layoutController } = useChatViewContext(); - const { hiddenSlots } = useStateStore(layoutController.state); - const isHidden = !!hiddenSlots[slot]; - - return ( -
- -
- ); -}; -``` - -`str-chat__slot--hidden` should be CSS-hidden while still mounted. - -## Deep-linking and serialization - -Navigation should be serializable with an SDK-level helper interface. - -```ts -type SerializedLayout = { - activeView: LayoutView; - activeSlot?: LayoutSlot; - slots: Array<{ - slot: LayoutSlot; - entity?: { key?: string; kind: LayoutEntityBinding['kind'] }; - hidden?: boolean; - parents?: Array<{ - key?: string; - kind: LayoutEntityBinding['kind']; - view?: LayoutView; - }>; - }>; -}; - -interface LayoutSerializationAdapter { - serialize(state: ChatViewLayoutState): SerializedLayout; - restore(serialized: SerializedLayout): Promise; -} -``` - -`openView` is the canonical entry point for applying route/view changes from deep links before opening concrete entities. - -## TSX example: dynamic 3-slot layout (any combination) - -```tsx -import React from 'react'; -import { ChatView, useChatViewContext } from './ChatView'; -import { Channel } from '../Channel'; -import { Thread } from '../Thread'; -import { MemberList, UserList, PinnedMessagesList } from '../Lists'; -import type { Channel as StreamChannel, Thread as StreamThread } from 'stream-chat'; -import { useStateStore } from '../../store'; - -const SlotOutlet = ({ slot }: { slot: string }) => { - const { layoutController } = useChatViewContext(); - const { slotBindings } = useStateStore(layoutController.state); - const entity = slotBindings[slot]; - if (!entity) return null; - - switch (entity.kind) { - case 'channel': - return ; - case 'thread': - return ( - - - - ); - case 'memberList': - return ; - case 'userList': - return ; - case 'pinnedMessagesList': - return ; - default: - return null; - } -}; - -const DynamicThreePaneLayout = () => { - const { layoutController } = useChatViewContext(); - const { visibleSlots } = useStateStore(layoutController.state); - return ( -
- {visibleSlots.map((slot) => ( -
- -
- ))} -
- ); -}; - -export const Example = ({ - channelA, - channelB, - threadA, -}: { - channelA: StreamChannel; - channelB: StreamChannel; - threadA: StreamThread; -}) => ( - - - - -); - -const LayoutBootstrap = ({ - channelA, - channelB, - threadA, -}: { - channelA: StreamChannel; - channelB: StreamChannel; - threadA: StreamThread; -}) => { - const { layoutController } = useChatViewContext(); - - React.useEffect(() => { - layoutController.open({ kind: 'channel', source: channelA, key: channelA.cid }); - layoutController.open({ kind: 'channel', source: channelB, key: channelB.cid }); - layoutController.open({ kind: 'thread', source: threadA, key: threadA.id }); - }, [channelA, channelB, layoutController, threadA]); - - return null; -}; -``` - -## Slot binding scenarios (documentation examples) - -### 1) Source of truth: where outlets get data - -All outlets read from `layoutController.state` (`slotBindings`) in `ChatView` context. -Example snapshot: - -```ts -{ - slot1: { kind: 'channel', source: channelGeneral, key: channelGeneral.cid }, - slot2: { kind: 'thread', source: thread101, key: thread101.id }, - slot3: { kind: 'channel', source: channelSupport, key: channelSupport.cid }, -} -``` - -### 2) Multiple channels side-by-side - -```ts -layoutController.bind('slot1', { - kind: 'channel', - source: channelGeneral, - key: channelGeneral.cid, -}); -layoutController.bind('slot2', { - kind: 'channel', - source: channelSupport, - key: channelSupport.cid, -}); -layoutController.bind('slot3', { - kind: 'channel', - source: channelRandom, - key: channelRandom.cid, -}); -``` - -### 3) Mixed layout: channel + thread + pinned list - -```ts -layoutController.bind('slot1', { - kind: 'channel', - source: channelGeneral, - key: channelGeneral.cid, -}); -layoutController.bind('slot2', { kind: 'thread', source: thread101, key: thread101.id }); -layoutController.bind('slot3', { - kind: 'pinnedMessagesList', - source: channelGeneral, - key: `pinned:${channelGeneral.cid}`, -}); -``` - -### 4) User action flow: channel list click -> open in next slot - -```tsx -const ChannelListItem = ({ channel }: { channel: StreamChannel }) => { - const { openChannel } = useChatViewNavigation(); - return ; -}; -``` - -### 5) User action flow: thread click -> open in next slot - -```tsx -const ThreadListItem = ({ thread }: { thread: StreamThread }) => { - const { openThread } = useChatViewNavigation(); - return ; -}; -``` - -### 6) View switch flow: from `threads` view to opening an arbitrary channel - -Scenario: - -- User is currently in `activeView='threads'`. -- User clicks a message-annotation action in a thread item - (for example: "View reply in channel" / "This reply was also sent to channel"). -- Expected outcome: - - channel opens in a workspace slot (via resolver chain), - - `activeView` switches to `'channels'`, - - channel-list slot is restored/shown when required by view policy. - -```tsx -const ViewReplyInChannelAction = ({ channel }: { channel: StreamChannel }) => { - const { openChannel, openView } = useChatViewNavigation(); - - const onViewInChannel = () => { - openView('channels'); - openChannel(channel, { slot: 'workspace' }); - }; - - return ; -}; -``` - -If `resolveTargetSlotChannelDefault` is used, slot decision still follows the same chain: - -1. honor `targetSlot` if provided -2. else use free slot -3. else replace thread slot (for channel entity) -4. else replace earliest occupied -5. else fallback to active/last slot - -### 7) Persistence note - -Motivation: - -- `slotBindings` store in-memory instances (`source`), which cannot be safely persisted to URL/localStorage/session storage. -- After refresh/navigation/app restart, those instances do not exist anymore. -- To restore user workspace reliably, persist only serializable identifiers (`entity.key`) and rebuild instances on restore. - -What this solves: - -- Users return to the same multi-pane layout after refresh. -- Deep links can reproduce slot arrangement. -- Persistence stays stable across runtime boundaries (tabs/sessions/process restarts). - -Recommended flow: - -1. Persist a compact snapshot: slot -> `entity.key` (+ optionally `kind`/`activeView`). -2. On restore, resolve each key back to a live instance (channel/thread/etc.). -3. Rebind resolved instances into slots using `layoutController.bind(...)` and restore `slotHistory`. -4. Skip entries that cannot be resolved (deleted channel, revoked access, etc.). - -Example: - -```ts -// persist -const serialized = Object.fromEntries( - Object.entries(layoutController.state.getLatestValue().slotBindings).map( - ([slot, entity]) => [slot, entity ? { key: entity.key, kind: entity.kind } : null], - ), -); - -// restore -const slot1 = serialized.slot1; -if (slot1?.kind === 'channel') { - const channel = resolveChannel(slot1.key); - if (channel) { - layoutController.bind('slot1', { kind: 'channel', source: channel, key: slot1.key }); - } -} -``` - -## Suggested migration path - -1. Keep low-level `LayoutController` in `ChatView` context backed by `StateStore`. -2. Add per-slot parent stack and back-pop transitions (`pushParent`/`popParent`) for one-slot/mobile workflows. -3. Move `ChannelList` into slot model (`kind: 'channelList'`) and remove dedicated entity-list-pane semantics. -4. Add `minSlots` + fallback slot rendering for pre-selection states. -5. Add mount-preserving slot hiding (`hiddenSlots` + `Slot` CSS class contract). -6. Add `openView` and layout serialization adapter for deep-link/navigation restore. -7. Add `useChatViewNavigation()` and move high-level domain methods there. +## Non-goals in this iteration -## Summary +The following are not yet implemented and remain future work: -- Recommended design: pass a generic `LayoutController` (or use default one) through `ChatView` and expose it in `ChatView` context. -- Keep `LayoutController` low-level and domain-agnostic; expose domain navigation via `useChatViewNavigation()`. -- Bindings should carry source instances so outlets render directly without extra lookup. -- Optional `key` handles deduplication and persistence. -- `ChannelList` is a slot entity, enabling one-slot mobile flows and list replacement (e.g. search results) in the same slot. -- `minSlots` and slot fallbacks provide stable empty-state layouts before entity selection. +- slot parent stack/back navigation (`slotHistory`) +- `channelList` as a slot entity kind +- `minSlots` + fallback slot rendering +- mount-preserving hide/unhide slot primitive +- `openView` and serializer/restore APIs +- separate `useChatViewNavigation()` high-level hook diff --git a/src/components/ChatView/specs/layoutController/state.json b/src/components/ChatView/specs/layoutController/state.json index 046ee5b509..2805f303a6 100644 --- a/src/components/ChatView/specs/layoutController/state.json +++ b/src/components/ChatView/specs/layoutController/state.json @@ -6,8 +6,8 @@ "task-4-header-toggle-wiring-for-entity-list-pane": "done", "task-5-built-in-two-step-dx-layout-api": "done", "task-6-tests-for-controller-resolvers-and-integration": "done", - "task-7-docs-and-spec-alignment": "pending", - "task-8-slot-parent-stack-and-back-navigation": "pending", + "task-7-docs-and-spec-alignment": "done", + "task-8-slot-parent-stack-and-back-navigation": "done", "task-9-unify-channellist-into-slot-model": "pending", "task-10-min-slots-and-fallback-workspace-states": "pending", "task-11-generic-slot-component-with-mount-preserving-hide-unhide": "pending", From 95ea03adc8b2a8a270f9a846da740b1c73a2a2ca Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 27 Feb 2026 11:53:01 +0100 Subject: [PATCH 08/32] feat(LayoutController): implement Unify ChannelList into Slot Model --- .../ChannelHeader/ChannelHeader.tsx | 40 ++++++- src/components/ChatView/ChatView.tsx | 112 ++++++++++++++---- .../ChatView/layout/WorkspaceLayout.tsx | 20 ++-- .../layoutController/layoutControllerTypes.ts | 10 ++ .../specs/layoutController/decisions.md | 25 ++++ .../ChatView/specs/layoutController/plan.md | 8 +- .../ChatView/specs/layoutController/spec.md | 16 +-- .../specs/layoutController/state.json | 2 +- 8 files changed, 186 insertions(+), 47 deletions(-) diff --git a/src/components/ChannelHeader/ChannelHeader.tsx b/src/components/ChannelHeader/ChannelHeader.tsx index cb92f9a258..77cebc4195 100644 --- a/src/components/ChannelHeader/ChannelHeader.tsx +++ b/src/components/ChannelHeader/ChannelHeader.tsx @@ -30,12 +30,18 @@ export type ChannelHeaderProps = { const channelHeaderLayoutSelector = ({ activeSlot, + activeView, entityListPaneOpen, + slotBindings, slotHistory, + visibleSlots, }: ChatViewLayoutState) => ({ activeSlot, + activeView, entityListPaneOpen, + slotBindings, slotHistory, + visibleSlots, }); /** @@ -60,12 +66,42 @@ export const ChannelHeader = (props: ChannelHeaderProps) => { overrideTitle, }); const onlineStatusText = useChannelHeaderOnlineStatus(); - const { activeSlot, entityListPaneOpen, slotHistory } = + const { + activeSlot, + activeView, + entityListPaneOpen, + slotBindings, + slotHistory, + visibleSlots, + } = useStateStore(layoutController.state, channelHeaderLayoutSelector) ?? channelHeaderLayoutSelector(layoutController.state.getLatestValue()); + const channelListSlot = visibleSlots.find( + (slot) => slotBindings[slot]?.kind === 'channelList', + ); const hasParentHistory = !!(activeSlot && slotHistory?.[activeSlot]?.length); const sidebarCollapsed = sidebarCollapsedProp ?? !entityListPaneOpen; - const handleSidebarToggle = onSidebarToggle ?? layoutController.toggleEntityListPane; + const handleSidebarToggle = + onSidebarToggle ?? + (() => { + if (!entityListPaneOpen) { + if (!channelListSlot) { + const targetSlot = activeSlot ?? visibleSlots[0]; + if (!targetSlot) return; + + layoutController.bind(targetSlot, { + key: 'channel-list', + kind: 'channelList', + source: { view: activeView }, + }); + } + + layoutController.setEntityListPaneOpen(true); + return; + } + + layoutController.setEntityListPaneOpen(false); + }); const handleHeaderAction = hasParentHistory ? () => { if (!activeSlot) return; diff --git a/src/components/ChatView/ChatView.tsx b/src/components/ChatView/ChatView.tsx index 9faca352c2..4b617ad731 100644 --- a/src/components/ChatView/ChatView.tsx +++ b/src/components/ChatView/ChatView.tsx @@ -104,10 +104,12 @@ export const useChatViewContext = () => useContext(ChatViewContext); const activeViewSelector = ({ activeView }: ChatViewLayoutState) => ({ activeView }); const workspaceLayoutStateSelector = ({ + activeView, entityListPaneOpen, slotBindings, visibleSlots, }: ChatViewLayoutState) => ({ + activeView, entityListPaneOpen, slotBindings, visibleSlots, @@ -117,21 +119,34 @@ const renderSlotBinding = ( entity: LayoutEntityBinding | undefined, slot: string, slotRenderers: ChatViewSlotRenderers | undefined, -) => { - if (!entity || !slotRenderers) return null; +): ReactNode | null => { + if (!entity) return null; switch (entity.kind) { + case 'channelList': + return ( + slotRenderers?.channelList?.({ entity, slot, source: entity.source }) ?? + (entity.source.view === 'threads' ? ( + + ) : ( + + )) + ); case 'channel': - return slotRenderers.channel?.({ entity, slot, source: entity.source }) ?? null; + return slotRenderers?.channel?.({ entity, slot, source: entity.source }) ?? null; case 'thread': - return slotRenderers.thread?.({ entity, slot, source: entity.source }) ?? null; + return slotRenderers?.thread?.({ entity, slot, source: entity.source }) ?? null; case 'memberList': - return slotRenderers.memberList?.({ entity, slot, source: entity.source }) ?? null; + return slotRenderers?.memberList?.({ entity, slot, source: entity.source }) ?? null; case 'userList': - return slotRenderers.userList?.({ entity, slot, source: entity.source }) ?? null; + return slotRenderers?.userList?.({ entity, slot, source: entity.source }) ?? null; + case 'searchResults': + return ( + slotRenderers?.searchResults?.({ entity, slot, source: entity.source }) ?? null + ); case 'pinnedMessagesList': return ( - slotRenderers.pinnedMessagesList?.({ entity, slot, source: entity.source }) ?? + slotRenderers?.pinnedMessagesList?.({ entity, slot, source: entity.source }) ?? null ); default: @@ -195,26 +210,73 @@ export const ChatView = ({ useStateStore(effectiveLayoutController.state, workspaceLayoutStateSelector) ?? workspaceLayoutStateSelector(effectiveLayoutController.state.getLatestValue()); + useEffect(() => { + if (layout !== 'nav-rail-entity-list-workspace') return; + + const existingChannelListSlot = workspaceLayoutState.visibleSlots.find( + (slot) => workspaceLayoutState.slotBindings[slot]?.kind === 'channelList', + ); + + if (existingChannelListSlot) { + const existingEntity = workspaceLayoutState.slotBindings[existingChannelListSlot]; + if ( + existingEntity?.kind === 'channelList' && + existingEntity.source.view !== workspaceLayoutState.activeView + ) { + effectiveLayoutController.bind(existingChannelListSlot, { + ...existingEntity, + source: { view: workspaceLayoutState.activeView }, + }); + } + return; + } + + const firstFreeSlot = workspaceLayoutState.visibleSlots.find( + (slot) => !workspaceLayoutState.slotBindings[slot], + ); + if (!firstFreeSlot) return; + + effectiveLayoutController.bind(firstFreeSlot, { + key: 'channel-list', + kind: 'channelList', + source: { view: workspaceLayoutState.activeView }, + }); + }, [ + effectiveLayoutController, + layout, + workspaceLayoutState.activeView, + workspaceLayoutState.slotBindings, + workspaceLayoutState.visibleSlots, + ]); + const content = - layout === 'nav-rail-entity-list-workspace' ? ( - : - } - entityListPaneOpen={workspaceLayoutState.entityListPaneOpen} - navRail={} - slots={workspaceLayoutState.visibleSlots.map((slot) => ({ - content: renderSlotBinding( - workspaceLayoutState.slotBindings[slot], + layout === 'nav-rail-entity-list-workspace' + ? (() => { + const slots = workspaceLayoutState.visibleSlots.map((slot) => ({ + content: renderSlotBinding( + workspaceLayoutState.slotBindings[slot], + slot, + slotRenderers, + ), slot, - slotRenderers, - ), - slot, - }))} - /> - ) : ( - children - ); + })); + const entityListSlot = slots.find( + ({ slot }) => workspaceLayoutState.slotBindings[slot]?.kind === 'channelList', + ); + + return ( + } + slots={slots.filter( + ({ slot }) => + workspaceLayoutState.slotBindings[slot]?.kind !== 'channelList', + )} + /> + ); + })() + : children; return ( diff --git a/src/components/ChatView/layout/WorkspaceLayout.tsx b/src/components/ChatView/layout/WorkspaceLayout.tsx index f92aa46854..0b8eb60a1e 100644 --- a/src/components/ChatView/layout/WorkspaceLayout.tsx +++ b/src/components/ChatView/layout/WorkspaceLayout.tsx @@ -9,15 +9,15 @@ export type WorkspaceLayoutSlot = { }; export type WorkspaceLayoutProps = { - entityListPane?: ReactNode; - entityListPaneOpen?: boolean; + entityListHidden?: boolean; + entityListSlot?: WorkspaceLayoutSlot; navRail?: ReactNode; slots: WorkspaceLayoutSlot[]; }; export const WorkspaceLayout = ({ - entityListPane, - entityListPaneOpen = true, + entityListHidden = false, + entityListSlot, navRail, slots, }: WorkspaceLayoutProps) => ( @@ -25,10 +25,14 @@ export const WorkspaceLayout = ({ {navRail ? (
{navRail}
) : null} - {entityListPaneOpen ? ( -
- {entityListPane} -
+ {entityListSlot?.content ? ( + ) : null}
Date: Fri, 27 Feb 2026 12:09:25 +0100 Subject: [PATCH 09/32] feat(LayoutController): implement Min Slots and Fallback Workspace States --- src/components/ChatView/ChatView.tsx | 78 +++++++++++++++++-- .../layoutController/layoutControllerTypes.ts | 2 + .../specs/layoutController/decisions.md | 44 +++++++++++ .../ChatView/specs/layoutController/plan.md | 8 +- .../ChatView/specs/layoutController/spec.md | 15 +++- .../specs/layoutController/state.json | 2 +- 6 files changed, 134 insertions(+), 15 deletions(-) diff --git a/src/components/ChatView/ChatView.tsx b/src/components/ChatView/ChatView.tsx index 4b617ad731..1d52b50d77 100644 --- a/src/components/ChatView/ChatView.tsx +++ b/src/components/ChatView/ChatView.tsx @@ -53,6 +53,10 @@ export type ChatViewSlotRendererProps source: LayoutEntityByKind['source']; }; +export type ChatViewSlotFallbackProps = { + slot: string; +}; + export type ChatViewSlotRenderers = Partial<{ [TKind in LayoutEntityBinding['kind']]: ( props: ChatViewSlotRendererProps, @@ -65,8 +69,13 @@ export type ChatViewProps = PropsWithChildren<{ layout?: ChatViewBuiltinLayout; layoutController?: LayoutController; maxSlots?: number; + minSlots?: number; resolveDuplicateEntity?: ResolveDuplicateEntity; resolveTargetSlot?: ResolveTargetSlot; + SlotFallback?: ComponentType; + slotFallbackComponents?: Partial< + Record> + >; slotRenderers?: ChatViewSlotRenderers; }>; @@ -80,9 +89,18 @@ type ChatViewContextValue = { }; const DEFAULT_MAX_SLOTS = 1; +const DEFAULT_MIN_SLOTS = 1; -const createVisibleSlots = (maxSlots: number) => - Array.from({ length: Math.max(0, maxSlots) }, (_, index) => `slot${index + 1}`); +const resolveInitialSlotCount = ({ + maxSlots, + minSlots, +}: { + maxSlots: number; + minSlots: number; +}) => Math.min(Math.max(1, minSlots), Math.max(1, maxSlots)); + +const createVisibleSlots = (slotCount: number) => + Array.from({ length: Math.max(0, slotCount) }, (_, index) => `slot${index + 1}`); const defaultLayoutController = createLayoutController({ initialState: { @@ -154,6 +172,24 @@ const renderSlotBinding = ( } }; +const DefaultSlotFallback = () => ( +
+ Select a channel to start messaging +
+); + +const resolveSlotFallbackComponent = ({ + slot, + SlotFallback, + slotFallbackComponents, +}: { + SlotFallback?: ComponentType; + slot: string; + slotFallbackComponents?: Partial< + Record> + >; +}) => slotFallbackComponents?.[slot] ?? SlotFallback ?? DefaultSlotFallback; + export const ChatView = ({ children, duplicateEntityPolicy, @@ -161,11 +197,15 @@ export const ChatView = ({ layout, layoutController, maxSlots = DEFAULT_MAX_SLOTS, + minSlots = DEFAULT_MIN_SLOTS, resolveDuplicateEntity, resolveTargetSlot, + SlotFallback, + slotFallbackComponents, slotRenderers, }: ChatViewProps) => { const { theme } = useChatContext(); + const initialSlotCount = resolveInitialSlotCount({ maxSlots, minSlots }); const internalLayoutController = useMemo( () => @@ -173,12 +213,21 @@ export const ChatView = ({ duplicateEntityPolicy, initialState: { activeView: 'channels', - visibleSlots: createVisibleSlots(maxSlots), + maxSlots, + minSlots, + visibleSlots: createVisibleSlots(initialSlotCount), }, resolveDuplicateEntity, resolveTargetSlot: resolveTargetSlot ?? resolveTargetSlotChannelDefault, }), - [duplicateEntityPolicy, maxSlots, resolveDuplicateEntity, resolveTargetSlot], + [ + duplicateEntityPolicy, + initialSlotCount, + maxSlots, + minSlots, + resolveDuplicateEntity, + resolveTargetSlot, + ], ); const effectiveLayoutController = layoutController ?? internalLayoutController; @@ -269,10 +318,23 @@ export const ChatView = ({ entityListHidden={!workspaceLayoutState.entityListPaneOpen} entityListSlot={entityListSlot} navRail={} - slots={slots.filter( - ({ slot }) => - workspaceLayoutState.slotBindings[slot]?.kind !== 'channelList', - )} + slots={slots + .filter( + ({ slot }) => + workspaceLayoutState.slotBindings[slot]?.kind !== 'channelList', + ) + .map(({ content, slot }) => { + const Fallback = resolveSlotFallbackComponent({ + slot, + SlotFallback, + slotFallbackComponents, + }); + + return { + content: content ?? , + slot, + }; + })} /> ); })() diff --git a/src/components/ChatView/layoutController/layoutControllerTypes.ts b/src/components/ChatView/layoutController/layoutControllerTypes.ts index 7d13ffde9e..c4d72ef296 100644 --- a/src/components/ChatView/layoutController/layoutControllerTypes.ts +++ b/src/components/ChatView/layoutController/layoutControllerTypes.ts @@ -38,6 +38,8 @@ export type ChatViewLayoutState = { activeSlot?: LayoutSlot; activeView: ChatView; entityListPaneOpen: boolean; + maxSlots?: number; + minSlots?: number; mode: LayoutMode; slotBindings: Record; slotHistory?: Record; diff --git a/src/components/ChatView/specs/layoutController/decisions.md b/src/components/ChatView/specs/layoutController/decisions.md index 93a5919389..2455090e3c 100644 --- a/src/components/ChatView/specs/layoutController/decisions.md +++ b/src/components/ChatView/specs/layoutController/decisions.md @@ -268,3 +268,47 @@ Binding kind already provides the semantic signal; adding a second classificatio **Tradeoffs / Consequences:** Controller legacy fields (`entityListPaneOpen` and related methods) still exist for backward compatibility, but built-in layout paths now derive list visibility from slot bindings rather than that flag. + +## Decision: Implement Task 10 min-slot initialization and unbound-slot fallback rendering in ChatView built-in layout + +**Date:** 2026-02-27 +**Context:** +Task 10 requires minimum slot rendering before entity selection and fallback content for unbound slots while preserving `maxSlots` as the upper bound. + +**Decision:** +Add `minSlots` to `ChatViewProps` and initialize internal `visibleSlots` count from a clamped value `minSlots..maxSlots`. Add optional `slotFallbackRenderer` prop and default fallback content for unbound workspace slots in built-in layout mode. Extend layout state type with optional `minSlots` and `maxSlots` metadata. + +**Reasoning:** +This guarantees a visible empty workspace pane (e.g., alongside `channelList`) before channel selection, while keeping existing resolver and slot binding behavior intact. + +**Alternatives considered:** + +- Keep initialization at `maxSlots` only and rely on blank slots — rejected because `minSlots` would have no practical effect. +- Render fallback only via consumer-provided renderer — rejected because acceptance requires out-of-the-box empty workspace behavior. + +**Tradeoffs / Consequences:** +Built-in fallback text is currently a simple default string unless `slotFallbackRenderer` is provided. Additional localization/styling refinements can be layered later without changing slot semantics. + +## Decision: Replace function-based fallback API with component-based fallback API supporting per-slot overrides + +**Date:** 2026-02-27 +**Context:** +The initial Task 10 fallback API used `slotFallbackRenderer(props)`, but customization needs are better expressed as mountable React components and per-slot overrides. + +**Decision:** +Change ChatView fallback API to: + +- `SlotFallback?: ComponentType<{ slot: string }>` as global fallback component, +- `slotFallbackComponents?: Partial>>` for per-slot overrides, +- resolution order: per-slot component -> global component -> SDK default fallback component. + +**Reasoning:** +Component-based API improves composability (hooks/context/local state in fallback UIs) and allows explicit per-slot customization without conditional render logic in userland callback functions. + +**Alternatives considered:** + +- Keep `slotFallbackRenderer` function — rejected due weaker composability and harder per-slot specialization ergonomics. +- Accept only per-slot components without global default — rejected because a global fallback component remains convenient for common cases. + +**Tradeoffs / Consequences:** +This is an API rename from `slotFallbackRenderer` to `SlotFallback`/`slotFallbackComponents`; consumers using the previous prop must migrate. diff --git a/src/components/ChatView/specs/layoutController/plan.md b/src/components/ChatView/specs/layoutController/plan.md index af26611f50..72b48283cc 100644 --- a/src/components/ChatView/specs/layoutController/plan.md +++ b/src/components/ChatView/specs/layoutController/plan.md @@ -223,9 +223,9 @@ Primary spec for this plan: **Dependencies:** Task 9 -**Status:** pending +**Status:** done -**Owner:** unassigned +**Owner:** codex **Scope:** @@ -235,8 +235,8 @@ Primary spec for this plan: **Acceptance Criteria:** -- [ ] `minSlots={2}` can render `channelList + empty workspace` before channel selection. -- [ ] Fallback content disappears when slot receives entity binding and reappears when cleared. +- [x] `minSlots={2}` can render `channelList + empty workspace` before channel selection. +- [x] Fallback content disappears when slot receives entity binding and reappears when cleared. ## Task 11: Generic Slot Component with Mount-Preserving Hide/Unhide diff --git a/src/components/ChatView/specs/layoutController/spec.md b/src/components/ChatView/specs/layoutController/spec.md index 9c5bdf30bf..ae0684ada8 100644 --- a/src/components/ChatView/specs/layoutController/spec.md +++ b/src/components/ChatView/specs/layoutController/spec.md @@ -115,6 +115,9 @@ Default chain for channel-centric behavior: - `entityInferers?: ChatViewEntityInferer[]` - `layoutController?: LayoutController` - `layout?: 'nav-rail-entity-list-workspace'` +- `minSlots?: number` +- `SlotFallback?: ComponentType<{ slot: string }>` +- `slotFallbackComponents?: Partial>>` - `slotRenderers?: ChatViewSlotRenderers` When `layoutController` is not provided, ChatView creates one internally and defaults `resolveTargetSlot` to `resolveTargetSlotChannelDefault`. @@ -132,6 +135,10 @@ Context (`useChatViewContext`) exposes both: - nav rail (`ChatViewSelector`) - entity list pane from the slot bound with `kind: 'channelList'` - workspace area from the remaining visible slots, rendered via `slotRenderers[kind]` +- unbound workspace slots via per-slot fallback components: + - `slotFallbackComponents[slot]` first + - then `SlotFallback` + - then SDK default fallback Custom-children mode remains unchanged when `layout` is omitted. @@ -140,7 +147,8 @@ Custom-children mode remains unchanged when `layout` is omitted. `ChannelHeader` now defaults sidebar toggle behavior to ChatView layout state: - derives list visibility from whether a `channelList` slot binding exists -- default toggle uses `layoutController.bind(...)` / `layoutController.clear(...)` for `channelList` entities +- default toggle uses hide/unhide (`setEntityListPaneOpen(false/true)`) to keep list slot mounted +- it binds a `channelList` slot only when opening and no list slot is bound yet - `onSidebarToggle` still overrides default behavior when passed ## Migration notes @@ -179,7 +187,11 @@ layoutController.bind('slot1', { ```tsx , thread: ({ source }) => , @@ -191,7 +203,6 @@ layoutController.bind('slot1', { The following are not yet implemented and remain future work: -- `minSlots` + fallback slot rendering - mount-preserving hide/unhide slot primitive - `openView` and serializer/restore APIs - separate `useChatViewNavigation()` high-level hook diff --git a/src/components/ChatView/specs/layoutController/state.json b/src/components/ChatView/specs/layoutController/state.json index 5738dc1a85..883c935b1a 100644 --- a/src/components/ChatView/specs/layoutController/state.json +++ b/src/components/ChatView/specs/layoutController/state.json @@ -9,7 +9,7 @@ "task-7-docs-and-spec-alignment": "done", "task-8-slot-parent-stack-and-back-navigation": "done", "task-9-unify-channellist-into-slot-model": "done", - "task-10-min-slots-and-fallback-workspace-states": "pending", + "task-10-min-slots-and-fallback-workspace-states": "done", "task-11-generic-slot-component-with-mount-preserving-hide-unhide": "pending", "task-12-deep-linking-serialization-and-openview": "pending", "task-13-high-level-navigation-hook-and-context-split": "pending", From f0a9ba286f5a5a5f7d46eaaadf1ac968279d3ba0 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 27 Feb 2026 12:20:52 +0100 Subject: [PATCH 10/32] feat(LayoutController): implement Generic Slot Component with Mount-Preserving Hide/Unhide --- .../ChatView/layout/WorkspaceLayout.tsx | 18 +++++--- .../specs/layoutController/decisions.md | 26 ++++++++++++ .../ChatView/specs/layoutController/plan.md | 8 ++-- .../specs/layoutController/state.json | 2 +- src/components/ChatView/styling/ChatView.scss | 41 +++++++++++++++++++ 5 files changed, 85 insertions(+), 10 deletions(-) diff --git a/src/components/ChatView/layout/WorkspaceLayout.tsx b/src/components/ChatView/layout/WorkspaceLayout.tsx index 0b8eb60a1e..4fd0af212e 100644 --- a/src/components/ChatView/layout/WorkspaceLayout.tsx +++ b/src/components/ChatView/layout/WorkspaceLayout.tsx @@ -1,10 +1,12 @@ import React from 'react'; import clsx from 'clsx'; +import { Slot } from './Slot'; import type { ReactNode } from 'react'; export type WorkspaceLayoutSlot = { content?: ReactNode; + hidden?: boolean; slot: string; }; @@ -26,23 +28,29 @@ export const WorkspaceLayout = ({
{navRail}
) : null} {entityListSlot?.content ? ( - + ) : null}
- {slots.map(({ content, slot }) => ( -
+ {slots.map(({ content, hidden, slot }) => ( +
+ ))}
diff --git a/src/components/ChatView/specs/layoutController/decisions.md b/src/components/ChatView/specs/layoutController/decisions.md index 2455090e3c..2abf501bea 100644 --- a/src/components/ChatView/specs/layoutController/decisions.md +++ b/src/components/ChatView/specs/layoutController/decisions.md @@ -312,3 +312,29 @@ Component-based API improves composability (hooks/context/local state in fallbac **Tradeoffs / Consequences:** This is an API rename from `slotFallbackRenderer` to `SlotFallback`/`slotFallbackComponents`; consumers using the previous prop must migrate. + +## Decision: Implement generic Slot primitive with hidden-state class contract in WorkspaceLayout + +**Date:** 2026-02-27 +**Context:** +Task 11 requires mount-preserving hide/unhide semantics and a consistent slot-level CSS contract for visibility. + +**Decision:** +Add `src/components/ChatView/layout/Slot.tsx` as a generic slot wrapper and migrate `WorkspaceLayout` to render both entity-list and workspace entries through this component. `Slot` exposes: + +- root class `str-chat__chat-view__slot`, +- hidden modifier class `str-chat__chat-view__slot--hidden`, +- `hidden` prop (mapped to `aria-hidden` and CSS class) while keeping the subtree mounted. + +Add corresponding ChatView SCSS classes for workspace layout shell and slot visibility. + +**Reasoning:** +Centralizing slot visibility behavior in one primitive avoids duplicating hide logic and ensures a stable class contract for future hidden-slot controller state wiring. + +**Alternatives considered:** + +- Keep raw `section` tags and toggle `hidden` directly in each caller — rejected because visibility contract becomes fragmented. +- Add explicit `--visible` modifier class — rejected as unnecessary; hidden state alone is sufficient and simpler. + +**Tradeoffs / Consequences:** +Current Task 11 implementation uses existing layout visibility inputs (`entityListHidden` / slot `hidden`) and class contract. Dedicated controller APIs for arbitrary hidden slots remain follow-up work. diff --git a/src/components/ChatView/specs/layoutController/plan.md b/src/components/ChatView/specs/layoutController/plan.md index 72b48283cc..f6d5f96f88 100644 --- a/src/components/ChatView/specs/layoutController/plan.md +++ b/src/components/ChatView/specs/layoutController/plan.md @@ -244,9 +244,9 @@ Primary spec for this plan: **Dependencies:** Task 10 -**Status:** pending +**Status:** done -**Owner:** unassigned +**Owner:** codex **Scope:** @@ -256,8 +256,8 @@ Primary spec for this plan: **Acceptance Criteria:** -- [ ] Hidden slots remain mounted (no pagination re-initialization). -- [ ] Slot visibility is controllable via layout state and reflected in CSS class contract. +- [x] Hidden slots remain mounted (no pagination re-initialization). +- [x] Slot visibility is controllable via layout state and reflected in CSS class contract. ## Task 12: Deep-Linking, Serialization, and openView diff --git a/src/components/ChatView/specs/layoutController/state.json b/src/components/ChatView/specs/layoutController/state.json index 883c935b1a..203b33906a 100644 --- a/src/components/ChatView/specs/layoutController/state.json +++ b/src/components/ChatView/specs/layoutController/state.json @@ -10,7 +10,7 @@ "task-8-slot-parent-stack-and-back-navigation": "done", "task-9-unify-channellist-into-slot-model": "done", "task-10-min-slots-and-fallback-workspace-states": "done", - "task-11-generic-slot-component-with-mount-preserving-hide-unhide": "pending", + "task-11-generic-slot-component-with-mount-preserving-hide-unhide": "done", "task-12-deep-linking-serialization-and-openview": "pending", "task-13-high-level-navigation-hook-and-context-split": "pending", "task-14-tests-for-slot-stack-unified-slots-and-navigation-dx": "pending" diff --git a/src/components/ChatView/styling/ChatView.scss b/src/components/ChatView/styling/ChatView.scss index 3cfd31ef93..1209d52f2f 100644 --- a/src/components/ChatView/styling/ChatView.scss +++ b/src/components/ChatView/styling/ChatView.scss @@ -63,3 +63,44 @@ flex-grow: 1; } } + +.str-chat__chat-view__workspace-layout { + display: flex; + width: 100%; + height: 100%; + min-width: 0; +} + +.str-chat__chat-view__workspace-layout-nav-rail { + display: flex; + flex-shrink: 0; +} + +.str-chat__chat-view__workspace-layout-entity-list-pane { + display: flex; + min-width: 0; +} + +.str-chat__chat-view__workspace-layout-slots { + display: flex; + flex: 1 1 auto; + min-width: 0; +} + +.str-chat__chat-view__workspace-layout-slots--empty { + justify-content: center; +} + +.str-chat__chat-view__workspace-layout-slot { + display: flex; + flex: 1 1 0; + min-width: 0; +} + +.str-chat__chat-view__slot { + min-width: 0; +} + +.str-chat__chat-view__slot--hidden { + display: none; +} From 3223e4624429f3dedfd20c70dae168887fc6b1a9 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 27 Feb 2026 12:31:32 +0100 Subject: [PATCH 11/32] feat(LayoutController): implement Deep-Linking, Serialization, and openView --- .../layoutController/LayoutController.ts | 31 +++++++++++++ .../layoutController/layoutControllerTypes.ts | 43 +++++++++++++++++++ .../specs/layoutController/decisions.md | 30 +++++++++++++ .../ChatView/specs/layoutController/plan.md | 12 +++--- .../specs/layoutController/state.json | 4 +- 5 files changed, 112 insertions(+), 8 deletions(-) diff --git a/src/components/ChatView/layoutController/LayoutController.ts b/src/components/ChatView/layoutController/LayoutController.ts index 81a268d350..9f2be5dd7d 100644 --- a/src/components/ChatView/layoutController/LayoutController.ts +++ b/src/components/ChatView/layoutController/LayoutController.ts @@ -16,6 +16,7 @@ const DEFAULT_LAYOUT_STATE: ChatViewLayoutState = { activeSlot: undefined, activeView: 'channels', entityListPaneOpen: true, + hiddenSlots: {}, mode: 'default', slotBindings: {}, slotHistory: {}, @@ -47,6 +48,10 @@ const buildInitialState = ( ): ChatViewLayoutState => ({ ...DEFAULT_LAYOUT_STATE, ...partialState, + hiddenSlots: { + ...DEFAULT_LAYOUT_STATE.hiddenSlots, + ...(partialState?.hiddenSlots ?? {}), + }, slotBindings: { ...DEFAULT_LAYOUT_STATE.slotBindings, ...(partialState?.slotBindings ?? {}), @@ -229,6 +234,20 @@ export const createLayoutController = ( state.partialNext({ activeView: next }); }; + const openView: LayoutController['openView'] = (view, options) => { + const targetSlot = options?.slot; + const shouldActivateSlot = options?.activateSlot ?? true; + + state.next((current) => ({ + ...current, + activeSlot: + shouldActivateSlot && targetSlot && current.visibleSlots.includes(targetSlot) + ? targetSlot + : current.activeSlot, + activeView: view, + })); + }; + const setMode: LayoutController['setMode'] = (next) => { state.partialNext({ mode: next }); }; @@ -244,6 +263,16 @@ export const createLayoutController = ( })); }; + const setSlotHidden: LayoutController['setSlotHidden'] = (slot, hidden) => { + state.next((current) => ({ + ...current, + hiddenSlots: { + ...(current.hiddenSlots ?? {}), + [slot]: hidden, + }, + })); + }; + const bind: LayoutController['bind'] = (slot, entity) => { if (!entity) { state.next((current) => clearSlotBinding(current, slot)); @@ -429,11 +458,13 @@ export const createLayoutController = ( openPinnedMessagesList, openThread, openUserList, + openView, popParent, pushParent, setActiveView, setEntityListPaneOpen, setMode, + setSlotHidden, state, toggleEntityListPane, }; diff --git a/src/components/ChatView/layoutController/layoutControllerTypes.ts b/src/components/ChatView/layoutController/layoutControllerTypes.ts index c4d72ef296..49c441e90b 100644 --- a/src/components/ChatView/layoutController/layoutControllerTypes.ts +++ b/src/components/ChatView/layoutController/layoutControllerTypes.ts @@ -8,6 +8,7 @@ import type { ChatView } from '../ChatView'; export type LayoutSlot = string; export type LayoutMode = string; +export type LayoutView = ChatView; export type UserListEntitySource = { query: string; @@ -38,6 +39,7 @@ export type ChatViewLayoutState = { activeSlot?: LayoutSlot; activeView: ChatView; entityListPaneOpen: boolean; + hiddenSlots?: Record; maxSlots?: number; minSlots?: number; mode: LayoutMode; @@ -84,6 +86,45 @@ export type CloseOptions = { restoreFromHistory?: boolean; }; +export type OpenViewOptions = { + activateSlot?: boolean; + slot?: LayoutSlot; +}; + +export type SerializedLayoutEntityBinding = { + key?: string; + kind: LayoutEntityBinding['kind']; + source: unknown; +}; + +export type ChatViewLayoutSnapshot = { + activeSlot?: LayoutSlot; + activeView: LayoutView; + entityListPaneOpen: boolean; + hiddenSlots: Record; + mode: LayoutMode; + slotBindings: Record; + slotHistory: Record; + slotMeta: Record; + visibleSlots: LayoutSlot[]; +}; + +export type SerializeLayoutEntityBinding = ( + entity: LayoutEntityBinding, +) => SerializedLayoutEntityBinding | undefined; + +export type DeserializeLayoutEntityBinding = ( + entity: SerializedLayoutEntityBinding, +) => LayoutEntityBinding | undefined; + +export type SerializeLayoutStateOptions = { + serializeEntityBinding?: SerializeLayoutEntityBinding; +}; + +export type RestoreLayoutStateOptions = { + deserializeEntityBinding?: DeserializeLayoutEntityBinding; +}; + export type CreateLayoutControllerOptions = { duplicateEntityPolicy?: DuplicateEntityPolicy; initialState?: Partial; @@ -97,6 +138,7 @@ export type LayoutController = { clear: (slot: LayoutSlot) => void; close: (slot: LayoutSlot, options?: CloseOptions) => void; open: (entity: LayoutEntityBinding, options?: OpenOptions) => OpenResult; + openView: (view: LayoutView, options?: OpenViewOptions) => void; openChannel: (channel: StreamChannel, options?: OpenOptions) => OpenResult; openMemberList: (channel: StreamChannel, options?: OpenOptions) => OpenResult; openPinnedMessagesList: (channel: StreamChannel, options?: OpenOptions) => OpenResult; @@ -107,6 +149,7 @@ export type LayoutController = { setActiveView: (next: ChatView) => void; setEntityListPaneOpen: (next: boolean) => void; setMode: (next: LayoutMode) => void; + setSlotHidden: (slot: LayoutSlot, hidden: boolean) => void; state: StateStore; toggleEntityListPane: () => void; }; diff --git a/src/components/ChatView/specs/layoutController/decisions.md b/src/components/ChatView/specs/layoutController/decisions.md index 2abf501bea..c8312ac1f1 100644 --- a/src/components/ChatView/specs/layoutController/decisions.md +++ b/src/components/ChatView/specs/layoutController/decisions.md @@ -338,3 +338,33 @@ Centralizing slot visibility behavior in one primitive avoids duplicating hide l **Tradeoffs / Consequences:** Current Task 11 implementation uses existing layout visibility inputs (`entityListHidden` / slot `hidden`) and class contract. Dedicated controller APIs for arbitrary hidden slots remain follow-up work. + +## Decision: Add openView + snapshot serialization/restore helpers with safe default entity handling + +**Date:** 2026-02-27 +**Context:** +Task 12 requires view-first navigation (`openView`) and layout snapshot round-tripping including slot bindings, hidden slots, and parent history while avoiding unsafe assumptions for non-serializable runtime entities. + +**Decision:** +Update controller and types to include: + +- `openView(view, options?)` on `LayoutController`, +- `hiddenSlots` in layout state and `setSlotHidden(slot, hidden)` command, +- typed snapshot model (`ChatViewLayoutSnapshot`) and serializer contracts in `layoutControllerTypes.ts`. + +Add `src/components/ChatView/layoutController/serialization.ts` with: + +- `serializeLayoutState(...)` / `restoreLayoutState(...)`, +- `serializeLayoutControllerState(...)` / `restoreLayoutControllerState(...)`, +- default serializer/deserializer that only handles plain-data entity kinds (`channelList`, `userList`, `searchResults`) and skips unresolved kinds unless custom serializer/deserializer callbacks are provided. + +**Reasoning:** +This enables deep-link and persistence flows without trying to serialize non-plain runtime objects (e.g., channel/thread instances), while still preserving history/visibility semantics for serializable bindings. + +**Alternatives considered:** + +- Attempt default serialization for all entity kinds — rejected due unsafe/non-deterministic runtime object encoding. +- Store only active view and drop slot state — rejected because Task 12 explicitly requires preserving stack/visibility semantics. + +**Tradeoffs / Consequences:** +Out-of-the-box round-trip fully preserves serializable entity kinds; channel/thread restoration requires consumer-provided deserialize hooks in the restore options. diff --git a/src/components/ChatView/specs/layoutController/plan.md b/src/components/ChatView/specs/layoutController/plan.md index f6d5f96f88..9a26f99cb9 100644 --- a/src/components/ChatView/specs/layoutController/plan.md +++ b/src/components/ChatView/specs/layoutController/plan.md @@ -265,9 +265,9 @@ Primary spec for this plan: **Dependencies:** Task 10 -**Status:** pending +**Status:** done -**Owner:** unassigned +**Owner:** codex **Scope:** @@ -277,8 +277,8 @@ Primary spec for this plan: **Acceptance Criteria:** -- [ ] View-first deep links (`openView` then entity opens) are supported. -- [ ] Layout snapshot round-trip preserves slot stack and visibility semantics. +- [x] View-first deep links (`openView` then entity opens) are supported. +- [x] Layout snapshot round-trip preserves slot stack and visibility semantics. ## Task 13: High-Level Navigation Hook and Context Split @@ -286,9 +286,9 @@ Primary spec for this plan: **Dependencies:** Task 12 -**Status:** pending +**Status:** in-progress -**Owner:** unassigned +**Owner:** codex **Scope:** diff --git a/src/components/ChatView/specs/layoutController/state.json b/src/components/ChatView/specs/layoutController/state.json index 203b33906a..956ea86fbb 100644 --- a/src/components/ChatView/specs/layoutController/state.json +++ b/src/components/ChatView/specs/layoutController/state.json @@ -11,8 +11,8 @@ "task-9-unify-channellist-into-slot-model": "done", "task-10-min-slots-and-fallback-workspace-states": "done", "task-11-generic-slot-component-with-mount-preserving-hide-unhide": "done", - "task-12-deep-linking-serialization-and-openview": "pending", - "task-13-high-level-navigation-hook-and-context-split": "pending", + "task-12-deep-linking-serialization-and-openview": "done", + "task-13-high-level-navigation-hook-and-context-split": "in-progress", "task-14-tests-for-slot-stack-unified-slots-and-navigation-dx": "pending" }, "flags": { From bad6681b0c27dba4f6b78cb95e7b12b4f87fda00 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 27 Feb 2026 12:57:34 +0100 Subject: [PATCH 12/32] feat(LayoutController): implement High-Level Navigation Hook and Context Split --- .../ChannelHeader/ChannelHeader.tsx | 30 +--- src/components/ChatView/ChatView.tsx | 5 +- .../ChatView/ChatViewNavigationContext.tsx | 150 ++++++++++++++++ src/components/ChatView/index.tsx | 2 + src/components/ChatView/layout/Slot.tsx | 27 +++ .../layoutController/serialization.ts | 169 ++++++++++++++++++ .../specs/layoutController/decisions.md | 27 +++ .../ChatView/specs/layoutController/plan.md | 6 +- .../specs/layoutController/state.json | 2 +- 9 files changed, 389 insertions(+), 29 deletions(-) create mode 100644 src/components/ChatView/ChatViewNavigationContext.tsx create mode 100644 src/components/ChatView/layout/Slot.tsx create mode 100644 src/components/ChatView/layoutController/serialization.ts diff --git a/src/components/ChannelHeader/ChannelHeader.tsx b/src/components/ChannelHeader/ChannelHeader.tsx index 77cebc4195..85f70423a3 100644 --- a/src/components/ChannelHeader/ChannelHeader.tsx +++ b/src/components/ChannelHeader/ChannelHeader.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { IconChevronLeft, IconLayoutAlignLeft } from '../Icons/icons'; import { Avatar as DefaultAvatar } from '../Avatar'; import { useChatViewContext } from '../ChatView'; +import { useChatViewNavigation } from '../ChatView/ChatViewNavigationContext'; import { useChannelHeaderOnlineStatus } from './hooks/useChannelHeaderOnlineStatus'; import { useChannelPreviewInfo } from '../ChannelPreview/hooks/useChannelPreviewInfo'; import { useChannelStateContext } from '../../context/ChannelStateContext'; @@ -30,14 +31,12 @@ export type ChannelHeaderProps = { const channelHeaderLayoutSelector = ({ activeSlot, - activeView, entityListPaneOpen, slotBindings, slotHistory, visibleSlots, }: ChatViewLayoutState) => ({ activeSlot, - activeView, entityListPaneOpen, slotBindings, slotHistory, @@ -59,6 +58,7 @@ export const ChannelHeader = (props: ChannelHeaderProps) => { const { channel } = useChannelStateContext(); const { layoutController } = useChatViewContext(); + const { hideChannelList, unhideChannelList } = useChatViewNavigation(); const { t } = useTranslationContext('ChannelHeader'); const { displayImage, displayTitle, groupChannelDisplayInfo } = useChannelPreviewInfo({ channel, @@ -66,14 +66,7 @@ export const ChannelHeader = (props: ChannelHeaderProps) => { overrideTitle, }); const onlineStatusText = useChannelHeaderOnlineStatus(); - const { - activeSlot, - activeView, - entityListPaneOpen, - slotBindings, - slotHistory, - visibleSlots, - } = + const { activeSlot, entityListPaneOpen, slotBindings, slotHistory, visibleSlots } = useStateStore(layoutController.state, channelHeaderLayoutSelector) ?? channelHeaderLayoutSelector(layoutController.state.getLatestValue()); const channelListSlot = visibleSlots.find( @@ -84,23 +77,12 @@ export const ChannelHeader = (props: ChannelHeaderProps) => { const handleSidebarToggle = onSidebarToggle ?? (() => { - if (!entityListPaneOpen) { - if (!channelListSlot) { - const targetSlot = activeSlot ?? visibleSlots[0]; - if (!targetSlot) return; - - layoutController.bind(targetSlot, { - key: 'channel-list', - kind: 'channelList', - source: { view: activeView }, - }); - } - - layoutController.setEntityListPaneOpen(true); + if (entityListPaneOpen) { + hideChannelList({ slot: channelListSlot }); return; } - layoutController.setEntityListPaneOpen(false); + unhideChannelList({ slot: channelListSlot ?? activeSlot }); }); const handleHeaderAction = hasParentHistory ? () => { diff --git a/src/components/ChatView/ChatView.tsx b/src/components/ChatView/ChatView.tsx index 1d52b50d77..353a3703d6 100644 --- a/src/components/ChatView/ChatView.tsx +++ b/src/components/ChatView/ChatView.tsx @@ -17,6 +17,7 @@ import { Icon } from '../Threads/icons'; import { UnreadCountBadge } from '../Threads/UnreadCountBadge'; import { useChatContext, useTranslationContext } from '../../context'; import { useStateStore } from '../../store'; +import { ChatViewNavigationProvider } from './ChatViewNavigationContext'; import { WorkspaceLayout } from './layout/WorkspaceLayout'; import { createLayoutController } from './layoutController/LayoutController'; import { resolveTargetSlotChannelDefault } from './layoutSlotResolvers'; @@ -342,7 +343,9 @@ export const ChatView = ({ return ( -
{content}
+ +
{content}
+
); }; diff --git a/src/components/ChatView/ChatViewNavigationContext.tsx b/src/components/ChatView/ChatViewNavigationContext.tsx new file mode 100644 index 0000000000..3e71ca115e --- /dev/null +++ b/src/components/ChatView/ChatViewNavigationContext.tsx @@ -0,0 +1,150 @@ +import React, { createContext, useContext, useMemo } from 'react'; + +import { useStateStore } from '../../store'; +import { useChatViewContext } from './ChatView'; + +import type { PropsWithChildren } from 'react'; +import type { Channel as StreamChannel, Thread as StreamThread } from 'stream-chat'; +import type { ChatView } from './ChatView'; +import type { + ChatViewLayoutState, + LayoutEntityBinding, + LayoutSlot, + OpenResult, +} from './layoutController/layoutControllerTypes'; + +export type ChatViewNavigation = { + closeChannel: (options?: { slot?: LayoutSlot }) => void; + closeThread: (options?: { slot?: LayoutSlot }) => void; + hideChannelList: (options?: { slot?: LayoutSlot }) => void; + openChannel: (channel: StreamChannel, options?: { slot?: LayoutSlot }) => OpenResult; + openThread: (thread: StreamThread, options?: { slot?: LayoutSlot }) => OpenResult; + openView: (view: ChatView, options?: { slot?: LayoutSlot }) => void; + unhideChannelList: (options?: { slot?: LayoutSlot }) => void; +}; + +const chatViewNavigationStateSelector = ({ + activeSlot, + activeView, + slotBindings, + visibleSlots, +}: ChatViewLayoutState) => ({ + activeSlot, + activeView, + slotBindings, + visibleSlots, +}); + +const ChatViewNavigationContext = createContext({ + closeChannel: () => undefined, + closeThread: () => undefined, + hideChannelList: () => undefined, + openChannel: () => ({ reason: 'no-available-slot', status: 'rejected' }), + openThread: () => ({ reason: 'no-available-slot', status: 'rejected' }), + openView: () => undefined, + unhideChannelList: () => undefined, +}); + +export const useChatViewNavigation = () => useContext(ChatViewNavigationContext); + +export const ChatViewNavigationProvider = ({ children }: PropsWithChildren) => { + const { layoutController } = useChatViewContext(); + const { activeSlot, activeView, slotBindings, visibleSlots } = + useStateStore(layoutController.state, chatViewNavigationStateSelector) ?? + chatViewNavigationStateSelector(layoutController.state.getLatestValue()); + + const value = useMemo(() => { + const findSlotByKind = (kind: LayoutEntityBinding['kind']) => + visibleSlots.find((slot) => slotBindings[slot]?.kind === kind); + const resolveSlot = (slot?: LayoutSlot) => slot ?? activeSlot; + + const openView: ChatViewNavigation['openView'] = (view, options) => { + layoutController.openView(view, { slot: options?.slot }); + }; + + const openChannel: ChatViewNavigation['openChannel'] = (channel, options) => { + openView('channels', options); + + return layoutController.open( + { + key: channel.cid ?? undefined, + kind: 'channel', + source: channel, + }, + { + targetSlot: options?.slot, + }, + ); + }; + + const closeChannel: ChatViewNavigation['closeChannel'] = (options) => { + const targetSlot = options?.slot ?? findSlotByKind('channel') ?? activeSlot; + if (!targetSlot) return; + layoutController.close(targetSlot); + }; + + const openThread: ChatViewNavigation['openThread'] = (thread, options) => { + openView('threads', options); + + return layoutController.open( + { + key: thread.id ?? undefined, + kind: 'thread', + source: thread, + }, + { + targetSlot: options?.slot, + }, + ); + }; + + const closeThread: ChatViewNavigation['closeThread'] = (options) => { + const targetSlot = options?.slot ?? findSlotByKind('thread') ?? activeSlot; + if (!targetSlot) return; + layoutController.close(targetSlot); + }; + + const hideChannelList: ChatViewNavigation['hideChannelList'] = (options) => { + const targetSlot = resolveSlot(options?.slot) ?? findSlotByKind('channelList'); + if (targetSlot) { + layoutController.setSlotHidden(targetSlot, true); + } + layoutController.setEntityListPaneOpen(false); + }; + + const unhideChannelList: ChatViewNavigation['unhideChannelList'] = (options) => { + const existingChannelListSlot = findSlotByKind('channelList'); + const targetSlot = resolveSlot(options?.slot) ?? existingChannelListSlot; + + if (targetSlot) { + if (!existingChannelListSlot) { + layoutController.bind(targetSlot, { + key: 'channel-list', + kind: 'channelList', + source: { view: activeView }, + }); + } + + layoutController.setSlotHidden(targetSlot, false); + } + + layoutController.setEntityListPaneOpen(true); + }; + + return { + closeChannel, + closeThread, + hideChannelList, + openChannel, + openThread, + openView, + unhideChannelList, + }; + }, [activeSlot, activeView, layoutController, slotBindings, visibleSlots]); + + return ( + + {children} + + ); +}; diff --git a/src/components/ChatView/index.tsx b/src/components/ChatView/index.tsx index 52f50b1f82..06dff6be00 100644 --- a/src/components/ChatView/index.tsx +++ b/src/components/ChatView/index.tsx @@ -1,4 +1,6 @@ export * from './ChatView'; +export * from './ChatViewNavigationContext'; export * from './layoutController/LayoutController'; export * from './layoutController/layoutControllerTypes'; +export * from './layoutController/serialization'; export * from './layoutSlotResolvers'; diff --git a/src/components/ChatView/layout/Slot.tsx b/src/components/ChatView/layout/Slot.tsx new file mode 100644 index 0000000000..4e5ff76bf0 --- /dev/null +++ b/src/components/ChatView/layout/Slot.tsx @@ -0,0 +1,27 @@ +import clsx from 'clsx'; +import React from 'react'; + +import type { ReactNode } from 'react'; + +export type SlotProps = { + children?: ReactNode; + className?: string; + hidden?: boolean; + slot: string; +}; + +export const Slot = ({ children, className, hidden = false, slot }: SlotProps) => ( +
+ {children} +
+); diff --git a/src/components/ChatView/layoutController/serialization.ts b/src/components/ChatView/layoutController/serialization.ts new file mode 100644 index 0000000000..1c7fe3caab --- /dev/null +++ b/src/components/ChatView/layoutController/serialization.ts @@ -0,0 +1,169 @@ +import type { + ChatViewLayoutSnapshot, + ChatViewLayoutState, + DeserializeLayoutEntityBinding, + RestoreLayoutStateOptions, + SerializeLayoutEntityBinding, + SerializeLayoutStateOptions, +} from './layoutControllerTypes'; +import type { LayoutController } from './layoutControllerTypes'; +import type { SerializedLayoutEntityBinding } from './layoutControllerTypes'; + +const isDefaultSerializableKind = (kind: SerializedLayoutEntityBinding['kind']) => + kind === 'channelList' || kind === 'userList' || kind === 'searchResults'; + +const defaultSerializeEntityBinding: SerializeLayoutEntityBinding = (entity) => { + if (!isDefaultSerializableKind(entity.kind)) return; + + return { + key: entity.key, + kind: entity.kind, + source: entity.source, + }; +}; + +const defaultDeserializeEntityBinding: DeserializeLayoutEntityBinding = (entity) => { + if (!isDefaultSerializableKind(entity.kind)) return; + + return { + key: entity.key, + kind: entity.kind, + source: entity.source, + }; +}; + +const serializeSlotHistory = ({ + serializeEntityBinding, + slotHistory, +}: { + serializeEntityBinding: SerializeLayoutEntityBinding; + slotHistory: ChatViewLayoutState['slotHistory']; +}) => + Object.entries(slotHistory ?? {}).reduce( + (nextHistory, [slot, entities]) => { + if (!entities?.length) return nextHistory; + + const serializedEntities = entities + .map(serializeEntityBinding) + .filter( + (entity): entity is SerializedLayoutEntityBinding => entity !== undefined, + ); + + nextHistory[slot] = serializedEntities.length ? serializedEntities : undefined; + + return nextHistory; + }, + {}, + ); + +const deserializeSlotHistory = ({ + deserializeEntityBinding, + slotHistory, +}: { + deserializeEntityBinding: DeserializeLayoutEntityBinding; + slotHistory: ChatViewLayoutSnapshot['slotHistory']; +}) => + Object.entries(slotHistory).reduce( + (nextHistory, [slot, entities]) => { + if (!entities?.length) return nextHistory; + + const deserializedEntities = entities + .map(deserializeEntityBinding) + .filter((entity): entity is NonNullable => entity !== undefined); + + if (deserializedEntities.length) { + nextHistory[slot] = deserializedEntities; + } + + return nextHistory; + }, + {}, + ); + +export const serializeLayoutState = ( + state: ChatViewLayoutState, + options: SerializeLayoutStateOptions = {}, +): ChatViewLayoutSnapshot => { + const serializeEntityBinding = + options.serializeEntityBinding ?? defaultSerializeEntityBinding; + + const slotBindings = state.visibleSlots.reduce( + (nextBindings, slot) => { + const binding = state.slotBindings[slot]; + if (!binding) { + nextBindings[slot] = undefined; + return nextBindings; + } + + nextBindings[slot] = serializeEntityBinding(binding); + return nextBindings; + }, + {}, + ); + + return { + activeSlot: state.activeSlot, + activeView: state.activeView, + entityListPaneOpen: state.entityListPaneOpen, + hiddenSlots: { + ...(state.hiddenSlots ?? {}), + }, + mode: state.mode, + slotBindings, + slotHistory: serializeSlotHistory({ + serializeEntityBinding, + slotHistory: state.slotHistory, + }), + slotMeta: { + ...state.slotMeta, + }, + visibleSlots: [...state.visibleSlots], + }; +}; + +export const restoreLayoutState = ( + snapshot: ChatViewLayoutSnapshot, + options: RestoreLayoutStateOptions = {}, +): ChatViewLayoutState => { + const deserializeEntityBinding = + options.deserializeEntityBinding ?? defaultDeserializeEntityBinding; + + const slotBindings = Object.entries(snapshot.slotBindings).reduce< + ChatViewLayoutState['slotBindings'] + >((nextBindings, [slot, entity]) => { + nextBindings[slot] = entity ? deserializeEntityBinding(entity) : undefined; + return nextBindings; + }, {}); + + return { + activeSlot: snapshot.activeSlot, + activeView: snapshot.activeView, + entityListPaneOpen: snapshot.entityListPaneOpen, + hiddenSlots: { + ...snapshot.hiddenSlots, + }, + mode: snapshot.mode, + slotBindings, + slotHistory: deserializeSlotHistory({ + deserializeEntityBinding, + slotHistory: snapshot.slotHistory, + }), + slotMeta: { + ...snapshot.slotMeta, + }, + visibleSlots: [...snapshot.visibleSlots], + }; +}; + +export const serializeLayoutControllerState = ( + controller: LayoutController, + options?: SerializeLayoutStateOptions, +) => serializeLayoutState(controller.state.getLatestValue(), options); + +export const restoreLayoutControllerState = ( + controller: LayoutController, + snapshot: ChatViewLayoutSnapshot, + options?: RestoreLayoutStateOptions, +) => { + controller.state.next(() => restoreLayoutState(snapshot, options)); +}; diff --git a/src/components/ChatView/specs/layoutController/decisions.md b/src/components/ChatView/specs/layoutController/decisions.md index c8312ac1f1..7b3ad64794 100644 --- a/src/components/ChatView/specs/layoutController/decisions.md +++ b/src/components/ChatView/specs/layoutController/decisions.md @@ -368,3 +368,30 @@ This enables deep-link and persistence flows without trying to serialize non-pla **Tradeoffs / Consequences:** Out-of-the-box round-trip fully preserves serializable entity kinds; channel/thread restoration requires consumer-provided deserialize hooks in the restore options. + +## Decision: Add dedicated ChatViewNavigation context/hook for high-level domain actions and route ChannelHeader through it + +**Date:** 2026-02-27 +**Context:** +Task 13 requires a less intimidating DX path for common navigation flows and a context split between low-level layout control and high-level domain actions. + +**Decision:** +Add `ChatViewNavigationContext.tsx` with `useChatViewNavigation()` and a provider mounted inside `ChatView`. The navigation hook exposes: + +- `openChannel`, `closeChannel`, +- `openThread`, `closeThread`, +- `hideChannelList`, `unhideChannelList`, +- `openView`. + +Update `ChannelHeader` to use `useChatViewNavigation()` for list hide/unhide behavior while keeping back action semantics based on slot history. Export the navigation context/hook via `ChatView/index.tsx`. + +**Reasoning:** +This gives consumers a domain-focused API without forcing direct `LayoutController` command orchestration for common flows, while still preserving low-level controller access for advanced integrations. + +**Alternatives considered:** + +- Keep all navigation logic in `ChannelHeader` and expose no new hook — rejected because it does not improve consumer DX. +- Replace low-level controller APIs entirely — rejected because advanced workflows still require low-level primitives. + +**Tradeoffs / Consequences:** +Some pre-existing high-level helpers on `LayoutController` remain available for compatibility, but the recommended consumer path is now `useChatViewNavigation()`. diff --git a/src/components/ChatView/specs/layoutController/plan.md b/src/components/ChatView/specs/layoutController/plan.md index 9a26f99cb9..3dd846ad85 100644 --- a/src/components/ChatView/specs/layoutController/plan.md +++ b/src/components/ChatView/specs/layoutController/plan.md @@ -286,7 +286,7 @@ Primary spec for this plan: **Dependencies:** Task 12 -**Status:** in-progress +**Status:** done **Owner:** codex @@ -298,8 +298,8 @@ Primary spec for this plan: **Acceptance Criteria:** -- [ ] Consumer DX path uses `useChatViewNavigation()` without direct low-level controller usage. -- [ ] Existing advanced integrations can still use low-level controller methods (`open`, `bind`, `clear`, etc.). +- [x] Consumer DX path uses `useChatViewNavigation()` without direct low-level controller usage. +- [x] Existing advanced integrations can still use low-level controller methods (`open`, `bind`, `clear`, etc.). ## Task 14: Tests for Slot Stack, Unified Slots, and Navigation DX diff --git a/src/components/ChatView/specs/layoutController/state.json b/src/components/ChatView/specs/layoutController/state.json index 956ea86fbb..338cacf6bc 100644 --- a/src/components/ChatView/specs/layoutController/state.json +++ b/src/components/ChatView/specs/layoutController/state.json @@ -12,7 +12,7 @@ "task-10-min-slots-and-fallback-workspace-states": "done", "task-11-generic-slot-component-with-mount-preserving-hide-unhide": "done", "task-12-deep-linking-serialization-and-openview": "done", - "task-13-high-level-navigation-hook-and-context-split": "in-progress", + "task-13-high-level-navigation-hook-and-context-split": "done", "task-14-tests-for-slot-stack-unified-slots-and-navigation-dx": "pending" }, "flags": { From 862a0ed3fe0852424a3369c4d74085f487524a72 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 27 Feb 2026 13:01:19 +0100 Subject: [PATCH 13/32] feat(LayoutController): adjust Thread and ThreadContext.tsx to be independent of Channel contexts --- src/components/Thread/Thread.tsx | 89 ++++++++++-------------- src/components/Threads/ThreadContext.tsx | 6 +- 2 files changed, 38 insertions(+), 57 deletions(-) diff --git a/src/components/Thread/Thread.tsx b/src/components/Thread/Thread.tsx index 93bcad2764..6475e4c437 100644 --- a/src/components/Thread/Thread.tsx +++ b/src/components/Thread/Thread.tsx @@ -10,12 +10,7 @@ import { MessageList, VirtualizedMessageList } from '../MessageList'; import { ThreadHeader as DefaultThreadHeader } from './ThreadHeader'; import { ThreadHead as DefaultThreadHead } from '../Thread/ThreadHead'; -import { - useChannelActionContext, - useChannelStateContext, - useChatContext, - useComponentContext, -} from '../../context'; +import { useChatContext, useComponentContext } from '../../context'; import { useThreadContext } from '../Threads'; import { useStateStore } from '../../store'; @@ -50,18 +45,16 @@ export type ThreadProps = { * The Thread component renders a parent Message with a list of replies */ export const Thread = (props: ThreadProps) => { - const { channel, channelConfig, thread } = useChannelStateContext('Thread'); const threadInstance = useThreadContext(); - if (!thread && !threadInstance) return null; - if (channelConfig?.replies === false) return null; + if (!threadInstance) return null; + if (threadInstance.channel.getConfig()?.replies === false) return null; // the wrapper ensures a key variable is set and the component recreates on thread switch return ( - // FIXME: TS is having trouble here as at least one of the two would always be defined ); }; @@ -69,7 +62,10 @@ export const Thread = (props: ThreadProps) => { const selector = (nextValue: ThreadState) => ({ isLoadingNext: nextValue.pagination.isLoadingNext, isLoadingPrev: nextValue.pagination.isLoadingPrev, + isStateStale: nextValue.isStateStale, + nextCursor: nextValue.pagination.nextCursor, parentMessage: nextValue.parentMessage, + prevCursor: nextValue.pagination.prevCursor, replies: nextValue.replies, }); @@ -88,15 +84,6 @@ const ThreadInner = (props: ThreadProps & { key: string }) => { } = props; const threadInstance = useThreadContext(); - - const { - thread, - threadHasMore, - threadLoadingMore, - threadMessages = [], - threadSuppressAutoscroll, - } = useChannelStateContext('Thread'); - const { closeThread, loadMoreThread } = useChannelActionContext('Thread'); const { customClasses } = useChatContext('Thread'); const { Message: ContextMessage, @@ -106,8 +93,17 @@ const ThreadInner = (props: ThreadProps & { key: string }) => { VirtualMessage, } = useComponentContext('Thread'); - const { isLoadingNext, isLoadingPrev, parentMessage, replies } = - useStateStore(threadInstance?.state, selector) ?? {}; + const { + isLoadingNext, + isLoadingPrev, + isStateStale, + nextCursor, + parentMessage, + prevCursor, + replies, + } = useStateStore(threadInstance?.state, selector) ?? {}; + + const closeThread = () => threadInstance?.deactivate(); const ThreadInput = PropInput ?? additionalMessageInputProps?.Input ?? ContextInput ?? MessageInputFlat; @@ -119,13 +115,12 @@ const ThreadInner = (props: ThreadProps & { key: string }) => { const ThreadMessageList = virtualized ? VirtualizedMessageList : MessageList; useEffect(() => { - if (threadInstance) return; - - if ((thread?.reply_count ?? 0) > 0) { - // FIXME: integrators can customize channel query options but cannot customize channel.getReplies() options - loadMoreThread(); + if (threadInstance && isStateStale) { + void threadInstance.reload(); } - }, [thread, loadMoreThread, threadInstance]); + }, [isStateStale, threadInstance]); + + if (!threadInstance || !parentMessage) return null; const threadProps: Pick< VirtualizedMessageListProps, @@ -136,24 +131,15 @@ const ThreadInner = (props: ThreadProps & { key: string }) => { | 'loadMore' | 'loadingMore' | 'messages' - > = threadInstance - ? { - loadingMore: isLoadingPrev, - loadingMoreNewer: isLoadingNext, - loadMore: threadInstance.loadPrevPage, - loadMoreNewer: threadInstance.loadNextPage, - messages: replies, - } - : { - hasMore: threadHasMore, - loadingMore: threadLoadingMore, - loadMore: loadMoreThread, - messages: threadMessages, - }; - - const messageAsThread = thread ?? parentMessage; - - if (!messageAsThread) return null; + > = { + hasMore: prevCursor !== null, + hasMoreNewer: nextCursor !== null, + loadingMore: isLoadingPrev, + loadingMoreNewer: isLoadingNext, + loadMore: threadInstance.loadPrevPage, + loadMoreNewer: threadInstance.loadNextPage, + messages: replies, + }; const threadClass = customClasses?.thread || @@ -163,8 +149,8 @@ const ThreadInner = (props: ThreadProps & { key: string }) => { const head = ( @@ -174,17 +160,16 @@ const ThreadInner = (props: ThreadProps & { key: string }) => { // Thread component needs a context which we can use for message composer
- + { focus={autoFocus} Input={ThreadInput} isThreadInput - parent={thread ?? parentMessage} + parent={parentMessage} {...additionalMessageInputProps} />
diff --git a/src/components/Threads/ThreadContext.tsx b/src/components/Threads/ThreadContext.tsx index e18dae2d41..8183cc14c8 100644 --- a/src/components/Threads/ThreadContext.tsx +++ b/src/components/Threads/ThreadContext.tsx @@ -1,7 +1,5 @@ import React, { createContext, useContext } from 'react'; -import { Channel } from '../../components'; - import type { PropsWithChildren } from 'react'; import type { Thread } from 'stream-chat'; @@ -15,7 +13,5 @@ export const ThreadProvider = ({ children, thread, }: PropsWithChildren<{ thread?: Thread }>) => ( - - {children} - + {children} ); From 4b28a746fb4c9e798d3992dde6e8785b9b90b465 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 27 Feb 2026 13:26:05 +0100 Subject: [PATCH 14/32] feat(LayoutController): implement Thread Component Layout-Controller Adaptation --- .../specs/layoutController/decisions.md | 45 +++++++++++++++++++ .../ChatView/specs/layoutController/plan.md | 39 +++++++++++++--- .../ChatView/specs/layoutController/spec.md | 15 +++++-- .../specs/layoutController/state.json | 3 +- src/components/Thread/Thread.tsx | 11 ++++- 5 files changed, 101 insertions(+), 12 deletions(-) diff --git a/src/components/ChatView/specs/layoutController/decisions.md b/src/components/ChatView/specs/layoutController/decisions.md index 7b3ad64794..94e4e263c5 100644 --- a/src/components/ChatView/specs/layoutController/decisions.md +++ b/src/components/ChatView/specs/layoutController/decisions.md @@ -395,3 +395,48 @@ This gives consumers a domain-focused API without forcing direct `LayoutControll **Tradeoffs / Consequences:** Some pre-existing high-level helpers on `LayoutController` remain available for compatibility, but the recommended consumer path is now `useChatViewNavigation()`. + +## Decision: Re-scope remaining roadmap by inserting Thread adaptation as Task 14 and renumbering tests to Task 15 + +**Date:** 2026-02-27 +**Context:** +After Task 13 completion, remaining work was a broad test task. New priority requires adapting `Thread.tsx` to the layout-controller API before final test stabilization. + +**Decision:** +Update collaboration artifacts to: + +- add new **Task 14**: `Thread.tsx` layout-controller adaptation, +- move existing tests task to **Task 15** and add dependency on Task 14, +- update execution phases and file-ownership summary accordingly, +- update `state.json` task keys and `spec.md` remaining-work notes to match new sequencing. + +**Reasoning:** +Thread behavior must align with layout-controller navigation semantics first; test stabilization should run after this integration change to avoid churn. + +**Alternatives considered:** + +- Keep tests as Task 14 and fold Thread adaptation into tests task — rejected because it mixes implementation and verification scopes. +- Insert Thread adaptation later without renumbering — rejected because user explicitly requested new Task 14 and renumbered tests Task 15. + +**Tradeoffs / Consequences:** +Any automation or scripts referencing old `task-14-tests-*` key should be updated to the new `task-15-tests-*` key. + +## Decision: Route Thread close/back action through ChatView navigation API with safe legacy fallback + +**Date:** 2026-02-27 +**Context:** +Task 14 requires adapting `src/components/Thread/Thread.tsx` to layout-controller-based navigation without changing Thread UI behavior or breaking non-ChatView usage. + +**Decision:** +Update `Thread.tsx` to use `useChatViewNavigation()` and route the close handler through `closeThread()` first, then call `threadInstance.deactivate()` as a compatibility fallback. + +**Reasoning:** +`closeThread()` enables slot-aware navigation semantics (including controller back-stack behavior) when Thread is rendered inside ChatView navigation context, while the explicit `deactivate()` keeps legacy behavior intact for non-ChatView contexts. + +**Alternatives considered:** + +- Replace `deactivate()` entirely with `closeThread()` — rejected because default/no-provider navigation path can be a no-op outside ChatView. +- Keep `deactivate()` only — rejected because it bypasses new layout-controller navigation orchestration. + +**Tradeoffs / Consequences:** +In ChatView contexts, both calls run in sequence; this favors compatibility but may be simplified later once all Thread usage is guaranteed to be navigation-context backed. diff --git a/src/components/ChatView/specs/layoutController/plan.md b/src/components/ChatView/specs/layoutController/plan.md index 3dd846ad85..3ec2c6b1ef 100644 --- a/src/components/ChatView/specs/layoutController/plan.md +++ b/src/components/ChatView/specs/layoutController/plan.md @@ -301,11 +301,35 @@ Primary spec for this plan: - [x] Consumer DX path uses `useChatViewNavigation()` without direct low-level controller usage. - [x] Existing advanced integrations can still use low-level controller methods (`open`, `bind`, `clear`, etc.). -## Task 14: Tests for Slot Stack, Unified Slots, and Navigation DX +## Task 14: Thread Component Layout-Controller Adaptation + +**File(s) to create/modify:** `src/components/Thread/Thread.tsx` + +**Dependencies:** Task 13 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Route `Thread.tsx` interaction handlers through `useChatViewNavigation()` (or equivalent ChatView layout API path) instead of legacy thread-only close assumptions. +- On close/back actions, use slot-aware transitions (`closeThread` + controller back-stack behavior) so one-slot mobile flow is deterministic. +- Keep existing Thread component rendering/UI behavior unchanged; adjust only action wiring and navigation interaction points. +- Preserve safe compatibility when Thread is rendered outside ChatView (no hard failure on missing layout navigation context). + +**Acceptance Criteria:** + +- [x] `Thread.tsx` uses ChatView layout-controller/navigation APIs for thread close/back transitions. +- [x] Thread close/back behavior follows slot-aware controller semantics in one-slot flow. +- [x] Thread UI rendering behavior is unchanged from current behavior. +- [x] Rendering Thread outside ChatView remains safe (no runtime crash/regression). + +## Task 15: Tests for Slot Stack, Unified Slots, and Navigation DX **File(s) to create/modify:** `src/components/ChatView/__tests__/layoutController.test.ts`, `src/components/ChatView/__tests__/ChatView.test.tsx`, `src/components/ChannelHeader/__tests__/ChannelHeader.test.js`, `src/components/ChatView/__tests__/ChatViewNavigation.test.tsx` (new) -**Dependencies:** Task 8, Task 9, Task 10, Task 11, Task 12, Task 13 +**Dependencies:** Task 8, Task 9, Task 10, Task 11, Task 12, Task 13, Task 14 **Status:** pending @@ -369,9 +393,13 @@ Phase 10 (After Task 12): - Task 13: High-Level Navigation Hook and Context Split -Phase 11 (After Tasks 8-13): +Phase 11 (After Task 13): + +- Task 14: Thread Component Layout-Controller Adaptation + +Phase 12 (After Tasks 8-14): -- Task 14: Tests for Slot Stack, Unified Slots, and Navigation DX +- Task 15: Tests for Slot Stack, Unified Slots, and Navigation DX ## File Ownership Summary @@ -390,4 +418,5 @@ Phase 11 (After Tasks 8-13): | 11 | `layout/Slot.tsx`, `ChatView/styling/*`, `layout/WorkspaceLayout.tsx` | | 12 | `layoutController/LayoutController.ts`, `layoutController/layoutControllerTypes.ts`, `layoutController/serialization.ts` | | 13 | `ChatView.tsx`, `ChatViewNavigationContext.tsx`, `index.tsx`, `ChannelHeader.tsx` | -| 14 | `ChatView/__tests__/layoutController.test.ts`, `ChatView/__tests__/ChatView.test.tsx`, `ChannelHeader/__tests__/ChannelHeader.test.js`, `ChatView/__tests__/ChatViewNavigation.test.tsx` | +| 14 | `src/components/Thread/Thread.tsx` | +| 15 | `ChatView/__tests__/layoutController.test.ts`, `ChatView/__tests__/ChatView.test.tsx`, `ChannelHeader/__tests__/ChannelHeader.test.js`, `ChatView/__tests__/ChatViewNavigation.test.tsx` | diff --git a/src/components/ChatView/specs/layoutController/spec.md b/src/components/ChatView/specs/layoutController/spec.md index ae0684ada8..0e34394fba 100644 --- a/src/components/ChatView/specs/layoutController/spec.md +++ b/src/components/ChatView/specs/layoutController/spec.md @@ -199,10 +199,17 @@ layoutController.bind('slot1', { /> ``` -## Non-goals in this iteration +## Remaining Follow-up Work The following are not yet implemented and remain future work: -- mount-preserving hide/unhide slot primitive -- `openView` and serializer/restore APIs -- separate `useChatViewNavigation()` high-level hook +- Full regression/navigation test sweep for slot stack, unified slots, and ChatView navigation DX + +## Thread.tsx Adaptation Plan + +`src/components/Thread/Thread.tsx` should adopt the ChatView layout API with the following changes: + +1. Use `useChatViewNavigation()` for thread-level navigation actions (open/close/back) instead of local-only thread-close assumptions. +2. On close/back actions, prefer slot-aware transitions (`closeThread`/controller back stack behavior) so one-slot mobile flow is deterministic. +3. Keep existing Thread UI/render behavior unchanged; only route interaction handlers through the navigation/layout API. +4. Preserve compatibility when Thread renders outside ChatView (fallback behavior should remain safe/no-op where layout context is absent). diff --git a/src/components/ChatView/specs/layoutController/state.json b/src/components/ChatView/specs/layoutController/state.json index 338cacf6bc..cf74ba7b04 100644 --- a/src/components/ChatView/specs/layoutController/state.json +++ b/src/components/ChatView/specs/layoutController/state.json @@ -13,7 +13,8 @@ "task-11-generic-slot-component-with-mount-preserving-hide-unhide": "done", "task-12-deep-linking-serialization-and-openview": "done", "task-13-high-level-navigation-hook-and-context-split": "done", - "task-14-tests-for-slot-stack-unified-slots-and-navigation-dx": "pending" + "task-14-thread-component-layout-controller-adaptation": "done", + "task-15-tests-for-slot-stack-unified-slots-and-navigation-dx": "pending" }, "flags": { "blocked": false, diff --git a/src/components/Thread/Thread.tsx b/src/components/Thread/Thread.tsx index 6475e4c437..386bca302b 100644 --- a/src/components/Thread/Thread.tsx +++ b/src/components/Thread/Thread.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import clsx from 'clsx'; import { LegacyThreadContext } from './LegacyThreadContext'; @@ -7,6 +7,7 @@ import type { MessageInputProps } from '../MessageInput'; import { MessageInput, MessageInputFlat } from '../MessageInput'; import type { MessageListProps, VirtualizedMessageListProps } from '../MessageList'; import { MessageList, VirtualizedMessageList } from '../MessageList'; +import { useChatViewNavigation } from '../ChatView/ChatViewNavigationContext'; import { ThreadHeader as DefaultThreadHeader } from './ThreadHeader'; import { ThreadHead as DefaultThreadHead } from '../Thread/ThreadHead'; @@ -103,7 +104,13 @@ const ThreadInner = (props: ThreadProps & { key: string }) => { replies, } = useStateStore(threadInstance?.state, selector) ?? {}; - const closeThread = () => threadInstance?.deactivate(); + const { closeThread: closeThreadFromNavigation } = useChatViewNavigation(); + + const closeThread = useCallback(() => { + closeThreadFromNavigation(); + // Keep legacy behavior when Thread is used outside ChatView navigation flow. + threadInstance?.deactivate(); + }, [closeThreadFromNavigation, threadInstance]); const ThreadInput = PropInput ?? additionalMessageInputProps?.Input ?? ContextInput ?? MessageInputFlat; From 55a7e37388b545ad69640e5544fb0ce975fa776e Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 27 Feb 2026 13:26:25 +0100 Subject: [PATCH 15/32] feat(LayoutController): add translations to "aria/Go back" --- src/i18n/de.json | 1 + src/i18n/en.json | 1 + src/i18n/es.json | 1 + src/i18n/fr.json | 1 + src/i18n/hi.json | 1 + src/i18n/it.json | 1 + src/i18n/ja.json | 1 + src/i18n/ko.json | 1 + src/i18n/nl.json | 1 + src/i18n/pt.json | 1 + src/i18n/ru.json | 1 + src/i18n/tr.json | 1 + 12 files changed, 12 insertions(+) diff --git a/src/i18n/de.json b/src/i18n/de.json index b2cb1f73ce..80d87d6a1a 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -63,6 +63,7 @@ "aria/File input": "Dateieingabe", "aria/File upload": "Datei hochladen", "aria/Flag Message": "Nachricht melden", + "aria/Go back": "Zurück", "aria/Image input": "Bildeingabe", "aria/Load More Channels": "Mehr Kanäle laden", "aria/Mark Message Unread": "Als ungelesen markieren", diff --git a/src/i18n/en.json b/src/i18n/en.json index e1edb38dca..d53f6e4cc9 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -63,6 +63,7 @@ "aria/File input": "File input", "aria/File upload": "File upload", "aria/Flag Message": "Flag Message", + "aria/Go back": "Go back", "aria/Image input": "Image input", "aria/Load More Channels": "Load More Channels", "aria/Mark Message Unread": "Mark Message Unread", diff --git a/src/i18n/es.json b/src/i18n/es.json index 3089ebcd67..fc0acd871d 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -70,6 +70,7 @@ "aria/File input": "Entrada de archivo", "aria/File upload": "Carga de archivo", "aria/Flag Message": "Marcar mensaje", + "aria/Go back": "Volver", "aria/Image input": "Entrada de imagen", "aria/Load More Channels": "Cargar más canales", "aria/Mark Message Unread": "Marcar como no leído", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index bac82f53f5..9bd7de3145 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -70,6 +70,7 @@ "aria/File input": "Entrée de fichier", "aria/File upload": "Téléchargement de fichier", "aria/Flag Message": "Signaler le message", + "aria/Go back": "Retour", "aria/Image input": "Entrée d'image", "aria/Load More Channels": "Charger plus de canaux", "aria/Mark Message Unread": "Marquer comme non lu", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 19cce68e21..66a043ef77 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -63,6 +63,7 @@ "aria/File input": "फ़ाइल इनपुट", "aria/File upload": "फ़ाइल अपलोड", "aria/Flag Message": "संदेश फ्लैग करें", + "aria/Go back": "वापस जाएं", "aria/Image input": "छवि इनपुट", "aria/Load More Channels": "और चैनल लोड करें", "aria/Mark Message Unread": "अपठित चिह्नित करें", diff --git a/src/i18n/it.json b/src/i18n/it.json index bc7f484495..662aedc3d3 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -70,6 +70,7 @@ "aria/File input": "Input di file", "aria/File upload": "Caricamento di file", "aria/Flag Message": "Segnala messaggio", + "aria/Go back": "Indietro", "aria/Image input": "Input di immagine", "aria/Load More Channels": "Carica altri canali", "aria/Mark Message Unread": "Contrassegna come non letto", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index a6e1a1b664..5b3206c5dd 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -62,6 +62,7 @@ "aria/File input": "ファイル入力", "aria/File upload": "ファイルアップロード", "aria/Flag Message": "メッセージをフラグ", + "aria/Go back": "戻る", "aria/Image input": "画像入力", "aria/Load More Channels": "さらにチャンネルを読み込む", "aria/Mark Message Unread": "未読としてマーク", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index dbfc74acf5..77af31fbaf 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -62,6 +62,7 @@ "aria/File input": "파일 입력", "aria/File upload": "파일 업로드", "aria/Flag Message": "메시지 신고", + "aria/Go back": "뒤로 가기", "aria/Image input": "이미지 입력", "aria/Load More Channels": "더 많은 채널 불러오기", "aria/Mark Message Unread": "읽지 않음으로 표시", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 10ff55f92f..680a60eac1 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -63,6 +63,7 @@ "aria/File input": "Bestandsinvoer", "aria/File upload": "Bestand uploaden", "aria/Flag Message": "Bericht markeren", + "aria/Go back": "Ga terug", "aria/Image input": "Afbeelding invoeren", "aria/Load More Channels": "Meer kanalen laden", "aria/Mark Message Unread": "Markeren als ongelezen", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index a75b5bf5df..e6f03e573c 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -70,6 +70,7 @@ "aria/File input": "Entrada de arquivo", "aria/File upload": "Carregar arquivo", "aria/Flag Message": "Reportar mensagem", + "aria/Go back": "Voltar", "aria/Image input": "Entrada de imagem", "aria/Load More Channels": "Carregar mais canais", "aria/Mark Message Unread": "Marcar como não lida", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 2b7c3324cc..59fd8d042e 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -77,6 +77,7 @@ "aria/File input": "Ввод файла", "aria/File upload": "Загрузка файла", "aria/Flag Message": "Пожаловаться на сообщение", + "aria/Go back": "Назад", "aria/Image input": "Ввод изображения", "aria/Load More Channels": "Загрузить больше каналов", "aria/Mark Message Unread": "Отметить как непрочитанное", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index 4250a4edb0..b2ebf650e8 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -63,6 +63,7 @@ "aria/File input": "Dosya girişi", "aria/File upload": "Dosya yükleme", "aria/Flag Message": "Mesajı bayrakla", + "aria/Go back": "Geri git", "aria/Image input": "Resim girişi", "aria/Load More Channels": "Daha Fazla Kanal Yükle", "aria/Mark Message Unread": "Okunmamış olarak işaretle", From f5f328ec6d1e857ea65d3ced458bc929e52cd46d Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 27 Feb 2026 16:47:39 +0100 Subject: [PATCH 16/32] feat(LayoutController): implement Slot Self-Visibility from Slot Prop and Remove Entity Semantics from LayoutController (Slot-Only Controller) --- .../ChannelHeader/ChannelHeader.tsx | 4 +- .../__tests__/ChannelHeader.test.js | 47 ++++ src/components/ChatView/ChatView.tsx | 148 ++++++++----- .../ChatView/ChatViewNavigationContext.tsx | 36 ++-- .../ChatView/__tests__/ChatView.test.tsx | 64 +++++- .../__tests__/ChatViewNavigation.test.tsx | 201 ++++++++++++++++++ .../__tests__/layoutController.test.ts | 163 ++++++++++++-- src/components/ChatView/layout/Slot.tsx | 54 +++-- .../ChatView/layout/WorkspaceLayout.tsx | 23 +- .../layoutController/LayoutController.ts | 167 +++++---------- .../layoutController/layoutControllerTypes.ts | 100 ++++----- .../layoutController/serialization.ts | 77 +++---- .../ChatView/layoutSlotResolvers.ts | 21 +- .../specs/layoutController/decisions.md | 90 ++++++++ .../ChatView/specs/layoutController/plan.md | 70 +++++- .../ChatView/specs/layoutController/spec.md | 30 +++ .../specs/layoutController/state.json | 4 +- src/components/Thread/ThreadSlot.tsx | 66 ++++++ 18 files changed, 1009 insertions(+), 356 deletions(-) create mode 100644 src/components/ChatView/__tests__/ChatViewNavigation.test.tsx create mode 100644 src/components/Thread/ThreadSlot.tsx diff --git a/src/components/ChannelHeader/ChannelHeader.tsx b/src/components/ChannelHeader/ChannelHeader.tsx index 85f70423a3..8a579a5138 100644 --- a/src/components/ChannelHeader/ChannelHeader.tsx +++ b/src/components/ChannelHeader/ChannelHeader.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { IconChevronLeft, IconLayoutAlignLeft } from '../Icons/icons'; import { Avatar as DefaultAvatar } from '../Avatar'; -import { useChatViewContext } from '../ChatView'; +import { getChatViewEntityBinding, useChatViewContext } from '../ChatView'; import { useChatViewNavigation } from '../ChatView/ChatViewNavigationContext'; import { useChannelHeaderOnlineStatus } from './hooks/useChannelHeaderOnlineStatus'; import { useChannelPreviewInfo } from '../ChannelPreview/hooks/useChannelPreviewInfo'; @@ -70,7 +70,7 @@ export const ChannelHeader = (props: ChannelHeaderProps) => { useStateStore(layoutController.state, channelHeaderLayoutSelector) ?? channelHeaderLayoutSelector(layoutController.state.getLatestValue()); const channelListSlot = visibleSlots.find( - (slot) => slotBindings[slot]?.kind === 'channelList', + (slot) => getChatViewEntityBinding(slotBindings[slot])?.kind === 'channelList', ); const hasParentHistory = !!(activeSlot && slotHistory?.[activeSlot]?.length); const sidebarCollapsed = sidebarCollapsedProp ?? !entityListPaneOpen; diff --git a/src/components/ChannelHeader/__tests__/ChannelHeader.test.js b/src/components/ChannelHeader/__tests__/ChannelHeader.test.js index 645a7fa94e..a65d808570 100644 --- a/src/components/ChannelHeader/__tests__/ChannelHeader.test.js +++ b/src/components/ChannelHeader/__tests__/ChannelHeader.test.js @@ -494,4 +494,51 @@ describe('ChannelHeader', () => { expect(onSidebarToggle).toHaveBeenCalledTimes(1); expect(layoutController.state.getLatestValue().entityListPaneOpen).toBe(true); }); + + it('should use back action when active slot has parent history', async () => { + const onSidebarToggle = jest.fn(); + const layoutController = createLayoutController({ + initialState: { + activeSlot: 'slot1', + entityListPaneOpen: true, + slotBindings: { + slot1: { + key: 'channel:active', + kind: 'channel', + source: { cid: 'messaging:active' }, + }, + }, + slotHistory: { + slot1: [ + { + key: 'channel-list', + kind: 'channelList', + source: { view: 'channels' }, + }, + ], + }, + visibleSlots: ['slot1'], + }, + }); + + await renderComponent({ + chatViewProps: { + layoutController, + }, + props: { + onSidebarToggle, + }, + }); + + act(() => { + screen.getByRole('button', { name: 'aria/Go back' }).click(); + }); + + await waitFor(() => + expect(layoutController.state.getLatestValue().slotBindings.slot1?.kind).toBe( + 'channelList', + ), + ); + expect(onSidebarToggle).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/ChatView/ChatView.tsx b/src/components/ChatView/ChatView.tsx index 353a3703d6..b1abbbad0f 100644 --- a/src/components/ChatView/ChatView.tsx +++ b/src/components/ChatView/ChatView.tsx @@ -23,32 +23,53 @@ import { createLayoutController } from './layoutController/LayoutController'; import { resolveTargetSlotChannelDefault } from './layoutSlotResolvers'; import type { PropsWithChildren, ReactNode } from 'react'; -import type { Thread, ThreadManagerState } from 'stream-chat'; +import type { Channel as StreamChannel, Thread, ThreadManagerState } from 'stream-chat'; import type { ChatViewLayoutState, - DuplicateEntityPolicy, + DuplicateSlotPolicy, LayoutController, - LayoutEntityBinding, - ResolveDuplicateEntity, + LayoutSlotBinding, + ResolveDuplicateSlot, ResolveTargetSlot, } from './layoutController/layoutControllerTypes'; export type ChatView = 'channels' | 'threads'; +export type UserListEntitySource = { + query: string; +}; + +export type ChannelListEntitySource = { + view?: ChatView; +}; + +export type SearchResultsEntitySource = { + query: string; +}; + +export type ChatViewEntityBinding = + | { key?: string; kind: 'channelList'; source: ChannelListEntitySource } + | { key?: string; kind: 'channel'; source: StreamChannel } + | { key?: string; kind: 'thread'; source: Thread } + | { key?: string; kind: 'memberList'; source: StreamChannel } + | { key?: string; kind: 'userList'; source: UserListEntitySource } + | { key?: string; kind: 'searchResults'; source: SearchResultsEntitySource } + | { key?: string; kind: 'pinnedMessagesList'; source: StreamChannel }; + export type ChatViewEntityInferer = { - kind: LayoutEntityBinding['kind']; + kind: ChatViewEntityBinding['kind']; match: (source: unknown) => boolean; - toBinding: (source: unknown) => LayoutEntityBinding; + toBinding: (source: unknown) => ChatViewEntityBinding; }; export type ChatViewBuiltinLayout = 'nav-rail-entity-list-workspace'; -type LayoutEntityByKind = Extract< - LayoutEntityBinding, +type LayoutEntityByKind = Extract< + ChatViewEntityBinding, { kind: TKind } >; -export type ChatViewSlotRendererProps = { +export type ChatViewSlotRendererProps = { entity: LayoutEntityByKind; slot: string; source: LayoutEntityByKind['source']; @@ -59,19 +80,19 @@ export type ChatViewSlotFallbackProps = { }; export type ChatViewSlotRenderers = Partial<{ - [TKind in LayoutEntityBinding['kind']]: ( + [TKind in ChatViewEntityBinding['kind']]: ( props: ChatViewSlotRendererProps, ) => ReactNode; }>; export type ChatViewProps = PropsWithChildren<{ - duplicateEntityPolicy?: DuplicateEntityPolicy; + duplicateEntityPolicy?: DuplicateSlotPolicy; entityInferers?: ChatViewEntityInferer[]; layout?: ChatViewBuiltinLayout; layoutController?: LayoutController; maxSlots?: number; minSlots?: number; - resolveDuplicateEntity?: ResolveDuplicateEntity; + resolveDuplicateEntity?: ResolveDuplicateSlot; resolveTargetSlot?: ResolveTargetSlot; SlotFallback?: ComponentType; slotFallbackComponents?: Partial< @@ -134,8 +155,26 @@ const workspaceLayoutStateSelector = ({ visibleSlots, }); +const isChatViewEntityBinding = (value: unknown): value is ChatViewEntityBinding => { + if (!value || typeof value !== 'object') return false; + const candidate = value as Partial; + return typeof candidate.kind === 'string' && 'source' in candidate; +}; + +export const getChatViewEntityBinding = ( + binding?: LayoutSlotBinding, +): ChatViewEntityBinding | undefined => + isChatViewEntityBinding(binding?.payload) ? binding.payload : undefined; + +export const createChatViewSlotBinding = ( + entity: ChatViewEntityBinding, +): LayoutSlotBinding => ({ + key: entity.key ? `${entity.kind}:${entity.key}` : undefined, + payload: entity, +}); + const renderSlotBinding = ( - entity: LayoutEntityBinding | undefined, + entity: ChatViewEntityBinding | undefined, slot: string, slotRenderers: ChatViewSlotRenderers | undefined, ): ReactNode | null => { @@ -264,19 +303,26 @@ export const ChatView = ({ if (layout !== 'nav-rail-entity-list-workspace') return; const existingChannelListSlot = workspaceLayoutState.visibleSlots.find( - (slot) => workspaceLayoutState.slotBindings[slot]?.kind === 'channelList', + (slot) => + getChatViewEntityBinding(workspaceLayoutState.slotBindings[slot])?.kind === + 'channelList', ); if (existingChannelListSlot) { - const existingEntity = workspaceLayoutState.slotBindings[existingChannelListSlot]; + const existingEntity = getChatViewEntityBinding( + workspaceLayoutState.slotBindings[existingChannelListSlot], + ); if ( existingEntity?.kind === 'channelList' && existingEntity.source.view !== workspaceLayoutState.activeView ) { - effectiveLayoutController.bind(existingChannelListSlot, { - ...existingEntity, - source: { view: workspaceLayoutState.activeView }, - }); + effectiveLayoutController.bind( + existingChannelListSlot, + createChatViewSlotBinding({ + ...existingEntity, + source: { view: workspaceLayoutState.activeView }, + }), + ); } return; } @@ -286,11 +332,14 @@ export const ChatView = ({ ); if (!firstFreeSlot) return; - effectiveLayoutController.bind(firstFreeSlot, { - key: 'channel-list', - kind: 'channelList', - source: { view: workspaceLayoutState.activeView }, - }); + effectiveLayoutController.bind( + firstFreeSlot, + createChatViewSlotBinding({ + key: 'channel-list', + kind: 'channelList', + source: { view: workspaceLayoutState.activeView }, + }), + ); }, [ effectiveLayoutController, layout, @@ -302,42 +351,25 @@ export const ChatView = ({ const content = layout === 'nav-rail-entity-list-workspace' ? (() => { - const slots = workspaceLayoutState.visibleSlots.map((slot) => ({ - content: renderSlotBinding( - workspaceLayoutState.slotBindings[slot], + const slots = workspaceLayoutState.visibleSlots.map((slot) => { + const content = renderSlotBinding( + getChatViewEntityBinding(workspaceLayoutState.slotBindings[slot]), slot, slotRenderers, - ), - slot, - })); - const entityListSlot = slots.find( - ({ slot }) => workspaceLayoutState.slotBindings[slot]?.kind === 'channelList', - ); - - return ( - } - slots={slots - .filter( - ({ slot }) => - workspaceLayoutState.slotBindings[slot]?.kind !== 'channelList', - ) - .map(({ content, slot }) => { - const Fallback = resolveSlotFallbackComponent({ - slot, - SlotFallback, - slotFallbackComponents, - }); - - return { - content: content ?? , - slot, - }; - })} - /> - ); + ); + const Fallback = resolveSlotFallbackComponent({ + slot, + SlotFallback, + slotFallbackComponents, + }); + + return { + content: content ?? , + slot, + }; + }); + + return } slots={slots} />; })() : children; diff --git a/src/components/ChatView/ChatViewNavigationContext.tsx b/src/components/ChatView/ChatViewNavigationContext.tsx index 3e71ca115e..2677781ec6 100644 --- a/src/components/ChatView/ChatViewNavigationContext.tsx +++ b/src/components/ChatView/ChatViewNavigationContext.tsx @@ -1,14 +1,17 @@ import React, { createContext, useContext, useMemo } from 'react'; import { useStateStore } from '../../store'; -import { useChatViewContext } from './ChatView'; +import { + createChatViewSlotBinding, + getChatViewEntityBinding, + useChatViewContext, +} from './ChatView'; import type { PropsWithChildren } from 'react'; import type { Channel as StreamChannel, Thread as StreamThread } from 'stream-chat'; -import type { ChatView } from './ChatView'; +import type { ChatView, ChatViewEntityBinding } from './ChatView'; import type { ChatViewLayoutState, - LayoutEntityBinding, LayoutSlot, OpenResult, } from './layoutController/layoutControllerTypes'; @@ -54,8 +57,10 @@ export const ChatViewNavigationProvider = ({ children }: PropsWithChildren) => { chatViewNavigationStateSelector(layoutController.state.getLatestValue()); const value = useMemo(() => { - const findSlotByKind = (kind: LayoutEntityBinding['kind']) => - visibleSlots.find((slot) => slotBindings[slot]?.kind === kind); + const findSlotByKind = (kind: ChatViewEntityBinding['kind']) => + visibleSlots.find( + (slot) => getChatViewEntityBinding(slotBindings[slot])?.kind === kind, + ); const resolveSlot = (slot?: LayoutSlot) => slot ?? activeSlot; const openView: ChatViewNavigation['openView'] = (view, options) => { @@ -66,11 +71,11 @@ export const ChatViewNavigationProvider = ({ children }: PropsWithChildren) => { openView('channels', options); return layoutController.open( - { + createChatViewSlotBinding({ key: channel.cid ?? undefined, kind: 'channel', source: channel, - }, + }), { targetSlot: options?.slot, }, @@ -87,11 +92,11 @@ export const ChatViewNavigationProvider = ({ children }: PropsWithChildren) => { openView('threads', options); return layoutController.open( - { + createChatViewSlotBinding({ key: thread.id ?? undefined, kind: 'thread', source: thread, - }, + }), { targetSlot: options?.slot, }, @@ -118,11 +123,14 @@ export const ChatViewNavigationProvider = ({ children }: PropsWithChildren) => { if (targetSlot) { if (!existingChannelListSlot) { - layoutController.bind(targetSlot, { - key: 'channel-list', - kind: 'channelList', - source: { view: activeView }, - }); + layoutController.bind( + targetSlot, + createChatViewSlotBinding({ + key: 'channel-list', + kind: 'channelList', + source: { view: activeView }, + }), + ); } layoutController.setSlotHidden(targetSlot, false); diff --git a/src/components/ChatView/__tests__/ChatView.test.tsx b/src/components/ChatView/__tests__/ChatView.test.tsx index ac4bc4b339..c941792e80 100644 --- a/src/components/ChatView/__tests__/ChatView.test.tsx +++ b/src/components/ChatView/__tests__/ChatView.test.tsx @@ -1,6 +1,6 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { StateStore } from 'stream-chat'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; jest.mock('../../ChannelList', () => ({ @@ -125,6 +125,66 @@ describe('ChatView', () => { expect(screen.getByTestId('channel-slot')).toHaveTextContent('messaging:workspace'); }); + it('renders fallback workspace content when minSlots reserves an empty slot', () => { + renderWithProviders( + , + ); + + expect(screen.getByTestId('channel-list')).toBeInTheDocument(); + expect(screen.getByText('Select a channel to start messaging')).toBeInTheDocument(); + }); + + it('keeps channelList slot mounted when hidden/unhidden', () => { + const layoutController = createLayoutController({ + initialState: { + visibleSlots: ['slot1', 'slot2'], + }, + }); + + const StatefulChannelList = () => { + const [count, setCount] = useState(0); + + return ( + + ); + }; + + const { container } = renderWithProviders( + , + }} + />, + ); + + fireEvent.click(screen.getByTestId('channel-list-counter')); + expect(screen.getByTestId('channel-list-counter')).toHaveTextContent('1'); + + act(() => { + layoutController.setEntityListPaneOpen(false); + }); + expect( + container.querySelector( + '.str-chat__chat-view__workspace-layout-entity-list-pane.str-chat__chat-view__slot--hidden', + ), + ).toBeInTheDocument(); + + act(() => { + layoutController.setEntityListPaneOpen(true); + }); + expect(screen.getByTestId('channel-list-counter')).toHaveTextContent('1'); + }); + it('preserves custom children layout when built-in layout is not set', () => { const Child = () => { const { layoutController } = useChatViewContext(); diff --git a/src/components/ChatView/__tests__/ChatViewNavigation.test.tsx b/src/components/ChatView/__tests__/ChatViewNavigation.test.tsx new file mode 100644 index 0000000000..dadd1aaab2 --- /dev/null +++ b/src/components/ChatView/__tests__/ChatViewNavigation.test.tsx @@ -0,0 +1,201 @@ +import React from 'react'; +import { StateStore } from 'stream-chat'; +import { fireEvent, render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { ChatView, useChatViewContext } from '../ChatView'; +import { useChatViewNavigation } from '../ChatViewNavigationContext'; + +import { ChatProvider } from '../../../context/ChatContext'; +import { TranslationProvider } from '../../../context/TranslationContext'; + +import type { Channel as StreamChannel, Thread as StreamThread } from 'stream-chat'; +import type { ChatContextValue } from '../../../context/ChatContext'; +import type { LayoutController } from '../layoutController/layoutControllerTypes'; + +const makeChannel = (cid: string) => ({ cid }) as unknown as StreamChannel; +const makeThread = (id: string) => ({ id }) as unknown as StreamThread; + +const createChatContextValue = (): ChatContextValue => + ({ + channelsQueryState: { + error: null, + queryInProgress: null, + setError: jest.fn(), + setQueryInProgress: jest.fn(), + }, + client: { + threads: { + state: new StateStore({ + unreadThreadCount: 0, + }), + }, + }, + closeMobileNav: jest.fn(), + getAppSettings: jest.fn(() => null), + latestMessageDatesByChannels: {}, + mutes: [], + openMobileNav: jest.fn(), + searchController: {}, + setActiveChannel: jest.fn(), + theme: 'str-chat__theme-light', + useImageFlagEmojisOnWindows: false, + }) as unknown as ChatContextValue; + +const renderWithProviders = (ui: React.ReactNode) => + render( + + key, userLanguage: 'en' }}> + {ui} + + , + ); + +describe('useChatViewNavigation', () => { + it('supports open/close thread flow with one-slot history restoration', () => { + const channel = makeChannel('messaging:navigation'); + const thread = makeThread('thread-navigation'); + let capturedController: LayoutController | undefined; + + const Harness = () => { + const navigation = useChatViewNavigation(); + const { layoutController } = useChatViewContext(); + capturedController = layoutController; + + return ( + <> + + + + + ); + }; + + renderWithProviders( + + + , + ); + + fireEvent.click(screen.getByText('open-channel')); + expect(capturedController?.state.getLatestValue()).toMatchObject({ + activeView: 'channels', + slotBindings: { + slot1: { kind: 'channel' }, + }, + }); + + fireEvent.click(screen.getByText('open-thread')); + expect(capturedController?.state.getLatestValue()).toMatchObject({ + activeView: 'threads', + slotBindings: { + slot1: { kind: 'thread' }, + }, + slotHistory: { + slot1: [{ kind: 'channel' }], + }, + }); + + fireEvent.click(screen.getByText('close-thread')); + expect(capturedController?.state.getLatestValue()).toMatchObject({ + activeView: 'threads', + slotBindings: { + slot1: { kind: 'channel' }, + }, + slotHistory: {}, + }); + }); + + it('hides and unhides channelList slot without requiring existing binding', () => { + let capturedController: LayoutController | undefined; + + const Harness = () => { + const navigation = useChatViewNavigation(); + const { layoutController } = useChatViewContext(); + capturedController = layoutController; + + return ( + <> + + + + ); + }; + + renderWithProviders( + + + , + ); + + fireEvent.click(screen.getByText('hide-list')); + expect(capturedController?.state.getLatestValue()).toMatchObject({ + entityListPaneOpen: false, + hiddenSlots: { + slot1: true, + }, + }); + + fireEvent.click(screen.getByText('unhide-list')); + expect(capturedController?.state.getLatestValue()).toMatchObject({ + entityListPaneOpen: true, + hiddenSlots: { + slot1: false, + }, + slotBindings: { + slot1: { + key: 'channel-list', + kind: 'channelList', + source: { view: 'channels' }, + }, + }, + }); + }); + + it('openView can activate provided slot', () => { + let capturedController: LayoutController | undefined; + + const Harness = () => { + const navigation = useChatViewNavigation(); + const { layoutController } = useChatViewContext(); + capturedController = layoutController; + + return ( + + ); + }; + + renderWithProviders( + + + , + ); + + fireEvent.click(screen.getByText('open-threads-slot2')); + expect(capturedController?.state.getLatestValue()).toMatchObject({ + activeSlot: 'slot2', + activeView: 'threads', + }); + }); +}); diff --git a/src/components/ChatView/__tests__/layoutController.test.ts b/src/components/ChatView/__tests__/layoutController.test.ts index 1d1f7bf005..e15816defc 100644 --- a/src/components/ChatView/__tests__/layoutController.test.ts +++ b/src/components/ChatView/__tests__/layoutController.test.ts @@ -1,4 +1,8 @@ import { createLayoutController } from '../layoutController/LayoutController'; +import { + restoreLayoutControllerState, + serializeLayoutControllerState, +} from '../layoutController/serialization'; import { resolveTargetSlotChannelDefault } from '../layoutSlotResolvers'; import type { Channel as StreamChannel, Thread as StreamThread } from 'stream-chat'; @@ -6,6 +10,10 @@ import type { ChatViewLayoutState } from '../layoutController/layoutControllerTy const makeChannel = (cid: string) => ({ cid }) as unknown as StreamChannel; const makeThread = (id: string) => ({ id }) as unknown as StreamThread; +const makeBinding = (kind: string, source: unknown, key?: string) => ({ + key, + payload: { key, kind, source }, +}); describe('layoutController', () => { it('returns opened, replaced, and rejected outcomes from open()', () => { @@ -16,10 +24,16 @@ describe('layoutController', () => { resolveTargetSlot: () => 'slot1', }); - const firstOpen = controller.openChannel(makeChannel('messaging:one')); - const secondOpen = controller.openChannel(makeChannel('messaging:two')); + const firstOpen = controller.open( + makeBinding('channel', makeChannel('messaging:one'), 'messaging:one'), + ); + const secondOpen = controller.open( + makeBinding('channel', makeChannel('messaging:two'), 'messaging:two'), + ); controller.clear('slot1'); - const rejectedOpen = controller.openChannel(makeChannel('messaging:three')); + const rejectedOpen = controller.open( + makeBinding('channel', makeChannel('messaging:three'), 'messaging:three'), + ); expect(firstOpen).toMatchObject({ slot: 'slot1', status: 'opened' }); expect(secondOpen).toMatchObject({ slot: 'slot1', status: 'replaced' }); @@ -36,7 +50,9 @@ describe('layoutController', () => { }, }); - controller.openChannel(makeChannel('messaging:one')); + controller.open( + makeBinding('channel', makeChannel('messaging:one'), 'messaging:one'), + ); const occupiedAt = controller.state.getLatestValue().slotMeta.slot1?.occupiedAt; controller.clear('slot1'); @@ -52,13 +68,21 @@ describe('layoutController', () => { }); const duplicateChannel = makeChannel('messaging:duplicate'); - rejectController.openChannel(duplicateChannel, { targetSlot: 'slot1' }); - const rejectResult = rejectController.openChannel(duplicateChannel, { - targetSlot: 'slot2', - }); + rejectController.open( + makeBinding('channel', duplicateChannel, duplicateChannel.cid), + { + targetSlot: 'slot1', + }, + ); + const rejectResult = rejectController.open( + makeBinding('channel', duplicateChannel, duplicateChannel.cid), + { + targetSlot: 'slot2', + }, + ); expect(rejectResult).toMatchObject({ - reason: 'duplicate-entity', + reason: 'duplicate-binding', status: 'rejected', }); @@ -67,16 +91,111 @@ describe('layoutController', () => { initialState: { visibleSlots: ['slot1', 'slot2'] }, }); - moveController.openChannel(makeChannel('messaging:one'), { targetSlot: 'slot1' }); - moveController.openChannel(makeChannel('messaging:two'), { targetSlot: 'slot2' }); - moveController.openChannel(makeChannel('messaging:one'), { targetSlot: 'slot2' }); + moveController.open( + makeBinding('channel', makeChannel('messaging:one'), 'messaging:one'), + { + targetSlot: 'slot1', + }, + ); + moveController.open( + makeBinding('channel', makeChannel('messaging:two'), 'messaging:two'), + { + targetSlot: 'slot2', + }, + ); + moveController.open( + makeBinding('channel', makeChannel('messaging:one'), 'messaging:one'), + { + targetSlot: 'slot2', + }, + ); const movedState = moveController.state.getLatestValue(); expect(movedState.slotBindings.slot1).toBeUndefined(); - expect(movedState.slotBindings.slot2?.kind).toBe('channel'); - expect((movedState.slotBindings.slot2?.source as StreamChannel).cid).toBe( - 'messaging:one', + expect((movedState.slotBindings.slot2?.payload as { kind: string }).kind).toBe( + 'channel', + ); + expect( + ( + (movedState.slotBindings.slot2?.payload as { source: StreamChannel }) + .source as StreamChannel + ).cid, + ).toBe('messaging:one'); + }); + + it('openView updates activeView and can activate target slot', () => { + const controller = createLayoutController({ + initialState: { + activeSlot: 'slot1', + activeView: 'channels', + visibleSlots: ['slot1', 'slot2'], + }, + }); + + controller.openView('threads'); + expect(controller.state.getLatestValue()).toMatchObject({ + activeSlot: 'slot1', + activeView: 'threads', + }); + + controller.openView('channels', { slot: 'slot2' }); + expect(controller.state.getLatestValue()).toMatchObject({ + activeSlot: 'slot2', + activeView: 'channels', + }); + }); + + it('serializes and restores hidden slots and serializable bindings', () => { + const sourceController = createLayoutController({ + initialState: { + hiddenSlots: { slot1: true }, + mode: 'test-mode', + slotBindings: { + slot1: makeBinding('channelList', { view: 'threads' }, 'channel-list'), + slot2: makeBinding('channel', makeChannel('messaging:one'), 'channel-1'), + }, + slotHistory: { + slot2: [ + makeBinding('searchResults', { query: 'abc' }, 'search:abc'), + makeBinding('channel', makeChannel('messaging:fallback'), 'channel-fallback'), + ], + }, + visibleSlots: ['slot1', 'slot2'], + }, + }); + + const snapshot = serializeLayoutControllerState(sourceController); + expect((snapshot.slotBindings.slot1?.payload as { kind: string }).kind).toBe( + 'channelList', + ); + expect((snapshot.slotBindings.slot2?.payload as { kind: string }).kind).toBe( + 'channel', ); + expect( + snapshot.slotHistory.slot2?.map( + (entry) => (entry.payload as { kind: string }).kind, + ), + ).toEqual(['searchResults', 'channel']); + + const restoreController = createLayoutController({ + initialState: { visibleSlots: ['slot1', 'slot2'] }, + }); + restoreLayoutControllerState(restoreController, snapshot); + + expect(restoreController.state.getLatestValue()).toMatchObject({ + hiddenSlots: { slot1: true }, + mode: 'test-mode', + slotBindings: { + slot1: makeBinding('channelList', { view: 'threads' }, 'channel-list'), + slot2: makeBinding('channel', makeChannel('messaging:one'), 'channel-1'), + }, + slotHistory: { + slot2: [ + makeBinding('searchResults', { query: 'abc' }, 'search:abc'), + makeBinding('channel', makeChannel('messaging:fallback'), 'channel-fallback'), + ], + }, + }); }); }); @@ -94,7 +213,7 @@ describe('resolveTargetSlotChannelDefault', () => { it('prefers requestedSlot when provided', () => { const slot = resolveTargetSlotChannelDefault({ - entity: { kind: 'channel', source: makeChannel('messaging:one') }, + binding: makeBinding('channel', makeChannel('messaging:one')), requestedSlot: 'slot2', state: makeState({}), }); @@ -105,13 +224,13 @@ describe('resolveTargetSlotChannelDefault', () => { it('replaces thread slot first when opening a channel into a full workspace', () => { const state = makeState({ slotBindings: { - slot1: { kind: 'channel', source: makeChannel('messaging:one') }, - slot2: { kind: 'thread', source: makeThread('thread-1') }, + slot1: makeBinding('channel', makeChannel('messaging:one')), + slot2: makeBinding('thread', makeThread('thread-1')), }, }); const slot = resolveTargetSlotChannelDefault({ - entity: { kind: 'channel', source: makeChannel('messaging:two') }, + binding: makeBinding('channel', makeChannel('messaging:two')), state, }); @@ -121,8 +240,8 @@ describe('resolveTargetSlotChannelDefault', () => { it('falls back to earliest occupied slot when only channels are present', () => { const state = makeState({ slotBindings: { - slot1: { kind: 'channel', source: makeChannel('messaging:one') }, - slot2: { kind: 'channel', source: makeChannel('messaging:two') }, + slot1: makeBinding('channel', makeChannel('messaging:one')), + slot2: makeBinding('channel', makeChannel('messaging:two')), }, slotMeta: { slot1: { occupiedAt: 10 }, @@ -131,7 +250,7 @@ describe('resolveTargetSlotChannelDefault', () => { }); const slot = resolveTargetSlotChannelDefault({ - entity: { kind: 'channel', source: makeChannel('messaging:three') }, + binding: makeBinding('channel', makeChannel('messaging:three')), state, }); diff --git a/src/components/ChatView/layout/Slot.tsx b/src/components/ChatView/layout/Slot.tsx index 4e5ff76bf0..cc324fad48 100644 --- a/src/components/ChatView/layout/Slot.tsx +++ b/src/components/ChatView/layout/Slot.tsx @@ -1,27 +1,49 @@ import clsx from 'clsx'; import React from 'react'; +import { useMemo } from 'react'; + +import { useStateStore } from '../../../store'; +import { getChatViewEntityBinding, useChatViewContext } from '../ChatView'; import type { ReactNode } from 'react'; +import type { ChatViewLayoutState } from '../layoutController/layoutControllerTypes'; export type SlotProps = { children?: ReactNode; className?: string; - hidden?: boolean; slot: string; }; -export const Slot = ({ children, className, hidden = false, slot }: SlotProps) => ( -
- {children} -
-); +const slotHiddenSelector = + (slot: string) => + ({ entityListPaneOpen, hiddenSlots, slotBindings }: ChatViewLayoutState) => ({ + entityListPaneOpen, + isChannelListSlot: + getChatViewEntityBinding(slotBindings[slot])?.kind === 'channelList', + isExplicitlyHidden: !!hiddenSlots?.[slot], + }); + +export const Slot = ({ children, className, slot }: SlotProps) => { + const { layoutController } = useChatViewContext(); + const selector = useMemo(() => slotHiddenSelector(slot), [slot]); + const { entityListPaneOpen, isChannelListSlot, isExplicitlyHidden } = + useStateStore(layoutController.state, selector) ?? + selector(layoutController.state.getLatestValue()); + const hidden = isExplicitlyHidden || (isChannelListSlot && !entityListPaneOpen); + + return ( +
+ {children} +
+ ); +}; diff --git a/src/components/ChatView/layout/WorkspaceLayout.tsx b/src/components/ChatView/layout/WorkspaceLayout.tsx index 4fd0af212e..95038dbdeb 100644 --- a/src/components/ChatView/layout/WorkspaceLayout.tsx +++ b/src/components/ChatView/layout/WorkspaceLayout.tsx @@ -6,46 +6,27 @@ import type { ReactNode } from 'react'; export type WorkspaceLayoutSlot = { content?: ReactNode; - hidden?: boolean; slot: string; }; export type WorkspaceLayoutProps = { - entityListHidden?: boolean; - entityListSlot?: WorkspaceLayoutSlot; navRail?: ReactNode; slots: WorkspaceLayoutSlot[]; }; -export const WorkspaceLayout = ({ - entityListHidden = false, - entityListSlot, - navRail, - slots, -}: WorkspaceLayoutProps) => ( +export const WorkspaceLayout = ({ navRail, slots }: WorkspaceLayoutProps) => (
{navRail ? (
{navRail}
) : null} - {entityListSlot?.content ? ( - - ) : null}
- {slots.map(({ content, hidden, slot }) => ( + {slots.map(({ content, slot }) => (