From 9ba445632017d70367ac3401d823065384a25f77 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 28 Oct 2026 15:03:10 +0100 Subject: [PATCH 1/6] docs(examples): add the UI Cookbook app for the React v15 docs Adds examples/cookbook: the base app and the code of every UI Cookbook recipe in the v15 docs, with the demo-data seed, the screenshot script and the CSS style-rule check. Adds the react-ui-cookbook agent skill that describes how recipes are written, and wires the workspace into the root scripts and CI. --- .agents/skills/react-ui-cookbook/SKILL.md | 195 +++++++ .claude/skills/react-ui-cookbook | 1 + .github/workflows/ci.yml | 2 +- .gitignore | 2 + AGENTS.md | 3 +- examples/cookbook/index.html | 12 + examples/cookbook/package.json | 34 ++ examples/cookbook/scripts/check-css.mjs | 296 ++++++++++ examples/cookbook/scripts/seed.mjs | 528 ++++++++++++++++++ examples/cookbook/scripts/shots.mjs | 157 ++++++ examples/cookbook/src/base/BaseApp.tsx | 143 +++++ examples/cookbook/src/base/OpenFromUrl.tsx | 59 ++ examples/cookbook/src/base/SidePanel.tsx | 222 ++++++++ examples/cookbook/src/base/Simulate.tsx | 47 ++ examples/cookbook/src/base/base.css | 89 +++ examples/cookbook/src/base/client.ts | 77 +++ examples/cookbook/src/base/demo-data.json | 173 ++++++ examples/cookbook/src/base/readOnly.ts | 37 ++ examples/cookbook/src/base/recipe.ts | 96 ++++ .../cookbook/src/base/searchController.ts | 28 + examples/cookbook/src/base/useClickInTurn.ts | 43 ++ examples/cookbook/src/main.tsx | 65 +++ .../src/recipes/ai/ai-chat-widget/App.tsx | 35 ++ .../ai/ai-chat-widget/AssistantWidget.css | 64 +++ .../ai/ai-chat-widget/AssistantWidget.tsx | 166 ++++++ .../ChannelsWithoutAssistant.tsx | 34 ++ .../ai/ai-chat-widget/OpenWidgetFromUrl.tsx | 18 + .../ai/ai-chat-widget/default.recipe.tsx | 25 + .../recipes/ai/ai-chat-widget/demo-data.json | 33 ++ .../ai/ai-chat-widget/isMessageAIGenerated.ts | 13 + .../src/recipes/ai/ai-chat-widget/shots.json | 16 + .../AssistantStatus.css | 52 ++ .../AssistantStatus.tsx | 62 ++ .../SimulateAnswer.tsx | 130 +++++ .../StopButton.css | 19 + .../StopButton.tsx | 33 ++ .../WithStreamedAnswers.tsx | 19 + .../default.recipe.tsx | 22 + .../demo-data.json | 27 + .../isMessageAIGenerated.ts | 13 + .../shots.json | 41 ++ .../useAnswerAnnouncements.ts | 42 ++ .../channel-invites/Invitations.css | 64 +++ .../channel-invites/Invitations.tsx | 171 ++++++ .../channel-invites/SimulateInvites.tsx | 27 + .../channel-invites/WithChannelInvites.tsx | 64 +++ .../channel-invites/default.recipe.tsx | 22 + .../channel-invites/demo-data.json | 42 ++ .../channel-list/channel-invites/shots.json | 24 + .../channel-invites/useChannelInvites.ts | 112 ++++ .../ChannelListLoadError.tsx | 57 ++ .../ChannelListSkeleton.css | 79 +++ .../ChannelListSkeleton.tsx | 47 ++ .../ChannelListState.css | 44 ++ .../EmptyChannelList.tsx | 39 ++ .../SimulateChannelListState.tsx | 52 ++ .../WithChannelListStates.tsx | 65 +++ .../default.recipe.tsx | 21 + .../empty-and-loading-states/shots.json | 24 + .../ChannelMembers.css | 34 ++ .../ChannelMembers.tsx | 28 + .../SimulatePresence.tsx | 45 ++ .../default.recipe.tsx | 21 + .../members-and-online-status/shots.json | 13 + .../SectionedChannelNavigation.css | 92 +++ .../SectionedChannelNavigation.tsx | 336 +++++++++++ .../SimulateSections.tsx | 63 +++ .../default.recipe.tsx | 25 + .../demo-data.json | 148 +++++ .../pinned-and-archived-channels/shots.json | 42 ++ .../useChannelSections.ts | 105 ++++ .../channel-list/preview-ui/ChannelChip.css | 41 ++ .../channel-list/preview-ui/ChannelChip.tsx | 53 ++ .../preview-ui/CompactChannelRow.css | 58 ++ .../preview-ui/CompactChannelRow.tsx | 96 ++++ .../preview-ui/SimulateUnread.tsx | 36 ++ .../channel-list/preview-ui/chips.recipe.tsx | 27 + .../preview-ui/default.recipe.tsx | 17 + .../channel-list/preview-ui/recent-strip.css | 9 + .../channel-list/preview-ui/shots.json | 22 + .../NewConversationButton.css | 13 + .../NewConversationButton.tsx | 61 ++ .../NewConversationDialog.css | 124 ++++ .../NewConversationDialog.tsx | 179 ++++++ .../SimulateNewConversation.tsx | 150 +++++ .../default.recipe.tsx | 12 + .../starting-a-conversation/shots.json | 63 +++ .../startConversation.ts | 87 +++ .../usePeopleSearch.ts | 47 ++ .../CustomAttachmentPreviewList.css | 22 + .../CustomAttachmentPreviewList.tsx | 42 ++ .../attachment-previews/DemoAttachments.tsx | 138 +++++ .../ImageAttachmentPreview.css | 65 +++ .../ImageAttachmentPreview.tsx | 71 +++ .../attachment-previews/UploadPercent.css | 9 + .../attachment-previews/UploadPercent.tsx | 14 + .../VoiceRecordingPreview.css | 14 + .../VoiceRecordingPreview.tsx | 35 ++ .../WithSquareImagePreviews.tsx | 15 + .../attachment-previews/default.recipe.tsx | 15 + .../attachment-previews/event.recipe.tsx | 15 + .../composer/attachment-previews/shots.json | 33 ++ .../upload-percent.recipe.tsx | 15 + .../attachment-previews/voice.recipe.tsx | 15 + .../custom-composer/AlsoSendInChannel.tsx | 27 + .../custom-composer/CustomMessageComposer.css | 34 ++ .../custom-composer/CustomMessageComposer.tsx | 67 +++ .../custom-composer/PrefillComposer.tsx | 31 + .../custom-composer/WithCustomComposer.tsx | 10 + .../custom-composer/default.recipe.tsx | 15 + .../composer/custom-composer/shots.json | 13 + .../CustomAutocompleteSuggestionItem.tsx | 6 + .../CustomSuggestionList.css | 21 + .../CustomSuggestionList.tsx | 50 ++ .../custom-suggestions/GroupMentionItem.tsx | 31 + .../StyledSuggestionList.css | 10 + .../StyledSuggestionList.tsx | 7 + .../SuggestionListWithTags.tsx | 30 + .../custom-suggestions/TypeFromUrl.tsx | 37 ++ .../WithCustomSuggestionList.tsx | 10 + .../custom-suggestions/default.recipe.tsx | 15 + .../composer/custom-suggestions/shots.json | 18 + .../custom-suggestions/styled-list.recipe.tsx | 18 + .../composer/gif-picker/DemoGifShots.tsx | 78 +++ .../gif-picker/GifAttachmentPreview.css | 28 + .../gif-picker/GifAttachmentPreview.tsx | 35 ++ .../recipes/composer/gif-picker/GifButton.tsx | 101 ++++ .../composer/gif-picker/GifChannel.tsx | 12 + .../recipes/composer/gif-picker/GifPicker.css | 92 +++ .../recipes/composer/gif-picker/GifPicker.tsx | 177 ++++++ .../composer/gif-picker/GifSearchSource.ts | 32 ++ .../composer/gif-picker/WithGifPicker.tsx | 14 + .../recipes/composer/gif-picker/catalogue.ts | 75 +++ .../composer/gif-picker/default.recipe.tsx | 18 + .../src/recipes/composer/gif-picker/gif.ts | 54 ++ .../src/recipes/composer/gif-picker/giphy.ts | 45 ++ .../recipes/composer/gif-picker/shots.json | 18 + .../inline-message-editing/EditFromUrl.tsx | 34 ++ .../InlineEditAction.tsx | 42 ++ .../InlineEditContext.tsx | 78 +++ .../inline-message-editing/InlineEditor.css | 49 ++ .../inline-message-editing/InlineEditor.tsx | 59 ++ .../WithInlineMessageEditing.tsx | 28 + .../inline-message-editing/default.recipe.tsx | 15 + .../inline-message-editing/shots.json | 14 + .../AddPollDialog.css | 7 + .../AddPollDialog.tsx | 104 ++++ .../ComposerPollPreview.css | 52 ++ .../ComposerPollPreview.tsx | 108 ++++ .../DemoPollShots.tsx | 136 +++++ .../PollChannel.tsx | 12 + .../WithPollWithText.tsx | 25 + .../default.recipe.tsx | 17 + .../installPollWithText.ts | 35 ++ .../removePendingPoll.ts | 24 + .../poll-with-text-and-attachments/shots.json | 28 + .../DemoLocationShots.tsx | 234 ++++++++ .../GeolocationWithMap.tsx | 54 ++ .../LocationColors.css | 18 + .../location-on-your-own-map/LocationMap.css | 37 ++ .../location-on-your-own-map/LocationMap.tsx | 70 +++ .../ShareLocationDialogWithMap.css | 26 + .../ShareLocationDialogWithMap.tsx | 70 +++ .../WithLocationMaps.tsx | 15 + .../default.recipe.tsx | 15 + .../location-on-your-own-map/shots.json | 84 +++ .../product-card/DemoProductShots.tsx | 112 ++++ .../product-card/ProductAttachmentPreview.css | 40 ++ .../product-card/ProductAttachmentPreview.tsx | 42 ++ .../product-card/ProductCard.css | 52 ++ .../product-card/ProductCard.tsx | 41 ++ .../product-card/ProductMessagePreview.css | 23 + .../product-card/ProductMessagePreview.tsx | 23 + .../product-card/ProductPicker.css | 45 ++ .../product-card/ProductPicker.tsx | 104 ++++ .../product-card/ProductQuotedMessage.tsx | 70 +++ .../product-card/WithProductAttachments.tsx | 24 + .../product-card/catalogue.ts | 29 + .../product-card/default.recipe.tsx | 25 + .../product-card/demo-data.json | 31 + .../product-card/installProductAttachments.ts | 32 ++ .../product-card/product.ts | 68 +++ .../productAttachmentsMiddleware.ts | 82 +++ .../product-card/sdk-default.recipe.tsx | 15 + .../product-card/shots.json | 71 +++ .../channel-header/CustomChannelHeader.css | 25 + .../channel-header/CustomChannelHeader.tsx | 70 +++ .../headers/channel-header/default.recipe.tsx | 6 + .../recipes/headers/channel-header/shots.json | 22 + .../thread-header/CustomThreadHeader.css | 33 ++ .../thread-header/CustomThreadHeader.tsx | 75 +++ .../headers/thread-header/ThreadPanel.tsx | 19 + .../headers/thread-header/default.recipe.tsx | 26 + .../recipes/headers/thread-header/shots.json | 12 + .../default.recipe.tsx | 12 + .../layout.css | 12 + .../shots.json | 10 + .../channel-and-thread-side-by-side/views.tsx | 68 +++ .../collapsible-sidebar/AutoCloseSidebar.tsx | 29 + .../collapsible-sidebar/SidebarContext.tsx | 32 ++ .../collapsible-sidebar/SidebarToggle.tsx | 20 + .../collapsible-sidebar/default.recipe.tsx | 24 + .../layout/collapsible-sidebar/shots.json | 17 + .../layout/collapsible-sidebar/sidebar.css | 62 ++ .../layout/collapsible-sidebar/views.tsx | 61 ++ .../SimulateCtrlClick.tsx | 28 + .../default.recipe.tsx | 21 + .../layout/ctrl-click-opens-beside/layout.css | 6 + .../layout/ctrl-click-opens-beside/shots.json | 11 + .../useOpenBesideOnWideWindows.ts | 29 + .../layout/ctrl-click-opens-beside/views.tsx | 76 +++ .../LayoutPersistence.tsx | 37 ++ .../LayoutPersistenceWithUrl.tsx | 85 +++ .../default.recipe.tsx | 19 + .../layoutSnapshot.ts | 39 ++ .../saved-layout.json | 28 + .../seedSavedLayout.ts | 12 + .../shots.json | 19 + .../storage.recipe.tsx | 18 + .../saving-and-restoring-the-layout/views.tsx | 59 ++ .../BrowserBack.tsx | 42 ++ .../default.recipe.tsx | 19 + .../layout.css | 23 + .../shots.json | 23 + .../views.tsx | 115 ++++ .../action-bar/CustomMessageUi.css | 153 +++++ .../action-bar/CustomMessageUi.tsx | 196 +++++++ .../action-bar/SimulateMessageStates.tsx | 54 ++ .../action-bar/WithCustomMessageUi.tsx | 10 + .../action-bar/default.recipe.tsx | 11 + .../message-actions/action-bar/shots.json | 14 + .../attachment-actions/ClickVoteLocally.tsx | 25 + .../attachment-actions/VoteAttachment.tsx | 15 + .../VoteAttachmentActions.css | 11 + .../VoteAttachmentActions.tsx | 34 ++ .../attachment-actions/default.recipe.tsx | 25 + .../attachment-actions/demo-data.json | 34 ++ .../attachment-actions/installVoteActions.ts | 17 + .../attachment-actions/shots.json | 18 + .../attachment-actions/voteActions.ts | 6 + .../voteActionsMiddleware.ts | 38 ++ .../CustomMessageActions.tsx | 66 +++ .../OpenMenuFromUrl.tsx | 25 + .../custom-message-actions/default.recipe.tsx | 15 + .../custom-message-actions/shots.json | 12 + .../editing-a-poll/EditPollAction.tsx | 94 ++++ .../editing-a-poll/EditPollDialog.css | 45 ++ .../editing-a-poll/EditPollDialog.tsx | 407 ++++++++++++++ .../editing-a-poll/OpenEditPollFromUrl.tsx | 55 ++ .../editing-a-poll/ShowPollLocally.tsx | 122 ++++ .../editing-a-poll/default.recipe.tsx | 21 + .../editing-a-poll/pollForm.ts | 183 ++++++ .../message-actions/editing-a-poll/shots.json | 48 ++ .../reactions/CustomMessageUi.css | 157 ++++++ .../reactions/CustomMessageUi.tsx | 228 ++++++++ .../reactions/SimulateMessageStates.tsx | 96 ++++ .../reactions/WithCustomMessageUi.tsx | 26 + .../reactions/default.recipe.tsx | 11 + .../message-actions/reactions/shots.json | 14 + .../custom-message/CustomMessageUi.css | 87 +++ .../custom-message/CustomMessageUi.tsx | 123 ++++ .../custom-message/SimulateMessageStates.tsx | 54 ++ .../custom-message/WithCustomMessageUi.tsx | 10 + .../custom-message/avatars.recipe.tsx | 4 + .../custom-message/default.recipe.tsx | 11 + .../grouped-metadata.recipe.tsx | 7 + .../custom-message/message-text.recipe.tsx | 4 + .../custom-message/metadata.recipe.tsx | 4 + .../custom-message/plain-text.recipe.tsx | 4 + .../custom-message/sender-names.recipe.tsx | 4 + .../message-list/custom-message/shots.json | 79 +++ .../custom-message/stepRecipe.tsx | 16 + .../steps/avatars/CustomMessageUi.css | 32 ++ .../steps/avatars/CustomMessageUi.tsx | 26 + .../grouped-metadata/CustomMessageUi.css | 78 +++ .../steps/message-text/CustomMessageUi.css | 43 ++ .../steps/message-text/CustomMessageUi.tsx | 28 + .../steps/metadata/CustomMessageUi.css | 68 +++ .../steps/metadata/CustomMessageUi.tsx | 115 ++++ .../steps/plain-text/CustomMessageUi.tsx | 7 + .../steps/sender-names/CustomMessageUi.css | 31 + .../steps/sender-names/CustomMessageUi.tsx | 23 + .../giphy-preview/GiphyPreviewActions.css | 52 ++ .../giphy-preview/GiphyPreviewActions.tsx | 117 ++++ .../giphy-preview/ShowGiphyPreviewLocally.tsx | 117 ++++ .../giphy-preview/WithGiphyPreview.tsx | 16 + .../giphy-preview/default.recipe.tsx | 20 + .../message-list/giphy-preview/shots.json | 13 + .../message-list/image-gallery/ImageGrid.css | 65 +++ .../message-list/image-gallery/ImageGrid.tsx | 86 +++ .../message-list/image-gallery/Lightbox.css | 70 +++ .../message-list/image-gallery/Lightbox.tsx | 74 +++ .../image-gallery/LightboxCaption.css | 8 + .../image-gallery/LightboxCaption.tsx | 13 + .../image-gallery/LightboxMedia.css | 7 + .../image-gallery/LightboxMedia.tsx | 26 + .../image-gallery/OpenLightboxFromUrl.tsx | 24 + .../image-gallery/ThumbnailStrip.css | 48 ++ .../image-gallery/ThumbnailStrip.tsx | 40 ++ .../image-gallery/WithImageGallery.tsx | 17 + .../image-gallery/default.recipe.tsx | 17 + .../message-list/image-gallery/demo-data.json | 107 ++++ .../message-list/image-gallery/shots.json | 41 ++ .../image-gallery/useLightboxNavigation.ts | 60 ++ .../pin-indicator/PinnedByIndicator.css | 22 + .../pin-indicator/PinnedByIndicator.tsx | 51 ++ .../pin-indicator/component.recipe.tsx | 7 + .../message-list/pin-indicator/css.recipe.tsx | 7 + .../pin-indicator/pin-indicator.css | 17 + .../message-list/pin-indicator/shots.json | 23 + .../ChannelHeaderWithPinnedBar.tsx | 10 + .../ClickPinnedBarFromUrl.tsx | 17 + .../pinned-messages-bar/PinnedMessagesBar.css | 37 ++ .../pinned-messages-bar/PinnedMessagesBar.tsx | 42 ++ .../pinned-messages-bar/default.recipe.tsx | 11 + .../pinned-messages-bar/demo-data.json | 114 ++++ .../pinned-messages-bar/shots.json | 17 + .../ReadReceiptAvatars.css | 15 + .../ReadReceiptAvatars.tsx | 61 ++ .../ReadReceiptsChannel.tsx | 12 + .../read-receipt-avatars/default.recipe.tsx | 9 + .../read-receipt-avatars/demo-data.json | 45 ++ .../read-receipt-avatars/shots.json | 9 + .../system-message/AddLocalSystemMessage.tsx | 61 ++ .../system-message/CustomSystemMessage.css | 34 ++ .../system-message/CustomSystemMessage.tsx | 37 ++ .../system-message/custom.recipe.tsx | 11 + .../system-message/default.recipe.tsx | 7 + .../message-list/system-message/shots.json | 17 + .../ScreenshotHelpers.tsx | 150 +++++ .../UnreadChannel.tsx | 14 + .../UnreadControls.css | 73 +++ .../UnreadControls.tsx | 99 ++++ .../component.recipe.tsx | 16 + .../unread-and-scroll-controls/css.recipe.tsx | 8 + .../default.recipe.tsx | 7 + .../unread-and-scroll-controls/demo-data.json | 127 +++++ .../unread-and-scroll-controls/shots.json | 94 ++++ .../unread-and-scroll-controls.css | 35 ++ .../custom-snackbars/SimulateSnackbars.tsx | 30 + .../custom-snackbars/Snackbar.css | 87 +++ .../custom-snackbars/Snackbar.tsx | 115 ++++ .../custom-snackbars/SnackbarStack.css | 24 + .../custom-snackbars/SnackbarStack.tsx | 65 +++ .../custom-snackbars/default.recipe.tsx | 15 + .../custom-snackbars/publishSnackbars.ts | 29 + .../notifications/custom-snackbars/shots.json | 12 + .../system-notification-banner/AppLayout.css | 12 + .../system-notification-banner/AppLayout.tsx | 19 + .../ConnectionStatusBanner.css | 9 + .../ConnectionStatusBanner.tsx | 19 + .../MaintenanceNotice.tsx | 34 ++ .../SimulateSystemNotification.tsx | 45 ++ .../SystemNotificationBanner.css | 90 +++ .../SystemNotificationBanner.tsx | 80 +++ .../connection.recipe.tsx | 16 + .../default.recipe.tsx | 16 + .../system-notification-banner/shots.json | 23 + .../channel-details/ChannelDetailsPanel.css | 14 + .../channel-details/ChannelDetailsPanel.tsx | 69 +++ .../ChannelHeaderWithDetails.css | 21 + .../ChannelHeaderWithDetails.tsx | 92 +++ .../channel-details/MembersWithPresence.css | 66 +++ .../channel-details/MembersWithPresence.tsx | 148 +++++ .../channel-details/OpenDetailsFromUrl.tsx | 31 + .../channel-details/channel-details-theme.css | 10 + .../panels/channel-details/default.recipe.tsx | 15 + .../recipes/panels/channel-details/shots.json | 28 + .../panels/drafts-list/DraftsButton.css | 7 + .../panels/drafts-list/DraftsButton.tsx | 44 ++ .../recipes/panels/drafts-list/DraftsList.css | 87 +++ .../recipes/panels/drafts-list/DraftsList.tsx | 278 +++++++++ .../panels/drafts-list/OpenDraftsFromUrl.tsx | 30 + .../panels/drafts-list/default.recipe.tsx | 21 + .../recipes/panels/drafts-list/demo-data.json | 68 +++ .../src/recipes/panels/drafts-list/shots.json | 30 + .../AddMembersButton.css | 40 ++ .../AddMembersButton.tsx | 136 +++++ .../ChannelHeaderWithAddMembers.tsx | 18 + .../OpenHistoryDialog.tsx | 22 + .../default.recipe.tsx | 15 + .../hide-history-for-new-members/shots.json | 9 + .../panels/message-info/MessageInfoAction.tsx | 61 ++ .../panels/message-info/MessageInfoPanel.css | 46 ++ .../panels/message-info/MessageInfoPanel.tsx | 122 ++++ .../panels/message-info/OpenInfoFromUrl.tsx | 34 ++ .../panels/message-info/default.recipe.tsx | 11 + .../panels/message-info/demo-data.json | 33 ++ .../recipes/panels/message-info/shots.json | 7 + .../OpenSavedItemsFromUrl.tsx | 36 ++ .../saved-items-and-reminders/SavedItems.css | 111 ++++ .../saved-items-and-reminders/SavedItems.tsx | 282 ++++++++++ .../SavedItemsButton.css | 19 + .../SavedItemsButton.tsx | 57 ++ .../default.recipe.tsx | 12 + .../saved-items-and-reminders/demo-data.json | 52 ++ .../saved-items-and-reminders/shots.json | 32 ++ .../threads-view/OnlyCookbookThreads.tsx | 17 + .../panels/threads-view/ScreenshotHelpers.tsx | 56 ++ .../recipes/panels/threads-view/ThreadRow.css | 75 +++ .../recipes/panels/threads-view/ThreadRow.tsx | 111 ++++ .../panels/threads-view/ThreadsView.css | 6 + .../panels/threads-view/ThreadsView.tsx | 40 ++ .../panels/threads-view/default.recipe.tsx | 54 ++ .../panels/threads-view/demo-data.json | 77 +++ .../recipes/panels/threads-view/shots.json | 14 + .../MessageListWithUserDetails.tsx | 29 + .../user-details/OpenProfileFromUrl.tsx | 24 + .../panels/user-details/UserDetailsPanel.css | 59 ++ .../panels/user-details/UserDetailsPanel.tsx | 186 ++++++ .../panels/user-details/default.recipe.tsx | 17 + .../panels/user-details/directConversation.ts | 19 + .../recipes/panels/user-details/shots.json | 29 + .../search/mentions-inbox/MentionsButton.css | 19 + .../search/mentions-inbox/MentionsButton.tsx | 57 ++ .../search/mentions-inbox/MentionsInbox.css | 69 +++ .../search/mentions-inbox/MentionsInbox.tsx | 185 ++++++ .../mentions-inbox/OpenMentionsFromUrl.tsx | 30 + .../search/mentions-inbox/default.recipe.tsx | 12 + .../search/mentions-inbox/demo-data.json | 74 +++ .../recipes/search/mentions-inbox/shots.json | 25 + .../CompactChannelResults.css | 30 + .../CompactChannelResults.tsx | 61 ++ .../search-customization/SidebarSearch.tsx | 17 + .../search-customization/SimulateSearch.tsx | 22 + .../search/search-customization/Workspace.css | 49 ++ .../search/search-customization/Workspace.tsx | 38 ++ .../compact-results.recipe.tsx | 19 + .../search-props.recipe.tsx | 7 + .../search/search-customization/shots.json | 16 + .../search-customization/top-bar.recipe.tsx | 20 + .../search/search-dialog/SearchDialog.css | 118 ++++ .../search/search-dialog/SearchDialog.tsx | 180 ++++++ .../search-dialog/SearchDialogButton.css | 32 ++ .../search-dialog/SearchDialogButton.tsx | 48 ++ .../search/search-dialog/SearchFilters.css | 60 ++ .../search/search-dialog/SearchFilters.tsx | 266 +++++++++ .../search/search-dialog/SearchResults.css | 120 ++++ .../search/search-dialog/SearchResults.tsx | 388 +++++++++++++ .../search-dialog/SimulateSearchDialog.tsx | 57 ++ .../search/search-dialog/default.recipe.tsx | 38 ++ .../search/search-dialog/searchController.ts | 159 ++++++ .../recipes/search/search-dialog/shots.json | 47 ++ .../brand-theme-and-dark-mode/dark.recipe.tsx | 7 + .../default.recipe.tsx | 7 + .../brand-theme-and-dark-mode/loadFont.ts | 11 + .../theming/brand-theme-and-dark-mode/rtl.css | 5 + .../brand-theme-and-dark-mode/rtl.recipe.tsx | 15 + .../sdk-dark.recipe.tsx | 6 + .../brand-theme-and-dark-mode/shots.json | 56 ++ .../brand-theme-and-dark-mode/square.css | 15 + .../square.recipe.tsx | 8 + .../brand-theme-and-dark-mode/theme.css | 136 +++++ .../replacing-the-icon-set/LucideFileIcon.tsx | 44 ++ .../OpenMenuFromUrl.tsx | 35 ++ .../WithLucideIcons.tsx | 12 + .../replacing-the-icon-set/before.recipe.tsx | 7 + .../replacing-the-icon-set/default.recipe.tsx | 11 + .../replacing-the-icon-set/lucide-icons.css | 25 + .../replacing-the-icon-set/lucideIcons.tsx | 212 +++++++ .../theming/replacing-the-icon-set/shots.json | 38 ++ examples/cookbook/src/vite-env.d.ts | 1 + examples/cookbook/tsconfig.app.json | 28 + examples/cookbook/tsconfig.json | 4 + examples/cookbook/tsconfig.node.json | 25 + examples/cookbook/vite.config.ts | 13 + package.json | 1 + yarn.lock | 83 +++ 468 files changed, 24015 insertions(+), 2 deletions(-) create mode 100644 .agents/skills/react-ui-cookbook/SKILL.md create mode 120000 .claude/skills/react-ui-cookbook create mode 100644 examples/cookbook/index.html create mode 100644 examples/cookbook/package.json create mode 100644 examples/cookbook/scripts/check-css.mjs create mode 100644 examples/cookbook/scripts/seed.mjs create mode 100644 examples/cookbook/scripts/shots.mjs create mode 100644 examples/cookbook/src/base/BaseApp.tsx create mode 100644 examples/cookbook/src/base/OpenFromUrl.tsx create mode 100644 examples/cookbook/src/base/SidePanel.tsx create mode 100644 examples/cookbook/src/base/Simulate.tsx create mode 100644 examples/cookbook/src/base/base.css create mode 100644 examples/cookbook/src/base/client.ts create mode 100644 examples/cookbook/src/base/demo-data.json create mode 100644 examples/cookbook/src/base/readOnly.ts create mode 100644 examples/cookbook/src/base/recipe.ts create mode 100644 examples/cookbook/src/base/searchController.ts create mode 100644 examples/cookbook/src/base/useClickInTurn.ts create mode 100644 examples/cookbook/src/main.tsx create mode 100644 examples/cookbook/src/recipes/ai/ai-chat-widget/App.tsx create mode 100644 examples/cookbook/src/recipes/ai/ai-chat-widget/AssistantWidget.css create mode 100644 examples/cookbook/src/recipes/ai/ai-chat-widget/AssistantWidget.tsx create mode 100644 examples/cookbook/src/recipes/ai/ai-chat-widget/ChannelsWithoutAssistant.tsx create mode 100644 examples/cookbook/src/recipes/ai/ai-chat-widget/OpenWidgetFromUrl.tsx create mode 100644 examples/cookbook/src/recipes/ai/ai-chat-widget/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/ai/ai-chat-widget/demo-data.json create mode 100644 examples/cookbook/src/recipes/ai/ai-chat-widget/isMessageAIGenerated.ts create mode 100644 examples/cookbook/src/recipes/ai/ai-chat-widget/shots.json create mode 100644 examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/AssistantStatus.css create mode 100644 examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/AssistantStatus.tsx create mode 100644 examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/SimulateAnswer.tsx create mode 100644 examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/StopButton.css create mode 100644 examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/StopButton.tsx create mode 100644 examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/WithStreamedAnswers.tsx create mode 100644 examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/demo-data.json create mode 100644 examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/isMessageAIGenerated.ts create mode 100644 examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/shots.json create mode 100644 examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/useAnswerAnnouncements.ts create mode 100644 examples/cookbook/src/recipes/channel-list/channel-invites/Invitations.css create mode 100644 examples/cookbook/src/recipes/channel-list/channel-invites/Invitations.tsx create mode 100644 examples/cookbook/src/recipes/channel-list/channel-invites/SimulateInvites.tsx create mode 100644 examples/cookbook/src/recipes/channel-list/channel-invites/WithChannelInvites.tsx create mode 100644 examples/cookbook/src/recipes/channel-list/channel-invites/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/channel-list/channel-invites/demo-data.json create mode 100644 examples/cookbook/src/recipes/channel-list/channel-invites/shots.json create mode 100644 examples/cookbook/src/recipes/channel-list/channel-invites/useChannelInvites.ts create mode 100644 examples/cookbook/src/recipes/channel-list/empty-and-loading-states/ChannelListLoadError.tsx create mode 100644 examples/cookbook/src/recipes/channel-list/empty-and-loading-states/ChannelListSkeleton.css create mode 100644 examples/cookbook/src/recipes/channel-list/empty-and-loading-states/ChannelListSkeleton.tsx create mode 100644 examples/cookbook/src/recipes/channel-list/empty-and-loading-states/ChannelListState.css create mode 100644 examples/cookbook/src/recipes/channel-list/empty-and-loading-states/EmptyChannelList.tsx create mode 100644 examples/cookbook/src/recipes/channel-list/empty-and-loading-states/SimulateChannelListState.tsx create mode 100644 examples/cookbook/src/recipes/channel-list/empty-and-loading-states/WithChannelListStates.tsx create mode 100644 examples/cookbook/src/recipes/channel-list/empty-and-loading-states/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/channel-list/empty-and-loading-states/shots.json create mode 100644 examples/cookbook/src/recipes/channel-list/members-and-online-status/ChannelMembers.css create mode 100644 examples/cookbook/src/recipes/channel-list/members-and-online-status/ChannelMembers.tsx create mode 100644 examples/cookbook/src/recipes/channel-list/members-and-online-status/SimulatePresence.tsx create mode 100644 examples/cookbook/src/recipes/channel-list/members-and-online-status/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/channel-list/members-and-online-status/shots.json create mode 100644 examples/cookbook/src/recipes/channel-list/pinned-and-archived-channels/SectionedChannelNavigation.css create mode 100644 examples/cookbook/src/recipes/channel-list/pinned-and-archived-channels/SectionedChannelNavigation.tsx create mode 100644 examples/cookbook/src/recipes/channel-list/pinned-and-archived-channels/SimulateSections.tsx create mode 100644 examples/cookbook/src/recipes/channel-list/pinned-and-archived-channels/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/channel-list/pinned-and-archived-channels/demo-data.json create mode 100644 examples/cookbook/src/recipes/channel-list/pinned-and-archived-channels/shots.json create mode 100644 examples/cookbook/src/recipes/channel-list/pinned-and-archived-channels/useChannelSections.ts create mode 100644 examples/cookbook/src/recipes/channel-list/preview-ui/ChannelChip.css create mode 100644 examples/cookbook/src/recipes/channel-list/preview-ui/ChannelChip.tsx create mode 100644 examples/cookbook/src/recipes/channel-list/preview-ui/CompactChannelRow.css create mode 100644 examples/cookbook/src/recipes/channel-list/preview-ui/CompactChannelRow.tsx create mode 100644 examples/cookbook/src/recipes/channel-list/preview-ui/SimulateUnread.tsx create mode 100644 examples/cookbook/src/recipes/channel-list/preview-ui/chips.recipe.tsx create mode 100644 examples/cookbook/src/recipes/channel-list/preview-ui/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/channel-list/preview-ui/recent-strip.css create mode 100644 examples/cookbook/src/recipes/channel-list/preview-ui/shots.json create mode 100644 examples/cookbook/src/recipes/channel-list/starting-a-conversation/NewConversationButton.css create mode 100644 examples/cookbook/src/recipes/channel-list/starting-a-conversation/NewConversationButton.tsx create mode 100644 examples/cookbook/src/recipes/channel-list/starting-a-conversation/NewConversationDialog.css create mode 100644 examples/cookbook/src/recipes/channel-list/starting-a-conversation/NewConversationDialog.tsx create mode 100644 examples/cookbook/src/recipes/channel-list/starting-a-conversation/SimulateNewConversation.tsx create mode 100644 examples/cookbook/src/recipes/channel-list/starting-a-conversation/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/channel-list/starting-a-conversation/shots.json create mode 100644 examples/cookbook/src/recipes/channel-list/starting-a-conversation/startConversation.ts create mode 100644 examples/cookbook/src/recipes/channel-list/starting-a-conversation/usePeopleSearch.ts create mode 100644 examples/cookbook/src/recipes/composer/attachment-previews/CustomAttachmentPreviewList.css create mode 100644 examples/cookbook/src/recipes/composer/attachment-previews/CustomAttachmentPreviewList.tsx create mode 100644 examples/cookbook/src/recipes/composer/attachment-previews/DemoAttachments.tsx create mode 100644 examples/cookbook/src/recipes/composer/attachment-previews/ImageAttachmentPreview.css create mode 100644 examples/cookbook/src/recipes/composer/attachment-previews/ImageAttachmentPreview.tsx create mode 100644 examples/cookbook/src/recipes/composer/attachment-previews/UploadPercent.css create mode 100644 examples/cookbook/src/recipes/composer/attachment-previews/UploadPercent.tsx create mode 100644 examples/cookbook/src/recipes/composer/attachment-previews/VoiceRecordingPreview.css create mode 100644 examples/cookbook/src/recipes/composer/attachment-previews/VoiceRecordingPreview.tsx create mode 100644 examples/cookbook/src/recipes/composer/attachment-previews/WithSquareImagePreviews.tsx create mode 100644 examples/cookbook/src/recipes/composer/attachment-previews/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/composer/attachment-previews/event.recipe.tsx create mode 100644 examples/cookbook/src/recipes/composer/attachment-previews/shots.json create mode 100644 examples/cookbook/src/recipes/composer/attachment-previews/upload-percent.recipe.tsx create mode 100644 examples/cookbook/src/recipes/composer/attachment-previews/voice.recipe.tsx create mode 100644 examples/cookbook/src/recipes/composer/custom-composer/AlsoSendInChannel.tsx create mode 100644 examples/cookbook/src/recipes/composer/custom-composer/CustomMessageComposer.css create mode 100644 examples/cookbook/src/recipes/composer/custom-composer/CustomMessageComposer.tsx create mode 100644 examples/cookbook/src/recipes/composer/custom-composer/PrefillComposer.tsx create mode 100644 examples/cookbook/src/recipes/composer/custom-composer/WithCustomComposer.tsx create mode 100644 examples/cookbook/src/recipes/composer/custom-composer/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/composer/custom-composer/shots.json create mode 100644 examples/cookbook/src/recipes/composer/custom-suggestions/CustomAutocompleteSuggestionItem.tsx create mode 100644 examples/cookbook/src/recipes/composer/custom-suggestions/CustomSuggestionList.css create mode 100644 examples/cookbook/src/recipes/composer/custom-suggestions/CustomSuggestionList.tsx create mode 100644 examples/cookbook/src/recipes/composer/custom-suggestions/GroupMentionItem.tsx create mode 100644 examples/cookbook/src/recipes/composer/custom-suggestions/StyledSuggestionList.css create mode 100644 examples/cookbook/src/recipes/composer/custom-suggestions/StyledSuggestionList.tsx create mode 100644 examples/cookbook/src/recipes/composer/custom-suggestions/SuggestionListWithTags.tsx create mode 100644 examples/cookbook/src/recipes/composer/custom-suggestions/TypeFromUrl.tsx create mode 100644 examples/cookbook/src/recipes/composer/custom-suggestions/WithCustomSuggestionList.tsx create mode 100644 examples/cookbook/src/recipes/composer/custom-suggestions/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/composer/custom-suggestions/shots.json create mode 100644 examples/cookbook/src/recipes/composer/custom-suggestions/styled-list.recipe.tsx create mode 100644 examples/cookbook/src/recipes/composer/gif-picker/DemoGifShots.tsx create mode 100644 examples/cookbook/src/recipes/composer/gif-picker/GifAttachmentPreview.css create mode 100644 examples/cookbook/src/recipes/composer/gif-picker/GifAttachmentPreview.tsx create mode 100644 examples/cookbook/src/recipes/composer/gif-picker/GifButton.tsx create mode 100644 examples/cookbook/src/recipes/composer/gif-picker/GifChannel.tsx create mode 100644 examples/cookbook/src/recipes/composer/gif-picker/GifPicker.css create mode 100644 examples/cookbook/src/recipes/composer/gif-picker/GifPicker.tsx create mode 100644 examples/cookbook/src/recipes/composer/gif-picker/GifSearchSource.ts create mode 100644 examples/cookbook/src/recipes/composer/gif-picker/WithGifPicker.tsx create mode 100644 examples/cookbook/src/recipes/composer/gif-picker/catalogue.ts create mode 100644 examples/cookbook/src/recipes/composer/gif-picker/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/composer/gif-picker/gif.ts create mode 100644 examples/cookbook/src/recipes/composer/gif-picker/giphy.ts create mode 100644 examples/cookbook/src/recipes/composer/gif-picker/shots.json create mode 100644 examples/cookbook/src/recipes/composer/inline-message-editing/EditFromUrl.tsx create mode 100644 examples/cookbook/src/recipes/composer/inline-message-editing/InlineEditAction.tsx create mode 100644 examples/cookbook/src/recipes/composer/inline-message-editing/InlineEditContext.tsx create mode 100644 examples/cookbook/src/recipes/composer/inline-message-editing/InlineEditor.css create mode 100644 examples/cookbook/src/recipes/composer/inline-message-editing/InlineEditor.tsx create mode 100644 examples/cookbook/src/recipes/composer/inline-message-editing/WithInlineMessageEditing.tsx create mode 100644 examples/cookbook/src/recipes/composer/inline-message-editing/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/composer/inline-message-editing/shots.json create mode 100644 examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/AddPollDialog.css create mode 100644 examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/AddPollDialog.tsx create mode 100644 examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/ComposerPollPreview.css create mode 100644 examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/ComposerPollPreview.tsx create mode 100644 examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/DemoPollShots.tsx create mode 100644 examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/PollChannel.tsx create mode 100644 examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/WithPollWithText.tsx create mode 100644 examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/installPollWithText.ts create mode 100644 examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/removePendingPoll.ts create mode 100644 examples/cookbook/src/recipes/composer/poll-with-text-and-attachments/shots.json create mode 100644 examples/cookbook/src/recipes/custom-attachments/location-on-your-own-map/DemoLocationShots.tsx create mode 100644 examples/cookbook/src/recipes/custom-attachments/location-on-your-own-map/GeolocationWithMap.tsx create mode 100644 examples/cookbook/src/recipes/custom-attachments/location-on-your-own-map/LocationColors.css create mode 100644 examples/cookbook/src/recipes/custom-attachments/location-on-your-own-map/LocationMap.css create mode 100644 examples/cookbook/src/recipes/custom-attachments/location-on-your-own-map/LocationMap.tsx create mode 100644 examples/cookbook/src/recipes/custom-attachments/location-on-your-own-map/ShareLocationDialogWithMap.css create mode 100644 examples/cookbook/src/recipes/custom-attachments/location-on-your-own-map/ShareLocationDialogWithMap.tsx create mode 100644 examples/cookbook/src/recipes/custom-attachments/location-on-your-own-map/WithLocationMaps.tsx create mode 100644 examples/cookbook/src/recipes/custom-attachments/location-on-your-own-map/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/custom-attachments/location-on-your-own-map/shots.json create mode 100644 examples/cookbook/src/recipes/custom-attachments/product-card/DemoProductShots.tsx create mode 100644 examples/cookbook/src/recipes/custom-attachments/product-card/ProductAttachmentPreview.css create mode 100644 examples/cookbook/src/recipes/custom-attachments/product-card/ProductAttachmentPreview.tsx create mode 100644 examples/cookbook/src/recipes/custom-attachments/product-card/ProductCard.css create mode 100644 examples/cookbook/src/recipes/custom-attachments/product-card/ProductCard.tsx create mode 100644 examples/cookbook/src/recipes/custom-attachments/product-card/ProductMessagePreview.css create mode 100644 examples/cookbook/src/recipes/custom-attachments/product-card/ProductMessagePreview.tsx create mode 100644 examples/cookbook/src/recipes/custom-attachments/product-card/ProductPicker.css create mode 100644 examples/cookbook/src/recipes/custom-attachments/product-card/ProductPicker.tsx create mode 100644 examples/cookbook/src/recipes/custom-attachments/product-card/ProductQuotedMessage.tsx create mode 100644 examples/cookbook/src/recipes/custom-attachments/product-card/WithProductAttachments.tsx create mode 100644 examples/cookbook/src/recipes/custom-attachments/product-card/catalogue.ts create mode 100644 examples/cookbook/src/recipes/custom-attachments/product-card/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/custom-attachments/product-card/demo-data.json create mode 100644 examples/cookbook/src/recipes/custom-attachments/product-card/installProductAttachments.ts create mode 100644 examples/cookbook/src/recipes/custom-attachments/product-card/product.ts create mode 100644 examples/cookbook/src/recipes/custom-attachments/product-card/productAttachmentsMiddleware.ts create mode 100644 examples/cookbook/src/recipes/custom-attachments/product-card/sdk-default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/custom-attachments/product-card/shots.json create mode 100644 examples/cookbook/src/recipes/headers/channel-header/CustomChannelHeader.css create mode 100644 examples/cookbook/src/recipes/headers/channel-header/CustomChannelHeader.tsx create mode 100644 examples/cookbook/src/recipes/headers/channel-header/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/headers/channel-header/shots.json create mode 100644 examples/cookbook/src/recipes/headers/thread-header/CustomThreadHeader.css create mode 100644 examples/cookbook/src/recipes/headers/thread-header/CustomThreadHeader.tsx create mode 100644 examples/cookbook/src/recipes/headers/thread-header/ThreadPanel.tsx create mode 100644 examples/cookbook/src/recipes/headers/thread-header/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/headers/thread-header/shots.json create mode 100644 examples/cookbook/src/recipes/layout/channel-and-thread-side-by-side/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/layout/channel-and-thread-side-by-side/layout.css create mode 100644 examples/cookbook/src/recipes/layout/channel-and-thread-side-by-side/shots.json create mode 100644 examples/cookbook/src/recipes/layout/channel-and-thread-side-by-side/views.tsx create mode 100644 examples/cookbook/src/recipes/layout/collapsible-sidebar/AutoCloseSidebar.tsx create mode 100644 examples/cookbook/src/recipes/layout/collapsible-sidebar/SidebarContext.tsx create mode 100644 examples/cookbook/src/recipes/layout/collapsible-sidebar/SidebarToggle.tsx create mode 100644 examples/cookbook/src/recipes/layout/collapsible-sidebar/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/layout/collapsible-sidebar/shots.json create mode 100644 examples/cookbook/src/recipes/layout/collapsible-sidebar/sidebar.css create mode 100644 examples/cookbook/src/recipes/layout/collapsible-sidebar/views.tsx create mode 100644 examples/cookbook/src/recipes/layout/ctrl-click-opens-beside/SimulateCtrlClick.tsx create mode 100644 examples/cookbook/src/recipes/layout/ctrl-click-opens-beside/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/layout/ctrl-click-opens-beside/layout.css create mode 100644 examples/cookbook/src/recipes/layout/ctrl-click-opens-beside/shots.json create mode 100644 examples/cookbook/src/recipes/layout/ctrl-click-opens-beside/useOpenBesideOnWideWindows.ts create mode 100644 examples/cookbook/src/recipes/layout/ctrl-click-opens-beside/views.tsx create mode 100644 examples/cookbook/src/recipes/layout/saving-and-restoring-the-layout/LayoutPersistence.tsx create mode 100644 examples/cookbook/src/recipes/layout/saving-and-restoring-the-layout/LayoutPersistenceWithUrl.tsx create mode 100644 examples/cookbook/src/recipes/layout/saving-and-restoring-the-layout/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/layout/saving-and-restoring-the-layout/layoutSnapshot.ts create mode 100644 examples/cookbook/src/recipes/layout/saving-and-restoring-the-layout/saved-layout.json create mode 100644 examples/cookbook/src/recipes/layout/saving-and-restoring-the-layout/seedSavedLayout.ts create mode 100644 examples/cookbook/src/recipes/layout/saving-and-restoring-the-layout/shots.json create mode 100644 examples/cookbook/src/recipes/layout/saving-and-restoring-the-layout/storage.recipe.tsx create mode 100644 examples/cookbook/src/recipes/layout/saving-and-restoring-the-layout/views.tsx create mode 100644 examples/cookbook/src/recipes/layout/single-pane-mobile-with-back-button/BrowserBack.tsx create mode 100644 examples/cookbook/src/recipes/layout/single-pane-mobile-with-back-button/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/layout/single-pane-mobile-with-back-button/layout.css create mode 100644 examples/cookbook/src/recipes/layout/single-pane-mobile-with-back-button/shots.json create mode 100644 examples/cookbook/src/recipes/layout/single-pane-mobile-with-back-button/views.tsx create mode 100644 examples/cookbook/src/recipes/message-actions/action-bar/CustomMessageUi.css create mode 100644 examples/cookbook/src/recipes/message-actions/action-bar/CustomMessageUi.tsx create mode 100644 examples/cookbook/src/recipes/message-actions/action-bar/SimulateMessageStates.tsx create mode 100644 examples/cookbook/src/recipes/message-actions/action-bar/WithCustomMessageUi.tsx create mode 100644 examples/cookbook/src/recipes/message-actions/action-bar/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/message-actions/action-bar/shots.json create mode 100644 examples/cookbook/src/recipes/message-actions/attachment-actions/ClickVoteLocally.tsx create mode 100644 examples/cookbook/src/recipes/message-actions/attachment-actions/VoteAttachment.tsx create mode 100644 examples/cookbook/src/recipes/message-actions/attachment-actions/VoteAttachmentActions.css create mode 100644 examples/cookbook/src/recipes/message-actions/attachment-actions/VoteAttachmentActions.tsx create mode 100644 examples/cookbook/src/recipes/message-actions/attachment-actions/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/message-actions/attachment-actions/demo-data.json create mode 100644 examples/cookbook/src/recipes/message-actions/attachment-actions/installVoteActions.ts create mode 100644 examples/cookbook/src/recipes/message-actions/attachment-actions/shots.json create mode 100644 examples/cookbook/src/recipes/message-actions/attachment-actions/voteActions.ts create mode 100644 examples/cookbook/src/recipes/message-actions/attachment-actions/voteActionsMiddleware.ts create mode 100644 examples/cookbook/src/recipes/message-actions/custom-message-actions/CustomMessageActions.tsx create mode 100644 examples/cookbook/src/recipes/message-actions/custom-message-actions/OpenMenuFromUrl.tsx create mode 100644 examples/cookbook/src/recipes/message-actions/custom-message-actions/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/message-actions/custom-message-actions/shots.json create mode 100644 examples/cookbook/src/recipes/message-actions/editing-a-poll/EditPollAction.tsx create mode 100644 examples/cookbook/src/recipes/message-actions/editing-a-poll/EditPollDialog.css create mode 100644 examples/cookbook/src/recipes/message-actions/editing-a-poll/EditPollDialog.tsx create mode 100644 examples/cookbook/src/recipes/message-actions/editing-a-poll/OpenEditPollFromUrl.tsx create mode 100644 examples/cookbook/src/recipes/message-actions/editing-a-poll/ShowPollLocally.tsx create mode 100644 examples/cookbook/src/recipes/message-actions/editing-a-poll/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/message-actions/editing-a-poll/pollForm.ts create mode 100644 examples/cookbook/src/recipes/message-actions/editing-a-poll/shots.json create mode 100644 examples/cookbook/src/recipes/message-actions/reactions/CustomMessageUi.css create mode 100644 examples/cookbook/src/recipes/message-actions/reactions/CustomMessageUi.tsx create mode 100644 examples/cookbook/src/recipes/message-actions/reactions/SimulateMessageStates.tsx create mode 100644 examples/cookbook/src/recipes/message-actions/reactions/WithCustomMessageUi.tsx create mode 100644 examples/cookbook/src/recipes/message-actions/reactions/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/message-actions/reactions/shots.json create mode 100644 examples/cookbook/src/recipes/message-list/custom-message/CustomMessageUi.css create mode 100644 examples/cookbook/src/recipes/message-list/custom-message/CustomMessageUi.tsx create mode 100644 examples/cookbook/src/recipes/message-list/custom-message/SimulateMessageStates.tsx create mode 100644 examples/cookbook/src/recipes/message-list/custom-message/WithCustomMessageUi.tsx create mode 100644 examples/cookbook/src/recipes/message-list/custom-message/avatars.recipe.tsx create mode 100644 examples/cookbook/src/recipes/message-list/custom-message/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/message-list/custom-message/grouped-metadata.recipe.tsx create mode 100644 examples/cookbook/src/recipes/message-list/custom-message/message-text.recipe.tsx create mode 100644 examples/cookbook/src/recipes/message-list/custom-message/metadata.recipe.tsx create mode 100644 examples/cookbook/src/recipes/message-list/custom-message/plain-text.recipe.tsx create mode 100644 examples/cookbook/src/recipes/message-list/custom-message/sender-names.recipe.tsx create mode 100644 examples/cookbook/src/recipes/message-list/custom-message/shots.json create mode 100644 examples/cookbook/src/recipes/message-list/custom-message/stepRecipe.tsx create mode 100644 examples/cookbook/src/recipes/message-list/custom-message/steps/avatars/CustomMessageUi.css create mode 100644 examples/cookbook/src/recipes/message-list/custom-message/steps/avatars/CustomMessageUi.tsx create mode 100644 examples/cookbook/src/recipes/message-list/custom-message/steps/grouped-metadata/CustomMessageUi.css create mode 100644 examples/cookbook/src/recipes/message-list/custom-message/steps/message-text/CustomMessageUi.css create mode 100644 examples/cookbook/src/recipes/message-list/custom-message/steps/message-text/CustomMessageUi.tsx create mode 100644 examples/cookbook/src/recipes/message-list/custom-message/steps/metadata/CustomMessageUi.css create mode 100644 examples/cookbook/src/recipes/message-list/custom-message/steps/metadata/CustomMessageUi.tsx create mode 100644 examples/cookbook/src/recipes/message-list/custom-message/steps/plain-text/CustomMessageUi.tsx create mode 100644 examples/cookbook/src/recipes/message-list/custom-message/steps/sender-names/CustomMessageUi.css create mode 100644 examples/cookbook/src/recipes/message-list/custom-message/steps/sender-names/CustomMessageUi.tsx create mode 100644 examples/cookbook/src/recipes/message-list/giphy-preview/GiphyPreviewActions.css create mode 100644 examples/cookbook/src/recipes/message-list/giphy-preview/GiphyPreviewActions.tsx create mode 100644 examples/cookbook/src/recipes/message-list/giphy-preview/ShowGiphyPreviewLocally.tsx create mode 100644 examples/cookbook/src/recipes/message-list/giphy-preview/WithGiphyPreview.tsx create mode 100644 examples/cookbook/src/recipes/message-list/giphy-preview/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/message-list/giphy-preview/shots.json create mode 100644 examples/cookbook/src/recipes/message-list/image-gallery/ImageGrid.css create mode 100644 examples/cookbook/src/recipes/message-list/image-gallery/ImageGrid.tsx create mode 100644 examples/cookbook/src/recipes/message-list/image-gallery/Lightbox.css create mode 100644 examples/cookbook/src/recipes/message-list/image-gallery/Lightbox.tsx create mode 100644 examples/cookbook/src/recipes/message-list/image-gallery/LightboxCaption.css create mode 100644 examples/cookbook/src/recipes/message-list/image-gallery/LightboxCaption.tsx create mode 100644 examples/cookbook/src/recipes/message-list/image-gallery/LightboxMedia.css create mode 100644 examples/cookbook/src/recipes/message-list/image-gallery/LightboxMedia.tsx create mode 100644 examples/cookbook/src/recipes/message-list/image-gallery/OpenLightboxFromUrl.tsx create mode 100644 examples/cookbook/src/recipes/message-list/image-gallery/ThumbnailStrip.css create mode 100644 examples/cookbook/src/recipes/message-list/image-gallery/ThumbnailStrip.tsx create mode 100644 examples/cookbook/src/recipes/message-list/image-gallery/WithImageGallery.tsx create mode 100644 examples/cookbook/src/recipes/message-list/image-gallery/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/message-list/image-gallery/demo-data.json create mode 100644 examples/cookbook/src/recipes/message-list/image-gallery/shots.json create mode 100644 examples/cookbook/src/recipes/message-list/image-gallery/useLightboxNavigation.ts create mode 100644 examples/cookbook/src/recipes/message-list/pin-indicator/PinnedByIndicator.css create mode 100644 examples/cookbook/src/recipes/message-list/pin-indicator/PinnedByIndicator.tsx create mode 100644 examples/cookbook/src/recipes/message-list/pin-indicator/component.recipe.tsx create mode 100644 examples/cookbook/src/recipes/message-list/pin-indicator/css.recipe.tsx create mode 100644 examples/cookbook/src/recipes/message-list/pin-indicator/pin-indicator.css create mode 100644 examples/cookbook/src/recipes/message-list/pin-indicator/shots.json create mode 100644 examples/cookbook/src/recipes/message-list/pinned-messages-bar/ChannelHeaderWithPinnedBar.tsx create mode 100644 examples/cookbook/src/recipes/message-list/pinned-messages-bar/ClickPinnedBarFromUrl.tsx create mode 100644 examples/cookbook/src/recipes/message-list/pinned-messages-bar/PinnedMessagesBar.css create mode 100644 examples/cookbook/src/recipes/message-list/pinned-messages-bar/PinnedMessagesBar.tsx create mode 100644 examples/cookbook/src/recipes/message-list/pinned-messages-bar/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/message-list/pinned-messages-bar/demo-data.json create mode 100644 examples/cookbook/src/recipes/message-list/pinned-messages-bar/shots.json create mode 100644 examples/cookbook/src/recipes/message-list/read-receipt-avatars/ReadReceiptAvatars.css create mode 100644 examples/cookbook/src/recipes/message-list/read-receipt-avatars/ReadReceiptAvatars.tsx create mode 100644 examples/cookbook/src/recipes/message-list/read-receipt-avatars/ReadReceiptsChannel.tsx create mode 100644 examples/cookbook/src/recipes/message-list/read-receipt-avatars/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/message-list/read-receipt-avatars/demo-data.json create mode 100644 examples/cookbook/src/recipes/message-list/read-receipt-avatars/shots.json create mode 100644 examples/cookbook/src/recipes/message-list/system-message/AddLocalSystemMessage.tsx create mode 100644 examples/cookbook/src/recipes/message-list/system-message/CustomSystemMessage.css create mode 100644 examples/cookbook/src/recipes/message-list/system-message/CustomSystemMessage.tsx create mode 100644 examples/cookbook/src/recipes/message-list/system-message/custom.recipe.tsx create mode 100644 examples/cookbook/src/recipes/message-list/system-message/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/message-list/system-message/shots.json create mode 100644 examples/cookbook/src/recipes/message-list/unread-and-scroll-controls/ScreenshotHelpers.tsx create mode 100644 examples/cookbook/src/recipes/message-list/unread-and-scroll-controls/UnreadChannel.tsx create mode 100644 examples/cookbook/src/recipes/message-list/unread-and-scroll-controls/UnreadControls.css create mode 100644 examples/cookbook/src/recipes/message-list/unread-and-scroll-controls/UnreadControls.tsx create mode 100644 examples/cookbook/src/recipes/message-list/unread-and-scroll-controls/component.recipe.tsx create mode 100644 examples/cookbook/src/recipes/message-list/unread-and-scroll-controls/css.recipe.tsx create mode 100644 examples/cookbook/src/recipes/message-list/unread-and-scroll-controls/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/message-list/unread-and-scroll-controls/demo-data.json create mode 100644 examples/cookbook/src/recipes/message-list/unread-and-scroll-controls/shots.json create mode 100644 examples/cookbook/src/recipes/message-list/unread-and-scroll-controls/unread-and-scroll-controls.css create mode 100644 examples/cookbook/src/recipes/notifications/custom-snackbars/SimulateSnackbars.tsx create mode 100644 examples/cookbook/src/recipes/notifications/custom-snackbars/Snackbar.css create mode 100644 examples/cookbook/src/recipes/notifications/custom-snackbars/Snackbar.tsx create mode 100644 examples/cookbook/src/recipes/notifications/custom-snackbars/SnackbarStack.css create mode 100644 examples/cookbook/src/recipes/notifications/custom-snackbars/SnackbarStack.tsx create mode 100644 examples/cookbook/src/recipes/notifications/custom-snackbars/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/notifications/custom-snackbars/publishSnackbars.ts create mode 100644 examples/cookbook/src/recipes/notifications/custom-snackbars/shots.json create mode 100644 examples/cookbook/src/recipes/notifications/system-notification-banner/AppLayout.css create mode 100644 examples/cookbook/src/recipes/notifications/system-notification-banner/AppLayout.tsx create mode 100644 examples/cookbook/src/recipes/notifications/system-notification-banner/ConnectionStatusBanner.css create mode 100644 examples/cookbook/src/recipes/notifications/system-notification-banner/ConnectionStatusBanner.tsx create mode 100644 examples/cookbook/src/recipes/notifications/system-notification-banner/MaintenanceNotice.tsx create mode 100644 examples/cookbook/src/recipes/notifications/system-notification-banner/SimulateSystemNotification.tsx create mode 100644 examples/cookbook/src/recipes/notifications/system-notification-banner/SystemNotificationBanner.css create mode 100644 examples/cookbook/src/recipes/notifications/system-notification-banner/SystemNotificationBanner.tsx create mode 100644 examples/cookbook/src/recipes/notifications/system-notification-banner/connection.recipe.tsx create mode 100644 examples/cookbook/src/recipes/notifications/system-notification-banner/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/notifications/system-notification-banner/shots.json create mode 100644 examples/cookbook/src/recipes/panels/channel-details/ChannelDetailsPanel.css create mode 100644 examples/cookbook/src/recipes/panels/channel-details/ChannelDetailsPanel.tsx create mode 100644 examples/cookbook/src/recipes/panels/channel-details/ChannelHeaderWithDetails.css create mode 100644 examples/cookbook/src/recipes/panels/channel-details/ChannelHeaderWithDetails.tsx create mode 100644 examples/cookbook/src/recipes/panels/channel-details/MembersWithPresence.css create mode 100644 examples/cookbook/src/recipes/panels/channel-details/MembersWithPresence.tsx create mode 100644 examples/cookbook/src/recipes/panels/channel-details/OpenDetailsFromUrl.tsx create mode 100644 examples/cookbook/src/recipes/panels/channel-details/channel-details-theme.css create mode 100644 examples/cookbook/src/recipes/panels/channel-details/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/panels/channel-details/shots.json create mode 100644 examples/cookbook/src/recipes/panels/drafts-list/DraftsButton.css create mode 100644 examples/cookbook/src/recipes/panels/drafts-list/DraftsButton.tsx create mode 100644 examples/cookbook/src/recipes/panels/drafts-list/DraftsList.css create mode 100644 examples/cookbook/src/recipes/panels/drafts-list/DraftsList.tsx create mode 100644 examples/cookbook/src/recipes/panels/drafts-list/OpenDraftsFromUrl.tsx create mode 100644 examples/cookbook/src/recipes/panels/drafts-list/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/panels/drafts-list/demo-data.json create mode 100644 examples/cookbook/src/recipes/panels/drafts-list/shots.json create mode 100644 examples/cookbook/src/recipes/panels/hide-history-for-new-members/AddMembersButton.css create mode 100644 examples/cookbook/src/recipes/panels/hide-history-for-new-members/AddMembersButton.tsx create mode 100644 examples/cookbook/src/recipes/panels/hide-history-for-new-members/ChannelHeaderWithAddMembers.tsx create mode 100644 examples/cookbook/src/recipes/panels/hide-history-for-new-members/OpenHistoryDialog.tsx create mode 100644 examples/cookbook/src/recipes/panels/hide-history-for-new-members/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/panels/hide-history-for-new-members/shots.json create mode 100644 examples/cookbook/src/recipes/panels/message-info/MessageInfoAction.tsx create mode 100644 examples/cookbook/src/recipes/panels/message-info/MessageInfoPanel.css create mode 100644 examples/cookbook/src/recipes/panels/message-info/MessageInfoPanel.tsx create mode 100644 examples/cookbook/src/recipes/panels/message-info/OpenInfoFromUrl.tsx create mode 100644 examples/cookbook/src/recipes/panels/message-info/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/panels/message-info/demo-data.json create mode 100644 examples/cookbook/src/recipes/panels/message-info/shots.json create mode 100644 examples/cookbook/src/recipes/panels/saved-items-and-reminders/OpenSavedItemsFromUrl.tsx create mode 100644 examples/cookbook/src/recipes/panels/saved-items-and-reminders/SavedItems.css create mode 100644 examples/cookbook/src/recipes/panels/saved-items-and-reminders/SavedItems.tsx create mode 100644 examples/cookbook/src/recipes/panels/saved-items-and-reminders/SavedItemsButton.css create mode 100644 examples/cookbook/src/recipes/panels/saved-items-and-reminders/SavedItemsButton.tsx create mode 100644 examples/cookbook/src/recipes/panels/saved-items-and-reminders/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/panels/saved-items-and-reminders/demo-data.json create mode 100644 examples/cookbook/src/recipes/panels/saved-items-and-reminders/shots.json create mode 100644 examples/cookbook/src/recipes/panels/threads-view/OnlyCookbookThreads.tsx create mode 100644 examples/cookbook/src/recipes/panels/threads-view/ScreenshotHelpers.tsx create mode 100644 examples/cookbook/src/recipes/panels/threads-view/ThreadRow.css create mode 100644 examples/cookbook/src/recipes/panels/threads-view/ThreadRow.tsx create mode 100644 examples/cookbook/src/recipes/panels/threads-view/ThreadsView.css create mode 100644 examples/cookbook/src/recipes/panels/threads-view/ThreadsView.tsx create mode 100644 examples/cookbook/src/recipes/panels/threads-view/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/panels/threads-view/demo-data.json create mode 100644 examples/cookbook/src/recipes/panels/threads-view/shots.json create mode 100644 examples/cookbook/src/recipes/panels/user-details/MessageListWithUserDetails.tsx create mode 100644 examples/cookbook/src/recipes/panels/user-details/OpenProfileFromUrl.tsx create mode 100644 examples/cookbook/src/recipes/panels/user-details/UserDetailsPanel.css create mode 100644 examples/cookbook/src/recipes/panels/user-details/UserDetailsPanel.tsx create mode 100644 examples/cookbook/src/recipes/panels/user-details/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/panels/user-details/directConversation.ts create mode 100644 examples/cookbook/src/recipes/panels/user-details/shots.json create mode 100644 examples/cookbook/src/recipes/search/mentions-inbox/MentionsButton.css create mode 100644 examples/cookbook/src/recipes/search/mentions-inbox/MentionsButton.tsx create mode 100644 examples/cookbook/src/recipes/search/mentions-inbox/MentionsInbox.css create mode 100644 examples/cookbook/src/recipes/search/mentions-inbox/MentionsInbox.tsx create mode 100644 examples/cookbook/src/recipes/search/mentions-inbox/OpenMentionsFromUrl.tsx create mode 100644 examples/cookbook/src/recipes/search/mentions-inbox/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/search/mentions-inbox/demo-data.json create mode 100644 examples/cookbook/src/recipes/search/mentions-inbox/shots.json create mode 100644 examples/cookbook/src/recipes/search/search-customization/CompactChannelResults.css create mode 100644 examples/cookbook/src/recipes/search/search-customization/CompactChannelResults.tsx create mode 100644 examples/cookbook/src/recipes/search/search-customization/SidebarSearch.tsx create mode 100644 examples/cookbook/src/recipes/search/search-customization/SimulateSearch.tsx create mode 100644 examples/cookbook/src/recipes/search/search-customization/Workspace.css create mode 100644 examples/cookbook/src/recipes/search/search-customization/Workspace.tsx create mode 100644 examples/cookbook/src/recipes/search/search-customization/compact-results.recipe.tsx create mode 100644 examples/cookbook/src/recipes/search/search-customization/search-props.recipe.tsx create mode 100644 examples/cookbook/src/recipes/search/search-customization/shots.json create mode 100644 examples/cookbook/src/recipes/search/search-customization/top-bar.recipe.tsx create mode 100644 examples/cookbook/src/recipes/search/search-dialog/SearchDialog.css create mode 100644 examples/cookbook/src/recipes/search/search-dialog/SearchDialog.tsx create mode 100644 examples/cookbook/src/recipes/search/search-dialog/SearchDialogButton.css create mode 100644 examples/cookbook/src/recipes/search/search-dialog/SearchDialogButton.tsx create mode 100644 examples/cookbook/src/recipes/search/search-dialog/SearchFilters.css create mode 100644 examples/cookbook/src/recipes/search/search-dialog/SearchFilters.tsx create mode 100644 examples/cookbook/src/recipes/search/search-dialog/SearchResults.css create mode 100644 examples/cookbook/src/recipes/search/search-dialog/SearchResults.tsx create mode 100644 examples/cookbook/src/recipes/search/search-dialog/SimulateSearchDialog.tsx create mode 100644 examples/cookbook/src/recipes/search/search-dialog/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/search/search-dialog/searchController.ts create mode 100644 examples/cookbook/src/recipes/search/search-dialog/shots.json create mode 100644 examples/cookbook/src/recipes/theming/brand-theme-and-dark-mode/dark.recipe.tsx create mode 100644 examples/cookbook/src/recipes/theming/brand-theme-and-dark-mode/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/theming/brand-theme-and-dark-mode/loadFont.ts create mode 100644 examples/cookbook/src/recipes/theming/brand-theme-and-dark-mode/rtl.css create mode 100644 examples/cookbook/src/recipes/theming/brand-theme-and-dark-mode/rtl.recipe.tsx create mode 100644 examples/cookbook/src/recipes/theming/brand-theme-and-dark-mode/sdk-dark.recipe.tsx create mode 100644 examples/cookbook/src/recipes/theming/brand-theme-and-dark-mode/shots.json create mode 100644 examples/cookbook/src/recipes/theming/brand-theme-and-dark-mode/square.css create mode 100644 examples/cookbook/src/recipes/theming/brand-theme-and-dark-mode/square.recipe.tsx create mode 100644 examples/cookbook/src/recipes/theming/brand-theme-and-dark-mode/theme.css create mode 100644 examples/cookbook/src/recipes/theming/replacing-the-icon-set/LucideFileIcon.tsx create mode 100644 examples/cookbook/src/recipes/theming/replacing-the-icon-set/OpenMenuFromUrl.tsx create mode 100644 examples/cookbook/src/recipes/theming/replacing-the-icon-set/WithLucideIcons.tsx create mode 100644 examples/cookbook/src/recipes/theming/replacing-the-icon-set/before.recipe.tsx create mode 100644 examples/cookbook/src/recipes/theming/replacing-the-icon-set/default.recipe.tsx create mode 100644 examples/cookbook/src/recipes/theming/replacing-the-icon-set/lucide-icons.css create mode 100644 examples/cookbook/src/recipes/theming/replacing-the-icon-set/lucideIcons.tsx create mode 100644 examples/cookbook/src/recipes/theming/replacing-the-icon-set/shots.json create mode 100644 examples/cookbook/src/vite-env.d.ts create mode 100644 examples/cookbook/tsconfig.app.json create mode 100644 examples/cookbook/tsconfig.json create mode 100644 examples/cookbook/tsconfig.node.json create mode 100644 examples/cookbook/vite.config.ts diff --git a/.agents/skills/react-ui-cookbook/SKILL.md b/.agents/skills/react-ui-cookbook/SKILL.md new file mode 100644 index 0000000000..7857e15b90 --- /dev/null +++ b/.agents/skills/react-ui-cookbook/SKILL.md @@ -0,0 +1,195 @@ +--- +name: react-ui-cookbook +description: + Write, port or update a recipe for the React v15 docs' UI Cookbooks group, with its code in + examples/cookbook and its screenshots taken from that app. Use whenever a task adds or changes a + UI Cookbook recipe, its CSS, its screenshots, the cookbook base app or its demo data, so every + recipe keeps the same look, cast and style rules. +--- + +# React UI Cookbook + +Every UI Cookbook recipe in the React v15 docs (getstream.io, `content/docs/chat-sdk/react/v15-rc/`, +URLs under `/chat/docs/sdk/react/v15/ui-cookbooks/`) is built on one base app, follows one set of +style rules, and is shot in one frame with one cast. The runnable code lives here, in +`examples/cookbook`; the docs page shows that same code. + +## The look + +- **Theme:** the cookbook theme, shown on the UI › Customizing › Theming docs page (source: + `src/recipes/theming/brand-theme-and-dark-mode/theme.css`, which the base app loads for every + recipe): a violet accent (`#7C3AED`) on lavender-tinted surfaces, soft violet bubbles (`#875DD0`) + with white text, links and mentions for the user's own messages, white ones for everyone + else's, 20px bubbles, 16–20px cards, pill-shaped buttons and inputs, Plus Jakarta Sans, and soft + violet-tinted shadows. Change the look only there, never in another recipe. Recipes are shot in the light theme; only the Theming recipe adds dark + shots. +- **Frame:** desktop 1280×720 at DPR 1 (`ChatView`: channel list 320px on the left, the open + channel in the middle, a thread on the right when the recipe needs one). Phone recipes use + 390×844. A shot with four columns (the channel list, the channel, a thread and the side panel) + uses 1600×720, set with `viewport` in `shots.json`, so the messages don't wrap word by word. + Crop each shot to the part the recipe changes, with a little surrounding UI. +- **Side panel:** a recipe that shows content in a panel (details, a profile, info) opens it in the + base app's one side panel (`src/base/SidePanel.tsx`), never in a column of its own: call + `useSidePanel().openSidePanel({ id, title, body })`, or `openUserProfile(userId)` for a profile + (the SDK's `userProfile` kind). The panel draws the title row and the close button; the recipe + writes only the body, and its section headings use `.cookbook-side-panel__section-title`. It + never closes the thread, and opening another panel replaces it: other content and closing + release the whole slot, so an earlier profile never comes back. The panel moves focus to its + title on open and gives it back to the opener on close; a recipe adds no focus code of its own. + A recipe that only changes the message lists or the profile body sets `RecipeConfig.MessageList` + and `RecipeConfig.sidePanelProps` instead of copying the channels view. The Overview shows its + code. Likewise, a recipe that adds a view beside the channels view sets `RecipeConfig.views` and + `RecipeConfig.layouts`, which the base app merges into its own. +- **Cast:** the user is Cleo; the others are Bob, Cara and Dan, with initials avatars and no + photos. Channels: Design review, Weekend trip, Book club, Garden plot, General, and a direct + message with Bob. All of it, with fixed message ids, is in `src/base/demo-data.json`. +- **Content:** neutral and friendly. The demo app is public: no real names, no personal data, no + brand names. +- **Read-only:** the base app blocks every request that would change the demo app (sending, + editing, deleting, reacting, flagging, pinning, adding members, updating channels); a blocked + request never settles, so the UI keeps its optimistic state. Reads, watching, typing and the + user's read state still go through (`src/base/readOnly.ts`). Data changes go in the seed, never + in a recipe or a screenshot helper. `?writes=on` turns the guard off, for an app of your own. + +## Style rules + +`yarn workspace @stream-io/stream-chat-react-cookbook check:css` checks them: literal colors and +sizes, `var()`s of anything but `--str-chat__` tokens, CSS outside the layer, the prefix, and +layout properties on SDK markup. It can't judge whether a token fits; review that by eye. + +1. **Tokens only.** Colors, spacing, radii and type come from `--str-chat__*` semantic tokens: + `--str-chat__text-secondary`, `--str-chat__spacing-sm`, `--str-chat__radius-md`, + `--str-chat__font-caption-default`, … No hex, `rgb()` or named colors, and no `px`/`rem`/`em` + for spacing, radius or type. Border widths and component sizes may use `px`. +2. **One layer.** All recipe CSS sits inside `@layer stream-overrides { … }`. The base app declares + the order (`modern-normalize, stream, cookbook-base, stream-overrides`), so a recipe never needs + extra specificity or `!important`. +3. **One prefix.** Classes a recipe adds start with `.cookbook-`, followed by a name for the part it + styles, for example `.cookbook-pinned-by` or `.cookbook-channel-header__title`. SDK classes + (`.str-chat…`) may be restyled. +4. **Leave the SDK's layout alone.** Never set `display`, `position`, `flex`, `grid`, `float` or + offsets on a rule whose target is an SDK class. Style your own markup, or change sizes through a + component variable such as `--str-chat__channel-list-width`. Where the SDK has no variable, a + comment starting `cookbook-allow-layout:` right above the rule says why; known case: the width of + a thread beside a channel. + +## Where things are + +``` +examples/cookbook/ + src/base/ the base app (BaseApp.tsx), its CSS (base.css), client, demo data, + the side panel (SidePanel.tsx), URL helpers (OpenFromUrl: + ?channel=&thread=; Simulate: ?simulate=typing&as=), and useClickInTurn + for screenshot helpers that click through the UI + src/recipes/// + *.tsx, *.css exactly the recipe code the docs page shows + .recipe.tsx default-exports a RecipeConfig: what the recipe changes in the base app + (ChannelHeader, Wrapper, MessageList, sidePanelProps, views and + layouts merged into the base ones, chatViewProps, chatViewChildren, + chatProps, openFromUrl, theme), and imports the recipe's CSS + shots.json the screenshots the docs page uses (format in scripts/shots.mjs) + demo-data.json optional: users and channels only this recipe needs, in the shape of + src/base/demo-data.json; seeded and listed with the base data + scripts/seed.mjs creates or brings back in line the cast, channels, members, messages, + replies and pins; sets a message's attachments, mentions and custom + fields after sending it; sets read state and receipts, invites, and + the current user's pinned and archived channels, reminders, saved + messages and drafts (fields below) + scripts/shots.mjs takes every shot from the running app, writes them to --out + scripts/check-css.mjs the style-rule check +``` + +`` and `` match the docs URL: `/ui-cookbooks/headers/channel-header/` lives in +`src/recipes/headers/channel-header/`. A page with two solutions has two variants, for example +`css.recipe.tsx` and `component.recipe.tsx`. Open a recipe at +`http://localhost:5176/?recipe=/&variant=`; `?recipe=base` is the base app +alone, and no parameter lists every recipe. + +## Adding or changing a recipe + +1. Read the recipe's docs page and the pages it links to. Check every API it uses against + `stream-chat-react` `master` and `stream-chat` `release-v10`. +2. Write the code in `src/recipes///`, following the style rules, and add a + `.recipe.tsx` for each solution the page shows. +3. Put demo data only this recipe needs in its folder's `demo-data.json` (new ids, never reuse + one); change `src/base/demo-data.json` only for data every recipe shares. A message may carry + `attachments` (for example image URLs), which the seed sets after sending, since the demo app + refuses uploads. A message may also carry `mentions` (user ids), which the seed sets after + sending, since the demo app refuses a new message with mentions. A message may carry + `reminderForCurrentUser`: `{}` saves it for later, `{ "remindAt": "" }` sets a fixed + time, and `{ "remindAt": "+2m" }` a time that many minutes after the first seed, kept on later + runs, which is how a reminder becomes overdue (the server refuses a past time). The demo app's + `messaging` type has `user_message_reminders` off, so the seed reports that and skips them. + A channel may carry `draftsForCurrentUser`, the current user's unsent drafts in it: + `{ "text" }` for the channel's draft, with `"parent"` (a main-list message id) for a thread's + draft and `"quotedMessage"` (a message id) for one that quotes a message. Each is saved only + when the stored draft differs. A draft in a base channel would show in that channel's composer + in every recipe, so drafts go in the recipe's own channels. + + The other fields the seed reads, each set only when the stored state differs: + - On a channel: `createdBy` (a cast id that creates it instead of the current user), + `invites` (user ids left with a pending invite), `readBy` and `deliveredTo` (members who + read or received its last message), `readUpTo` (`{ userId: messageId }`), `unreadFrom` (a + message id the current user's unread state starts at), and `pinnedForCurrentUser` / + `archivedForCurrentUser`. + - On a message: `pinnedBy` (its author), `custom` (custom fields, such as + `{ "ai_generated": true }`), and `unreadForCurrentUser` on a thread's parent (its latest + reply stays unread). + + Then run `seed`. +4. List the shots in `shots.json`, reusing the file names the page already references. +5. Run the checks and the shots (below), then look at every image. +6. Copy the code into the docs page so it matches the folder exactly, formatted with the docs' + prettier (double quotes, width 80), apart from the base app's setup, which the page either + shows in full or links to on the UI Cookbooks Overview. Two rewrites are allowed: imports of + the shared side panel and the New conversation dialog become `./SidePanel` and + `./NewConversationDialog`, and the `cookbook-allow-layout:` prefix is dropped from comments. +7. In getstream.io, copy the images into `content/docs/chat-sdk/react/v15-rc/_assets/` and run the + docs checks. + +## The docs page + +Every recipe page has the same shape: + +1. What it builds, with the main screenshot right after the intro. +2. Which level it uses, from the Overview's order: restyle, replace one part, compose, or change + behaviour in the state layer. +3. "Start from the base app", linking to the Overview. +4. The steps, each with its code and, where it helps, a screenshot next to it. +5. How it works. +6. Related pages. + +A component and its stylesheet go in one `` block, the code tab labelled `React` and the +stylesheet tab `CSS`, each with its `title` (see the Pin indicator page). + +Docs prose follows the v15 docs conventions: name methods as `Class.method()`, name every field with +its owner (`channel.state.typing`), say "paginator" for the object that loads items, and put example +scenarios in an `` titled **Example**. + +## Commands + +Run from the repository root unless noted. + +```bash +yarn install && yarn build # the SDK the cookbook imports +yarn workspace @stream-io/stream-chat-react-cookbook seed # demo data (idempotent) +yarn workspace @stream-io/stream-chat-react-cookbook dev # http://localhost:5176 +yarn workspace @stream-io/stream-chat-react-cookbook shots --out /content/docs/chat-sdk/react/v15-rc/_assets [--only /] +yarn workspace @stream-io/stream-chat-react-cookbook check:css +yarn workspace @stream-io/stream-chat-react-cookbook build # type-check + build, as CI does +``` + +`shots` needs the dev server running and Playwright's Chromium (`npx playwright install chromium`). +A relative `--out` is resolved from the repository root; an absolute path is safest. Shots use the `en-US` locale +and the UTC time zone, so dates look the same on every machine. File names must be unique across +all recipes. + +## Done when + +- [ ] `check:css` passes. +- [ ] `build` passes, and `npx eslint --max-warnings 0` and `npx prettier --check` pass for the + changed files. +- [ ] Every shot in `shots.json` is taken, looked at, and shows what the page says. +- [ ] The docs page's code matches the recipe folder. +- [ ] In getstream.io: the docs checks and the sample checker pass on the page, and the site builds. +- [ ] Anything that only renders wrong because of the SDK is reported, not hidden with CSS. diff --git a/.claude/skills/react-ui-cookbook b/.claude/skills/react-ui-cookbook new file mode 120000 index 0000000000..ab78786dce --- /dev/null +++ b/.claude/skills/react-ui-cookbook @@ -0,0 +1 @@ +../../.agents/skills/react-ui-cookbook \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1260e5a65..a313c35095 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,7 @@ jobs: run: git diff --exit-code -- src/i18n/keys.ts - name: Build the example apps - # Type-checks and bundles every `examples/*` workspace (vite, tutorial, ai-chatbot) against + # Type-checks and bundles every `examples/*` workspace (vite, tutorial, ai-chatbot, cookbook) against # the `dist` built above, so an SDK change that breaks an example (or a stream-chat API one # relies on) fails here. None of the builds need Stream credentials. env: diff --git a/.gitignore b/.gitignore index b83ec27e19..7b971ec522 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,8 @@ typings/ /.env.local /.env.development /.env.production +# Local env files in the example apps, such as the cookbook's Giphy key +examples/*/.env*.local # next.js build output .next diff --git a/AGENTS.md b/AGENTS.md index 19e6fd0a2b..04bbdc4241 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ Respect repo-specific rules. Do not suppress lint rules broadly; justify and sco - `src/` — library source: `components/`, `context/`, `hooks/`, `store/`, `i18n/`, `styling/`, `a11y/`, `plugins/`, `utils/`, `types/`, `constants/`, `mock-builders/` - `src/plugins/` — separately exported entry points: `AIComponents`, `ChannelDetail`, `Emojis`, `SlotGeometry`, `SlotLayout`, `encoders` - `scripts/` — build/validation scripts -- `examples/` — private example workspaces: `examples/tutorial` (one folder per tutorial step), `examples/vite` (complete app), `examples/ai-chatbot` (Next.js AI chatbot) +- `examples/` — private example workspaces: `examples/tutorial` (one folder per tutorial step), `examples/vite` (complete app), `examples/ai-chatbot` (Next.js AI chatbot), `examples/cookbook` (the base app and code of the docs' UI Cookbook recipes; see `.agents/skills/react-ui-cookbook/SKILL.md`) - `ai-docs/` — integrator-facing migration guides (`ai-migration-v14-v15.md`, `i18n-v15-migration.md`, `instance-configuration.md`, …) and the hand-reviewed `i18n-v15-key-map.json` - `developers/` — dev notes (`BRANCHES.md`, `COMMIT.md`, `DEPRECATIONS.md`, `DEVELOPMENT.md`, `DOCUMENTATION.md`, `PR.md`, `RELEASE.md`) @@ -84,6 +84,7 @@ yarn validate-entry-isolation # fails if the main entry's ESM graph reaches ai-c # Examples yarn start:tutorial # @stream-io/stream-chat-react-tutorial dev server yarn start:vite # @stream-io/stream-chat-react-vite dev server +yarn start:cookbook # @stream-io/stream-chat-react-cookbook dev server (UI Cookbook recipes) yarn start:ai-chatbot # @stream-io/stream-chat-react-ai-chatbot dev server (run yarn build first) yarn examples:build # build all example workspaces ``` diff --git a/examples/cookbook/index.html b/examples/cookbook/index.html new file mode 100644 index 0000000000..67a3c0deba --- /dev/null +++ b/examples/cookbook/index.html @@ -0,0 +1,12 @@ + + + + + + UI Cookbook + + +
+ + + diff --git a/examples/cookbook/package.json b/examples/cookbook/package.json new file mode 100644 index 0000000000..a1ba21760f --- /dev/null +++ b/examples/cookbook/package.json @@ -0,0 +1,34 @@ +{ + "name": "@stream-io/stream-chat-react-cookbook", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --port 5176 --strictPort", + "build": "tsc -b && vite build", + "preview": "vite preview --port 5176 --strictPort", + "check:css": "node scripts/check-css.mjs", + "seed": "node scripts/seed.mjs", + "shots": "node scripts/shots.mjs" + }, + "dependencies": { + "@stream-io/state-store": "^1.1.6", + "leaflet": "^1.9.4", + "lucide-react": "^1.61.0", + "modern-normalize": "^3.0.1", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-leaflet": "^5.0.0", + "stream-chat": "10.0.0-rc.25", + "stream-chat-react": "workspace:^" + }, + "devDependencies": { + "@playwright/test": "^1.60.0", + "@types/leaflet": "^1.9.20", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "typescript": "^6.0.3", + "vite": "^8.1.3" + } +} diff --git a/examples/cookbook/scripts/check-css.mjs b/examples/cookbook/scripts/check-css.mjs new file mode 100644 index 0000000000..e46e6f76d5 --- /dev/null +++ b/examples/cookbook/scripts/check-css.mjs @@ -0,0 +1,296 @@ +/** + * Checks every recipe stylesheet against the cookbook's style rules: + * + * layer every rule sits inside `@layer stream-overrides { … }`; no other layer, no @import + * colors colors come from --str-chat__ tokens: no hex, rgb()/hsl()…, named colors, or var()s + * of other custom properties. Setting a --str-chat__ token itself (theming) is allowed + * scale spacing, radius and type use --str-chat__ tokens, not px/rem/em values + * prefix classes the recipe adds start with `cookbook-` (SDK classes `str-chat…` may be restyled) + * layout no display/position/flex/grid/alignment/offsets on a rule that styles SDK markup, + * unless a comment right above the rule contains `cookbook-allow-layout:` and says why + * + * yarn workspace @stream-io/stream-chat-react-cookbook check:css + */ +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.dirname(fileURLToPath(new URL('../package.json', import.meta.url))); +const recipesDir = path.join(root, 'src/recipes'); + +const COLOR_PROPERTIES = + /^(color|background|background-color|background-image|border(-(top|right|bottom|left|block|inline)(-(start|end))?)?(-color)?|outline(-color)?|fill|stroke|box-shadow|text-shadow|text-decoration(-color)?|caret-color|accent-color|column-rule(-color)?)$/; +const SCALE_PROPERTIES = + /^(padding|margin|gap|row-gap|column-gap|border(-[a-z-]+)?-radius|font|font-size|line-height|letter-spacing)(-.+)?$/; +const LAYOUT_PROPERTIES = + /^(display|position|float|order|inset(-.+)?|top|right|bottom|left|flex(-.+)?|grid(-.+)?|place-.+|align-.+|justify-.+)$/; +const LITERAL_COLOR = /#[0-9a-f]{3,8}\b|\b(rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\(/i; +const SCALE_LITERAL = /(^|[\s(,])-?\d*\.?\d+(px|rem|em)\b/; +// CSS named colors (the keywords a literal color can hide behind). +const NAMED_COLORS = new Set( + `aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet + brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue + darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange + darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise + darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen + fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred + indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan + lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen + lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta + maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue + mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin + navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen + paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red + rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue + slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white + whitesmoke yellow yellowgreen`.split(/\s+/), +); + +const listCssFiles = async (dir) => { + const files = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) files.push(...(await listCssFiles(full))); + else if (entry.name.endsWith('.css')) files.push(full); + } + return files; +}; + +const problems = []; +const report = (file, line, rule, message) => + problems.push(`${path.relative(root, file)}:${line} [${rule}] ${message}`); + +/** + * Replaces comments with spaces (so offsets and line numbers stay put) and returns them with their + * end offsets, so a rule can find the comments written right above it. + */ +const extractComments = (css) => { + const comments = []; + let stripped = ''; + let i = 0; + while (i < css.length) { + if (css.startsWith('/*', i)) { + const end = css.indexOf('*/', i + 2); + if (end === -1) return { error: i }; + comments.push({ end: end + 2, text: css.slice(i + 2, end) }); + stripped += css.slice(i, end + 2).replace(/[^\n]/g, ' '); + i = end + 2; + } else { + stripped += css[i++]; + } + } + return { comments, stripped }; +}; + +/** Splits comment-free CSS into blocks, and statements outside any block. */ +const parseBlocks = (css) => { + const blocks = []; + const statements = []; + const stack = []; + let start = 0; + const lineOf = (index) => css.slice(0, index).split('\n').length; + for (let i = 0; i < css.length; i++) { + const char = css[i]; + if (char === '{') { + const raw = css.slice(start, i); + const block = { + declarations: [], + depth: stack.length, + line: lineOf(start + raw.search(/\S/)), + parent: stack[stack.length - 1], + selector: raw.trim().replace(/\s+/g, ' '), + selectorStart: start + raw.search(/\S/), + }; + blocks.push(block); + stack.push(block); + start = i + 1; + } else if (char === '}' || char === ';') { + const text = css.slice(start, i).trim(); + if (text) { + if (stack.length) stack[stack.length - 1].declarations.push(text); + else + statements.push({ + line: lineOf(start + css.slice(start, i).search(/\S/)), + text, + }); + } + if (char === '}') stack.pop(); + start = i + 1; + } + } + return { blocks, statements }; +}; + +/** The comments between the previous rule boundary and this block's selector. */ +const commentsAbove = (comments, original, block) => { + const before = original.slice(0, block.selectorStart); + const boundary = Math.max( + before.lastIndexOf('{'), + before.lastIndexOf('}'), + before.lastIndexOf(';'), + ); + return comments.filter((c) => c.end > boundary && c.end <= block.selectorStart); +}; + +/** The parts of a value outside var(): what's left once every var(--name, fallback) is removed. */ +const outsideVars = (value) => { + let result = ''; + let depth = 0; + for (let i = 0; i < value.length; i++) { + if (value.startsWith('var(', i) && depth === 0) { + depth = 1; + i += 3; + continue; + } + if (depth) { + if (value[i] === '(') depth++; + else if (value[i] === ')') depth--; + continue; + } + result += value[i]; + } + return result; +}; + +const varNames = (value) => [...value.matchAll(/var\(\s*(--[\w-]+)/g)].map((m) => m[1]); +const varFallbacks = (value) => + [...value.matchAll(/var\(\s*--[\w-]+\s*,([^)]*)\)/g)].map((m) => m[1]); + +const hasLiteralColor = (text) => + LITERAL_COLOR.test(text) || + (text.match(/[a-z]+/gi) ?? []).some((word) => NAMED_COLORS.has(word.toLowerCase())); + +// The rule styles SDK markup when its selector involves an SDK class and the element it targets +// (the last compound of the selector) isn't one the recipe added. +const stylesSdkMarkup = (selector) => + selector.split(',').some((part) => { + const compounds = part.trim().split(/\s*[\s>+~]\s*/); + const target = compounds[compounds.length - 1]; + return /\.str-chat/.test(part) && !/\.cookbook-/.test(target); + }); + +for (const file of await listCssFiles(recipesDir)) { + const original = await readFile(file, 'utf8'); + const extracted = extractComments(original); + if (extracted.error !== undefined) { + report( + file, + original.slice(0, extracted.error).split('\n').length, + 'syntax', + 'unterminated comment', + ); + continue; + } + const { comments, stripped } = extracted; + const { blocks, statements } = parseBlocks(stripped); + + for (const statement of statements) { + report( + file, + statement.line, + 'layer', + `"${statement.text}" is outside @layer stream-overrides`, + ); + } + + for (const block of blocks) { + if (block.depth === 0) { + if (block.selector !== '@layer stream-overrides') { + report( + file, + block.line, + 'layer', + `"${block.selector}" is outside @layer stream-overrides`, + ); + } + continue; + } + if (block.selector.startsWith('@layer') || block.selector.startsWith('@import')) { + report( + file, + block.line, + 'layer', + `recipes don't add layers of their own: "${block.selector}"`, + ); + continue; + } + if (block.selector.startsWith('@')) continue; + + for (const className of block.selector.match(/\.[a-zA-Z_-][\w-]*/g) ?? []) { + const name = className.slice(1); + if (!name.startsWith('cookbook-') && !name.startsWith('str-chat')) { + report(file, block.line, 'prefix', `.${name} should start with .cookbook-`); + } + } + + const sdkMarkup = stylesSdkMarkup(block.selector); + const layoutAllowed = commentsAbove(comments, original, block).some((c) => + /cookbook-allow-layout:\s*\S/.test(c.text), + ); + + for (const declaration of block.declarations) { + const separator = declaration.indexOf(':'); + if (separator === -1) continue; + const property = declaration.slice(0, separator).trim().toLowerCase(); + const value = declaration.slice(separator + 1).trim(); + const nonTokenVars = varNames(value).filter( + (name) => !name.startsWith('--str-chat__'), + ); + const literalText = [outsideVars(value), ...varFallbacks(value)].join(' '); + + // Setting an SDK token is theming, so `--str-chat__*` may hold literal values. A recipe's own + // custom properties may hold sizes, never colors. + if (property.startsWith('--')) { + if (!property.startsWith('--str-chat__') && hasLiteralColor(literalText)) { + report( + file, + block.line, + 'colors', + `${property}: ${value} — use a --str-chat__ color token`, + ); + } + continue; + } + + if ( + COLOR_PROPERTIES.test(property) && + (hasLiteralColor(literalText) || nonTokenVars.length) + ) { + report( + file, + block.line, + 'colors', + `${property}: ${value} — use a --str-chat__ color token`, + ); + } + if ( + SCALE_PROPERTIES.test(property) && + (SCALE_LITERAL.test(literalText) || nonTokenVars.length) + ) { + report( + file, + block.line, + 'scale', + `${property}: ${value} — use a --str-chat__ spacing, radius or font token`, + ); + } + if (sdkMarkup && LAYOUT_PROPERTIES.test(property) && !layoutAllowed) { + report( + file, + block.line, + 'layout', + `${property} on "${block.selector}" changes the SDK's layout; style your own markup, use a component variable, or justify it with a cookbook-allow-layout: comment`, + ); + } + } + } +} + +if (problems.length) { + console.error(problems.join('\n')); + console.error( + `\n${problems.length} problem(s). The rules are in .agents/skills/react-ui-cookbook/SKILL.md.`, + ); + process.exit(1); +} +console.log('Recipe CSS follows the cookbook style rules.'); diff --git a/examples/cookbook/scripts/seed.mjs b/examples/cookbook/scripts/seed.mjs new file mode 100644 index 0000000000..8961727a8b --- /dev/null +++ b/examples/cookbook/scripts/seed.mjs @@ -0,0 +1,528 @@ +/** + * Seeds into Stream's public demo app, from src/base/demo-data.json, the cookbook's users, + * channels, members, messages and their custom fields, the users a message mentions, attachments, thread replies, pins, + * receipts, members' read positions, the current user's channel and thread read state, and the + * channels the current user pinned or archived, channels another cast member created with the + * current user's invite still pending, the current user's reminders and messages saved for + * later, and the current user's unsent drafts in channels and threads. Running it again brings + * the app back in line with the file: missing channels, members, invites and messages are added (existing messages are looked up + * first, so only the missing ones are sent), changed names, texts and mentions are updated, and + * nothing that already matches is touched, so a re-run against unchanged data changes nothing. + * + * yarn workspace @stream-io/stream-chat-react-cookbook seed + */ +import { existsSync } from 'node:fs'; +import { readdir, readFile } from 'node:fs/promises'; +import { nsToMs, StreamChat } from 'stream-chat'; + +const readJson = async (url) => JSON.parse(await readFile(url, 'utf8')); +const demoData = await readJson(new URL('../src/base/demo-data.json', import.meta.url)); + +// A recipe that needs data of its own keeps it in src/recipes///demo-data.json, in +// the same shape (`users` and `channels`); it is seeded together with the base data. +const recipesDir = new URL('../src/recipes/', import.meta.url); +for (const group of await readdir(recipesDir, { withFileTypes: true })) { + if (!group.isDirectory()) continue; + for (const recipe of await readdir(new URL(`${group.name}/`, recipesDir), { + withFileTypes: true, + })) { + const file = new URL(`${group.name}/${recipe.name}/demo-data.json`, recipesDir); + if (!recipe.isDirectory() || !existsSync(file)) continue; + const extra = await readJson(file); + for (const user of extra.users ?? []) { + if (!demoData.users.some(({ id }) => id === user.id)) demoData.users.push(user); + } + demoData.channels.push(...(extra.channels ?? [])); + } +} + +const fetchToken = async (userId) => { + const url = new URL('https://pronto.getstream.io/api/auth/create-token'); + url.searchParams.set('environment', demoData.tokenEnvironment); + url.searchParams.set('user_id', userId); + const response = await fetch(url); + if (!response.ok) + throw new Error(`Token request for ${userId} failed: ${response.status}`); + return (await response.json()).token; +}; + +const isAlreadyExists = (error) => + /already exists/i.test(String(error?.message ?? error)); + +// One connected client per demo user. Connecting also creates the user with its name. +const clients = new Map(); + +try { + for (const user of demoData.users) { + const client = new StreamChat(demoData.apiKey, { + client: { allowServerSideConnect: true }, + }); + clients.set(user.id, client); + await client.connectUser(user, () => fetchToken(user.id)); + console.log(`user ${user.id} (${user.name})`); + } + + const owner = clients.get(demoData.currentUserId); + + for (const channelData of demoData.channels) { + // `createdBy` creates the channel as another cast member, for a channel the current user + // doesn't belong to, such as one they're only invited to. Every other channel is the current + // user's. An existing channel keeps its creator. + if (channelData.createdBy && !clients.has(channelData.createdBy)) + console.warn( + ` createdBy ${channelData.createdBy} of ${channelData.id} skipped: not a cast member`, + ); + const creator = clients.get(channelData.createdBy) ?? owner; + const channel = await creator + .channel('messaging', channelData.id, { + // Custom fields such as the name go under `custom`; the SDK reads `data.custom.name`. + ...(channelData.name && { custom: { name: channelData.name } }), + members: channelData.members.map((user_id) => ({ user_id })), + }) + .watch(); + console.log(`channel ${channelData.id}`); + + // An existing channel keeps its data and members, so reconcile both with the file. + if (channelData.name && channel.data?.custom?.name !== channelData.name) { + await channel.updatePartial({ set: { 'custom.name': channelData.name } }); + console.log(` name → ${channelData.name}`); + } + const { members } = channel.state.getLatestValue(); + const missingMembers = channelData.members.filter((id) => !members[id]); + if (missingMembers.length) { + await channel.addMembers({ members: missingMembers }); + console.log(` members + ${missingMembers.join(', ')}`); + } + + // `invites` lists users the creator invited and who haven't answered yet, for a recipe that + // shows pending invites. A user with a pending invite is left alone, so a re-run changes + // nothing. A user who accepted or declined is removed and invited again, which makes the invite + // pending once more. A user who is also in `members`, or is the creator, is skipped with a + // warning: they would join the channel without an invite. + for (const userId of channelData.invites ?? []) { + if (channelData.members.includes(userId) || clients.get(userId) === creator) { + console.warn( + ` invite of ${userId} skipped: already a member of ${channelData.id}`, + ); + continue; + } + if (!clients.has(userId)) { + console.warn(` invite of ${userId} skipped: not a cast member`); + continue; + } + const member = members[userId]; + const isPending = + member?.invited && !member.invite_accepted_at && !member.invite_rejected_at; + if (isPending) continue; + if (member) await channel.removeMembers({ members: [userId] }); + await channel.inviteMembers({ members: [userId] }); + console.log(` invited ${userId}`); + } + + // The demo app allows each user only a few messages a minute, and a send that fails because + // the message already exists still counts. So the messages already in the channel, thread + // replies included, are looked up by id first, in batches of 100, and only the missing ones + // are sent. + const storedById = new Map(); + const messageIds = channelData.messages.map(({ id }) => id); + for (let start = 0; start < messageIds.length; start += 100) { + const { messages: stored } = await channel.getMessagesById( + messageIds.slice(start, start + 100), + ); + for (const storedMessage of stored) storedById.set(storedMessage.id, storedMessage); + } + + // Messages go out in order, each from its author, so the list reads as a conversation. + // Receipts are for the main list, so the last message that isn't a thread reply is kept. + let lastSent; + for (const message of channelData.messages) { + const author = clients.get(message.user); + let sent = storedById.get(message.id); + if (!sent) { + try { + sent = ( + await author.channel('messaging', channelData.id).sendMessage({ + message: { + id: message.id, + text: message.text, + ...(message.parent && { parent_id: message.parent }), + }, + }) + ).message; + console.log(` message ${message.id}`); + } catch (error) { + // The id is taken outside this channel, or the lookup missed it: read it as it is. + if (!isAlreadyExists(error)) throw error; + sent = (await author.getMessage({ id: message.id })).message; + } + } + // An existing message whose text differs from the file gets the file's text. + if (sent.text !== message.text) { + await author.updateMessagePartial( + { id: message.id }, + { set: { text: message.text } }, + ); + console.log(` text of ${message.id} updated`); + } + + // `custom` holds a message's custom fields, for example `ai_generated` on an assistant's + // reply. They are set after it's sent, by its author, and only the ones whose stored value + // differs, so a re-run changes nothing. A message without `custom` keeps whatever it has. + // A key that names a top-level field (`text`, `id`, `user`) would overwrite that field, so + // it is skipped with a warning. + const reservedKeys = ['text', 'id', 'user']; + const changedCustom = Object.entries(message.custom ?? {}).filter( + ([key, value]) => { + if (reservedKeys.includes(key)) { + console.warn( + ` custom field "${key}" of ${message.id} skipped: it's a reserved field`, + ); + return false; + } + return ( + JSON.stringify(sent.custom?.[key] ?? sent[key]) !== JSON.stringify(value) + ); + }, + ); + if (changedCustom.length) { + await author.updateMessagePartial( + { id: message.id }, + { + set: Object.fromEntries( + changedCustom.map(([key, value]) => [`custom.${key}`, value]), + ), + }, + ); + console.log(` custom fields of ${message.id} set`); + } + + // `mentions` lists the ids of the users the text mentions (`@Cleo`), for a recipe that shows + // mentions; the server stores them as `mentioned_users`. The demo app refuses a new message + // with mentions, so they are set after it's sent, by its author, and only when the stored ids + // differ, so a re-run changes nothing. A message without `mentions` keeps whatever it has. + // An id that isn't a cast member and a member of the channel is skipped with a warning. + const mentions = (message.mentions ?? []).filter((userId) => { + const known = clients.has(userId) && channelData.members.includes(userId); + if (!known) + console.warn( + ` mention of ${userId} in ${message.id} skipped: not a cast member and a member of ${channelData.id}`, + ); + return known; + }); + const sameIds = (a, b) => + JSON.stringify([...a].sort()) === JSON.stringify([...b].sort()); + const mentionedIds = (sent.mentioned_users ?? []).map(({ id }) => id); + if (message.mentions && !sameIds(mentionedIds, mentions)) { + await author.updateMessagePartial( + { id: message.id }, + { set: { mentioned_users: mentions } }, + ); + console.log(` mentions of ${message.id} set`); + } + + // The demo app refuses uploads, so a message's attachments (image URLs and the like) are set + // on it after it's sent, by its author. Compared by value, so a re-run changes nothing. + // The server adds fields of its own to each attachment, so only the fields the file sets are compared. + const sameAttachments = (stored = []) => + stored.length === message.attachments.length && + message.attachments.every((attachment, index) => + Object.entries(attachment).every( + ([key, value]) => + // Fields the attachment type doesn't define come back under `custom`. + JSON.stringify(stored[index]?.[key] ?? stored[index]?.custom?.[key]) === + JSON.stringify(value), + ), + ); + if (message.attachments && !sameAttachments(sent.attachments)) { + await author.updateMessagePartial( + { id: message.id }, + { set: { attachments: message.attachments } }, + ); + console.log(` attachments of ${message.id} set`); + } + + // Only the author may pin their message in the demo app. An existing pin keeps its date, + // so a screenshot showing it doesn't change on every seed. + if (message.pinnedBy && !sent.pinned) { + await clients.get(message.pinnedBy).pinMessage({ id: message.id }); + console.log(` pinned ${message.id}`); + } + if (!message.parent) lastSent = sent; + } + + // `reminderForCurrentUser` gives a message a reminder of the current user's, for a recipe that + // lists saved messages and reminders: `{}` saves the message for later with no time, + // `{ remindAt: }` sets a fixed time, and `{ remindAt: "+2m" }` a time that many + // minutes after the run that creates it. The server refuses a time in the past, so a relative + // time is how a reminder becomes overdue: it is created once and then kept, like a pin's date, + // so its label stays the same on later runs. Reminders are looked up first and set only when + // one is missing or differs from the file (deleted and created again, which also turns a + // reminder into a saved message and back), so a re-run changes nothing. A message without the + // field keeps whatever reminder it has. A channel type with reminders turned off refuses them + // (`user_message_reminders`), which is reported once per channel and skipped. + const withReminder = channelData.messages.filter( + (message) => message.reminderForCurrentUser, + ); + if (withReminder.length && !channelData.members.includes(demoData.currentUserId)) { + console.warn( + ` reminders skipped: ${demoData.currentUserId} is not a member of ${channelData.id}`, + ); + } else if (withReminder.length) { + const { reminders: storedReminders } = await owner.queryReminders({ + filter: { message_id: { $in: withReminder.map(({ id }) => id) } }, + }); + for (const message of withReminder) { + const { remindAt } = message.reminderForCurrentUser; + const relativeMinutes = remindAt?.match(/^\+(\d+)m$/)?.[1]; + const stored = storedReminders.find( + ({ message_id }) => message_id === message.id, + ); + const storedMs = stored?.remind_at != null ? nsToMs(stored.remind_at) : null; + const isInLine = relativeMinutes + ? storedMs !== null + : storedMs === (remindAt ? Date.parse(remindAt) : null); + if (stored && isInLine) continue; + const wantedMs = relativeMinutes + ? Date.now() + Number(relativeMinutes) * 60 * 1000 + : remindAt && Date.parse(remindAt); + try { + if (stored) await owner.deleteReminder({ message_id: message.id }); + await owner.createReminder( + { message_id: message.id }, + wantedMs ? { remind_at: new Date(wantedMs) } : {}, + ); + console.log(` reminder of ${message.id} ${remindAt ?? 'saved for later'}`); + } catch (error) { + console.warn(` reminders skipped in ${channelData.id}: ${error.message}`); + break; + } + } + } + + // A member listed in both `readBy` and `readUpTo` would be marked read by one step and unread + // by the other on every run, and the current user's read state is set further down, so both + // are left out with a warning. That keeps a re-run from changing anything. + const readUpToIds = Object.keys(channelData.readUpTo ?? {}); + const readStateConflict = (userId, step) => { + if (userId === demoData.currentUserId) { + console.warn( + ` ${step} by ${userId} skipped: the seed sets the current user's own read state (read, or unreadFrom)`, + ); + return true; + } + if ((channelData.readBy ?? []).includes(userId) && readUpToIds.includes(userId)) { + console.warn( + ` ${step} by ${userId} skipped: in both readBy and readUpTo of ${channelData.id}`, + ); + return true; + } + return false; + }; + + // Receipts for a recipe that shows them: `readBy` users read the channel up to its last + // message, `deliveredTo` users only received it. Each is marked once, as that user, so a re-run + // keeps the times a screenshot shows. A channel without a message in its main list has nothing + // to mark. + const receipts = [ + ...(channelData.readBy ?? []) + .filter((id) => !readStateConflict(id, 'read')) + .map((id) => [id, 'read']), + ...(channelData.deliveredTo ?? []).map((id) => [id, 'delivered']), + ]; + if (receipts.length && !lastSent) + console.warn(` receipts skipped: ${channelData.id} has no message to mark`); + for (const [userId, kind] of lastSent ? receipts : []) { + const isMember = channelData.members.includes(userId) && clients.has(userId); + if (!isMember) { + console.warn(` ${kind} by ${userId} skipped: not a member of ${channelData.id}`); + continue; + } + const memberChannel = clients.get(userId).channel('messaging', channelData.id); + await memberChannel.watch(); + const own = memberChannel.state.read[userId]; + const reached = kind === 'read' ? own?.last_read : own?.last_delivered_at; + if (reached && reached >= lastSent.created_at) continue; + if (kind === 'read') await memberChannel.markRead(); + else + await clients.get(userId).markChannelsDelivered({ + latest_delivered_messages: [{ cid: memberChannel.cid, id: lastSent.id }], + }); + console.log(` ${kind} by ${userId}`); + } + + // `readUpTo` maps a member to the last main-list message they've read, for a recipe that shows + // who read how far. As that member, the channel is marked unread from the next main-list + // message, or read when it's the last one. Like the receipts above, it's marked only when the + // member's read state differs: the last read message id, or for the last message the time of the + // last read, so a re-run changes nothing. + const mainList = channelData.messages.filter((message) => !message.parent); + for (const [userId, messageId] of Object.entries(channelData.readUpTo ?? {})) { + if (readStateConflict(userId, 'read up to')) continue; + const isMember = channelData.members.includes(userId) && clients.has(userId); + if (!isMember) { + console.warn( + ` read up to by ${userId} skipped: not a member of ${channelData.id}`, + ); + continue; + } + const readIndex = mainList.findIndex(({ id }) => id === messageId); + if (readIndex === -1) { + console.warn( + ` read up to by ${userId} skipped: ${messageId} is not a main-list message of ${channelData.id}`, + ); + continue; + } + const nextMessage = mainList[readIndex + 1]; + const memberChannel = clients.get(userId).channel('messaging', channelData.id); + await memberChannel.watch(); + const own = memberChannel.state.read[userId]; + const isReadUpTo = nextMessage + ? own?.unread_messages > 0 && own.last_read_message_id === messageId + : !!own?.last_read && own.last_read >= lastSent.created_at; + if (isReadUpTo) continue; + if (nextMessage) await memberChannel.markUnread({ message_id: nextMessage.id }); + else await memberChannel.markRead(); + console.log(` read up to ${messageId} by ${userId}`); + } + + // The current user has read everything, so no badge depends on when the seed ran. A channel + // with `unreadFrom` (a message id) is the exception: it stays unread from that message on, for + // a recipe that shows unread state. A query returns no `first_unread_message_id`, so the check + // is that the last read message is the one before it. It is marked only when that differs, so + // a re-run changes nothing, and a run after the channel was read in the app marks it again. + const unreadIndex = channelData.unreadFrom + ? mainList.findIndex(({ id }) => id === channelData.unreadFrom) + : -1; + // On a channel another cast member created, `channel` is that member's, so the current user's + // read state is left alone. + if (creator !== owner) { + if (channelData.unreadFrom) + console.warn(` unread from skipped: ${channelData.id} has createdBy`); + } else if (channelData.unreadFrom && unreadIndex === -1) { + console.warn( + ` unread from skipped: ${channelData.unreadFrom} is not a main-list message of ${channelData.id}`, + ); + } else if (channelData.unreadFrom) { + const lastReadId = mainList[unreadIndex - 1]?.id; + const ownRead = channel.state.read[demoData.currentUserId]; + const isUnreadFrom = + ownRead?.unread_messages > 0 && ownRead.last_read_message_id === lastReadId; + if (!isUnreadFrom) { + await channel.markUnread({ message_id: channelData.unreadFrom }); + console.log(` unread from ${channelData.unreadFrom}`); + } + } else { + await channel.markRead().catch(() => undefined); + } + + // A parent message with `unreadForCurrentUser` keeps its thread unread for the current user, + // for a recipe that shows unread threads: opening a thread in the app marks it read on the + // server. `Channel.markUnread({ thread_id })` marks only the thread's latest reply unread (the + // server takes no message id with a thread id), so the thread ends up with one unread reply + // whatever its history. It is marked only when the server counts a different number, so a + // re-run changes nothing. + const unreadThreadIds = channelData.messages + .filter((message) => message.unreadForCurrentUser) + .map(({ id }) => id); + if (unreadThreadIds.length) { + const { threads = [] } = await owner.unreadCounts(); + for (const threadId of unreadThreadIds) { + const hasReplies = channelData.messages.some(({ parent }) => parent === threadId); + if (!mainList.some(({ id }) => id === threadId) || !hasReplies) { + console.warn( + ` unread thread ${threadId} skipped: not a parent message with replies in ${channelData.id}`, + ); + continue; + } + const unreadCount = + threads.find(({ parent_message_id }) => parent_message_id === threadId) + ?.unread_count ?? 0; + if (unreadCount === 1) continue; + // More than one unread reply: read the thread first, so only the latest one stays unread. + if (unreadCount > 1) await channel.markRead({ thread_id: threadId }); + await channel.markUnread({ thread_id: threadId }); + console.log(` thread ${threadId} unread`); + } + } + + // `pinnedForCurrentUser` and `archivedForCurrentUser` pin or archive the channel for the current + // user, for a recipe that shows pinned and archived channels; a channel without them is neither. + // Both live on the user's own membership, so they need the user to be a member. Each is changed + // only when the membership differs, so a re-run keeps the pin date a sorted list shows. + const membershipFlags = [ + ['pinned', channelData.pinnedForCurrentUser, 'pinned_at', 'pin', 'unpin'], + [ + 'archived', + channelData.archivedForCurrentUser, + 'archived_at', + 'archive', + 'unarchive', + ], + ]; + // On a channel another cast member created, `channel` is that member's, so its membership + // isn't the current user's and is left alone, like the read state above. + const isOwnMember = channelData.members.includes(demoData.currentUserId); + for (const [label, wanted, dateField, setMethod, unsetMethod] of membershipFlags) { + if (creator !== owner) { + if (wanted) console.warn(` ${label} skipped: ${channelData.id} has createdBy`); + continue; + } + const isSet = channel.state.membership?.[dateField] != null; + if (!!wanted === isSet) continue; + if (!isOwnMember) { + console.warn( + ` ${label} skipped: ${demoData.currentUserId} is not a member of ${channelData.id}`, + ); + continue; + } + await channel[wanted ? setMethod : unsetMethod](); + console.log(` ${wanted ? label : `not ${label}`} for ${demoData.currentUserId}`); + } + + // `draftsForCurrentUser` lists the current user's unsent drafts in the channel, for a recipe + // that lists drafts: `{ text }` for the channel's own draft, with `parent` (a main-list message + // id) for a thread's draft, and `quotedMessage` (a message id) for a draft that quotes one. + // Drafts are per user, so they are saved as the current user, and only when the stored draft + // differs, so a re-run keeps the time a screenshot shows. A channel or thread without an entry + // keeps whatever draft it has. A refused draft is reported and skipped. + for (const draft of channelData.draftsForCurrentUser ?? []) { + const where = draft.parent ? `thread ${draft.parent}` : channelData.id; + if (creator !== owner || !isOwnMember) { + console.warn(` draft in ${where} skipped: not the current user's own channel`); + continue; + } + const unknownId = [draft.parent, draft.quotedMessage].find( + (id) => id && !mainList.some((message) => message.id === id), + ); + if (unknownId) { + console.warn( + ` draft in ${where} skipped: ${unknownId} is not a main-list message of ${channelData.id}`, + ); + continue; + } + // A channel or thread without a draft answers with an error. + const stored = await channel + .getDraft({ parent_id: draft.parent }) + .then(({ draft: found }) => found) + .catch(() => undefined); + const isSame = + stored?.message.text === draft.text && + (stored.message.quoted_message_id ?? undefined) === draft.quotedMessage; + if (isSame) continue; + try { + await channel.createDraft({ + message: { + text: draft.text, + ...(draft.parent && { parent_id: draft.parent }), + ...(draft.quotedMessage && { quoted_message_id: draft.quotedMessage }), + }, + }); + console.log(` draft in ${where} saved`); + } catch (error) { + console.warn(` draft in ${where} skipped: ${error.message}`); + } + } + } +} finally { + for (const client of clients.values()) await client.disconnectUser(); +} diff --git a/examples/cookbook/scripts/shots.mjs b/examples/cookbook/scripts/shots.mjs new file mode 100644 index 0000000000..f5d7d76234 --- /dev/null +++ b/examples/cookbook/scripts/shots.mjs @@ -0,0 +1,157 @@ +/** + * Takes the screenshots every recipe's shots.json lists, from the running cookbook app, and writes + * them where the docs read them. + * + * yarn workspace @stream-io/stream-chat-react-cookbook dev # in another terminal + * yarn workspace @stream-io/stream-chat-react-cookbook shots \ + * --out /path/to/getstream.io/content/docs/chat-sdk/react/v15-rc/_assets [--only headers/channel-header] + * + * A relative --out is resolved from the repository root. + * + * A shot in shots.json: + * file output file name (PNG) + * recipe recipe id, or "base" for the SDK's default look; defaults to the folder's recipe + * variant recipe variant (file name before .recipe.tsx); defaults to "default" + * query extra URL parameters, e.g. { "channel": "...", "thread": "..." } + * viewport { width, height }; defaults to the 1280×720 frame + * waitFor selector that must be visible before the shot (default: the message list) + * hover selector to hover first + * clip selector of the element to capture; omit for the whole frame + * hasText only the clip element containing this text + * padding pixels around the clip element (default 8) + * settleMs extra wait after everything is visible (default 800) + */ +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium } from '@playwright/test'; + +const root = path.dirname(fileURLToPath(new URL('../package.json', import.meta.url))); +const recipesDir = path.join(root, 'src/recipes'); +const args = process.argv.slice(2); +// Accepts `--name value` and `--name=value`. +const argValue = (name) => { + const inline = args.find((arg) => arg.startsWith(`${name}=`)); + if (inline) return inline.slice(name.length + 1); + const index = args.indexOf(name); + return index === -1 ? undefined : args[index + 1]; +}; + +// `yarn workspace … shots` runs in examples/cookbook, and Yarn reports no other starting folder, +// so a relative --out is resolved from the repository root (PROJECT_CWD) for a predictable result. +const outArg = argValue('--out'); +const outDir = outArg && path.resolve(process.env.PROJECT_CWD ?? process.cwd(), outArg); +const only = argValue('--only'); +const baseUrl = argValue('--url') ?? 'http://localhost:5176'; +if (!outDir) { + console.error('Pass --out , e.g. the docs v15-rc/_assets folder.'); + process.exit(1); +} + +const findShotFiles = async () => { + const files = []; + for (const group of await readdir(recipesDir, { withFileTypes: true })) { + if (!group.isDirectory()) continue; + for (const recipe of await readdir(path.join(recipesDir, group.name), { + withFileTypes: true, + })) { + if (!recipe.isDirectory()) continue; + const id = `${group.name}/${recipe.name}`; + if (only && id !== only) continue; + const file = path.join(recipesDir, id, 'shots.json'); + try { + files.push({ id, shots: JSON.parse(await readFile(file, 'utf8')) }); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + } + } + return files; +}; + +const shotFiles = await findShotFiles(); +if (!shotFiles.length) { + console.error(only ? `No recipe ${only} with a shots.json.` : 'No shots.json found.'); + process.exit(1); +} +const seen = new Map(); +for (const { id, shots } of shotFiles) { + for (const { file } of shots) { + if (seen.has(file)) { + console.error( + `${file} is listed by both ${seen.get(file)} and ${id}; names must be unique.`, + ); + process.exit(1); + } + seen.set(file, id); + } +} + +const browser = await chromium.launch(); +let failures = 0; + +try { + for (const { id, shots } of shotFiles) { + for (const shot of shots) { + const viewport = shot.viewport ?? { height: 720, width: 1280 }; + // A fixed locale and time zone, so dates in a shot don't depend on the machine taking it. + const page = await browser.newPage({ + deviceScaleFactor: 1, + locale: 'en-US', + timezoneId: 'UTC', + viewport, + }); + try { + const params = new URLSearchParams({ + recipe: shot.recipe ?? id, + variant: shot.variant ?? 'default', + ...shot.query, + }); + await page.goto(`${baseUrl}/?${params}`); + await page + .locator(shot.waitFor ?? '.str-chat__message-list') + .first() + .waitFor(); + if (shot.hover) await page.locator(shot.hover).first().hover(); + await page.waitForTimeout(shot.settleMs ?? 800); + + const target = path.join(outDir, shot.file); + if (shot.clip) { + let locator = page.locator(shot.clip); + if (shot.hasText) locator = locator.filter({ hasText: shot.hasText }); + const element = locator.first(); + if (!(await element.count())) throw new Error(`nothing matches ${shot.clip}`); + await element.scrollIntoViewIfNeeded(); + const box = await element.boundingBox(); + if (!box || box.width <= 0 || box.height <= 0) { + throw new Error(`${shot.clip} isn't visible`); + } + const padding = shot.padding ?? 8; + const x = Math.max(0, box.x - padding); + const y = Math.max(0, box.y - padding); + await page.screenshot({ + clip: { + height: Math.min(viewport.height, box.y + box.height + padding) - y, + width: Math.min(viewport.width, box.x + box.width + padding) - x, + x, + y, + }, + path: target, + }); + } else { + await page.screenshot({ path: target }); + } + console.log(`✓ ${id}: ${shot.file}`); + } catch (error) { + failures++; + console.error(`✗ ${id}: ${shot.file}: ${error.message}`); + } finally { + await page.close(); + } + } + } +} finally { + await browser.close(); +} + +process.exit(failures ? 1 : 0); diff --git a/examples/cookbook/src/base/BaseApp.tsx b/examples/cookbook/src/base/BaseApp.tsx new file mode 100644 index 0000000000..7ce1d58590 --- /dev/null +++ b/examples/cookbook/src/base/BaseApp.tsx @@ -0,0 +1,143 @@ +import { Fragment, useEffect, useMemo } from 'react'; +import { ChannelPaginator } from 'stream-chat'; +import type { SortParamRequest } from 'stream-chat'; +import { + ChannelHeader, + ChannelNavigation, + Chat, + MessageComposer, + MessageList, + ThreadHeader, + useCreateChatClient, + useThreadContext, +} from 'stream-chat-react'; +import { + ChannelSlot, + ChatView, + ThreadSlot, + useActiveThread, +} from 'stream-chat-react/slot-layout'; +import type { LayoutDescriptor } from 'stream-chat-react/slot-layout'; +import { apiKey, channelFilters, currentUser, tokenProvider } from './client'; +import { OpenFromUrl } from './OpenFromUrl'; +import { makeReadOnly } from './readOnly'; +import type { RecipeConfig } from './recipe'; +import { createDemoSearchController } from './searchController'; +import { SIDE_PANEL_SLOT, SidePanel, SidePanelProvider } from './SidePanel'; +import { Simulate } from './Simulate'; + +const sort: SortParamRequest[] = [{ direction: -1, field: 'last_message_at' }]; + +// The channel, a thread opened from it, then the side panel recipes open their content in. +const defaultLayouts: LayoutDescriptor[] = [ + { id: 'channels', slots: ['channel', 'thread', SIDE_PANEL_SLOT] }, +]; + +// The base layouts with the recipe's: one with the same id replaces the base one, a new id is added. +const mergeLayouts = (base: LayoutDescriptor[], added: LayoutDescriptor[] = []) => [ + ...base.map((layout) => added.find(({ id }) => id === layout.id) ?? layout), + ...added.filter(({ id }) => !base.some((layout) => layout.id === id)), +]; + +// Marks the open thread as on screen, so its new replies are marked read. +const ActiveThread = () => { + const thread = useThreadContext(); + useActiveThread({ activeThread: thread }); + return null; +}; + +/** + * The app every recipe starts from: `Chat`, `ChatView` with the channel list, the open channel, a + * thread beside it and the side panel after them, in the cookbook's theme. A recipe changes it + * through `RecipeConfig`. + */ +export const BaseApp = ({ recipe }: { recipe: RecipeConfig }) => { + const client = useCreateChatClient({ + apiKey, + tokenOrProvider: tokenProvider, + userData: currentUser, + }); + + // Nothing a visitor clicks in the cookbook changes the shared demo app. + useEffect(() => (client ? makeReadOnly(client) : undefined), [client]); + + useEffect(() => { + if (!client || recipe.registerChannelPaginator === false) return; + const paginator = new ChannelPaginator({ + client, + filters: channelFilters, + id: 'cookbook', + // Presence keeps members' online status current, which several recipes show. + requestOptions: { presence: true, state: true }, + sort, + }); + client.channelManager.insertPaginator({ paginator }); + return () => { + client.channelManager.removePaginator(paginator); + }; + }, [client, recipe]); + + const chatProps = useMemo( + () => + client + ? { + searchController: createDemoSearchController(client), + ...recipe.chatProps?.(client), + } + : {}, + [client, recipe], + ); + + if (!client) return
Connecting…
; + + const RecipeChannelHeader = recipe.ChannelHeader ?? ChannelHeader; + const RecipeChannelNavigation = recipe.ChannelNavigation ?? ChannelNavigation; + const RecipeMessageList = recipe.MessageList ?? MessageList; + const Wrapper = recipe.Wrapper ?? Fragment; + + const defaultChannelsView = ( + <> + + + + + + + + + + + + + + + ); + + return ( +
+ + + + + {recipe.openFromUrl !== false && } + + {recipe.chatViewChildren} + + + + +
+ ); +}; diff --git a/examples/cookbook/src/base/OpenFromUrl.tsx b/examples/cookbook/src/base/OpenFromUrl.tsx new file mode 100644 index 0000000000..ebaeb14a76 --- /dev/null +++ b/examples/cookbook/src/base/OpenFromUrl.tsx @@ -0,0 +1,59 @@ +import { useEffect } from 'react'; +import { useChatContext, useWorkspaceNavigation } from 'stream-chat-react'; +import { isDemoChannelId } from './client'; + +/** + * `?channel=` opens that channel, and `&thread=` also opens the thread + * beside it, so a screenshot can start from the exact state its recipe shows. + */ +export const OpenFromUrl = () => { + const { client } = useChatContext(); + const { openChannel, openThread } = useWorkspaceNavigation(); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const channelId = params.get('channel'); + const threadParentId = params.get('thread'); + if (!channelId) return; + if (!isDemoChannelId(channelId)) { + console.error( + `?channel=${channelId} isn't one of the cookbook's channels; not opening it.`, + ); + return; + } + + let cancelled = false; + void (async () => { + try { + const channel = await client.channel('messaging', channelId).watch(); + if (cancelled) return; + openChannel(channel); + if (!threadParentId) return; + + // The parent may be older than the first page the channel loaded. + if (!channel.messagePaginator.getItem(threadParentId)) { + await channel.messagePaginator.jumpToMessage(threadParentId, { + suppressFocusSignal: true, + }); + } + const message = channel.messagePaginator.getItem(threadParentId); + if (cancelled) return; + if (message) openThread({ channel, message }); + else + console.warn( + `No message ${threadParentId} in ${channelId} to open a thread from.`, + ); + } catch (error) { + console.error(`Couldn't open ${channelId} from the URL`, error); + } + })(); + + return () => { + cancelled = true; + }; + // Runs once: the URL is read on load, and navigation afterwards is the user's. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return null; +}; diff --git a/examples/cookbook/src/base/SidePanel.tsx b/examples/cookbook/src/base/SidePanel.tsx new file mode 100644 index 0000000000..558deaff84 --- /dev/null +++ b/examples/cookbook/src/base/SidePanel.tsx @@ -0,0 +1,222 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; +import type { ComponentType, PropsWithChildren, ReactNode, RefObject } from 'react'; +import { Button, useComponentContextIcons } from 'stream-chat-react'; +import { useChatViewNavigation, useSlotEntity } from 'stream-chat-react/slot-layout'; + +/** The side panel's slot in the channels layout, after the channel and the thread. */ +export const SIDE_PANEL_SLOT = 'sidePanel'; + +/** What a recipe opens in the side panel. */ +export type SidePanelContent = { + /** Names what the panel shows, so a button can tell whether its own content is the open one. */ + id: string; + /** The heading of the panel's title row, and the panel's name for assistive technology. */ + title: string; + /** + * The body renders its own title row and close button, as the SDK's `ChannelDetail` does, so the + * panel leaves its own out and `title` only names it. + */ + ownHeader?: boolean; + /** Everything under the title row. It reads its data itself, so it stays current. */ + body: ReactNode; +}; + +type SidePanelState = { + content?: SidePanelContent; + /** The element that had focus when the panel opened, to give it back when the panel closes. */ + openerRef: RefObject; + /** The panel's `aside` while it's on screen. */ + panelRef: RefObject; + setContent: (content?: SidePanelContent) => void; +}; + +const SidePanelStateContext = createContext({ + openerRef: { current: null }, + panelRef: { current: null }, + setContent: () => undefined, +}); + +/** Holds the content the side panel shows, for the buttons that open it and the panel to share. */ +export const SidePanelProvider = ({ children }: PropsWithChildren) => { + const [content, setContent] = useState(); + const openerRef = useRef(null); + const panelRef = useRef(null); + const value = useMemo(() => ({ content, openerRef, panelRef, setContent }), [content]); + + return ( + + {children} + + ); +}; + +/** The `id` the panel gives a user's profile, so a button can tell whether that profile is open. */ +const userProfileId = (userId: string) => `user-profile:${userId}`; + +/** + * Where focus goes when the panel closes: the element that opened it, or, when that one is gone + * (an item of a menu that has closed, say), the open channel's composer. + */ +const focusTargetAfterClose = (opener: HTMLElement | null) => + opener?.isConnected + ? opener + : document.querySelector( + '.str-chat__channel .str-chat__textarea textarea', + ); + +/** + * Opens and closes the side panel. It shows one thing at a time: opening anything replaces what it + * showed. A user's profile is the SDK's `userProfile` kind, bound in the panel's slot; everything + * else is content held by `SidePanelProvider`. Use it inside `ChatView`. + * + * Opening remembers the element that had focus; closing from inside the panel gives focus back to + * it. + */ +export const useSidePanel = () => { + const { content, openerRef, panelRef, setContent } = useContext(SidePanelStateContext); + const { open, release } = useChatViewNavigation(); + const profile = useSlotEntity({ kind: 'userProfile', slot: SIDE_PANEL_SLOT }); + + // Focus inside the panel means it moved there from the opener, which stays the one to go back to. + // Focus on the page itself, after a click on something that can't take focus, names no opener. + const rememberOpener = useCallback(() => { + const active = document.activeElement; + if (panelRef.current?.contains(active)) return; + openerRef.current = + active instanceof HTMLElement && active !== document.body ? active : null; + }, [openerRef, panelRef]); + + /** Shows `next` in the side panel, in place of whatever it showed. */ + const openSidePanel = useCallback( + (next: SidePanelContent) => { + rememberOpener(); + // Empties the slot of any profile and every layer on it. + release(SIDE_PANEL_SLOT); + setContent(next); + }, + [release, rememberOpener, setContent], + ); + + /** Shows the user's profile in the side panel, in place of whatever it showed. */ + const openUserProfile = useCallback( + (userId: string) => { + rememberOpener(); + setContent(undefined); + open( + { key: userId, kind: 'userProfile', source: { userId } }, + { slot: SIDE_PANEL_SLOT }, + ); + }, + [open, rememberOpener, setContent], + ); + + /** + * Empties the side panel. Focus that was inside it goes back to what opened it. Focus on the page + * body counts as inside too: some browsers leave it there after a click on a button, so a panel + * that closes on its own can move focus to the channel's composer. + */ + const closeSidePanel = useCallback(() => { + const active = document.activeElement; + const hadFocus = + active === document.body || !!(active && panelRef.current?.contains(active)); + release(SIDE_PANEL_SLOT); + setContent(undefined); + if (hadFocus) focusTargetAfterClose(openerRef.current)?.focus(); + openerRef.current = null; + }, [openerRef, panelRef, release, setContent]); + + /** The id of what the panel shows, or `undefined` while it's closed. */ + const openId = content?.id ?? (profile ? userProfileId(profile.userId) : undefined); + + return { closeSidePanel, openId, openSidePanel, openUserProfile }; +}; + +export type SidePanelProps = { + /** Renders a user's profile under the title row. Without it, the panel never shows a profile. */ + UserProfile?: ComponentType<{ userId: string }>; +}; + +/** + * The column at the far right of the channels view, after the channel and the thread, while it has + * something to show: a title row with a close button, then the body. Render it as the view's last + * child. + */ +export const SidePanel = ({ UserProfile }: SidePanelProps) => { + const { content } = useContext(SidePanelStateContext); + const profile = useSlotEntity({ kind: 'userProfile', slot: SIDE_PANEL_SLOT }); + + const shown: SidePanelContent | undefined = + content ?? + (profile && UserProfile + ? { + body: , + id: userProfileId(profile.userId), + title: 'Profile', + } + : undefined); + + if (!shown) return null; + // Keyed by what it shows, so other content starts afresh. + return ; +}; + +/** + * The panel around one piece of content: an `aside` named by the title, the title row with its + * close button (left out when the body brings its own), then the body. It takes focus when it + * opens: the title, or the whole panel when the body has its own title row. + */ +const SidePanelFrame = ({ body, ownHeader, title }: SidePanelContent) => { + const { IconXmark } = useComponentContextIcons(); + const { panelRef } = useContext(SidePanelStateContext); + const { closeSidePanel } = useSidePanel(); + const headingRef = useRef(null); + const titleId = useId(); + + useEffect(() => { + (ownHeader ? panelRef.current : headingRef.current)?.focus(); + }, [ownHeader, panelRef]); + + return ( + + ); +}; diff --git a/examples/cookbook/src/base/Simulate.tsx b/examples/cookbook/src/base/Simulate.tsx new file mode 100644 index 0000000000..34309d3193 --- /dev/null +++ b/examples/cookbook/src/base/Simulate.tsx @@ -0,0 +1,47 @@ +import { useEffect } from 'react'; +import { StreamChat } from 'stream-chat'; +import { apiKey, fetchToken, isDemoChannelId } from './client'; + +/** + * `?simulate=typing&as=&channel=` signs in a second user in the page and keeps them + * typing in the channel, for screenshots of typing indicators. A second client is needed because a + * user never sees their own typing. + */ +export const Simulate = () => { + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const userId = params.get('as'); + const channelId = params.get('channel'); + if (params.get('simulate') !== 'typing' || !userId || !isDemoChannelId(channelId)) + return; + + const other = new StreamChat(apiKey); + let cancelled = false; + let timer: ReturnType | undefined; + + void (async () => { + try { + await other.connectUser({ id: userId }, () => fetchToken(userId)); + if (cancelled) return; + const channel = await other.channel('messaging', channelId).watch(); + if (cancelled) return; + // `keystroke()` sends typing.start and is throttled by the SDK, so repeating it keeps + // the indicator up for as long as the page is open. + await channel.keystroke(); + timer = setInterval(() => void channel.keystroke(), 2000); + } catch (error) { + console.error('Simulated typing failed', error); + } + })(); + + // Cleanup can run while the client is still connecting (StrictMode does that on purpose); the + // `cancelled` checks above stop the rest, and disconnecting here closes whatever was opened. + return () => { + cancelled = true; + if (timer) clearInterval(timer); + void other.disconnectUser(); + }; + }, []); + + return null; +}; diff --git a/examples/cookbook/src/base/base.css b/examples/cookbook/src/base/base.css new file mode 100644 index 0000000000..3f78b5c025 --- /dev/null +++ b/examples/cookbook/src/base/base.css @@ -0,0 +1,89 @@ +/* + * Layer order, first to last: a CSS reset, the SDK's stylesheet, the base app, then every recipe's + * CSS in `stream-overrides`. A recipe's rules win over both the SDK and the base app without extra + * specificity or !important. This is the setup the docs' Theming page teaches; recipes put all their + * CSS in `stream-overrides` and never add a layer of their own. + */ +@layer modern-normalize, stream, cookbook-base, stream-overrides; + +@import 'modern-normalize/modern-normalize.css' layer(modern-normalize); +@import 'stream-chat-react/dist/css/index.css' layer(stream); +@import 'stream-chat-react/dist/css/channel-detail.css' layer(stream); + +/* The base app: the Quick start's layout.css, in the 1280×720 frame screenshots are taken in. */ +@layer cookbook-base { + html, + body, + #root { + height: 100%; + margin: 0; + } + + .str-chat { + --str-chat__channel-list-width: 320px; + } + + .cookbook-frame { + height: 100%; + } + + .cookbook-status { + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-body-default); + padding: var(--str-chat__spacing-xl); + } + + /* + * The side panel (SidePanel.tsx) recipes open their content in: a 360px column after the channel + * and the thread, divided from them like the thread is. + */ + .cookbook-side-panel { + display: flex; + flex: 0 0 360px; + flex-direction: column; + min-width: 0; + min-height: 0; + border-inline-start: 1px solid var(--str-chat__border-core-default); + background: var(--str-chat__background-core-elevation-1); + color: var(--str-chat__text-primary); + } + + /* As tall as the channel and thread headers beside it. */ + .cookbook-side-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--str-chat__spacing-sm); + box-sizing: border-box; + height: var(--str-chat__channel-header-height); + padding: var(--str-chat__spacing-md); + border-block-end: 1px solid var(--str-chat__border-core-default); + } + + .cookbook-side-panel__title { + margin: 0; + font: var(--str-chat__font-heading-sm); + } + + /* The panel and its title take focus when it opens, only so reading starts there: no ring. */ + .cookbook-side-panel:focus, + .cookbook-side-panel__title:focus { + outline: none; + } + + /* The body scrolls on its own and sets no padding, so a body can fill it edge to edge. */ + .cookbook-side-panel__body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + overflow-y: auto; + } + + /* The heading of a section inside a panel's body. */ + .cookbook-side-panel__section-title { + margin: 0 0 var(--str-chat__spacing-xs); + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-caption-emphasis); + } +} diff --git a/examples/cookbook/src/base/client.ts b/examples/cookbook/src/base/client.ts new file mode 100644 index 0000000000..a30c0fe5a8 --- /dev/null +++ b/examples/cookbook/src/base/client.ts @@ -0,0 +1,77 @@ +import type { ChannelFilters } from 'stream-chat'; +import demoData from './demo-data.json'; + +/** + * The cookbook runs against Stream's public demo app. Its token endpoint signs a token for any + * user id, so the app needs no secret. Everything in that app is public: keep the content neutral. + */ +export const apiKey = demoData.apiKey; + +export const currentUser = demoData.users.find( + (user) => user.id === demoData.currentUserId, +) as { id: string; name: string }; + +type DemoChannel = { id: string }; +type DemoData = { channels?: DemoChannel[] }; + +const channelIdsOf = (data: DemoData) => + (data.channels ?? []).map((channel) => channel.id); + +// A recipe that needs data of its own keeps it in its folder's demo-data.json, in the same shape. +// Keyed by recipe id (`/`). +const recipeDemoData = Object.fromEntries( + Object.entries( + import.meta.glob('../recipes/*/*/demo-data.json', { + eager: true, + import: 'default', + }), + ).map(([path, data]) => [ + path.replace(/^\.\.\/recipes\/|\/demo-data\.json$/g, ''), + data, + ]), +); + +/** Every channel the cookbook owns: the base ones and every recipe's own. */ +export const demoChannelIds = [demoData, ...Object.values(recipeDemoData)].flatMap( + channelIdsOf, +); + +/** + * The channels the open recipe shows in its lists and search: the base ones plus that recipe's + * own, so one recipe's channels never appear in another recipe's screenshots. + */ +export const visibleChannelIds = [ + ...channelIdsOf(demoData), + ...channelIdsOf( + recipeDemoData[new URLSearchParams(window.location.search).get('recipe') ?? ''] ?? {}, + ), +]; + +/** + * The base app's channel filters: only the open recipe's channels with the user in them, so other + * activity in the shared demo app never shows up. A recipe that registers its own paginators builds + * its filters on these. + */ +export const channelFilters: ChannelFilters = { + id: { $in: visibleChannelIds }, + members: { $in: [currentUser.id] }, + type: 'messaging', +}; + +export const fetchToken = async (userId: string): Promise => { + const url = new URL('https://pronto.getstream.io/api/auth/create-token'); + url.searchParams.set('environment', demoData.tokenEnvironment); + url.searchParams.set('user_id', userId); + const response = await fetch(url); + if (!response.ok) throw new Error(`Token request failed: ${response.status}`); + return ((await response.json()) as { token: string }).token; +}; + +export const tokenProvider = () => fetchToken(currentUser.id); + +/** + * `Channel.watch()` creates a channel that doesn't exist yet. Helpers that take a channel id from the + * URL open only the cookbook's own channels, so a typo never creates one in the shared demo app. + */ +export const isDemoChannelId = (id: string | null): id is string => + !!id && demoChannelIds.includes(id); diff --git a/examples/cookbook/src/base/demo-data.json b/examples/cookbook/src/base/demo-data.json new file mode 100644 index 0000000000..af1a16ebb8 --- /dev/null +++ b/examples/cookbook/src/base/demo-data.json @@ -0,0 +1,173 @@ +{ + "apiKey": "xzwhhgtazy6h", + "tokenEnvironment": "public-shared-chat-redesign", + "currentUserId": "docs-v15-ck-cleo", + "users": [ + { "id": "docs-v15-ck-cleo", "name": "Cleo" }, + { "id": "docs-v15-ck-bob", "name": "Bob" }, + { "id": "docs-v15-ck-cara", "name": "Cara" }, + { "id": "docs-v15-ck-dan", "name": "Dan" } + ], + "channels": [ + { + "id": "docs-v15-cb-design", + "name": "Design review", + "members": [ + "docs-v15-ck-cleo", + "docs-v15-ck-bob", + "docs-v15-ck-cara", + "docs-v15-ck-dan" + ], + "messages": [ + { + "id": "docs-v15-ck-design-1", + "user": "docs-v15-ck-cara", + "text": "I shared the new icon set in the design folder." + }, + { + "id": "docs-v15-ck-design-2", + "user": "docs-v15-ck-bob", + "text": "They look great. Can we use the rounded ones in the sidebar?" + }, + { + "id": "docs-v15-ck-design-3", + "user": "docs-v15-ck-cleo", + "text": "Yes, the rounded set fits better with the avatars." + }, + { + "id": "docs-v15-ck-design-4", + "user": "docs-v15-ck-dan", + "text": "Should the buttons follow the same style?" + }, + { + "id": "docs-v15-ck-design-4-r1", + "user": "docs-v15-ck-cara", + "parent": "docs-v15-ck-design-4", + "text": "I think so, the corners should match." + }, + { + "id": "docs-v15-ck-design-4-r2", + "user": "docs-v15-ck-cleo", + "parent": "docs-v15-ck-design-4", + "text": "Agreed, let us try a 12px radius." + }, + { + "id": "docs-v15-ck-design-4-r3", + "user": "docs-v15-ck-bob", + "parent": "docs-v15-ck-design-4", + "text": "I can update the button component today." + }, + { + "id": "docs-v15-ck-design-5", + "user": "docs-v15-ck-cara", + "text": "Review is on Thursday at 10:00, the agenda is in the doc.", + "pinnedBy": "docs-v15-ck-cara" + }, + { + "id": "docs-v15-ck-design-6", + "user": "docs-v15-ck-cara", + "text": "I will send the updated mockups after lunch." + }, + { + "id": "docs-v15-ck-design-7", + "user": "docs-v15-ck-bob", + "text": "Thanks Cara, looking forward to it!" + } + ] + }, + { + "id": "docs-v15-cb-trip", + "name": "Weekend trip", + "members": ["docs-v15-ck-cleo", "docs-v15-ck-bob", "docs-v15-ck-cara"], + "messages": [ + { + "id": "docs-v15-ck-trip-1", + "user": "docs-v15-ck-bob", + "text": "The cabin is booked for Saturday" + }, + { + "id": "docs-v15-ck-trip-2", + "user": "docs-v15-ck-cara", + "text": "I will bring the board games" + }, + { + "id": "docs-v15-ck-trip-3", + "user": "docs-v15-ck-cleo", + "text": "Perfect, I will bring snacks" + } + ] + }, + { + "id": "docs-v15-cb-dm-bob", + "members": ["docs-v15-ck-cleo", "docs-v15-ck-bob"], + "messages": [ + { + "id": "docs-v15-ck-dm-bob-1", + "user": "docs-v15-ck-cleo", + "text": "Lunch tomorrow?" + }, + { + "id": "docs-v15-ck-dm-bob-2", + "user": "docs-v15-ck-bob", + "text": "Sure, see you at noon 🙂" + } + ] + }, + { + "id": "docs-v15-cb-books", + "name": "Book club", + "members": ["docs-v15-ck-cleo", "docs-v15-ck-cara", "docs-v15-ck-dan"], + "messages": [ + { + "id": "docs-v15-ck-books-1", + "user": "docs-v15-ck-dan", + "text": "Who has finished chapter five?" + }, + { + "id": "docs-v15-ck-books-2", + "user": "docs-v15-ck-cara", + "text": "Great, the meeting is on Tuesday then." + } + ] + }, + { + "id": "docs-v15-cb-garden", + "name": "Garden plot", + "members": ["docs-v15-ck-cleo", "docs-v15-ck-dan"], + "messages": [ + { + "id": "docs-v15-ck-garden-1", + "user": "docs-v15-ck-dan", + "text": "The tomatoes need water this weekend." + }, + { + "id": "docs-v15-ck-garden-2", + "user": "docs-v15-ck-cleo", + "text": "I will water them on Sunday morning." + } + ] + }, + { + "id": "docs-v15-cb-general", + "name": "General", + "members": [ + "docs-v15-ck-cleo", + "docs-v15-ck-bob", + "docs-v15-ck-cara", + "docs-v15-ck-dan" + ], + "messages": [ + { + "id": "docs-v15-ck-general-1", + "user": "docs-v15-ck-dan", + "text": "Welcome to the team chat, everyone!" + }, + { + "id": "docs-v15-ck-general-2", + "user": "docs-v15-ck-bob", + "text": "Thanks Dan, happy to be here." + } + ] + } + ] +} diff --git a/examples/cookbook/src/base/readOnly.ts b/examples/cookbook/src/base/readOnly.ts new file mode 100644 index 0000000000..61cc0b8ab7 --- /dev/null +++ b/examples/cookbook/src/base/readOnly.ts @@ -0,0 +1,37 @@ +import type { StreamChat } from 'stream-chat'; + +/** + * Requests the cookbook may send to the shared demo app: reads, watching, typing events and the + * user's own read state. Everything else (sending, editing, deleting, reacting, flagging, pinning, + * adding members, updating channels) would change data every screenshot depends on. + */ +const ALLOWED_WRITES = [ + /\/chat\/channels$/, // queryChannels + /\/chat\/channels\/read$/, + /\/chat\/channels\/[^/]+\/[^/]+\/(query|event|read|unread|stop-watching)$/, + /\/chat\/channels\/[^/]+\/[^/]+\/bans\/query$/, + /\/chat\/(drafts|reminders|scheduled_messages)\/query$/, + /\/chat\/threads$/, + /\/polls\/query$/, +]; + +/** + * Makes the cookbook read-only against the demo app: a blocked request never settles, so the UI + * keeps its optimistic state (a reaction shows, a sent message stays "sending") and the server + * stays unchanged. `?writes=on` turns it off, for testing against an app of your own. + */ +export const makeReadOnly = (client: StreamChat) => { + if (new URLSearchParams(window.location.search).get('writes') === 'on') + return () => undefined; + + const id = client.axiosInstance.interceptors.request.use((config) => { + const method = (config.method ?? 'get').toLowerCase(); + const path = (config.url ?? '').split('?')[0]; + if (method === 'get' || ALLOWED_WRITES.some((pattern) => pattern.test(path))) + return config; + console.info(`Cookbook is read-only: ${method.toUpperCase()} ${path} was not sent.`); + return new Promise(() => undefined); + }); + + return () => client.axiosInstance.interceptors.request.eject(id); +}; diff --git a/examples/cookbook/src/base/recipe.ts b/examples/cookbook/src/base/recipe.ts new file mode 100644 index 0000000000..644ba5ad6f --- /dev/null +++ b/examples/cookbook/src/base/recipe.ts @@ -0,0 +1,96 @@ +import type { ComponentType, PropsWithChildren, ReactNode } from 'react'; +import type { StreamChat } from 'stream-chat'; +import type { ChatProps } from 'stream-chat-react'; +import type { ChatViewProps, LayoutDescriptor } from 'stream-chat-react/slot-layout'; +import type { SidePanelProps } from './SidePanel'; + +/** + * What one recipe variant changes in the base app. Each `*.recipe.tsx` file under `src/recipes/` + * default-exports one, and imports its own CSS, so only the selected recipe's styles load. + */ +export type RecipeConfig = { + /** Replaces the SDK's `ChannelHeader` in the channel panel. */ + ChannelHeader?: ComponentType; + /** + * Replaces the SDK's `ChannelNavigation` in the base app's channels view. Ignored when + * `views.channels` or `chatViewProps.views` replaces the view. + */ + ChannelNavigation?: ComponentType; + /** + * Extra props for `Chat`, built once per client, for example a `searchController`. The base app + * passes `client` and `theme` itself. + */ + chatProps?: (client: StreamChat) => Partial>; + /** Rendered inside `ChatView`, beside the active view, for code that must outlive view switches. */ + chatViewChildren?: ReactNode; + /** + * Props for `ChatView`: views, layouts, slot rules, navigation overrides, slot renderers. Each + * one replaces the base app's, so `chatViewProps.views` and `chatViewProps.layouts` win over + * `RecipeConfig.views` and `RecipeConfig.layouts`, which add to them. + */ + chatViewProps?: Omit; + /** + * Layouts merged into the base app's by `id`: a new `id` adds a layout, and a base one's `id` + * (`channels`) replaces it. Each view in `RecipeConfig.views` needs its layout here. + */ + layouts?: LayoutDescriptor[]; + /** + * Replaces the SDK's `MessageList` in the base app's channels view: the channel's and the + * thread's alike, so both lists behave the same. Ignored when `views.channels` or + * `chatViewProps.views` replaces the view. + */ + MessageList?: ComponentType; + /** + * `false` turns off the base app's `?channel=` / `?thread=` opener, for a recipe that reads the + * URL itself. + */ + openFromUrl?: boolean; + /** + * Props for the base app's `SidePanel`, for example the `UserProfile` it renders a profile + * with. Ignored when `views.channels` or `chatViewProps.views` replaces the channels view. + */ + sidePanelProps?: SidePanelProps; + /** + * `false` leaves out the base app's channel paginator, for a recipe that registers its own on + * `client.channelManager`, with `channelFilters` from `base/client` to show the same channels. + */ + registerChannelPaginator?: boolean; + /** The `theme` passed to `Chat`, for example `str-chat__theme-dark`. */ + theme?: string; + /** + * Views merged into the base app's by key: a new key adds a view beside the channels view, and + * `channels` replaces it. Each view needs a layout with the same `id` in `RecipeConfig.layouts`, + * and a way to switch to it, for example `ChatView.Selector` in `chatViewChildren`. + */ + views?: ChatViewProps['views']; + /** + * Wraps `ChatView` inside `Chat` and `SidePanelProvider`, for example with `WithComponents`, so + * what it renders can open the side panel. + */ + Wrapper?: ComponentType; +}; + +type RecipeModule = { default: RecipeConfig }; + +// `src/recipes///.recipe.tsx` → id `/`, variant ``. +const recipeModules = import.meta.glob('../recipes/*/*/*.recipe.tsx'); + +const parseRecipePath = (path: string) => { + const match = path.match(/\.\.\/recipes\/([^/]+\/[^/]+)\/([^/]+)\.recipe\.tsx$/); + return match ? { id: match[1], variant: match[2] } : undefined; +}; + +export const listRecipes = () => + Object.keys(recipeModules) + .map(parseRecipePath) + .filter((entry): entry is { id: string; variant: string } => !!entry) + .sort((a, b) => `${a.id}/${a.variant}`.localeCompare(`${b.id}/${b.variant}`)); + +/** Loads `?recipe=/&variant=`; the variant defaults to `default`. */ +export const loadRecipe = async ( + id: string, + variant = 'default', +): Promise => { + const load = recipeModules[`../recipes/${id}/${variant}.recipe.tsx`]; + return load ? (await load()).default : undefined; +}; diff --git a/examples/cookbook/src/base/searchController.ts b/examples/cookbook/src/base/searchController.ts new file mode 100644 index 0000000000..3274e7bbe2 --- /dev/null +++ b/examples/cookbook/src/base/searchController.ts @@ -0,0 +1,28 @@ +import { + ChannelSearchSource, + MessageSearchSource, + SearchController, + UserSearchSource, +} from 'stream-chat'; +import type { StreamChat } from 'stream-chat'; +import { visibleChannelIds } from './client'; +import demoData from './demo-data.json'; + +/** + * The default sources, kept to the cookbook's own channels and cast, so results from other data + * in the shared demo app never show up in a screenshot. The base app passes it to `Chat`; a recipe's + * `chatProps` can replace it. + */ +export const createDemoSearchController = (client: StreamChat) => + new SearchController({ + client, + sources: [ + new ChannelSearchSource(client, { filters: { id: { $in: visibleChannelIds } } }), + new UserSearchSource(client, { + filters: { id: { $in: demoData.users.map((user) => user.id) } }, + }), + new MessageSearchSource(client, { + messageSearchChannelFilters: { id: { $in: visibleChannelIds } }, + }), + ], + }); diff --git a/examples/cookbook/src/base/useClickInTurn.ts b/examples/cookbook/src/base/useClickInTurn.ts new file mode 100644 index 0000000000..5341735b4b --- /dev/null +++ b/examples/cookbook/src/base/useClickInTurn.ts @@ -0,0 +1,43 @@ +import { useEffect } from 'react'; + +/** + * One click of a screenshot helper: `find` returns the element to click, or nothing while it isn't + * on screen yet. `retry`, when given, runs while `find` finds nothing, to bring the element on + * screen, for example by opening the menu that holds it again. + */ +export type ClickStep = { + find: () => HTMLElement | null | undefined; + retry?: () => void; +}; + +/** The first element matching `selector` whose text includes `text`. */ +export const findByText = (selector: string, text: string) => + [...document.querySelectorAll(selector)].find((element) => + element.textContent?.includes(text), + ); + +/** + * For screenshot helpers that open a recipe's UI from the URL, as a user would: clicks each step's + * element in turn, waiting for each one to appear first. It checks every 200 ms and stops after + * the last click, or when the component unmounts. An empty list clicks nothing. Pass the same + * array on every render, for example one built at module level from the URL, or the clicks start + * over. + */ +export const useClickInTurn = (steps: ClickStep[]) => { + useEffect(() => { + if (!steps.length) return; + let next = 0; + const timer = setInterval(() => { + const step = steps[next]; + const target = step.find(); + if (!target) { + step.retry?.(); + return; + } + target.click(); + next += 1; + if (next === steps.length) clearInterval(timer); + }, 200); + return () => clearInterval(timer); + }, [steps]); +}; diff --git a/examples/cookbook/src/main.tsx b/examples/cookbook/src/main.tsx new file mode 100644 index 0000000000..c12a65e2e9 --- /dev/null +++ b/examples/cookbook/src/main.tsx @@ -0,0 +1,65 @@ +import { StrictMode } from 'react'; +import type { ReactNode } from 'react'; +import ReactDOM from 'react-dom/client'; +import { BaseApp } from './base/BaseApp'; +import { listRecipes, loadRecipe } from './base/recipe'; +import './base/base.css'; + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); +const params = new URLSearchParams(window.location.search); +const recipeId = params.get('recipe'); + +// Screens outside the app carry `str-chat`, so the SDK's tokens resolve in them too. +const Status = ({ children }: { children: ReactNode }) => ( +
{children}
+); + +const RecipeIndex = () => ( + + + +); + +const start = async () => { + // The cookbook's look is the theme the Theming › Brand theme and dark mode recipe builds. + // `?look=sdk` leaves it out, for screenshots of the SDK's own default look. + if (params.get('look') !== 'sdk') { + const [{ loadFont }] = await Promise.all([ + import('./recipes/theming/brand-theme-and-dark-mode/loadFont'), + import('./recipes/theming/brand-theme-and-dark-mode/theme.css'), + ]); + loadFont(); + } + + // No `?recipe=` shows the list of recipes; `?recipe=base` is the base app on its own. + if (!recipeId) return root.render(); + + const variant = params.get('variant') || 'default'; + const recipe = recipeId === 'base' ? {} : await loadRecipe(recipeId, variant); + if (!recipe) { + return root.render( + + No recipe “{recipeId}” with a variant “{variant}”. + , + ); + } + + root.render( + + + , + ); +}; + +start().catch((error: unknown) => { + console.error(error); + root.render(The recipe failed to load: {String(error)}); +}); diff --git a/examples/cookbook/src/recipes/ai/ai-chat-widget/App.tsx b/examples/cookbook/src/recipes/ai/ai-chat-widget/App.tsx new file mode 100644 index 0000000000..2a67bd287e --- /dev/null +++ b/examples/cookbook/src/recipes/ai/ai-chat-widget/App.tsx @@ -0,0 +1,35 @@ +import { Chat, useCreateChatClient } from 'stream-chat-react'; +import { AssistantWidget } from './AssistantWidget'; +import { isMessageAIGenerated } from './isMessageAIGenerated'; + +const apiKey = 'YOUR_API_KEY'; +const userId = 'YOUR_USER_ID'; +const userToken = 'YOUR_USER_TOKEN'; +// The user's channel with the assistant, which your backend creates. +const assistantChannelId = 'YOUR_ASSISTANT_CHANNEL_ID'; + +/** + * An app that has no chat of its own, only the widget. Load the SDK's stylesheet in a CSS layer, as + * the base app's `chat.css` does, so the widget's CSS wins over it. + */ +export const App = () => { + const client = useCreateChatClient({ + apiKey, + tokenOrProvider: userToken, + userData: { id: userId }, + }); + + return ( + <> +
{/* Your app's own pages. */}
+ {client && ( + + {/* The SDK's tokens are set on `.str-chat`; without ChatView, the widget needs one. */} +
+ +
+
+ )} + + ); +}; diff --git a/examples/cookbook/src/recipes/ai/ai-chat-widget/AssistantWidget.css b/examples/cookbook/src/recipes/ai/ai-chat-widget/AssistantWidget.css new file mode 100644 index 0000000000..fdc96e8ad4 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/ai-chat-widget/AssistantWidget.css @@ -0,0 +1,64 @@ +@layer stream-overrides { + /* + * Pinned to the bottom right of the page, the window stacked above the launcher. 96px clears a + * composer along the bottom of the page, such as the base app's. + */ + .cookbook-assistant-widget { + position: fixed; + z-index: 10; + inset-block-end: 96px; + inset-inline-end: var(--str-chat__spacing-lg); + display: flex; + flex-direction: column-reverse; + align-items: flex-end; + gap: var(--str-chat__spacing-sm); + } + + .cookbook-assistant-launcher { + box-shadow: var(--str-chat__box-shadow-3); + } + + /* A compact card, never wider or taller than the page leaves room for. */ + .cookbook-assistant-window { + display: flex; + flex-direction: column; + width: min(380px, calc(100vw - 2 * var(--str-chat__spacing-lg))); + height: min(520px, calc(100dvh - 200px)); + overflow: hidden; + border: 1px solid var(--str-chat__border-core-default); + border-radius: var(--str-chat__radius-xl); + background: var(--str-chat__background-core-elevation-1); + box-shadow: var(--str-chat__box-shadow-3); + color: var(--str-chat__text-primary); + } + + .cookbook-assistant-window__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--str-chat__spacing-sm); + padding: var(--str-chat__spacing-sm) var(--str-chat__spacing-sm) + var(--str-chat__spacing-sm) var(--str-chat__spacing-md); + border-block-end: 1px solid var(--str-chat__border-core-default); + } + + .cookbook-assistant-window__title { + margin: 0; + font: var(--str-chat__font-heading-sm); + } + + /* The channel fills the space under the header. */ + .cookbook-assistant-window__body { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + } + + .cookbook-assistant-window__note { + margin: 0; + padding: var(--str-chat__spacing-md); + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-body-default); + } +} diff --git a/examples/cookbook/src/recipes/ai/ai-chat-widget/AssistantWidget.tsx b/examples/cookbook/src/recipes/ai/ai-chat-widget/AssistantWidget.tsx new file mode 100644 index 0000000000..db545200df --- /dev/null +++ b/examples/cookbook/src/recipes/ai/ai-chat-widget/AssistantWidget.tsx @@ -0,0 +1,166 @@ +import { useEffect, useId, useRef, useState } from 'react'; +import type { KeyboardEvent } from 'react'; +import type { Channel as StreamChannel } from 'stream-chat'; +import { + AIStateIndicator, + Button, + Channel, + LoadingIndicator, + MessageComposer, + MessageList, + useChatContext, + useComponentContextIcons, +} from 'stream-chat-react'; +import './AssistantWidget.css'; + +/** + * Watches the user's channel with the assistant the first time the window opens, and keeps it + * afterwards, until `channelId` names another channel. A user who never opens the window costs no + * request. + */ +const useAssistantChannel = (channelId: string, isOpen: boolean) => { + const { client } = useChatContext(); + const [channel, setChannel] = useState(); + const [failed, setFailed] = useState(false); + + useEffect(() => { + if (!isOpen || channel?.id === channelId) return; + let cancelled = false; + setFailed(false); + client + .channel('messaging', channelId) + .watch() + .then((watched) => !cancelled && setChannel(watched)) + .catch(() => !cancelled && setFailed(true)); + return () => { + cancelled = true; + }; + }, [channel, channelId, client, isOpen]); + + return { channel, failed }; +}; + +/** + * A menu, picker or suggestion list inside the window is open, so Escape closes that first. Their + * toggles carry `aria-expanded="true"`, and the composer's textarea carries `aria-controls` while + * its suggestions show. + */ +const hasOpenPopup = (container: HTMLElement | null) => + !!container?.querySelector('[aria-expanded="true"], textarea[aria-controls]'); + +/** + * The Escape was pressed in the window or on the launcher, and nothing inside the window used it, + * as the composer does to leave a command. A modal opened from the window renders in a portal, but + * React still bubbles its keydown here, and that Escape closes only the modal. + */ +const isEscapeForWindow = ( + event: KeyboardEvent, + container: HTMLElement | null, + launcher: HTMLElement | null, +) => { + const target = event.target as Node; + return ( + (!!container?.contains(target) || !!launcher?.contains(target)) && + !event.defaultPrevented && + !hasOpenPopup(container) + ); +}; + +type AssistantWidgetProps = { + /** The id of the user's `messaging` channel with the assistant. */ + channelId: string; + /** The window's heading, which also names it for assistive technology. */ + title?: string; +}; + +/** + * A launcher button at the bottom right of the page that opens a small, non-modal chat window with + * the assistant. Escape or the close button closes it and gives focus back to the launcher. + */ +export const AssistantWidget = ({ + channelId, + title = 'Assistant', +}: AssistantWidgetProps) => { + const { IconMessageBubble, IconXmark } = useComponentContextIcons(); + const [isOpen, setIsOpen] = useState(false); + const { channel, failed } = useAssistantChannel(channelId, isOpen); + const launcherRef = useRef(null); + const windowRef = useRef(null); + const windowId = useId(); + const titleId = useId(); + + const close = () => { + setIsOpen(false); + launcherRef.current?.focus(); + }; + + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || !isOpen) return; + // Escape inside the open widget is the widget's, so handlers on the page don't act on it too, + // such as the channel list's search, which focuses its input on any Escape. + event.stopPropagation(); + if (!isEscapeForWindow(event, windowRef.current, launcherRef.current)) return; + event.preventDefault(); + close(); + }; + + return ( +
+ + {isOpen && ( + + )} +
+ ); +}; diff --git a/examples/cookbook/src/recipes/ai/ai-chat-widget/ChannelsWithoutAssistant.tsx b/examples/cookbook/src/recipes/ai/ai-chat-widget/ChannelsWithoutAssistant.tsx new file mode 100644 index 0000000000..1125ed27d1 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/ai-chat-widget/ChannelsWithoutAssistant.tsx @@ -0,0 +1,34 @@ +import { useEffect } from 'react'; +import { ChannelPaginator } from 'stream-chat'; +import type { SortParamRequest } from 'stream-chat'; +import { useChatContext } from 'stream-chat-react'; +import { channelFilters, visibleChannelIds } from '../../../base/client'; + +const sort: SortParamRequest[] = [{ direction: -1, field: 'last_message_at' }]; + +/** + * Not part of the recipe: the base app's channel list without `channelId`, the assistant's channel, + * which only the widget opens. Registered in place of the base app's paginator. + */ +export const ChannelsWithoutAssistant = ({ channelId }: { channelId: string }) => { + const { client } = useChatContext(); + + useEffect(() => { + const paginator = new ChannelPaginator({ + client, + filters: { + ...channelFilters, + id: { $in: visibleChannelIds.filter((id) => id !== channelId) }, + }, + id: 'cookbook', + requestOptions: { presence: true, state: true }, + sort, + }); + client.channelManager.insertPaginator({ paginator }); + return () => { + client.channelManager.removePaginator(paginator); + }; + }, [channelId, client]); + + return null; +}; diff --git a/examples/cookbook/src/recipes/ai/ai-chat-widget/OpenWidgetFromUrl.tsx b/examples/cookbook/src/recipes/ai/ai-chat-widget/OpenWidgetFromUrl.tsx new file mode 100644 index 0000000000..45783fbf47 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/ai-chat-widget/OpenWidgetFromUrl.tsx @@ -0,0 +1,18 @@ +import { useClickInTurn } from '../../../base/useClickInTurn'; +import type { ClickStep } from '../../../base/useClickInTurn'; + +// `?widget=open` clicks the launcher, as the user would. +const steps: ClickStep[] = + new URLSearchParams(window.location.search).get('widget') === 'open' + ? [ + { + find: () => document.querySelector('.cookbook-assistant-launcher'), + }, + ] + : []; + +/** For the screenshots only, not part of the recipe: opens the widget from the URL. */ +export const OpenWidgetFromUrl = () => { + useClickInTurn(steps); + return null; +}; diff --git a/examples/cookbook/src/recipes/ai/ai-chat-widget/default.recipe.tsx b/examples/cookbook/src/recipes/ai/ai-chat-widget/default.recipe.tsx new file mode 100644 index 0000000000..ee2d451b6d --- /dev/null +++ b/examples/cookbook/src/recipes/ai/ai-chat-widget/default.recipe.tsx @@ -0,0 +1,25 @@ +import type { RecipeConfig } from '../../../base/recipe'; +import { AssistantWidget } from './AssistantWidget'; +import { ChannelsWithoutAssistant } from './ChannelsWithoutAssistant'; +import { isMessageAIGenerated } from './isMessageAIGenerated'; +import { OpenWidgetFromUrl } from './OpenWidgetFromUrl'; + +// The user's channel with the assistant, from this recipe's demo-data.json. +const ASSISTANT_CHANNEL_ID = 'docs-v15-cb-widget-assistant'; + +// A chat bubble at the bottom right of the base app that opens the user's channel with the +// assistant. The channel list leaves that channel out. OpenWidgetFromUrl only serves the +// screenshots. +const recipe: RecipeConfig = { + chatProps: () => ({ isMessageAIGenerated }), + chatViewChildren: ( + <> + + + + + ), + registerChannelPaginator: false, +}; + +export default recipe; diff --git a/examples/cookbook/src/recipes/ai/ai-chat-widget/demo-data.json b/examples/cookbook/src/recipes/ai/ai-chat-widget/demo-data.json new file mode 100644 index 0000000000..eab2234339 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/ai-chat-widget/demo-data.json @@ -0,0 +1,33 @@ +{ + "users": [{ "id": "docs-v15-ck-assistant", "name": "Assistant" }], + "channels": [ + { + "id": "docs-v15-cb-widget-assistant", + "members": ["docs-v15-ck-cleo", "docs-v15-ck-assistant"], + "messages": [ + { + "id": "docs-v15-ck-widget-assistant-1", + "user": "docs-v15-ck-cleo", + "text": "Can you suggest a few short novels for the book club?" + }, + { + "id": "docs-v15-ck-widget-assistant-2", + "user": "docs-v15-ck-assistant", + "text": "Happy to help. Short novels work well for a monthly club: pick ones under 200 pages, so everyone finishes in time. Would you like a mystery, a classic or something light?", + "custom": { "ai_generated": true } + }, + { + "id": "docs-v15-ck-widget-assistant-3", + "user": "docs-v15-ck-cleo", + "text": "Something light, please." + }, + { + "id": "docs-v15-ck-widget-assistant-4", + "user": "docs-v15-ck-assistant", + "text": "Then look for a warm, funny story with a small cast, such as a road trip or a summer by the sea. They are quick to read and easy to talk about.", + "custom": { "ai_generated": true } + } + ] + } + ] +} diff --git a/examples/cookbook/src/recipes/ai/ai-chat-widget/isMessageAIGenerated.ts b/examples/cookbook/src/recipes/ai/ai-chat-widget/isMessageAIGenerated.ts new file mode 100644 index 0000000000..848e5f81d1 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/ai-chat-widget/isMessageAIGenerated.ts @@ -0,0 +1,13 @@ +import type { LocalMessage } from 'stream-chat'; + +declare module 'stream-chat' { + // The field the backend sets on the assistant's replies. Optional, because every message in the + // app shares this interface. + interface CustomMessageData { + ai_generated?: boolean; + } +} + +/** The assistant wrote the message: the backend marks its replies with `ai_generated`. */ +export const isMessageAIGenerated = (message: LocalMessage) => + !!message.custom?.ai_generated; diff --git a/examples/cookbook/src/recipes/ai/ai-chat-widget/shots.json b/examples/cookbook/src/recipes/ai/ai-chat-widget/shots.json new file mode 100644 index 0000000000..24845c207b --- /dev/null +++ b/examples/cookbook/src/recipes/ai/ai-chat-widget/shots.json @@ -0,0 +1,16 @@ +[ + { + "file": "ai-chat-widget.png", + "query": { "channel": "docs-v15-cb-design", "widget": "open" }, + "waitFor": ".cookbook-assistant-window [data-message-id=\"docs-v15-ck-widget-assistant-4\"]", + "settleMs": 1500 + }, + { + "file": "ai-chat-widget-launcher.png", + "query": { "channel": "docs-v15-cb-design" }, + "waitFor": ".cookbook-assistant-launcher", + "clip": ".cookbook-assistant-widget", + "padding": 96, + "settleMs": 1500 + } +] diff --git a/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/AssistantStatus.css b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/AssistantStatus.css new file mode 100644 index 0000000000..20a44ab9c2 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/AssistantStatus.css @@ -0,0 +1,52 @@ +@layer stream-overrides { + /* A quiet line under the last message: three pulsing dots and what the assistant is doing. */ + .cookbook-assistant-status { + display: flex; + align-items: center; + gap: var(--str-chat__spacing-xs); + padding: var(--str-chat__spacing-xs) var(--str-chat__spacing-md); + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-caption-default); + } + + .cookbook-assistant-status[data-ai-state='AI_STATE_ERROR'] { + color: var(--str-chat__accent-error); + } + + .cookbook-assistant-status__dots { + display: flex; + gap: var(--str-chat__spacing-xxs); + } + + .cookbook-assistant-status__dots span { + width: 6px; + height: 6px; + border-radius: var(--str-chat__radius-full); + background: var(--str-chat__accent-primary); + animation: cookbook-assistant-pulse 1.2s ease-in-out infinite; + } + + .cookbook-assistant-status__dots span:nth-child(2) { + animation-delay: 0.2s; + } + + .cookbook-assistant-status__dots span:nth-child(3) { + animation-delay: 0.4s; + } + + @media (prefers-reduced-motion: reduce) { + .cookbook-assistant-status__dots span { + animation: none; + } + } + + @keyframes cookbook-assistant-pulse { + 0%, + 100% { + opacity: 0.3; + } + 50% { + opacity: 1; + } + } +} diff --git a/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/AssistantStatus.tsx b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/AssistantStatus.tsx new file mode 100644 index 0000000000..48363d2312 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/AssistantStatus.tsx @@ -0,0 +1,62 @@ +import { useEffect } from 'react'; +import { AIStates } from 'stream-chat'; +import type { AIState } from 'stream-chat'; +import { + TypingIndicator, + useAIState, + useChannel, + useThreadContext, +} from 'stream-chat-react'; +import type { TypingIndicatorProps } from 'stream-chat-react'; +import { useAnswerAnnouncements } from './useAnswerAnnouncements'; +import './AssistantStatus.css'; + +/** What the status line says in each state that shows it. */ +const labels: Partial> = { + [AIStates.Error]: 'Couldn’t finish the answer', + [AIStates.ExternalSources]: 'Checking sources…', + [AIStates.Generating]: 'Writing…', + [AIStates.Thinking]: 'Thinking…', +}; + +/** + * The assistant's state at the end of the channel's message list, where the answer appears, and + * the default typing indicator while the assistant is idle. Screen reader announcements come from + * `useAnswerAnnouncements()`, so the line itself isn't a live region. + */ +const ChannelAssistantStatus = (props: TypingIndicatorProps) => { + const { isMessageListScrolledToBottom = true, scrollToBottom } = props; + const channel = useChannel(); + const { aiState } = useAIState(channel); + const label = labels[aiState]; + useAnswerAnnouncements(aiState); + + // Keeps the line in view when it appears, as the typing indicator does. + useEffect(() => { + if (label && isMessageListScrolledToBottom) scrollToBottom?.(); + }, [isMessageListScrolledToBottom, label, scrollToBottom]); + + if (!label) return ; + + return ( +
+ {aiState !== AIStates.Error && ( +
+ ); +}; + +/** + * Fills the `TypingIndicator` slot. The assistant answers in the channel, so a thread keeps the + * default typing indicator. + */ +export const AssistantStatus = (props: TypingIndicatorProps) => { + const thread = useThreadContext(); + return thread ? : ; +}; diff --git a/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/SimulateAnswer.tsx b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/SimulateAnswer.tsx new file mode 100644 index 0000000000..c8e90ffc68 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/SimulateAnswer.tsx @@ -0,0 +1,130 @@ +import { useEffect } from 'react'; +import { AIStates, msToNs, nsToMs } from 'stream-chat'; +import type { Channel, Event, LocalMessage, MessagePaginatorState } from 'stream-chat'; +import { useChatContext } from 'stream-chat-react'; +import { isDemoChannelId } from '../../../base/client'; + +const params = new URLSearchParams(window.location.search); +const phases = ['thinking', 'streaming', 'done'] as const; +type Phase = (typeof phases)[number]; +const requestedPhase = params.get('phase') as Phase | null; +const runs = params.get('simulate') === 'answer'; +const channelId = params.get('channel'); + +const ASSISTANT_ID = 'docs-v15-ck-assistant'; +const QUESTION_ID = 'docs-v15-ck-garden-assistant-3'; +// Exists only in the page: the demo app never stores it. +const ANSWER_ID = 'docs-v15-ck-garden-assistant-answer'; +const ANSWER = [ + 'Good picks for a first season with six hours of sun:', + '', + '- **Lettuce** grows fast, and you can pick the leaves as you need them.', + '- **Bush beans** need no support and crop for weeks.', + '- **Zucchini** gives plenty from one or two plants.', + '- **Radishes** are ready in about a month.', + '', + 'Water in the morning, and mulch the beds to keep the soil moist.', +].join('\n'); +// The streaming phase stops here, about halfway through the answer. +const STREAMING_STOP = ANSWER.indexOf('- **Zucchini**'); + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** The question the simulation answers is loaded in the channel's message list. */ +const hasQuestion = ({ items }: MessagePaginatorState) => + !!items?.some(({ id }) => id === QUESTION_ID); + +/** + * For the screenshots: `?simulate=answer&channel=&phase=thinking|streaming|done` plays, in + * the page only, the events a backend sends while the assistant answers the channel's last + * question, and stops at the phase: the assistant thinking, the answer half written, or the answer + * finished. Without `phase`, it plays to the end. When it stops, it sets + * `data-simulated-answer` on the root element, for the screenshot to wait for. + */ +export const SimulateAnswer = () => { + const { client } = useChatContext(); + + useEffect(() => { + if (!runs || !isDemoChannelId(channelId)) return; + const channel: Channel = client.channel('messaging', channelId); + const lastPhase = phases.indexOf(requestedPhase ?? 'done'); + let cancelled = false; + + const dispatch = (event: Record) => { + if (cancelled) return; + client.dispatchEvent({ + channel_id: channel.id, + channel_type: channel.type, + cid: channel.cid, + created_at: msToNs(Date.now()), + custom: {}, + ...event, + } as unknown as Event); + }; + + const answerWith = (question: LocalMessage, text: string) => ({ + ...question, + created_at: msToNs(nsToMs(question.created_at) + 60_000), + custom: { ai_generated: true }, + html: '', + id: ANSWER_ID, + text, + updated_at: msToNs(Date.now()), + // The assistant as the channel's earlier answer has it, so the answer shows its avatar. + user: channel.messagePaginator.state + .getLatestValue() + .items?.find(({ user }) => user?.id === ASSISTANT_ID)?.user, + user_id: ASSISTANT_ID, + }); + + const play = async (question: LocalMessage) => { + // The backend posts the empty answer first, then says it's thinking about it. + dispatch({ message: answerWith(question, ''), type: 'message.new' }); + dispatch({ + ai_state: AIStates.Thinking, + message_id: ANSWER_ID, + type: 'ai_indicator.update', + }); + if (lastPhase === 0) return; + + await wait(800); + dispatch({ + ai_state: AIStates.Generating, + message_id: ANSWER_ID, + type: 'ai_indicator.update', + }); + const end = lastPhase === 1 ? STREAMING_STOP : ANSWER.length; + for (let length = 40; length < end; length += 40) { + await wait(250); + const text = ANSWER.slice(0, length); + dispatch({ message: answerWith(question, text), type: 'message.updated' }); + } + await wait(250); + const text = ANSWER.slice(0, end); + dispatch({ message: answerWith(question, text), type: 'message.updated' }); + if (lastPhase === 1) return; + + dispatch({ type: 'ai_indicator.clear' }); + }; + + const finish = () => { + if (!cancelled) document.documentElement.dataset.simulatedAnswer = 'true'; + }; + + // Plays once, as soon as the question is loaded. + let started = false; + const unsubscribe = channel.messagePaginator.state.subscribe((state) => { + if (started || !hasQuestion(state)) return; + started = true; + const question = state.items?.find(({ id }) => id === QUESTION_ID); + if (question) void play(question).then(finish); + }); + + return () => { + cancelled = true; + unsubscribe(); + }; + }, [client]); + + return null; +}; diff --git a/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/StopButton.css b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/StopButton.css new file mode 100644 index 0000000000..57ab0549d3 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/StopButton.css @@ -0,0 +1,19 @@ +@layer stream-overrides { + /* The size of the send button's icon, so the button keeps its size when it swaps in. */ + .cookbook-stop-button__icon { + display: flex; + align-items: center; + justify-content: center; + width: var(--str-chat__icon-size-md); + height: var(--str-chat__icon-size-md); + } + + /* A filled square, the usual sign for stop, in the button's text color. */ + .cookbook-stop-button__icon::before { + content: ''; + width: 10px; + height: 10px; + border-radius: var(--str-chat__radius-xxs); + background: currentColor; + } +} diff --git a/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/StopButton.tsx b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/StopButton.tsx new file mode 100644 index 0000000000..47123002ce --- /dev/null +++ b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/StopButton.tsx @@ -0,0 +1,33 @@ +import type { ComponentProps, MouseEvent } from 'react'; +import { Button, useMessageComposerContext } from 'stream-chat-react'; +import './StopButton.css'; + +/** + * Fills the composer's `StopAIGenerationButton` slot, which shows in place of the send button + * while the assistant answers. It gets a button's props, and its `onClick` calls + * `Channel.stopAIResponse()`. The button goes away once the answer stops, so the focus moves to + * the text field, where the user writes the next question. + */ +export const StopButton = ({ onClick }: ComponentProps<'button'>) => { + const { textareaRef } = useMessageComposerContext(); + + const stop = (event: MouseEvent) => { + onClick?.(event); + textareaRef.current?.focus(); + }; + + return ( + + ); +}; diff --git a/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/WithStreamedAnswers.tsx b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/WithStreamedAnswers.tsx new file mode 100644 index 0000000000..00b4e9c112 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/WithStreamedAnswers.tsx @@ -0,0 +1,19 @@ +import type { PropsWithChildren } from 'react'; +import { WithComponents } from 'stream-chat-react'; +import { AssistantStatus } from './AssistantStatus'; +import { StopButton } from './StopButton'; + +// Declared once, so WithComponents gets the same object on every render. +const overrides = { + StopAIGenerationButton: StopButton, + TypingIndicator: AssistantStatus, +}; + +/** + * The assistant's state at the end of the message list, and a Stop button in place of the send + * button while it answers. `StreamedMessageText` needs no override: `MessageUI` renders it for + * every message `Chat`'s `isMessageAIGenerated` returns `true` for. + */ +export const WithStreamedAnswers = ({ children }: PropsWithChildren) => ( + {children} +); diff --git a/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/default.recipe.tsx b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/default.recipe.tsx new file mode 100644 index 0000000000..2648863990 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/default.recipe.tsx @@ -0,0 +1,22 @@ +import type { PropsWithChildren } from 'react'; +import type { RecipeConfig } from '../../../base/recipe'; +import { isMessageAIGenerated } from './isMessageAIGenerated'; +import { SimulateAnswer } from './SimulateAnswer'; +import { WithStreamedAnswers } from './WithStreamedAnswers'; + +// "Streamed answers with a stop button": the assistant's replies type out as they stream, its state +// shows at the end of the message list, and a Stop button replaces the send button while it +// answers. SimulateAnswer only serves the screenshots. +const Wrapper = ({ children }: PropsWithChildren) => ( + + + {children} + +); + +const recipe: RecipeConfig = { + chatProps: () => ({ isMessageAIGenerated }), + Wrapper, +}; + +export default recipe; diff --git a/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/demo-data.json b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/demo-data.json new file mode 100644 index 0000000000..f1332cfa3d --- /dev/null +++ b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/demo-data.json @@ -0,0 +1,27 @@ +{ + "users": [{ "id": "docs-v15-ck-assistant", "name": "Assistant" }], + "channels": [ + { + "id": "docs-v15-cb-garden-assistant", + "members": ["docs-v15-ck-cleo", "docs-v15-ck-assistant"], + "messages": [ + { + "id": "docs-v15-ck-garden-assistant-1", + "user": "docs-v15-ck-cleo", + "text": "Hi! Can you help me plan my garden plot?" + }, + { + "id": "docs-v15-ck-garden-assistant-2", + "user": "docs-v15-ck-assistant", + "text": "Of course. Tell me what you'd like to grow and how much sun the plot gets.", + "custom": { "ai_generated": true } + }, + { + "id": "docs-v15-ck-garden-assistant-3", + "user": "docs-v15-ck-cleo", + "text": "It gets about six hours of sun. Which vegetables are easy for a first season?" + } + ] + } + ] +} diff --git a/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/isMessageAIGenerated.ts b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/isMessageAIGenerated.ts new file mode 100644 index 0000000000..848e5f81d1 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/isMessageAIGenerated.ts @@ -0,0 +1,13 @@ +import type { LocalMessage } from 'stream-chat'; + +declare module 'stream-chat' { + // The field the backend sets on the assistant's replies. Optional, because every message in the + // app shares this interface. + interface CustomMessageData { + ai_generated?: boolean; + } +} + +/** The assistant wrote the message: the backend marks its replies with `ai_generated`. */ +export const isMessageAIGenerated = (message: LocalMessage) => + !!message.custom?.ai_generated; diff --git a/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/shots.json b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/shots.json new file mode 100644 index 0000000000..3aa21ce0eb --- /dev/null +++ b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/shots.json @@ -0,0 +1,41 @@ +[ + { + "file": "streamed-answer-thinking.png", + "query": { + "channel": "docs-v15-cb-garden-assistant", + "simulate": "answer", + "phase": "thinking" + }, + "waitFor": ".cookbook-assistant-status", + "clip": ".str-chat__channel", + "padding": 0, + "settleMs": 1500, + "viewport": { "width": 1280, "height": 760 } + }, + { + "file": "streamed-answer-streaming.png", + "query": { + "channel": "docs-v15-cb-garden-assistant", + "simulate": "answer", + "phase": "streaming" + }, + "waitFor": "html[data-simulated-answer] .cookbook-stop-button", + "clip": ".str-chat__channel", + "padding": 0, + "settleMs": 2500, + "viewport": { "width": 1280, "height": 760 } + }, + { + "file": "streamed-answer-done.png", + "query": { + "channel": "docs-v15-cb-garden-assistant", + "simulate": "answer", + "phase": "done" + }, + "waitFor": "html[data-simulated-answer] .str-chat__message-composer", + "clip": ".str-chat__channel", + "padding": 0, + "settleMs": 6000, + "viewport": { "width": 1280, "height": 760 } + } +] diff --git a/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/useAnswerAnnouncements.ts b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/useAnswerAnnouncements.ts new file mode 100644 index 0000000000..cd73c243d8 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/useAnswerAnnouncements.ts @@ -0,0 +1,42 @@ +import { useEffect, useRef } from 'react'; +import { AIStates } from 'stream-chat'; +import type { AIState } from 'stream-chat'; +import { useAriaLiveAnnouncer } from 'stream-chat-react'; + +/** The assistant is working on an answer: thinking, checking sources or writing it. */ +const isAnswering = (aiState: AIState) => + aiState === AIStates.Thinking || + aiState === AIStates.ExternalSources || + aiState === AIStates.Generating; + +/** What to say when an answer ends in each state that isn't answering. */ +const endings: Partial> = { + [AIStates.Error]: 'The assistant couldn’t finish the answer', + [AIStates.Idle]: 'The assistant finished answering', + [AIStates.Stop]: 'Answer stopped', +}; + +/** + * Tells a screen reader how the assistant's answer goes, without reading every streamed word: + * that it started, and that it finished, stopped or failed. The finished answer is then one + * message the user reads like any other. Each change is announced once: a repeated state, such as + * StrictMode running the effect twice, says nothing, and an ending is announced only after an + * answer, so mounting on an earlier error stays quiet. + */ +export const useAnswerAnnouncements = (aiState: AIState) => { + const announce = useAriaLiveAnnouncer(); + // Starts idle, so opening the channel mid-answer announces it too. + const previousState = useRef(AIStates.Idle); + + useEffect(() => { + if (previousState.current === aiState) return; + const wasAnswering = isAnswering(previousState.current); + previousState.current = aiState; + + if (isAnswering(aiState)) { + if (!wasAnswering) announce('The assistant is answering'); + } else if (wasAnswering && endings[aiState]) { + announce(endings[aiState]); + } + }, [aiState, announce]); +}; diff --git a/examples/cookbook/src/recipes/channel-list/channel-invites/Invitations.css b/examples/cookbook/src/recipes/channel-list/channel-invites/Invitations.css new file mode 100644 index 0000000000..19ec7e5dc3 --- /dev/null +++ b/examples/cookbook/src/recipes/channel-list/channel-invites/Invitations.css @@ -0,0 +1,64 @@ +@layer stream-overrides { + /* Sits between the search bar and the channel list, at the height its rows need, and + separated from the channels by a line. */ + .cookbook-invitations { + border-bottom: 1px solid var(--str-chat__border-core-default); + } + + .cookbook-invitations__title { + margin: 0; + padding: var(--str-chat__spacing-sm) var(--str-chat__spacing-md) + var(--str-chat__spacing-xs); + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-caption-emphasis); + } + + .cookbook-invitations__list { + margin: 0; + padding: 0; + list-style: none; + } + + /* The avatar beside the channel's name, who invited the user, and the two buttons. */ + .cookbook-invitation { + display: flex; + align-items: flex-start; + gap: var(--str-chat__spacing-sm); + padding: var(--str-chat__spacing-xs) var(--str-chat__spacing-md) + var(--str-chat__spacing-sm); + } + + .cookbook-invitation__body { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--str-chat__spacing-xxs); + min-width: 0; + } + + .cookbook-invitation__title { + margin: 0; + overflow: hidden; + color: var(--str-chat__text-primary); + font: var(--str-chat__font-body-emphasis); + text-overflow: ellipsis; + white-space: nowrap; + } + + .cookbook-invitation__by { + margin: 0; + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-caption-default); + } + + .cookbook-invitation__actions { + display: flex; + gap: var(--str-chat__spacing-xs); + margin-top: var(--str-chat__spacing-xs); + } + + .cookbook-invitations__more { + display: flex; + padding: 0 var(--str-chat__spacing-md) var(--str-chat__spacing-xs); + } +} diff --git a/examples/cookbook/src/recipes/channel-list/channel-invites/Invitations.tsx b/examples/cookbook/src/recipes/channel-list/channel-invites/Invitations.tsx new file mode 100644 index 0000000000..a3a609b0fb --- /dev/null +++ b/examples/cookbook/src/recipes/channel-list/channel-invites/Invitations.tsx @@ -0,0 +1,171 @@ +import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'; +import type { Channel, ChannelPaginator, ChannelPaginatorState } from 'stream-chat'; +import { + Button, + ChannelAvatar, + LoadMoreButton, + useAriaLiveAnnouncer, + useChannelPreviewInfo, + useChatContext, + useStateStore, +} from 'stream-chat-react'; +import './Invitations.css'; + +type Answer = 'accept' | 'decline'; + +const invitesSelector = ({ hasMoreTail, isLoading, items }: ChannelPaginatorState) => ({ + hasMoreTail, + isLoading, + items, +}); + +/** + * The section has nothing to show but more to load: the first page was never loaded, or the user + * answered every loaded invitation while more remain on the server. + */ +const needsPage = (items: Channel[] | undefined, hasMoreTail: boolean) => + items === undefined || (items.length === 0 && hasMoreTail); + +/** + * One invitation: the channel, who invited the user, and Accept and Decline. Each button's name + * includes the channel's name, so a screen reader tells the rows apart. While the request runs, + * both buttons stay focusable but do nothing, and the pressed one reads "Joining…" or + * "Declining…". Once the server answers, the row leaves the section. If the focus is in the row + * then, it moves to the next row's first button, or else the previous row's. + */ +const Invitation = ({ + channel, + paginator, +}: { + channel: Channel; + paginator: ChannelPaginator; +}) => { + const { client } = useChatContext(); + const announce = useAriaLiveAnnouncer(); + const { displayImage, displayTitle, groupChannelDisplayInfo } = useChannelPreviewInfo({ + channel, + }); + const [answering, setAnswering] = useState(); + const rowRef = useRef(null); + const inviter = channel.data?.created_by?.name; + + // Runs as the row leaves, while it is still on the page, whatever removed it. + useLayoutEffect(() => { + const row = rowRef.current; + return () => { + if (!row?.contains(document.activeElement)) return; + const neighbour = row.nextElementSibling ?? row.previousElementSibling; + neighbour?.querySelector('button')?.focus(); + }; + }, []); + + const answer = async (choice: Answer) => { + if (answering) return; + setAnswering(choice); + try { + if (choice === 'accept') await channel.acceptInvite(); + else await channel.rejectInvite(); + // An accepted channel joins the channel list through the `member.updated` event that + // follows, which removes the row too if it arrives first. + paginator.removeItem({ item: channel }); + announce( + choice === 'accept' + ? `Joined ${displayTitle}` + : `Declined the invitation to ${displayTitle}`, + ); + } catch (error) { + setAnswering(undefined); + client.notifications.addError({ + message: `Couldn’t answer the invitation to ${displayTitle}`, + origin: { context: { error, panel: 'channel-list' }, emitter: 'Invitation' }, + }); + } + }; + + return ( +
  • +
  • + ); +}; + +/** + * The channels the user is invited to, as a list of invitations under an "Invitations" heading. + * Loads its first page on mount, and each further page when the user clicks "Load more", or when + * the user answered every loaded invitation while more remain. Shows nothing until the first page + * arrives, or when there are no invitations. + */ +export const Invitations = ({ paginator }: { paginator: ChannelPaginator }) => { + const { hasMoreTail, isLoading, items } = useStateStore( + paginator.state, + invitesSelector, + ); + const titleId = useId(); + + useEffect(() => { + if (needsPage(items, hasMoreTail)) void paginator.toTail(); + }, [hasMoreTail, items, paginator]); + + if (!items?.length) return null; + + return ( +
    +

    + Invitations +

    +
      + {items.map((channel) => ( + + ))} +
    + {hasMoreTail && ( +
    + void paginator.toTail()} /> +
    + )} +
    + ); +}; diff --git a/examples/cookbook/src/recipes/channel-list/channel-invites/SimulateInvites.tsx b/examples/cookbook/src/recipes/channel-list/channel-invites/SimulateInvites.tsx new file mode 100644 index 0000000000..66430165c7 --- /dev/null +++ b/examples/cookbook/src/recipes/channel-list/channel-invites/SimulateInvites.tsx @@ -0,0 +1,27 @@ +import type { ClickStep } from '../../../base/useClickInTurn'; +import { useClickInTurn } from '../../../base/useClickInTurn'; + +const params = new URLSearchParams(window.location.search); +const accepts = params.get('simulate') === 'accept'; +const channelName = params.get('row') ?? ''; + +const steps: ClickStep[] = accepts + ? [ + { + find: () => + document.querySelector( + `[aria-label="Accept invitation to ${CSS.escape(channelName)}"]`, + ), + }, + ] + : []; + +/** + * For the screenshots: `?simulate=accept&row=` presses that invitation's Accept + * button, as a click would. The cookbook is read-only, so the request never settles and the button + * keeps reading "Joining…". + */ +export const SimulateInvites = () => { + useClickInTurn(steps); + return null; +}; diff --git a/examples/cookbook/src/recipes/channel-list/channel-invites/WithChannelInvites.tsx b/examples/cookbook/src/recipes/channel-list/channel-invites/WithChannelInvites.tsx new file mode 100644 index 0000000000..1939261663 --- /dev/null +++ b/examples/cookbook/src/recipes/channel-list/channel-invites/WithChannelInvites.tsx @@ -0,0 +1,64 @@ +import { createContext, useContext, useMemo } from 'react'; +import type { ComponentType, PropsWithChildren } from 'react'; +import type { + ChannelFilters, + ChannelPaginator, + SearchControllerState, +} from 'stream-chat'; +import { + Search as DefaultSearch, + useChatContext, + useComponentContext, + useStateStore, + WithComponents, +} from 'stream-chat-react'; +import { Invitations } from './Invitations'; +import { useChannelInvites } from './useChannelInvites'; + +const InvitesContext = createContext<{ + invites?: ChannelPaginator; + Search: ComponentType; +}>({ Search: DefaultSearch }); + +const searchSelector = ({ isActive }: SearchControllerState) => ({ isActive }); + +/** + * The app's search, then the invitations while no search is open. `ChannelNavigation` renders its + * `Search` slot right above the channel list, so the invitations sit there too. + */ +const SearchWithInvitations = () => { + const { invites, Search } = useContext(InvitesContext); + const { searchController } = useChatContext(); + const { isActive } = useStateStore(searchController.state, searchSelector); + + return ( + <> + + {!isActive && invites && } + + ); +}; + +// Declared once, so WithComponents gets the same object on every render. +const overrides = { Search: SearchWithInvitations }; + +/** + * Shows the channels the user is invited to above the channel list, and keeps them out of the + * list until the user accepts. `filters` scope both to the user's channels; pass a stable object. + * Keeps the app's own `Search` override, if it has one. + */ +export const WithChannelInvites = ({ + children, + filters, +}: PropsWithChildren<{ filters: ChannelFilters }>) => { + const invites = useChannelInvites(filters); + // Read outside this component's own WithComponents, where `Search` is still the app's. + const { Search = DefaultSearch } = useComponentContext(); + const value = useMemo(() => ({ invites, Search }), [invites, Search]); + + return ( + + {children} + + ); +}; diff --git a/examples/cookbook/src/recipes/channel-list/channel-invites/default.recipe.tsx b/examples/cookbook/src/recipes/channel-list/channel-invites/default.recipe.tsx new file mode 100644 index 0000000000..7aed9aca3b --- /dev/null +++ b/examples/cookbook/src/recipes/channel-list/channel-invites/default.recipe.tsx @@ -0,0 +1,22 @@ +import type { PropsWithChildren } from 'react'; +import { channelFilters } from '../../../base/client'; +import type { RecipeConfig } from '../../../base/recipe'; +import { SimulateInvites } from './SimulateInvites'; +import { WithChannelInvites } from './WithChannelInvites'; + +// "Channel invites": an Invitations section above the channel list, whose channel list holds only +// the channels the user joined. The recipe registers that list's paginator itself, scoped to the +// cookbook's channels. SimulateInvites only serves the screenshots. +const Wrapper = ({ children }: PropsWithChildren) => ( + + + {children} + +); + +const recipe: RecipeConfig = { + registerChannelPaginator: false, + Wrapper, +}; + +export default recipe; diff --git a/examples/cookbook/src/recipes/channel-list/channel-invites/demo-data.json b/examples/cookbook/src/recipes/channel-list/channel-invites/demo-data.json new file mode 100644 index 0000000000..bec1489f1b --- /dev/null +++ b/examples/cookbook/src/recipes/channel-list/channel-invites/demo-data.json @@ -0,0 +1,42 @@ +{ + "channels": [ + { + "id": "docs-v15-cb-bird-walk", + "name": "Bird walk", + "createdBy": "docs-v15-ck-bob", + "members": ["docs-v15-ck-bob", "docs-v15-ck-dan"], + "invites": ["docs-v15-ck-cleo"], + "messages": [ + { + "id": "docs-v15-ck-bird-walk-1", + "user": "docs-v15-ck-bob", + "text": "We start at the lake at eight on Sunday. Bring binoculars if you have them." + }, + { + "id": "docs-v15-ck-bird-walk-2", + "user": "docs-v15-ck-dan", + "text": "I saw two herons there last week." + } + ] + }, + { + "id": "docs-v15-cb-chess", + "name": "Chess club", + "createdBy": "docs-v15-ck-cara", + "members": ["docs-v15-ck-cara", "docs-v15-ck-bob"], + "invites": ["docs-v15-ck-cleo"], + "messages": [ + { + "id": "docs-v15-ck-chess-1", + "user": "docs-v15-ck-cara", + "text": "Games every Thursday evening at the library, all levels welcome." + }, + { + "id": "docs-v15-ck-chess-2", + "user": "docs-v15-ck-bob", + "text": "I'll bring the spare boards." + } + ] + } + ] +} diff --git a/examples/cookbook/src/recipes/channel-list/channel-invites/shots.json b/examples/cookbook/src/recipes/channel-list/channel-invites/shots.json new file mode 100644 index 0000000000..c61e972d2f --- /dev/null +++ b/examples/cookbook/src/recipes/channel-list/channel-invites/shots.json @@ -0,0 +1,24 @@ +[ + { + "file": "channel-list-invitations.png", + "query": { + "channel": "docs-v15-cb-design" + }, + "waitFor": ".cookbook-invitation + .cookbook-invitation", + "clip": ".str-chat__channel-list", + "padding": 0, + "settleMs": 1500 + }, + { + "file": "channel-list-invitation-joining.png", + "query": { + "channel": "docs-v15-cb-design", + "simulate": "accept", + "row": "Bird walk" + }, + "waitFor": ".cookbook-invitation [aria-busy='true']", + "clip": ".cookbook-invitations", + "padding": 0, + "settleMs": 1000 + } +] diff --git a/examples/cookbook/src/recipes/channel-list/channel-invites/useChannelInvites.ts b/examples/cookbook/src/recipes/channel-list/channel-invites/useChannelInvites.ts new file mode 100644 index 0000000000..5a6a2b5876 --- /dev/null +++ b/examples/cookbook/src/recipes/channel-list/channel-invites/useChannelInvites.ts @@ -0,0 +1,112 @@ +import { useEffect, useState } from 'react'; +import { ChannelPaginator } from 'stream-chat'; +import type { + Channel, + ChannelFilters, + ChannelMemberResponse, + ChannelPaginatorOptions, + SortParamRequest, +} from 'stream-chat'; +import { useChatContext } from 'stream-chat-react'; + +/** The user joined the channel: they weren't invited, or they accepted the invite. */ +const hasJoined = (channel: Channel) => { + const { invite_accepted_at, invited } = channel.state.membership; + return !invited || !!invite_accepted_at; +}; + +/** The member answered their invite, on this device or another. */ +const hasAnswered = ({ invite_accepted_at, invite_rejected_at }: ChannelMemberResponse) => + !!(invite_accepted_at || invite_rejected_at); + +/** + * A channel paginator that checks the `joined` filter on the channel's membership, as the server + * does, when an event asks whether a channel belongs in the list. + */ +class JoinedChannelPaginator extends ChannelPaginator { + constructor(options: ChannelPaginatorOptions) { + super(options); + // First in the list: the last default resolver takes every field it reaches. + this.setFilterResolvers([ + { matchesField: (field) => field === 'joined', resolve: hasJoined }, + ...this._filterFieldToDataResolvers, + ]); + } +} + +// The channel with the latest message first. +const byLastMessage: SortParamRequest[] = [{ direction: -1, field: 'last_message_at' }]; +// The newest channel first. +const byCreatedAt: SortParamRequest[] = [{ direction: -1, field: 'created_at' }]; + +/** + * Registers the channel list's paginator on `client.channelManager`, limited to channels the user + * joined, and returns a second paginator with the channels the user is invited to. `filters` + * scope both to the user's channels; pass a stable object. Returns `undefined` until both exist. + */ +export const useChannelInvites = (filters: ChannelFilters) => { + const { client } = useChatContext(); + const [invites, setInvites] = useState(); + + useEffect(() => { + const channels = new JoinedChannelPaginator({ + client, + filters: { ...filters, joined: true }, + id: 'channels', + requestOptions: { presence: true, state: true }, + sort: byLastMessage, + }); + const invites = new ChannelPaginator({ + client, + filters: { ...filters, invite: 'pending' }, + id: 'invites', + paginatorOptions: { pageSize: 5 }, + sort: byCreatedAt, + }); + client.channelManager.insertPaginator({ paginator: channels }); + + // A paginator runs one query at a time, so an invite that arrives during a load is noted, and + // loaded once that load ends. The reload keeps the rows on screen until its page arrives. + let reloadWhenIdle = false; + const reload = () => { + if (invites.isLoading) { + reloadWhenIdle = true; + return; + } + reloadWhenIdle = false; + void invites.toTail({ keepPreviousItems: true, reset: 'yes' }); + }; + const offLoadEnd = invites.state.subscribeWithSelector( + ({ isLoading }) => ({ isLoading }), + ({ isLoading }) => { + if (!isLoading && reloadWhenIdle) reload(); + }, + ); + + // `ChannelNavigation` renders a channel list for every paginator on the manager, so the + // invites stay off it and follow the events themselves. The invites query watches its + // channels, so a watched channel's answer arrives as `member.updated`, not as a + // notification. A new invite arrives as `notification.invited`. + const { unsubscribe } = client.on((event) => { + if (event.type === 'notification.invited') reload(); + if ( + event.type === 'member.updated' && + event.member.user_id === client.userId && + hasAnswered(event.member) + ) + invites.removeItem({ id: event.cid }); + }); + setInvites(invites); + + return () => { + unsubscribe(); + offLoadEnd(); + client.channelManager.removePaginator(channels); + channels.dispose(); + invites.dispose(); + setInvites(undefined); + }; + }, [client, filters]); + + return invites; +}; diff --git a/examples/cookbook/src/recipes/channel-list/empty-and-loading-states/ChannelListLoadError.tsx b/examples/cookbook/src/recipes/channel-list/empty-and-loading-states/ChannelListLoadError.tsx new file mode 100644 index 0000000000..49e620e77b --- /dev/null +++ b/examples/cookbook/src/recipes/channel-list/empty-and-loading-states/ChannelListLoadError.tsx @@ -0,0 +1,57 @@ +import { useEffect } from 'react'; +import { + Button, + IconExclamationCircleFill, + IconRetry, + useAriaLiveAnnouncer, +} from 'stream-chat-react'; +import './ChannelListState.css'; + +const title = 'Couldn’t load your conversations'; + +/** + * Shown in place of the channel list when its first page failed to load. `onRetry` queries the first + * page again. The view stays mounted while the retry runs, so the Retry button keeps focus: it turns + * busy, and reads Retry again if the retry fails. + */ +export const ChannelListLoadError = ({ + isRetrying, + onRetry, +}: { + isRetrying: boolean; + onRetry: () => void; +}) => { + const announce = useAriaLiveAnnouncer(); + + // Announced when the error first shows and again whenever a retry fails. Polite, so it follows + // the SDK's own error notification, which is announced assertively at the same moment. The dedupe + // window swallows the second run of the effect in development. + useEffect(() => { + if (isRetrying) return; + announce(title, { dedupeMs: 1000, priority: 'polite' }); + }, [announce, isRetrying]); + + return ( +
    +
    + +
    +

    {title}

    +

    + Something went wrong. Try again. +

    + {/* aria-disabled, not disabled: a disabled button would lose focus. */} + +
    + ); +}; diff --git a/examples/cookbook/src/recipes/channel-list/empty-and-loading-states/ChannelListSkeleton.css b/examples/cookbook/src/recipes/channel-list/empty-and-loading-states/ChannelListSkeleton.css new file mode 100644 index 0000000000..38fbea5d8c --- /dev/null +++ b/examples/cookbook/src/recipes/channel-list/empty-and-loading-states/ChannelListSkeleton.css @@ -0,0 +1,79 @@ +@layer stream-overrides { + .cookbook-channel-list-skeleton { + display: flex; + flex-direction: column; + } + + /* The same size and spacing as a channel list row, so nothing moves when the rows arrive. */ + .cookbook-channel-list-skeleton__row { + display: flex; + align-items: center; + gap: var(--str-chat__spacing-md); + padding: calc(var(--str-chat__spacing-sm) + var(--str-chat__spacing-xxs)); + border-bottom: 1px solid var(--str-chat__border-core-subtle); + } + + .cookbook-channel-list-skeleton__avatar { + flex-shrink: 0; + width: var(--str-chat__size-48); + height: var(--str-chat__size-48); + border-radius: var(--str-chat__radius-full); + } + + .cookbook-channel-list-skeleton__lines { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--str-chat__spacing-xs); + min-width: 0; + } + + .cookbook-channel-list-skeleton__first-line { + display: flex; + justify-content: space-between; + gap: var(--str-chat__spacing-md); + } + + .cookbook-channel-list-skeleton__bar { + height: 14px; + border-radius: var(--str-chat__radius-full); + } + + .cookbook-channel-list-skeleton__bar--time { + flex-shrink: 0; + width: var(--str-chat__size-48); + } + + /* The skeleton tokens are a shimmer laid over a surface, so the shapes get a surface below it that + stands out from the list. One shimmer moves across every shape. */ + .cookbook-channel-list-skeleton__avatar, + .cookbook-channel-list-skeleton__bar { + background-color: var(--str-chat__background-core-surface-default); + background-image: linear-gradient( + 90deg, + var(--str-chat__skeleton-loading-base) 0%, + var(--str-chat__skeleton-loading-highlight) 50%, + var(--str-chat__skeleton-loading-base) 100% + ); + background-repeat: no-repeat; + background-size: 200% 100%; + animation: cookbook-channel-list-shimmer 1.4s linear infinite; + } + + @media (prefers-reduced-motion: reduce) { + .cookbook-channel-list-skeleton__avatar, + .cookbook-channel-list-skeleton__bar { + animation: none; + } + } + + @keyframes cookbook-channel-list-shimmer { + from { + background-position: 200% 0; + } + + to { + background-position: -200% 0; + } + } +} diff --git a/examples/cookbook/src/recipes/channel-list/empty-and-loading-states/ChannelListSkeleton.tsx b/examples/cookbook/src/recipes/channel-list/empty-and-loading-states/ChannelListSkeleton.tsx new file mode 100644 index 0000000000..ad6ac2500b --- /dev/null +++ b/examples/cookbook/src/recipes/channel-list/empty-and-loading-states/ChannelListSkeleton.tsx @@ -0,0 +1,47 @@ +import { useEffect } from 'react'; +import { useAriaLiveAnnouncer } from 'stream-chat-react'; +import './ChannelListSkeleton.css'; + +// Different widths per row, so the placeholder reads as a list of conversations. +const rows = [ + { preview: '88%', title: '52%' }, + { preview: '70%', title: '38%' }, + { preview: '80%', title: '60%' }, + { preview: '64%', title: '44%' }, + { preview: '76%', title: '34%' }, +]; + +/** + * Placeholder rows shaped like the channel list's rows: an avatar, the title and the time on the + * first line, the last message on the second. Screen readers skip the shapes and hear one message + * through the SDK's announcer instead. + */ +export const ChannelListSkeleton = () => { + const announce = useAriaLiveAnnouncer(); + + // Delayed, so a first page that arrives quickly is not announced at all; unmounting cancels it. + useEffect(() => announce('Loading conversations…', { delayMs: 300 }), [announce]); + + return ( +