From 767cadd37c7964ab6cef38a3405a1a959308c3f5 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 14 Aug 2026 17:59:26 +0200 Subject: [PATCH 1/2] feat(context): drop componentName param from context consumer hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `componentName` argument existed only to interpolate a name into a console.warn fired when a hook was called outside its provider. React DevTools attributes those calls far better, and the diagnostic was largely inert already: of the 7 hooks that accepted it, 3 ignored it outright and useTranslationContext could never reach its warn branch (its context carries a real default). Many of the 138 call sites passed the wrong name — hook names, or stale component names — and 5 passed `Component.name`, which a minifier mangles. Removes the parameter from all 7 hooks and their 138 call sites, and normalizes missing-provider behaviour behind a shared `requireContext` helper: - Required contexts throw, naming the hook and the provider. This replaces `return {} as T`, a typed lie that deferred failure to an opaque "cannot read properties of undefined" several frames later. - Contexts with a meaningful default keep it and gain an honest type: useTranslationContext, useComponentContext, useChannelInstanceContext (now Partial), useModalContext, useAriaLiveAnnouncer, useMessageTranslationViewContext. - Components that legitimately render both inside and outside a provider read the raw context instead (Audio, VoiceRecording, CardAudio, Timestamp, MessageRepliesCountButton, MessageComposerUI, ModalGallery). Two supporting changes were needed so their hooks could throw: - WithDragAndDropUpload probed the `{}` fallback via `Object.keys(ctx).length > 0`; it now uses a `useIsWithinMessageComposerContext()` predicate. Adds the test file this component previously lacked, covering both upload modes. - GalleryHeader read the message context while rendering outside any MessageProvider. GalleryItem now carries `user` and `createdAt`, so channel media shows a correct per-item sender and timestamp — which it previously could not display at all. Adds a `timestamp.GalleryTimestamp` key. ChatViewContext loses its module-level LayoutController default, which silently shared one instance across unrelated subtrees. Contract pinned by src/context/__tests__/missingProviderContract.test.tsx. --- .../Accessibility/NotificationAnnouncer.tsx | 3 +- .../hooks/useIncomingMessageAnnouncements.ts | 2 +- .../Accessibility/useAriaLiveAnnouncer.ts | 19 +--- .../Attachment/AttachmentActions.tsx | 2 +- src/components/Attachment/Audio.tsx | 8 +- .../Attachment/LinkPreview/CardAudio.tsx | 8 +- .../LinkPreview/UnableToRenderCard.tsx | 2 +- src/components/Attachment/ModalGallery.tsx | 25 ++++- .../Attachment/UnsupportedAttachment.tsx | 2 +- src/components/Attachment/VoiceRecording.tsx | 8 +- .../AudioPlayback/components/ProgressBar.tsx | 2 +- .../components/WaveProgressBar.tsx | 2 +- src/components/Avatar/AvatarStack.tsx | 2 +- src/components/Channel/Channel.tsx | 5 +- .../Channel/hooks/useChannelConfig.ts | 2 +- .../hooks/useChannelContainerClasses.ts | 2 +- .../ChannelHeader/ChannelHeader.tsx | 2 +- .../ChannelListItem/ChannelListItem.tsx | 16 ++- .../ChannelListItemTimestamp.tsx | 2 +- .../ChannelListItem/ChannelListItemUI.tsx | 4 +- .../hooks/useChannelDisplayName.ts | 4 +- .../hooks/useIsChannelMuted.ts | 2 +- .../DateSeparator/DateSeparator.tsx | 2 +- .../Dialog/components/ContextMenu.tsx | 3 +- .../EmptyStateIndicator.tsx | 2 +- src/components/Form/SwitchField.tsx | 2 +- src/components/Gallery/GalleryContext.tsx | 21 ++-- src/components/Gallery/GalleryHeader.tsx | 56 ++++++--- .../Gallery/__tests__/Gallery.test.tsx | 10 +- .../Gallery/__tests__/GalleryContext.test.tsx | 13 +-- .../Gallery/__tests__/GalleryUI.test.tsx | 75 ++++++++----- .../Gallery/__tests__/Image.test.tsx | 16 ++- .../Gallery/__tests__/ModalGallery.test.tsx | 28 +++-- src/components/Gallery/decisions.md | 30 ++++- src/components/LoadMore/LoadMoreButton.tsx | 2 +- .../Loading/LoadingErrorIndicator.tsx | 2 +- .../Loading/progress-indicators.tsx | 2 +- .../__tests__/AudioRecorder.test.tsx | 23 ++-- .../MediaRecorder/hooks/useMediaRecorder.ts | 2 +- src/components/Message/Message.tsx | 4 +- src/components/Message/MessageBlocked.tsx | 2 +- .../Message/MessageEditedIndicator.tsx | 6 +- .../Message/MessageRepliesCountButton.tsx | 12 +- src/components/Message/MessageStatus.tsx | 6 +- src/components/Message/MessageText.tsx | 4 +- src/components/Message/MessageTimestamp.tsx | 4 +- .../Message/MessageTranslationIndicator.tsx | 2 +- src/components/Message/MessageUI.tsx | 6 +- src/components/Message/QuotedMessage.tsx | 2 +- .../Message/StreamedMessageText.tsx | 2 +- src/components/Message/Timestamp.tsx | 10 +- .../Message/hooks/useDeleteHandler.ts | 4 +- .../Message/hooks/useFlagHandler.ts | 4 +- .../useMessageAlsoSentInChannelNavigation.ts | 2 +- .../Message/hooks/useMuteHandler.ts | 4 +- src/components/Message/hooks/usePinHandler.ts | 4 +- .../Message/hooks/useReactionHandler.ts | 5 +- .../Message/hooks/useReactionsFetcher.ts | 2 +- src/components/Message/hooks/useUserRole.ts | 2 +- .../FileAttachmentPreview.tsx | 2 +- .../MessageComposer/MessageComposer.tsx | 5 +- .../MessageComposer/MessageComposerUI.tsx | 8 +- .../MessageComposer/WithDragAndDropUpload.tsx | 5 +- .../__tests__/WithDragAndDropUpload.test.tsx | 106 ++++++++++++++++++ .../MessageComposer/hooks/useSendMessageFn.ts | 2 +- .../hooks/useUpdateMessageFn.ts | 2 +- src/components/MessageComposer/icons.tsx | 2 +- .../MessageList/FloatingDateSeparator.tsx | 4 +- src/components/MessageList/MessageList.tsx | 4 +- .../UnreadMessagesNotification.tsx | 4 +- .../MessageList/UnreadMessagesSeparator.tsx | 4 +- .../MessageList/VirtualizedMessageList.tsx | 4 +- .../VirtualizedMessageListComponents.tsx | 8 +- .../hooks/MessageList/useEnrichedMessages.ts | 4 +- .../MessageList/useMessageListElements.tsx | 4 +- .../useMessageListScrollManager.ts | 2 +- .../VirtualizedMessageList/useGiphyPreview.ts | 2 +- .../Poll/PollActions/PollActions.tsx | 4 +- .../PollCreationDialog/OptionFieldSet.tsx | 2 +- .../PollCreationDialogControls.tsx | 2 +- src/components/Poll/PollHeader.tsx | 2 +- src/components/Poll/PollOptionList.tsx | 2 +- .../ReactFileUtilities/UploadButton.tsx | 2 +- src/components/Reactions/MessageReactions.tsx | 4 +- .../Reactions/MessageReactionsDetail.tsx | 4 +- src/components/Reactions/ReactionSelector.tsx | 14 +-- .../Reactions/ReactionSelectorWithButton.tsx | 7 +- .../__tests__/MessageReactions.test.tsx | 3 - .../__tests__/MessageReactionsDetail.test.tsx | 5 - .../Reactions/hooks/useFetchReactions.ts | 5 +- .../Reactions/hooks/useProcessReactions.tsx | 6 +- src/components/Search/SearchContext.tsx | 7 +- .../Search/SearchResults/SearchResults.tsx | 2 +- .../Search/SearchSourceResultsContext.tsx | 11 +- .../Search/hooks/useSearchFocusedMessage.ts | 2 +- .../hooks/useLatestMessagePreview.ts | 4 +- .../TextareaComposer/TextareaComposer.tsx | 2 +- src/components/Thread/Thread.tsx | 4 +- src/components/Thread/ThreadHead.tsx | 2 +- src/components/Thread/ThreadHeader.tsx | 2 +- src/components/Thread/ThreadStart.tsx | 2 +- .../Threads/ThreadList/ThreadList.tsx | 2 +- .../ThreadList/ThreadListEmptyPlaceholder.tsx | 2 +- .../Threads/ThreadList/ThreadListItemUI.tsx | 2 +- .../TypingIndicator/TypingIndicator.tsx | 2 +- .../TypingIndicator/TypingIndicatorHeader.tsx | 2 +- src/context/ChannelInstanceContext.tsx | 20 ++-- src/context/ChatContext.tsx | 16 +-- src/context/ComponentContext.tsx | 14 +-- src/context/DialogManagerContext.tsx | 8 +- src/context/MessageBounceContext.tsx | 21 ++-- src/context/MessageComposerContext.tsx | 32 +++--- src/context/MessageContext.tsx | 15 +-- src/context/MessageListContext.tsx | 20 ++-- src/context/MessageTranslationViewContext.tsx | 8 +- src/context/ModalContext.tsx | 21 ++-- src/context/PollContext.tsx | 7 +- src/context/TranslationContext.tsx | 20 ++-- src/context/VirtualizedMessageListContext.tsx | 7 +- .../missingProviderContract.test.tsx | 104 +++++++++++++++++ src/context/requireContext.ts | 25 +++++ src/context/useChannel.ts | 3 +- src/i18n/keys.ts | 1 + src/i18n/runtimeDefaults.ts | 4 + .../SectionNavigatorHeader.tsx | 2 +- .../ChannelFilesEmptyList.tsx | 2 +- .../ChannelFilesView/ChannelFilesView.tsx | 2 +- .../ChannelMediaEmptyList.tsx | 2 +- .../ChannelMediaView/ChannelMediaView.tsx | 4 +- .../ChannelMediaView.utils.ts | 8 +- .../PinnedMessagesView/PinnedMessagesView.tsx | 2 +- src/plugins/Emojis/EmojiPicker.tsx | 4 +- src/plugins/SlotLayout/ChatView.tsx | 38 ++----- .../SlotLayout/workspaceNavigationAdapter.tsx | 2 +- 134 files changed, 728 insertions(+), 466 deletions(-) create mode 100644 src/components/MessageComposer/__tests__/WithDragAndDropUpload.test.tsx create mode 100644 src/context/__tests__/missingProviderContract.test.tsx create mode 100644 src/context/requireContext.ts diff --git a/src/components/Accessibility/NotificationAnnouncer.tsx b/src/components/Accessibility/NotificationAnnouncer.tsx index 7d91d1c523..6f543da2bb 100644 --- a/src/components/Accessibility/NotificationAnnouncer.tsx +++ b/src/components/Accessibility/NotificationAnnouncer.tsx @@ -60,8 +60,7 @@ const defaultNotificationFilter: NotificationAnnouncementFilter = () => true; * satisfies this and no extra setup is needed. * * Rendered **without** a provider ancestor it mounts successfully but **announces nothing** — - * `useAriaLiveAnnouncer()` returns a no-op and logs a `console.warn` ("… called outside of an - * AriaLiveAnnouncerProvider"). This is intentional post-F4: the component no longer carries a + * `useAriaLiveAnnouncer()` returns a no-op. This is intentional post-F4: the component no longer carries a * fallback live region, so a single announcer owns all output and there are no duplicate/competing * regions. If you render it outside `Chat`, wrap it in `AriaLiveAnnouncerProvider` + `AriaLiveOutlet` * yourself. diff --git a/src/components/Accessibility/hooks/useIncomingMessageAnnouncements.ts b/src/components/Accessibility/hooks/useIncomingMessageAnnouncements.ts index 0f2d3375bd..ca4fe5e860 100644 --- a/src/components/Accessibility/hooks/useIncomingMessageAnnouncements.ts +++ b/src/components/Accessibility/hooks/useIncomingMessageAnnouncements.ts @@ -51,7 +51,7 @@ export const useIncomingMessageAnnouncements = ({ threadList = false, }: UseIncomingMessageAnnouncementsParams) => { const announce = useAriaLiveAnnouncer(); - const { t } = useTranslationContext('useIncomingMessageAnnouncements'); + const { t } = useTranslationContext(); const lastAnnouncementTimestampRef = useRef(0); const flushTimeoutRef = useRef | undefined>(undefined); const announcedMessageIdsRef = useRef(new Set()); diff --git a/src/components/Accessibility/useAriaLiveAnnouncer.ts b/src/components/Accessibility/useAriaLiveAnnouncer.ts index db4448486a..3a6eb35f42 100644 --- a/src/components/Accessibility/useAriaLiveAnnouncer.ts +++ b/src/components/Accessibility/useAriaLiveAnnouncer.ts @@ -41,16 +41,9 @@ export const AriaLiveAnnouncerContext = createContext< AriaLiveAnnouncerContextValue | undefined >(undefined); -export const useAriaLiveAnnouncer = () => { - const contextValue = useContext(AriaLiveAnnouncerContext); - - if (!contextValue) { - console.warn( - 'The useAriaLiveAnnouncer hook was called outside of an AriaLiveAnnouncerProvider.', - ); - - return noopAnnounce; - } - - return contextValue.announce; -}; +/** + * Deliberately does not throw: announcements are a progressive enhancement, so outside an + * `AriaLiveAnnouncerProvider` this degrades to a no-op rather than breaking the caller. + */ +export const useAriaLiveAnnouncer = () => + useContext(AriaLiveAnnouncerContext)?.announce ?? noopAnnounce; diff --git a/src/components/Attachment/AttachmentActions.tsx b/src/components/Attachment/AttachmentActions.tsx index 87e4324ebf..ef5274ab62 100644 --- a/src/components/Attachment/AttachmentActions.tsx +++ b/src/components/Attachment/AttachmentActions.tsx @@ -46,7 +46,7 @@ const UnMemoizedAttachmentActions = (props: AttachmentActionsProps) => { title, type, } = props; - const { t } = useTranslationContext('UnMemoizedAttachmentActions'); + const { t } = useTranslationContext(); const { announceInteraction } = useInteractionAnnouncements(); const { reserve: reserveFocusReturn, restore: restoreFocusReturn } = useFocusReturn(); const buttonRefs = useRef>([]); diff --git a/src/components/Attachment/Audio.tsx b/src/components/Attachment/Audio.tsx index 8a8e3cb846..b54056a5ef 100644 --- a/src/components/Attachment/Audio.tsx +++ b/src/components/Attachment/Audio.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useContext } from 'react'; import type { Attachment, VoiceRecordingAttachment } from 'stream-chat'; import { @@ -8,7 +8,7 @@ import { import type { AudioPlayerState } from '../AudioPlayback/AudioPlayer'; import { useAudioPlayer } from '../AudioPlayback/WithAudioPlayback'; import { useStateStore } from '../../store'; -import { useComponentContext, useMessageContext } from '../../context'; +import { MessageContext, useComponentContext } from '../../context'; import type { AudioPlayer } from '../AudioPlayback/AudioPlayer'; import { PlayButton } from '../Button/PlayButton'; import { FileIcon } from '../FileIcon'; @@ -102,7 +102,9 @@ export const Audio = (props: AudioProps) => { * with the default SDK components, but can be done with custom API calls.In this case all the Audio * widgets will share the state. */ - const { message } = useMessageContext() ?? {}; + // Rendered both inside a message and standalone, so the message context is genuinely + // optional here — read the context directly rather than through the throwing hook. + const { message } = useContext(MessageContext) ?? {}; const threadInstance = useThreadContext(); const audioPlayer = useAudioPlayer({ diff --git a/src/components/Attachment/LinkPreview/CardAudio.tsx b/src/components/Attachment/LinkPreview/CardAudio.tsx index 1657704d9c..2e4f2c1a3d 100644 --- a/src/components/Attachment/LinkPreview/CardAudio.tsx +++ b/src/components/Attachment/LinkPreview/CardAudio.tsx @@ -1,9 +1,9 @@ import { type AudioPlayerState, ProgressBar, useAudioPlayer } from '../../AudioPlayback'; -import { useMessageContext } from '../../../context'; +import { MessageContext } from '../../../context'; import { useStateStore } from '../../../store'; import { PlayButton } from '../../Button'; import type { AudioProps } from '../Audio'; -import React from 'react'; +import React, { useContext } from 'react'; import { IconLink } from '../../Icons'; import { SafeAnchor } from '../../SafeAnchor'; import type { CardProps } from './Card'; @@ -56,7 +56,9 @@ const AudioWidget = ({ mimeType, src }: { src: string; mimeType?: string }) => { * with the default SDK components, but can be done with custom API calls.In this case all the Audio * widgets will share the state. */ - const { message } = useMessageContext() ?? {}; + // Rendered both inside a message and standalone, so the message context is genuinely + // optional here — read the context directly rather than through the throwing hook. + const { message } = useContext(MessageContext) ?? {}; const threadInstance = useThreadContext(); const audioPlayer = useAudioPlayer({ diff --git a/src/components/Attachment/LinkPreview/UnableToRenderCard.tsx b/src/components/Attachment/LinkPreview/UnableToRenderCard.tsx index 7c559c8388..d1a429aa44 100644 --- a/src/components/Attachment/LinkPreview/UnableToRenderCard.tsx +++ b/src/components/Attachment/LinkPreview/UnableToRenderCard.tsx @@ -4,7 +4,7 @@ import clsx from 'clsx'; import React from 'react'; export const UnableToRenderCard = ({ type }: { type?: Attachment['type'] }) => { - const { t } = useTranslationContext('Card'); + const { t } = useTranslationContext(); return (
+ items.map((item) => ({ + ...item, + createdAt: item.createdAt ?? message?.created_at, + user: item.user ?? message?.user ?? undefined, + })), + [items, message?.created_at, message?.user], + ); const usesDefaultBaseImage = BaseImage === DefaultBaseImage; const closeModal = useCallback(() => { @@ -114,7 +133,7 @@ export const ModalGallery = ({ closeOnBackgroundClick={closeOnBackgroundClick} GalleryUI={GalleryUI} initialIndex={selectedIndex} - items={items} + items={itemsWithSender} onRequestClose={closeModal} /> diff --git a/src/components/Attachment/UnsupportedAttachment.tsx b/src/components/Attachment/UnsupportedAttachment.tsx index fd2c4dd712..66adeb5b9c 100644 --- a/src/components/Attachment/UnsupportedAttachment.tsx +++ b/src/components/Attachment/UnsupportedAttachment.tsx @@ -8,7 +8,7 @@ export type UnsupportedAttachmentProps = { }; export const UnsupportedAttachment = () => { - const { t } = useTranslationContext('UnsupportedAttachment'); + const { t } = useTranslationContext(); return (
{ - const { t } = useTranslationContext('ProgressBar'); + const { t } = useTranslationContext(); const { handleDrag, handleDragStart, diff --git a/src/components/AudioPlayback/components/WaveProgressBar.tsx b/src/components/AudioPlayback/components/WaveProgressBar.tsx index ce11b8cd6c..4fee30ae69 100644 --- a/src/components/AudioPlayback/components/WaveProgressBar.tsx +++ b/src/components/AudioPlayback/components/WaveProgressBar.tsx @@ -37,7 +37,7 @@ export const WaveProgressBar = ({ seek, waveformData, }: WaveProgressBarProps) => { - const { t } = useTranslationContext('WaveProgressBar'); + const { t } = useTranslationContext(); const [trackAxisX, setTrackAxisX] = useState<{ barCount: number; barWidth: number; diff --git a/src/components/Avatar/AvatarStack.tsx b/src/components/Avatar/AvatarStack.tsx index b04d2ebf20..0165325b74 100644 --- a/src/components/Avatar/AvatarStack.tsx +++ b/src/components/Avatar/AvatarStack.tsx @@ -17,7 +17,7 @@ export function AvatarStack({ badgeSize?: BadgeSize; capLimit?: number; }) { - const { Avatar = DefaultAvatar } = useComponentContext(AvatarStack.name); + const { Avatar = DefaultAvatar } = useComponentContext(); const displayInfoToRender = useMemo( () => (displayInfo.length > capLimit ? displayInfo.slice(0, capLimit) : displayInfo), diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 5547bfba48..a3e09a5fc8 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -154,9 +154,8 @@ const ChannelInner = ( const { LoadingErrorIndicator, LoadingIndicator = DefaultLoadingIndicator } = useComponentContext(); - const { client, latestMessageDatesByChannels, searchController } = - useChatContext('Channel'); - const { t } = useTranslationContext('Channel'); + const { client, latestMessageDatesByChannels, searchController } = useChatContext(); + const { t } = useTranslationContext(); const windowsEmojiClass = useImageFlagEmojisOnWindowsClass(); const channelConfig = useChannelConfig({ cid: channel.cid }); diff --git a/src/components/Channel/hooks/useChannelConfig.ts b/src/components/Channel/hooks/useChannelConfig.ts index e430dd9040..c25286ca71 100644 --- a/src/components/Channel/hooks/useChannelConfig.ts +++ b/src/components/Channel/hooks/useChannelConfig.ts @@ -8,7 +8,7 @@ const channelConfigsSelector = (value: ChannelConfigsState) => ({ // todo: why is channel config stored on client? export const useChannelConfig = ({ cid }: { cid: string | undefined }) => { - const { client } = useChatContext('useChannelConfig'); + const { client } = useChatContext(); const channelConfigsState = useStateStore(client.configsStore, channelConfigsSelector); if (!cid) return undefined; diff --git a/src/components/Channel/hooks/useChannelContainerClasses.ts b/src/components/Channel/hooks/useChannelContainerClasses.ts index a717078450..14fceaa45b 100644 --- a/src/components/Channel/hooks/useChannelContainerClasses.ts +++ b/src/components/Channel/hooks/useChannelContainerClasses.ts @@ -2,7 +2,7 @@ import { useChatContext } from '../../../context/ChatContext'; import type { ChatContextValue } from '../../../context/ChatContext'; export const useImageFlagEmojisOnWindowsClass = () => { - const { useImageFlagEmojisOnWindows } = useChatContext('Channel'); + const { useImageFlagEmojisOnWindows } = useChatContext(); return useImageFlagEmojisOnWindows && navigator.userAgent.match(/Win/) ? 'str-chat--windows-flags' : ''; diff --git a/src/components/ChannelHeader/ChannelHeader.tsx b/src/components/ChannelHeader/ChannelHeader.tsx index 6eb42ce0aa..14d03c9a60 100644 --- a/src/components/ChannelHeader/ChannelHeader.tsx +++ b/src/components/ChannelHeader/ChannelHeader.tsx @@ -16,7 +16,7 @@ const textComposerTypingSelector = ({ typing }: TextComposerState) => ({ typing const ChannelHeaderSubtitle = () => { const channel = useChannel(); const channelConfig = useChannelConfig({ cid: channel.cid }); - const { client } = useChatContext('ChannelHeaderSubtitle'); + const { client } = useChatContext(); const messageComposer = useMessageComposerController(); const { typing = {} } = useStateStore(messageComposer.textComposer?.state, textComposerTypingSelector) ?? {}; diff --git a/src/components/ChannelListItem/ChannelListItem.tsx b/src/components/ChannelListItem/ChannelListItem.tsx index 75e5d3a1f0..a04830282b 100644 --- a/src/components/ChannelListItem/ChannelListItem.tsx +++ b/src/components/ChannelListItem/ChannelListItem.tsx @@ -22,6 +22,7 @@ import { useWorkspaceNavigation, } from '../../context'; import { useChannelMembershipState } from '../ChannelList'; +import { requireContext } from '../../context/requireContext'; export type ChannelListItemUIProps = ChannelListItemProps & { /** @@ -73,11 +74,16 @@ export type ChannelListItemProps = { watchers?: { limit?: number; offset?: number }; }; -const ChannelListItemContext = React.createContext<{ channel: Channel }>({ - channel: null as unknown as Channel, -}); +const ChannelListItemContext = React.createContext<{ channel: Channel } | undefined>( + undefined, +); -export const useChannelListItemContext = () => useContext(ChannelListItemContext); +export const useChannelListItemContext = () => + requireContext( + useContext(ChannelListItemContext), + 'useChannelListItemContext', + 'ChannelListItemUI', + ); const lastMessageSelector = ({ lastMessage }: MessagePaginatorAggregateState) => ({ lastMessage: lastMessage ?? undefined, @@ -86,7 +92,7 @@ const lastMessageSelector = ({ lastMessage }: MessagePaginatorAggregateState) => export const ChannelListItem = (props: ChannelListItemProps) => { const { active, channel, channelUpdateCount } = props; const { ChannelListItemUI = DefaultChannelListItemUI } = useComponentContext(); - const { client } = useChatContext('ChannelPreview'); + const { client } = useChatContext(); // Active = THIS channel is currently open in the workspace. Keyed on the channel's own // cid (never "the first channel slot"), so multiple open channels each highlight independently. const channelOpenInSlot = useWorkspaceNavigation().isChannelActive( diff --git a/src/components/ChannelListItem/ChannelListItemTimestamp.tsx b/src/components/ChannelListItem/ChannelListItemTimestamp.tsx index 4ed9161f46..9518c94f59 100644 --- a/src/components/ChannelListItem/ChannelListItemTimestamp.tsx +++ b/src/components/ChannelListItem/ChannelListItemTimestamp.tsx @@ -12,7 +12,7 @@ export type ChannelListItemTimestampProps = { export function ChannelListItemTimestamp({ previewedMessage, }: ChannelListItemTimestampProps) { - const { t, tDateTimeParser } = useTranslationContext('ChannelListItemTimestamp'); + const { t, tDateTimeParser } = useTranslationContext(); const timestamp = previewedMessage?.created_at; const normalizedTimestamp = diff --git a/src/components/ChannelListItem/ChannelListItemUI.tsx b/src/components/ChannelListItem/ChannelListItemUI.tsx index 977e97f7c5..414bb657ae 100644 --- a/src/components/ChannelListItem/ChannelListItemUI.tsx +++ b/src/components/ChannelListItem/ChannelListItemUI.tsx @@ -40,8 +40,8 @@ const UnMemoizedChannelListItemUI = (props: ChannelListItemUIProps) => { ChannelListItemActionButtons = DefaultChannelListItemActionButtons, SummarizedMessagePreview = DefaultSummarizedMessagePreview, } = useComponentContext(); - const { client, isMessageAIGenerated } = useChatContext('ChannelListItemUI'); - const { t, tDateTimeParser, userLanguage } = useTranslationContext('ChannelListItemUI'); + const { client, isMessageAIGenerated } = useChatContext(); + const { t, tDateTimeParser, userLanguage } = useTranslationContext(); const { openChannel } = useWorkspaceNavigation(); const { announceInteraction } = useInteractionAnnouncements(); diff --git a/src/components/ChannelListItem/hooks/useChannelDisplayName.ts b/src/components/ChannelListItem/hooks/useChannelDisplayName.ts index 20139c251d..c08470a59e 100644 --- a/src/components/ChannelListItem/hooks/useChannelDisplayName.ts +++ b/src/components/ChannelListItem/hooks/useChannelDisplayName.ts @@ -45,8 +45,8 @@ function computeChannelDisplayName( export const useChannelDisplayName = ( channel: Channel | undefined, ): string | undefined => { - const { client } = useChatContext('useChannelDisplayName'); - const { t } = useTranslationContext('useChannelDisplayName'); + const { client } = useChatContext(); + const { t } = useTranslationContext(); const directMessageLabel = t( 'channelListItem.channelDisplayName.directMessage.label', 'Direct message', diff --git a/src/components/ChannelListItem/hooks/useIsChannelMuted.ts b/src/components/ChannelListItem/hooks/useIsChannelMuted.ts index 88d2c15ee4..925f38ce9b 100644 --- a/src/components/ChannelListItem/hooks/useIsChannelMuted.ts +++ b/src/components/ChannelListItem/hooks/useIsChannelMuted.ts @@ -20,7 +20,7 @@ const getMuteStatus = (channel: Channel) => : { createdAt: null, expiresAt: null, muted: false }; export const useIsChannelMuted = (channel: Channel) => { - const { client } = useChatContext('useIsChannelMuted'); + const { client } = useChatContext(); const [muted, setMuted] = useState(() => getMuteStatus(channel)); diff --git a/src/components/DateSeparator/DateSeparator.tsx b/src/components/DateSeparator/DateSeparator.tsx index d130406cfd..95a913b784 100644 --- a/src/components/DateSeparator/DateSeparator.tsx +++ b/src/components/DateSeparator/DateSeparator.tsx @@ -32,7 +32,7 @@ const UnMemoizedDateSeparator = (props: DateSeparatorProps) => { ...restTimestampFormatterOptions } = props; - const { t, tDateTimeParser } = useTranslationContext('DateSeparator'); + const { t, tDateTimeParser } = useTranslationContext(); const formattedDate = getDateString({ calendar, diff --git a/src/components/Dialog/components/ContextMenu.tsx b/src/components/Dialog/components/ContextMenu.tsx index 5f35cb33bc..012f07a791 100644 --- a/src/components/Dialog/components/ContextMenu.tsx +++ b/src/components/Dialog/components/ContextMenu.tsx @@ -22,6 +22,7 @@ import { useComponentContext, useTranslationContext } from '../../../context'; import { createRovingFocusKeyDownHandler } from '../../../a11y/a11yUtils'; import { VisuallyHidden } from '../../VisuallyHidden'; import { useStableId } from '../../UtilityComponents/useStableId'; +import { requireContext } from '../../../context/requireContext'; /** * ContextMenu module @@ -589,7 +590,7 @@ const ContextMenuContext = React.createContext - useContext(ContextMenuContext) as ContextMenuContextValue; + requireContext(useContext(ContextMenuContext), 'useContextMenuContext', 'ContextMenu'); type ContextMenuLevel = { focusRestoreRequest?: ContextMenuFocusRestoreRequest; diff --git a/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx b/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx index 79d7bb63e1..b4fe2ff013 100644 --- a/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx +++ b/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx @@ -13,7 +13,7 @@ export type EmptyStateIndicatorProps = { const UnMemoizedEmptyStateIndicator = (props: EmptyStateIndicatorProps) => { const { listType, messageText } = props; - const { t } = useTranslationContext('EmptyStateIndicator'); + const { t } = useTranslationContext(); if (listType === 'thread') return null; diff --git a/src/components/Form/SwitchField.tsx b/src/components/Form/SwitchField.tsx index ff879c8e05..942a150db4 100644 --- a/src/components/Form/SwitchField.tsx +++ b/src/components/Form/SwitchField.tsx @@ -51,7 +51,7 @@ export const SwitchField = ({ // Read the announcer optionally: a SwitchField may render outside an announcer provider (e.g. in // isolation), where it simply stays silent rather than warning. const announce = useContext(AriaLiveAnnouncerContext)?.announce; - const { t } = useTranslationContext('SwitchField'); + const { t } = useTranslationContext(); const [uncontrolledChecked, setUncontrolledChecked] = useState(Boolean(defaultChecked)); const isControlled = checked !== undefined; diff --git a/src/components/Gallery/GalleryContext.tsx b/src/components/Gallery/GalleryContext.tsx index c7d1c081cc..a03adb1ba2 100644 --- a/src/components/Gallery/GalleryContext.tsx +++ b/src/components/Gallery/GalleryContext.tsx @@ -1,12 +1,18 @@ import { createContext, useContext } from 'react'; +import type { UserResponse } from 'stream-chat'; +import { requireContext } from '../../context/requireContext'; import { toBaseImageDescriptors } from '../BaseImage'; import type { BaseImageProps } from '../BaseImage'; import type { Dimensions } from '../../types/types'; export type GalleryItem = Omit & { + /** When the media was shared; drives the gallery header timestamp. */ + createdAt?: string | Date; dimensions?: Dimensions; imageUrl?: string; + /** User who shared the media; drives the gallery header title. */ + user?: UserResponse; videoThumbnailUrl?: string; videoUrl?: string; }; @@ -51,16 +57,5 @@ export type GalleryContextValue = { export const GalleryContext = createContext(undefined); -export const useGalleryContext = () => { - const contextValue = useContext(GalleryContext); - - if (!contextValue) { - console.warn( - `The useGalleryContext hook was called outside of the GalleryContext provider. Make sure this hook is called within a child of the Gallery component.`, - ); - - return {} as GalleryContextValue; - } - - return contextValue; -}; +export const useGalleryContext = () => + requireContext(useContext(GalleryContext), 'useGalleryContext', 'Gallery'); diff --git a/src/components/Gallery/GalleryHeader.tsx b/src/components/Gallery/GalleryHeader.tsx index 0c029b7777..3300a96d89 100644 --- a/src/components/Gallery/GalleryHeader.tsx +++ b/src/components/Gallery/GalleryHeader.tsx @@ -4,28 +4,56 @@ import { sanitizeUrl } from '@braintree/sanitize-url'; import { type GalleryItem } from './GalleryContext'; import { Button } from '../Button'; import { IconArrowDownCircle, IconXmark } from '../Icons'; -import { MessageTimestamp as DefaultMessageTimestamp } from '../Message/MessageTimestamp'; -import { - ModalContext, - useComponentContext, - useMessageContext, - useTranslationContext, -} from '../../context'; +import { ModalContext, useChatContext, useTranslationContext } from '../../context'; +import { getDateString, isDate } from '../../i18n/utils'; type GalleryHeaderProps = { currentItem: GalleryItem; }; +const normalizeTimestamp = (timestamp: GalleryItem['createdAt']) => { + if (!timestamp) return undefined; + return isDate(timestamp) ? timestamp.toISOString() : timestamp; +}; + +/** + * The gallery renders outside any `MessageProvider` — channel media flattens many messages into a + * single item list — so the timestamp is derived from the item rather than from message context. + */ +const GalleryTimestamp = ({ createdAt }: Pick) => { + const { t, tDateTimeParser } = useTranslationContext(); + const normalizedTimestamp = normalizeTimestamp(createdAt); + + const when = useMemo( + () => + getDateString({ + messageCreatedAt: normalizedTimestamp, + t, + tDateTimeParser, + timestampTranslationKey: 'timestamp.GalleryTimestamp', + }), + [normalizedTimestamp, t, tDateTimeParser], + ); + + if (!when) return null; + + return ( + + ); +}; + export const GalleryHeader = ({ currentItem }: GalleryHeaderProps) => { const { t } = useTranslationContext(); - const { MessageTimestamp = DefaultMessageTimestamp } = useComponentContext('GalleryUI'); - const { isMyMessage, message } = useMessageContext('GalleryUI'); + const { client } = useChatContext(); const modalContext = useContext(ModalContext); + const { createdAt, user } = currentItem; const headerTitle = - (isMyMessage?.() && t('common.you.label', 'You')) || - message?.user?.name || - message?.user?.id || + (!!user?.id && user.id === client.userID && t('common.you.label', 'You')) || + user?.name || + user?.id || currentItem.title || t('common.userUploadedContent.label', 'User uploaded content'); const downloadUrl = useMemo(() => { @@ -44,9 +72,7 @@ export const GalleryHeader = ({ currentItem }: GalleryHeaderProps) => {