diff --git a/src/components/Attachment/Geolocation.tsx b/src/components/Attachment/Geolocation.tsx index c58974217..4752cf07f 100644 --- a/src/components/Attachment/Geolocation.tsx +++ b/src/components/Attachment/Geolocation.tsx @@ -3,9 +3,13 @@ import { useEffect } from 'react'; import { useRef, useState } from 'react'; import React from 'react'; import type { Coords, SharedLocationResponseData } from 'stream-chat'; -import { useChannel, useChatContext, useTranslationContext } from '../../context'; +import { + useChannel, + useChatContext, + useComponentContextIcons, + useTranslationContext, +} from '../../context'; import { ExternalLinkIcon } from './icons'; -import { IconLocation } from '../Icons'; import { Button } from '../Button'; import { convertTimestampToDate, nowNs, nsToMs } from 'stream-chat'; @@ -141,6 +145,7 @@ export type GeolocationAttachmentMapPlaceholderProps = { const DefaultGeolocationAttachmentMapPlaceholder = ({ location, }: GeolocationAttachmentMapPlaceholderProps) => { + const { IconLocation } = useComponentContextIcons(); const { t } = useTranslationContext(); return ( diff --git a/src/components/Attachment/Giphy.tsx b/src/components/Attachment/Giphy.tsx index f226c71dd..5fe007726 100644 --- a/src/components/Attachment/Giphy.tsx +++ b/src/components/Attachment/Giphy.tsx @@ -3,8 +3,11 @@ import { BaseImage as DefaultBaseImage } from '../BaseImage'; import { toGalleryItemDescriptors } from '../Gallery'; import { getGiphyDescriptiveTitle } from './giphyAccessibility'; import clsx from 'clsx'; -import { useComponentContext, useTranslationContext } from '../../context'; -import { IconGiphy } from '../Icons'; +import { + useComponentContext, + useComponentContextIcons, + useTranslationContext, +} from '../../context'; import { type CSSProperties, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { type ImageAttachmentConfiguration, @@ -79,9 +82,13 @@ export const Giphy = ({ attachment }: GiphyAttachmentProps) => { ); }; -const GiphyBadge = () => ( -
- - Giphy -
-); +const GiphyBadge = () => { + const { IconGiphy } = useComponentContextIcons(); + + return ( +
+ + Giphy +
+ ); +}; diff --git a/src/components/Attachment/LinkPreview/Card.tsx b/src/components/Attachment/LinkPreview/Card.tsx index a660ba6a3..979c08fa8 100644 --- a/src/components/Attachment/LinkPreview/Card.tsx +++ b/src/components/Attachment/LinkPreview/Card.tsx @@ -6,9 +6,9 @@ import { useAttachmentContext } from '../../../context/AttachmentContext'; import type { Attachment } from 'stream-chat'; import type { RenderAttachmentProps } from '../utils'; import type { Dimensions } from '../../../types/types'; -import { IconLink } from '../../Icons'; import { UnableToRenderCard } from './UnableToRenderCard'; import clsx from 'clsx'; +import { useComponentContextIcons } from '../../../context'; type CardRootProps = { cardUrl: string | undefined; @@ -62,6 +62,7 @@ const CardHeader = (props: CardHeaderProps) => { type CardContentProps = RenderAttachmentProps['attachment']; const CardContent = (props: CardContentProps) => { + const { IconLink } = useComponentContextIcons(); const { og_scrape_url, text, title, title_link } = props; const url = title_link || og_scrape_url; diff --git a/src/components/Attachment/LinkPreview/CardAudio.tsx b/src/components/Attachment/LinkPreview/CardAudio.tsx index 28c163b99..4941b40b1 100644 --- a/src/components/Attachment/LinkPreview/CardAudio.tsx +++ b/src/components/Attachment/LinkPreview/CardAudio.tsx @@ -4,7 +4,7 @@ import { useStateStore } from '../../../store'; import { PlayButton } from '../../Button'; import type { AudioProps } from '../Audio'; import React, { useContext } from 'react'; -import { IconLink } from '../../Icons'; +import { useComponentContextIcons } from '../../../context'; import { SafeAnchor } from '../../SafeAnchor'; import type { CardProps } from './Card'; import { useThreadContext } from '../../Threads'; @@ -22,22 +22,26 @@ const SourceLink = ({ author_name, showUrl, url, -}: Pick & { url: string; showUrl?: boolean }) => ( -
- - & { url: string; showUrl?: boolean }) => { + const { IconLink } = useComponentContextIcons(); + + return ( +
- {showUrl ? url : author_name || getHostFromURL(url)} - -
-); + + + {showUrl ? url : author_name || getHostFromURL(url)} + +
+ ); +}; const audioPlayerStateSelector = (state: AudioPlayerState) => ({ durationSeconds: state.durationSeconds, diff --git a/src/components/Attachment/ModalGallery.tsx b/src/components/Attachment/ModalGallery.tsx index 1e906f3c5..3e64782ad 100644 --- a/src/components/Attachment/ModalGallery.tsx +++ b/src/components/Attachment/ModalGallery.tsx @@ -11,9 +11,9 @@ import { GlobalModal, type ModalCloseSource } from '../Modal'; import { MessageContext, useComponentContext, + useComponentContextIcons, useTranslationContext, } from '../../context'; -import { IconRetry } from '../Icons'; import { VideoThumbnail } from '../VideoPlayer/VideoThumbnail'; const MAX_VISIBLE_THUMBNAILS = 4; @@ -160,6 +160,7 @@ const ThumbnailButton = ({ overflowCount, showOverlay, }: ThumbnailButtonProps) => { + const { IconRetry } = useComponentContextIcons(); const { t } = useTranslationContext(); const imageUrl = item.imageUrl; const [isLoadFailed, setIsLoadFailed] = useState(false); diff --git a/src/components/Attachment/UnsupportedAttachment.tsx b/src/components/Attachment/UnsupportedAttachment.tsx index 66adeb5b9..0cdd2732c 100644 --- a/src/components/Attachment/UnsupportedAttachment.tsx +++ b/src/components/Attachment/UnsupportedAttachment.tsx @@ -1,13 +1,13 @@ import React from 'react'; import type { Attachment } from 'stream-chat'; -import { useTranslationContext } from '../../context'; -import { IconUnsupportedAttachment } from '../Icons'; +import { useComponentContextIcons, useTranslationContext } from '../../context'; export type UnsupportedAttachmentProps = { attachment: Attachment; }; export const UnsupportedAttachment = () => { + const { IconUnsupportedAttachment } = useComponentContextIcons(); const { t } = useTranslationContext(); return (
{ + const { IconEyeFill } = useComponentContextIcons(); const { t } = useTranslationContext(); return (
diff --git a/src/components/Attachment/__tests__/Giphy.test.tsx b/src/components/Attachment/__tests__/Giphy.test.tsx index 27eeb75da..ce248905c 100644 --- a/src/components/Attachment/__tests__/Giphy.test.tsx +++ b/src/components/Attachment/__tests__/Giphy.test.tsx @@ -11,31 +11,37 @@ const { channelStateMock } = vi.hoisted(() => ({ }, })); -vi.mock('../../../context', () => ({ - useChannelStateContext: () => channelStateMock, - useComponentContext: () => ({}), - useTranslationContext: () => ({ - t: (key: string, second?: unknown, third?: unknown) => { - const defaultValue = typeof second === 'string' ? second : undefined; - const options = ((typeof second === 'object' ? second : third) ?? {}) as Record< - string, - unknown - >; - let template = defaultValue; - if (template === undefined && typeof options.count === 'number') { - template = ( - options.count === 1 ? options.defaultValue_one : options.defaultValue_other - ) as string | undefined; - } - template ??= options.defaultValue as string | undefined; - template ??= key; - return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (whole, name: string) => { - const value = options[name]; - return value === undefined || value === null ? whole : String(value); - }); - }, - }), -})); +vi.mock('../../../context', async (importOriginal) => { + const actual = await importOriginal(); + return { + useChannelStateContext: () => channelStateMock, + useComponentContext: () => ({}), + // The real hook: with no provider it returns the SDK icons, which is what these + // assertions are written against. + useComponentContextIcons: actual.useComponentContextIcons, + useTranslationContext: () => ({ + t: (key: string, second?: unknown, third?: unknown) => { + const defaultValue = typeof second === 'string' ? second : undefined; + const options = ((typeof second === 'object' ? second : third) ?? {}) as Record< + string, + unknown + >; + let template = defaultValue; + if (template === undefined && typeof options.count === 'number') { + template = ( + options.count === 1 ? options.defaultValue_one : options.defaultValue_other + ) as string | undefined; + } + template ??= options.defaultValue as string | undefined; + template ??= key; + return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (whole, name: string) => { + const value = options[name]; + return value === undefined || value === null ? whole : String(value); + }); + }, + }), + }; +}); describe('Giphy accessible name', () => { it('uses the giphy title as the image accessible name', () => { diff --git a/src/components/Attachment/components/DownloadButton.tsx b/src/components/Attachment/components/DownloadButton.tsx index a58b199a3..bbf938b99 100644 --- a/src/components/Attachment/components/DownloadButton.tsx +++ b/src/components/Attachment/components/DownloadButton.tsx @@ -2,8 +2,7 @@ import React from 'react'; import clsx from 'clsx'; import { sanitizeUrl } from '@braintree/sanitize-url'; -import { useTranslationContext } from '../../../context'; -import { IconDownload } from '../../Icons'; +import { useComponentContextIcons, useTranslationContext } from '../../../context'; export type DownloadButtonProps = { /** Attachment asset URL (e.g. `asset_url`). */ @@ -25,6 +24,7 @@ export const DownloadButton = ({ suggestedFileName, tooltipTitle, }: DownloadButtonProps) => { + const { IconDownload } = useComponentContextIcons(); const { t } = useTranslationContext(); if (!assetUrl) return null; const href = sanitizeUrl(assetUrl); diff --git a/src/components/Avatar/Avatar.tsx b/src/components/Avatar/Avatar.tsx index 79149a909..51a8080db 100644 --- a/src/components/Avatar/Avatar.tsx +++ b/src/components/Avatar/Avatar.tsx @@ -6,7 +6,7 @@ import React, { useMemo, useState, } from 'react'; -import { IconUser } from '../Icons'; +import { useComponentContextIcons } from '../../context'; export type AvatarProps = { /** Custom icon rendered when there is no image and no initials */ @@ -51,7 +51,7 @@ const getInitials = (name?: string) => { */ export const Avatar = ({ className, - FallbackIcon = IconUser, + FallbackIcon, imageUrl, initials: customInitials, isOnline, @@ -59,6 +59,9 @@ export const Avatar = ({ userName, ...rest }: AvatarProps) => { + const { IconUser } = useComponentContextIcons(); + // The prop still wins over the context slot: it targets one avatar, the slot rebrands all of them. + const ResolvedFallbackIcon = FallbackIcon ?? IconUser; const [error, setError] = useState(false); useEffect(() => () => setError(false), [imageUrl]); @@ -113,7 +116,7 @@ export const Avatar = ({ {sizeAwareInitials}
)} - {!sizeAwareInitials.length && } + {!sizeAwareInitials.length && } )}
diff --git a/src/components/Badge/Badge.tsx b/src/components/Badge/Badge.tsx index 66fe564f7..2dd543c56 100644 --- a/src/components/Badge/Badge.tsx +++ b/src/components/Badge/Badge.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx'; import React, { type ComponentProps } from 'react'; -import { IconExclamationMarkFill } from '../Icons'; +import { useComponentContextIcons } from '../../context'; export type BadgeVariant = | 'default' @@ -47,8 +47,12 @@ export const ErrorBadge = ({ className, size = 'sm', ...rest -}: Omit) => ( - - - -); +}: Omit) => { + const { IconExclamationMarkFill } = useComponentContextIcons(); + + return ( + + + + ); +}; diff --git a/src/components/Badge/MediaBadge.tsx b/src/components/Badge/MediaBadge.tsx index 3301c6e96..a91560ea2 100644 --- a/src/components/Badge/MediaBadge.tsx +++ b/src/components/Badge/MediaBadge.tsx @@ -1,4 +1,4 @@ -import { IconMicrophoneSolid, IconVideoFill } from '../Icons'; +import { useComponentContextIcons } from '../../context'; import React, { type ComponentType } from 'react'; import type { LocalAttachment, LocalVoiceRecordingAttachment } from 'stream-chat'; import clsx from 'clsx'; @@ -10,12 +10,14 @@ export type MediaBadgeProps = { variant: 'video' | 'voice-recording' | string; }; -const MediaBadgeVariantToIcon: Record = { - video: IconVideoFill, - voiceRecording: IconMicrophoneSolid, -}; - export const MediaBadge = ({ attachment, variant }: MediaBadgeProps) => { + const { IconMicrophoneSolid, IconVideoFill } = useComponentContextIcons(); + + const MediaBadgeVariantToIcon: Record = { + video: IconVideoFill, + voiceRecording: IconMicrophoneSolid, + }; + const Icon = MediaBadgeVariantToIcon[variant]; const { duration } = (attachment as LocalVoiceRecordingAttachment).custom ?? {}; return ( diff --git a/src/components/BaseImage/ImagePlaceholder.tsx b/src/components/BaseImage/ImagePlaceholder.tsx index c1a5f6c6f..664262898 100644 --- a/src/components/BaseImage/ImagePlaceholder.tsx +++ b/src/components/BaseImage/ImagePlaceholder.tsx @@ -1,7 +1,7 @@ import React from 'react'; import clsx from 'clsx'; import { useTranslationContext } from '../../context/TranslationContext'; -import { IconImage } from '../Icons'; +import { useComponentContextIcons } from '../../context'; export type ImagePlaceholderProps = { className?: string; @@ -9,6 +9,8 @@ export type ImagePlaceholderProps = { export const ImagePlaceholder = ({ className }: ImagePlaceholderProps) => { const { t } = useTranslationContext(); + const { IconImage } = useComponentContextIcons(); + return (
& { isPlaying: boolean; @@ -11,6 +10,8 @@ export type PlayButtonProps = ComponentProps<'button'> & { export const PlayButton = ({ className, isPlaying, ...props }: PlayButtonProps) => { const { t } = useTranslationContext(); + const { IconPauseFill, IconPlayFill } = useComponentContextIcons(); + return ( -); +}: BaseContextMenuButtonProps) => { + const { IconChevronRight } = useComponentContextIcons(); + const ResolvedSubmenuIcon = SubmenuIcon ?? IconChevronRight; + + return ( + + ); +}; export type UserContextMenuButtonProps = Pick & ComponentProps<'button'>; @@ -674,6 +682,7 @@ export function ContextMenuContent({ ...props }: ContextMenuContentProps) { const { t } = useTranslationContext(); + const { IconChevronLeft } = useComponentContextIcons(); const resolvedBackLabel = backLabel ?? t('common.back.label', 'Back'); const { ['aria-describedby']: rootAriaDescribedBy, diff --git a/src/components/Dialog/components/Prompt.tsx b/src/components/Dialog/components/Prompt.tsx index c488878a0..c65fece88 100644 --- a/src/components/Dialog/components/Prompt.tsx +++ b/src/components/Dialog/components/Prompt.tsx @@ -1,8 +1,11 @@ import React, { type ComponentProps, type PropsWithChildren } from 'react'; import clsx from 'clsx'; import { Button, type ButtonProps } from '../../Button'; -import { IconArrowLeft, IconXmark } from '../../Icons'; -import { useModalContext, useTranslationContext } from '../../../context'; +import { + useComponentContextIcons, + useModalContext, + useTranslationContext, +} from '../../../context'; import { useAriaIdentifiers } from '../../../a11y/hooks/useAriaIdentifiers'; const PromptRoot = ({ children, className, ...props }: ComponentProps<'div'>) => ( @@ -34,6 +37,7 @@ const PromptHeader = ({ titleId, TrailingContent, }: PromptHeaderProps) => { + const { IconArrowLeft, IconXmark } = useComponentContextIcons(); const { t } = useTranslationContext(); const { dialogId } = useModalContext(); const { descriptionId: derivedDescriptionId, titleId: derivedTitleId } = diff --git a/src/components/Dialog/components/Viewer.tsx b/src/components/Dialog/components/Viewer.tsx index cf7cd93d6..83a322fae 100644 --- a/src/components/Dialog/components/Viewer.tsx +++ b/src/components/Dialog/components/Viewer.tsx @@ -1,8 +1,11 @@ import React, { type ComponentProps, type PropsWithChildren } from 'react'; import clsx from 'clsx'; import { Button, type ButtonProps } from '../../Button'; -import { IconArrowLeft, IconXmark } from '../../Icons'; -import { useModalContext, useTranslationContext } from '../../../context'; +import { + useComponentContextIcons, + useModalContext, + useTranslationContext, +} from '../../../context'; import { useAriaIdentifiers } from '../../../a11y/hooks/useAriaIdentifiers'; const ViewerRoot = ({ children, className, ...props }: ComponentProps<'div'>) => ( @@ -30,6 +33,7 @@ const ViewerHeader = ({ title, titleId, }: ViewerHeaderProps) => { + const { IconArrowLeft, IconXmark } = useComponentContextIcons(); const { t } = useTranslationContext(); const { dialogId } = useModalContext(); const { descriptionId: derivedDescriptionId, titleId: derivedTitleId } = diff --git a/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx b/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx index b4fe2ff01..cac960b14 100644 --- a/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx +++ b/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useTranslationContext } from '../../context/TranslationContext'; -import { IconMessageBubble, IconMessageBubbles } from '../Icons'; +import { useComponentContextIcons } from '../../context'; import { asDynamicKey } from '../../i18n/utils'; export type EmptyStateIndicatorProps = { @@ -14,6 +14,7 @@ const UnMemoizedEmptyStateIndicator = (props: EmptyStateIndicatorProps) => { const { listType, messageText } = props; const { t } = useTranslationContext(); + const { IconMessageBubble, IconMessageBubbles } = useComponentContextIcons(); if (listType === 'thread') return null; diff --git a/src/components/Form/NumericInput.tsx b/src/components/Form/NumericInput.tsx index fa0bd768a..2b5a32f80 100644 --- a/src/components/Form/NumericInput.tsx +++ b/src/components/Form/NumericInput.tsx @@ -1,9 +1,8 @@ import clsx from 'clsx'; import React, { forwardRef, useCallback } from 'react'; import type { ChangeEvent, ComponentProps, KeyboardEvent } from 'react'; -import { useTranslationContext } from '../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../context'; import { useStableId } from '../UtilityComponents/useStableId'; -import { IconMinus, IconPlusSmall } from '../Icons'; import { Button } from '../Button'; export type NumericInputProps = Omit< @@ -49,6 +48,7 @@ export const NumericInput = forwardRef( }, ref, ) { + const { IconMinus, IconPlusSmall } = useComponentContextIcons(); const generatedId = useStableId(); const id = idProp ?? generatedId; const { t } = useTranslationContext(); diff --git a/src/components/Form/TextInput.tsx b/src/components/Form/TextInput.tsx index cb6479f67..32695c2ae 100644 --- a/src/components/Form/TextInput.tsx +++ b/src/components/Form/TextInput.tsx @@ -2,7 +2,7 @@ import clsx from 'clsx'; import React, { forwardRef } from 'react'; import type { ComponentProps, ReactNode } from 'react'; import { useStableId } from '../UtilityComponents/useStableId'; -import { IconCheckmark, IconExclamationMark } from '../Icons'; +import { useComponentContextIcons } from '../../context'; export type TextInputVariant = 'outline' | 'ghost'; @@ -79,6 +79,7 @@ type TextInputFieldMessageProps = }; const TextInputFieldMessage = (props: TextInputFieldMessageProps) => { + const { IconCheckmark, IconExclamationMark } = useComponentContextIcons(); if (props.kind === 'neutral') { return (
) => { }; export const GalleryHeader = ({ currentItem }: GalleryHeaderProps) => { + const { IconArrowDownCircle, IconXmark } = useComponentContextIcons(); const { t } = useTranslationContext(); const { client } = useChatContext(); const modalContext = useContext(ModalContext); diff --git a/src/components/Gallery/GalleryUI.tsx b/src/components/Gallery/GalleryUI.tsx index e0a1688b9..b1b0d7604 100644 --- a/src/components/Gallery/GalleryUI.tsx +++ b/src/components/Gallery/GalleryUI.tsx @@ -4,8 +4,11 @@ import { BaseImage } from '../BaseImage'; import { GalleryHeader } from './GalleryHeader'; import { useGalleryContext } from './GalleryContext'; import { Button, type ButtonProps } from '../Button'; -import { IconChevronLeft, IconChevronRight } from '../Icons'; -import { ModalContext, useTranslationContext } from '../../context'; +import { + ModalContext, + useComponentContextIcons, + useTranslationContext, +} from '../../context'; import { VideoPlayer } from '../VideoPlayer'; import { VideoThumbnail } from '../VideoPlayer/VideoThumbnail'; @@ -15,6 +18,7 @@ const SWIPE_THRESHOLD = 50; const TRANSITION_DURATION = 300; export const GalleryUI = () => { + const { IconChevronLeft, IconChevronRight } = useComponentContextIcons(); const { t } = useTranslationContext(); const { closeOnBackgroundClick, diff --git a/src/components/Icons/index.ts b/src/components/Icons/index.ts index 01f3a3b08..e27a8fbf8 100644 --- a/src/components/Icons/index.ts +++ b/src/components/Icons/index.ts @@ -1,2 +1,3 @@ export { createIcon } from './createIcon'; +export type { IconComponent, IconName, IconSlots } from './slots'; export * from './icons'; diff --git a/src/components/Icons/slots.ts b/src/components/Icons/slots.ts new file mode 100644 index 000000000..23479af06 --- /dev/null +++ b/src/components/Icons/slots.ts @@ -0,0 +1,24 @@ +import type { ComponentType } from 'react'; + +import type { BaseIconProps } from './BaseIcon'; +import type * as icons from './icons'; + +/** + * The contract an icon override must satisfy. `BaseIconProps` rather than plain SVG props, so an + * override still accepts `decorative` and can opt out of `aria-hidden` the same way an SDK icon + * does. + */ +export type IconComponent = ComponentType; + +/** + * Every icon the SDK ships, derived from the icon module rather than hand-listed. A new icon in + * `icons.tsx` becomes overridable the moment it is exported, and a removed one stops type-checking + * at its call sites — neither can drift out of sync with this type. + */ +export type IconName = keyof typeof icons; + +/** + * Icon overrides supplied through `ComponentContext.icons`. Deep-merged with sibling entries by + * `WithComponents`, so a consumer can rebrand a single icon without clearing the others. + */ +export type IconSlots = Partial>; diff --git a/src/components/Loading/LoadingIndicator.tsx b/src/components/Loading/LoadingIndicator.tsx index 1ba631195..2c187d957 100644 --- a/src/components/Loading/LoadingIndicator.tsx +++ b/src/components/Loading/LoadingIndicator.tsx @@ -1,8 +1,13 @@ -import React, { type ComponentProps } from 'react'; -import { IconLoading } from '../Icons'; +import React from 'react'; +import { useComponentContextIcons } from '../../context'; +import type { BaseIconProps } from '../Icons/BaseIcon'; -export type LoadingIndicatorProps = ComponentProps; +// Typed off the icon contract rather than off a concrete icon: the rendered icon now comes from +// the `IconLoading` slot, which any override can replace. +export type LoadingIndicatorProps = BaseIconProps; -export const LoadingIndicator = (props: LoadingIndicatorProps) => ( - -); +export const LoadingIndicator = (props: LoadingIndicatorProps) => { + const { IconLoading } = useComponentContextIcons(); + + return ; +}; diff --git a/src/components/Location/ShareLocationDialog.tsx b/src/components/Location/ShareLocationDialog.tsx index f8af9596a..128b863ac 100644 --- a/src/components/Location/ShareLocationDialog.tsx +++ b/src/components/Location/ShareLocationDialog.tsx @@ -5,14 +5,13 @@ import React, { useMemo, useState, } from 'react'; -import { useTranslationContext } from '../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../context'; import { ContextMenuBody, ContextMenuButton, ContextMenuRoot, Prompt } from '../Dialog'; import { Dropdown, type DropdownTriggerProps, useDropdownContext, } from '../Form/Dropdown'; -import { IconChevronDown } from '../Icons'; import { useMessageComposerController } from '../MessageComposer/hooks/useMessageComposerController'; import { SwitchField } from '../Form/SwitchField'; import { useNotificationApi } from '../Notifications'; @@ -65,6 +64,7 @@ export const ShareLocationDialog = ({ GeolocationMap = DefaultGeolocationMap, shareDurations = DEFAULT_SHARE_LOCATION_DURATIONS, }: ShareLocationDialogProps) => { + const { IconChevronDown } = useComponentContextIcons(); const { addNotification } = useNotificationApi(); const { t } = useTranslationContext(); const messageComposer = useMessageComposerController(); @@ -101,7 +101,7 @@ export const ShareLocationDialog = ({ ) : null, }), - [selectedDurationLabel], + [IconChevronDown, selectedDurationLabel], ); const getPosition = useCallback( diff --git a/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx b/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx index a0c758d08..cd7dbc2e1 100644 --- a/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx +++ b/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx @@ -1,13 +1,17 @@ import { CheckSignIcon } from '../../MessageComposer/icons'; -import { IconDelete, IconPauseFill, IconVoice } from '../../Icons'; import React from 'react'; -import { useMessageComposerContext, useTranslationContext } from '../../../context'; +import { + useComponentContextIcons, + useMessageComposerContext, + useTranslationContext, +} from '../../../context'; import { isRecording } from './recordingStateIdentity'; import { Button } from '../../Button'; import { useNotificationApi } from '../../Notifications'; import { UploadProgressIndicator } from '../../Loading/UploadProgressIndicator'; const ToggleRecordingButton = () => { + const { IconPauseFill, IconVoice } = useComponentContextIcons(); const { t } = useTranslationContext(); const { recordingController: { recorder, recordingState }, @@ -41,6 +45,7 @@ const ToggleRecordingButton = () => { }; export const AudioRecorderRecordingControls = () => { + const { IconDelete } = useComponentContextIcons(); const { addNotification } = useNotificationApi(); const { t } = useTranslationContext(); const { diff --git a/src/components/MediaRecorder/AudioRecorder/AudioRecordingButtonWithNotification.tsx b/src/components/MediaRecorder/AudioRecorder/AudioRecordingButtonWithNotification.tsx index 9ecde6184..37de4a27f 100644 --- a/src/components/MediaRecorder/AudioRecorder/AudioRecordingButtonWithNotification.tsx +++ b/src/components/MediaRecorder/AudioRecorder/AudioRecordingButtonWithNotification.tsx @@ -4,12 +4,12 @@ import React, { forwardRef, useRef } from 'react'; import { useAttachmentManagerState } from '../../MessageComposer/hooks/useAttachmentManagerState'; import { useComponentContext, + useComponentContextIcons, useMessageComposerContext, useTranslationContext, } from '../../../context'; import { Callout, useDialogOnNearestManager } from '../../Dialog'; import { Button } from '../../Button'; -import { IconVoice } from '../../Icons'; const dialogId = 'recording-permission-denied-notification'; @@ -68,6 +68,7 @@ export const DefaultStartRecordingAudioButton = forwardRef< HTMLButtonElement, StartRecordingAudioButtonProps >(function StartRecordingAudioButton(props, ref) { + const { IconVoice } = useComponentContextIcons(); const { t } = useTranslationContext(); return ( diff --git a/src/components/MediaRecorder/AudioRecorder/AudioRecordingPlayback.tsx b/src/components/MediaRecorder/AudioRecorder/AudioRecordingPlayback.tsx index 3c95532fb..8c3f21f1c 100644 --- a/src/components/MediaRecorder/AudioRecorder/AudioRecordingPlayback.tsx +++ b/src/components/MediaRecorder/AudioRecorder/AudioRecordingPlayback.tsx @@ -3,9 +3,8 @@ import { DurationDisplay, WaveProgressBar } from '../../AudioPlayback'; import type { AudioPlayerState } from '../../AudioPlayback/AudioPlayer'; import { useAudioPlayer } from '../../AudioPlayback/WithAudioPlayback'; import { useStateStore } from '../../../store'; -import { IconPauseFill, IconPlayFill } from '../../Icons'; import { Button } from '../../Button'; -import { useTranslationContext } from '../../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../../context'; import clsx from 'clsx'; const audioPlayerStateSelector = (state: AudioPlayerState) => ({ @@ -27,6 +26,7 @@ export const AudioRecordingPlayback = ({ src, waveformData, }: AudioRecordingPlayerProps) => { + const { IconPauseFill, IconPlayFill } = useComponentContextIcons(); const { t } = useTranslationContext(); const audioPlayer = useAudioPlayer({ durationSeconds, diff --git a/src/components/MediaRecorder/AudioRecorder/AudioRecordingPreview.tsx b/src/components/MediaRecorder/AudioRecorder/AudioRecordingPreview.tsx index 2b929807f..a455011da 100644 --- a/src/components/MediaRecorder/AudioRecorder/AudioRecordingPreview.tsx +++ b/src/components/MediaRecorder/AudioRecorder/AudioRecordingPreview.tsx @@ -1,8 +1,7 @@ import React, { useEffect, useState } from 'react'; import { useTimeElapsed } from './hooks/useTimeElapsed'; -import { useMessageComposerContext } from '../../../context'; +import { useComponentContextIcons, useMessageComposerContext } from '../../../context'; import { RecordingTimer } from './RecordingTimer'; -import { IconVoice } from '../../Icons'; type WaveformProps = { maxDataPointsDrawn?: number; @@ -47,6 +46,7 @@ const AudioRecordingWaveform = ({ maxDataPointsDrawn = 200 }: WaveformProps) => ); }; export const AudioRecordingPreview = () => { + const { IconVoice } = useComponentContextIcons(); const { recordingController: { recorder }, } = useMessageComposerContext(); diff --git a/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecordingPreview.test.tsx b/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecordingPreview.test.tsx index b94deb35d..a0a9d5ab5 100644 --- a/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecordingPreview.test.tsx +++ b/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecordingPreview.test.tsx @@ -32,10 +32,16 @@ const addNotificationSpy = vi.fn(); const mockClient = { notifications: { addError: addNotificationSpy } }; const tSpy = (s) => s; -vi.mock('../../../../context', () => ({ - useChatContext: () => ({ client: mockClient }), - useTranslationContext: () => ({ t: tSpy }), -})); +vi.mock('../../../../context', async (importOriginal) => { + const actual = await importOriginal(); + return { + useChatContext: () => ({ client: mockClient }), + // The real hook: with no provider it returns the SDK icons, which is what these + // assertions are written against. + useComponentContextIcons: actual.useComponentContextIcons, + useTranslationContext: () => ({ t: tSpy }), + }; +}); vi.mock('../../../Notifications', async (importOriginal) => ({ ...(await importOriginal()), diff --git a/src/components/Message/MessageAlsoSentInChannelIndicator.tsx b/src/components/Message/MessageAlsoSentInChannelIndicator.tsx index 9612ecf32..4223a716b 100644 --- a/src/components/Message/MessageAlsoSentInChannelIndicator.tsx +++ b/src/components/Message/MessageAlsoSentInChannelIndicator.tsx @@ -1,7 +1,6 @@ import React from 'react'; -import { IconArrowUpRight } from '../Icons'; -import { useTranslationContext } from '../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../context'; import { useMessageAlsoSentInChannelNavigation } from './hooks'; export type MessageAlsoSentInChannelIndicatorProps = { @@ -21,6 +20,7 @@ export type MessageAlsoSentInChannelIndicatorProps = { export const MessageAlsoSentInChannelIndicator = ({ onView, }: MessageAlsoSentInChannelIndicatorProps = {}) => { + const { IconArrowUpRight } = useComponentContextIcons(); const { t } = useTranslationContext(); const { isInThread, isShownInChannel, viewReference } = useMessageAlsoSentInChannelNavigation(); diff --git a/src/components/Message/MessageDeletedBubble.tsx b/src/components/Message/MessageDeletedBubble.tsx index 8d3103feb..437257ec1 100644 --- a/src/components/Message/MessageDeletedBubble.tsx +++ b/src/components/Message/MessageDeletedBubble.tsx @@ -1,16 +1,17 @@ import React from 'react'; -import { IconNoSign } from '../Icons'; import { useTranslationContext } from '../../context/TranslationContext'; import type { LocalMessage } from 'stream-chat'; import { MessageBubble } from './MessageBubble'; +import { useComponentContextIcons } from '../../context'; export type MessageDeletedProps = { message: LocalMessage; }; export const MessageDeletedBubble = () => { + const { IconNoSign } = useComponentContextIcons(); const { t } = useTranslationContext(); return ( diff --git a/src/components/Message/MessageStatus.tsx b/src/components/Message/MessageStatus.tsx index 54a31ade2..d9fb3fe95 100644 --- a/src/components/Message/MessageStatus.tsx +++ b/src/components/Message/MessageStatus.tsx @@ -8,8 +8,8 @@ import { useEnterLeaveHandlers } from '../Tooltip/hooks'; import { useChatContext } from '../../context/ChatContext'; import { useMessageContext } from '../../context/MessageContext'; import { useTranslationContext } from '../../context/TranslationContext'; -import { IconCheckmark1Small, IconChecks, IconClock } from '../Icons'; import { useThreadContext } from '../Threads'; +import { useComponentContextIcons } from '../../context'; export type MessageStatusProps = { /* Custom component to render when message is considered delivered, not read. The default UI renders MessageDeliveredIcon and a tooltip with string 'Delivered'. */ @@ -27,6 +27,7 @@ export type MessageStatusProps = { }; const UnMemoizedMessageStatus = (props: MessageStatusProps) => { + const { IconCheckmark1Small, IconChecks, IconClock } = useComponentContextIcons(); const { MessageDeliveredStatus, MessageReadStatus, diff --git a/src/components/Message/MessageTranslationIndicator.tsx b/src/components/Message/MessageTranslationIndicator.tsx index 810a9823f..264881e2e 100644 --- a/src/components/Message/MessageTranslationIndicator.tsx +++ b/src/components/Message/MessageTranslationIndicator.tsx @@ -1,8 +1,8 @@ import type { LocalMessage } from 'stream-chat'; import React, { useCallback, useMemo } from 'react'; -import { IconTranslate } from '../Icons'; import { getTranslatedMessageText, + useComponentContextIcons, useMessageContext, useTranslationContext, } from '../../context'; @@ -15,6 +15,7 @@ export type TranslationIndicatorProps = { export const MessageTranslationIndicator = ({ message: propMessage, }: TranslationIndicatorProps) => { + const { IconTranslate } = useComponentContextIcons(); const { t, userLanguage } = useTranslationContext(); const { message: contextMessage, diff --git a/src/components/Message/PinIndicator.tsx b/src/components/Message/PinIndicator.tsx index 770c9d066..919fb6db6 100644 --- a/src/components/Message/PinIndicator.tsx +++ b/src/components/Message/PinIndicator.tsx @@ -1,7 +1,10 @@ import React from 'react'; -import { IconPin } from '../Icons'; -import { useChatContext, useTranslationContext } from '../../context'; +import { + useChatContext, + useComponentContextIcons, + useTranslationContext, +} from '../../context'; import type { LocalMessage } from 'stream-chat'; export type PinIndicatorProps = { @@ -13,6 +16,7 @@ export type PinIndicatorProps = { * Name is taken from message.pinned_by (who pinned). */ export const PinIndicator = ({ message }: PinIndicatorProps) => { + const { IconPin } = useComponentContextIcons(); const { t } = useTranslationContext(); const { client } = useChatContext(); diff --git a/src/components/Message/ReminderNotification.tsx b/src/components/Message/ReminderNotification.tsx index 710658647..069d104e5 100644 --- a/src/components/Message/ReminderNotification.tsx +++ b/src/components/Message/ReminderNotification.tsx @@ -1,8 +1,7 @@ import React from 'react'; -import { useTranslationContext } from '../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../context'; import { useStateStore } from '../../store'; import type { Reminder, ReminderState } from 'stream-chat'; -import { IconBell, IconBookmark } from '../Icons'; import { nsToDate, nsToMs } from 'stream-chat'; export type ReminderNotificationProps = { @@ -14,6 +13,7 @@ const reminderStateSelector = (state: ReminderState) => ({ }); function SavedForLaterContent() { + const { IconBookmark } = useComponentContextIcons(); const { t } = useTranslationContext(); return (
@@ -26,6 +26,7 @@ function SavedForLaterContent() { const THRESHOLD_RELATIVE_MINUTES = 59; function RemindMeContent({ reminder }: { reminder: Reminder }) { + const { IconBell } = useComponentContextIcons(); const { t } = useTranslationContext(); const { timeLeftMs } = useStateStore(reminder?.state, reminderStateSelector) ?? {}; diff --git a/src/components/MessageActions/DownloadSubmenu.tsx b/src/components/MessageActions/DownloadSubmenu.tsx index aa69552e6..6a899e1c3 100644 --- a/src/components/MessageActions/DownloadSubmenu.tsx +++ b/src/components/MessageActions/DownloadSubmenu.tsx @@ -1,12 +1,15 @@ import React from 'react'; -import { useMessageContext, useTranslationContext } from '../../context'; +import { + useComponentContextIcons, + useMessageContext, + useTranslationContext, +} from '../../context'; import { ContextMenuBackButton, ContextMenuButton, ContextMenuHeader, useContextMenuContext, } from '../Dialog'; -import { IconChevronLeft, IconDownload } from '../Icons'; import { type DownloadableAttachment, downloadAllAttachments, @@ -18,6 +21,7 @@ const msgActionsBoxButtonClassName = 'str-chat__message-actions-list-item-button' as const; export const DownloadSubmenuHeader = () => { + const { IconChevronLeft } = useComponentContextIcons(); const { returnToParentMenu: goBack } = useContextMenuContext(); const { t } = useTranslationContext(); return ( @@ -31,6 +35,7 @@ export const DownloadSubmenuHeader = () => { }; export const DownloadSubmenu = () => { + const { IconDownload } = useComponentContextIcons(); const { closeMenu } = useContextMenuContext(); const { message } = useMessageContext(); const { t } = useTranslationContext(); diff --git a/src/components/MessageActions/MessageActions.defaults.tsx b/src/components/MessageActions/MessageActions.defaults.tsx index d5916e151..0e2648499 100644 --- a/src/components/MessageActions/MessageActions.defaults.tsx +++ b/src/components/MessageActions/MessageActions.defaults.tsx @@ -2,30 +2,6 @@ import React, { forwardRef, useState } from 'react'; import { GlobalModal } from '../Modal'; -import { - IconAudio, - IconBell, - IconBellOff, - IconBookmark, - IconBookmarkRemove, - IconCopy, - IconDelete, - IconDownload, - IconEdit, - IconEmoji, - IconFlag, - IconMore, - IconMute, - IconNoSign, - IconNotification, - IconPin, - IconQuote, - IconReply, - IconRetry, - IconThread, - IconUnpin, - IconUserCheck, -} from '../Icons'; import { isMessageDeleted, isUserMuted } from '../Message/utils'; import { useMessageComposerController } from '../MessageComposer/hooks/useMessageComposerController'; import { savePreEditSnapshot } from '../MessageComposer/preEditSnapshot'; @@ -36,6 +12,7 @@ import { ReactionSelectorWithButton } from '../Reactions/ReactionSelectorWithBut import { useChatContext, useComponentContext, + useComponentContextIcons, useMessageContext, useTranslationContext, } from '../../context'; @@ -77,6 +54,7 @@ const getNotificationError = (error: unknown): Error | undefined => { const DefaultMessageActionComponents = { dropdown: { React() { + const { IconEmoji } = useComponentContextIcons(); const { ReactionSelector = DefaultReactionSelector } = useComponentContext(); const { anchorReferenceElement } = useContextMenuContext(); const { isMyMessage, message, threadList } = useMessageContext(); @@ -135,6 +113,7 @@ const DefaultMessageActionComponents = { ); }, ThreadReply() { + const { IconThread } = useComponentContextIcons(); const { closeMenu } = useContextMenuContext(); const { handleOpenThread } = useMessageContext(); const { t } = useTranslationContext(); @@ -155,6 +134,7 @@ const DefaultMessageActionComponents = { ); }, Quote() { + const { IconQuote } = useComponentContextIcons(); const { closeMenu } = useContextMenuContext(); const { message } = useMessageContext(); const { t } = useTranslationContext(); @@ -188,6 +168,7 @@ const DefaultMessageActionComponents = { ); }, Download() { + const { IconDownload } = useComponentContextIcons(); const { closeMenu, openSubmenu } = useContextMenuContext(); const { message } = useMessageContext(); const { t } = useTranslationContext(); @@ -223,6 +204,7 @@ const DefaultMessageActionComponents = { ); }, Pin() { + const { IconPin, IconUnpin } = useComponentContextIcons(); const { closeMenu } = useContextMenuContext(); const { handlePin, message } = useMessageContext(); const { addNotification } = useNotificationApi(); @@ -279,6 +261,7 @@ const DefaultMessageActionComponents = { ); }, CopyMessageText() { + const { IconCopy } = useComponentContextIcons(); const { closeMenu } = useContextMenuContext(); const { message } = useMessageContext(); const { t } = useTranslationContext(); @@ -298,6 +281,7 @@ const DefaultMessageActionComponents = { ); }, Resend() { + const { IconRetry } = useComponentContextIcons(); const { closeMenu } = useContextMenuContext(); const { handleRetry, message } = useMessageContext(); const { t } = useTranslationContext(); @@ -317,6 +301,7 @@ const DefaultMessageActionComponents = { ); }, Edit() { + const { IconEdit } = useComponentContextIcons(); const messageComposer = useMessageComposerController(); const { message } = useMessageContext(); const { t } = useTranslationContext(); @@ -338,6 +323,7 @@ const DefaultMessageActionComponents = { ); }, MarkUnread() { + const { IconNotification } = useComponentContextIcons(); const { closeMenu } = useContextMenuContext(); const { handleMarkUnread, message } = useMessageContext(); const { addNotification } = useNotificationApi(); @@ -392,6 +378,7 @@ const DefaultMessageActionComponents = { ); }, RemindMe() { + const { IconBell, IconBellOff } = useComponentContextIcons(); const { closeMenu, openSubmenu } = useContextMenuContext(); const { client } = useChatContext(); const { addNotification } = useNotificationApi(); @@ -455,6 +442,7 @@ const DefaultMessageActionComponents = { ); }, SaveForLater() { + const { IconBookmark, IconBookmarkRemove } = useComponentContextIcons(); const { closeMenu } = useContextMenuContext(); const { client } = useChatContext(); const { addNotification } = useNotificationApi(); @@ -532,6 +520,7 @@ const DefaultMessageActionComponents = { ); }, Flag() { + const { IconFlag } = useComponentContextIcons(); const { closeMenu } = useContextMenuContext(); const { handleFlag, message } = useMessageContext(); const { addNotification } = useNotificationApi(); @@ -580,6 +569,7 @@ const DefaultMessageActionComponents = { ); }, Mute() { + const { IconAudio, IconMute } = useComponentContextIcons(); const { closeMenu } = useContextMenuContext(); const { handleMute, message } = useMessageContext(); const { addNotification } = useNotificationApi(); @@ -639,6 +629,7 @@ const DefaultMessageActionComponents = { ); }, Delete() { + const { IconDelete } = useComponentContextIcons(); const { closeMenu } = useContextMenuContext(); const { addNotification } = useNotificationApi(); const { Modal = GlobalModal } = useComponentContext(); @@ -704,6 +695,7 @@ const DefaultMessageActionComponents = { ); }, BlockUser() { + const { IconNoSign, IconUserCheck } = useComponentContextIcons(); const { closeMenu } = useContextMenuContext(); const { client } = useChatContext(); const { message } = useMessageContext(); @@ -740,6 +732,7 @@ const DefaultMessageActionComponents = { quick: { // eslint-disable-next-line react/display-name DropdownToggle: forwardRef((_, ref) => { + const { IconMore } = useComponentContextIcons(); const { t } = useTranslationContext(); const { message, threadList } = useMessageContext(); const dropdownDialogIsOpen = useDialogIsOpen( @@ -779,9 +772,12 @@ const DefaultMessageActionComponents = { ); }), React() { - return ; + // No `ReactionIcon`: the component resolves the `IconEmoji` slot itself. Passing it from + // here would read the same value and only add a hop that invites a direct import later. + return ; }, Reply() { + const { IconReply } = useComponentContextIcons(); const { handleOpenThread } = useMessageContext(); const { t } = useTranslationContext(); diff --git a/src/components/MessageActions/RemindMeSubmenu.tsx b/src/components/MessageActions/RemindMeSubmenu.tsx index f90c8c243..f396008d6 100644 --- a/src/components/MessageActions/RemindMeSubmenu.tsx +++ b/src/components/MessageActions/RemindMeSubmenu.tsx @@ -1,5 +1,10 @@ import React from 'react'; -import { useChatContext, useMessageContext, useTranslationContext } from '../../context'; +import { + useChatContext, + useComponentContextIcons, + useMessageContext, + useTranslationContext, +} from '../../context'; import { useNotificationApi } from '../Notifications'; import { ContextMenuBackButton, @@ -7,7 +12,6 @@ import { ContextMenuHeader, useContextMenuContext, } from '../Dialog'; -import { IconChevronLeft } from '../Icons'; const getErrorMessage = (error: unknown, fallback: string) => error instanceof Error && error.message ? error.message : fallback; @@ -23,6 +27,7 @@ const getNotificationError = (error: unknown): Error | undefined => { }; export const RemindMeSubmenuHeader = () => { + const { IconChevronLeft } = useComponentContextIcons(); const { t } = useTranslationContext(); const { returnToParentMenu } = useContextMenuContext(); return ( diff --git a/src/components/MessageBounce/MessageBouncePrompt.tsx b/src/components/MessageBounce/MessageBouncePrompt.tsx index 528776cac..87171900b 100644 --- a/src/components/MessageBounce/MessageBouncePrompt.tsx +++ b/src/components/MessageBounce/MessageBouncePrompt.tsx @@ -1,12 +1,12 @@ import type { MouseEventHandler } from 'react'; import React from 'react'; import { + useComponentContextIcons, useMessageBounceContext, useModalContext, useTranslationContext, } from '../../context'; import { Button } from '../Button'; -import { IconExclamationMark } from '../Icons'; import { Alert } from '../Dialog'; import type { PropsWithChildrenOnly } from '../../types/types'; @@ -14,6 +14,7 @@ export type MessageBouncePromptProps = PropsWithChildrenOnly; // todo: shall we rename this to MessageBounceAlert? export function MessageBouncePrompt({ children }: MessageBouncePromptProps) { + const { IconExclamationMark } = useComponentContextIcons(); const { handleDelete, handleEdit, handleRetry } = useMessageBounceContext(); const { t } = useTranslationContext(); const { close } = useModalContext(); diff --git a/src/components/MessageComposer/AttachmentPreviewList/AudioAttachmentPreview.tsx b/src/components/MessageComposer/AttachmentPreviewList/AudioAttachmentPreview.tsx index 87b766672..3cf1d4df6 100644 --- a/src/components/MessageComposer/AttachmentPreviewList/AudioAttachmentPreview.tsx +++ b/src/components/MessageComposer/AttachmentPreviewList/AudioAttachmentPreview.tsx @@ -4,13 +4,12 @@ import { type LocalAudioAttachment, type LocalVoiceRecordingAttachment, } from 'stream-chat'; -import { useTranslationContext } from '../../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../../context'; import React, { useEffect } from 'react'; import clsx from 'clsx'; import { UploadProgressIndicator } from '../../Loading/UploadProgressIndicator'; import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton'; import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot'; -import { IconExclamationMark, IconExclamationTriangleFill } from '../../Icons'; import { PlayButton } from '../../Button'; import { type AudioPlayerState, @@ -41,6 +40,7 @@ export const AudioAttachmentPreview = ({ handleRetry, removeAttachments, }: AudioAttachmentPreviewProps) => { + const { IconExclamationMark, IconExclamationTriangleFill } = useComponentContextIcons(); const { t } = useTranslationContext(); const { id, previewUri, uploadPermissionCheck, uploadProgress, uploadState } = attachment.localMetadata ?? {}; diff --git a/src/components/MessageComposer/AttachmentPreviewList/FileAttachmentPreview.tsx b/src/components/MessageComposer/AttachmentPreviewList/FileAttachmentPreview.tsx index 65b4a4ec7..4c2bf346a 100644 --- a/src/components/MessageComposer/AttachmentPreviewList/FileAttachmentPreview.tsx +++ b/src/components/MessageComposer/AttachmentPreviewList/FileAttachmentPreview.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useTranslationContext } from '../../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../../context'; import { FileIcon } from '../../FileIcon'; import { UploadProgressIndicator } from '../../Loading/UploadProgressIndicator'; import { AttachmentUploadedSizeIndicator } from './AttachmentUploadedSizeIndicator'; @@ -7,7 +7,6 @@ import type { LocalAudioAttachment, LocalFileAttachment } from 'stream-chat'; import type { UploadAttachmentPreviewProps } from './types'; import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton'; import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot'; -import { IconExclamationMark, IconExclamationTriangleFill } from '../../Icons'; export type FileAttachmentPreviewProps = UploadAttachmentPreviewProps< @@ -19,6 +18,7 @@ export const FileAttachmentPreview = ({ handleRetry, removeAttachments, }: FileAttachmentPreviewProps) => { + const { IconExclamationMark, IconExclamationTriangleFill } = useComponentContextIcons(); const { t } = useTranslationContext(); const { id, uploadPermissionCheck, uploadProgress, uploadState } = attachment.localMetadata ?? {}; diff --git a/src/components/MessageComposer/AttachmentPreviewList/GeolocationPreview.tsx b/src/components/MessageComposer/AttachmentPreviewList/GeolocationPreview.tsx index 1c9d996a0..444dc7da8 100644 --- a/src/components/MessageComposer/AttachmentPreviewList/GeolocationPreview.tsx +++ b/src/components/MessageComposer/AttachmentPreviewList/GeolocationPreview.tsx @@ -2,18 +2,22 @@ import type { LiveLocationPreview, StaticLocationPreview } from 'stream-chat'; import type { ComponentType } from 'react'; import React from 'react'; import { useTranslationContext } from '../../../context'; -import { IconLocation } from '../../Icons'; +import { useComponentContextIcons } from '../../../context'; import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton'; type GeolocationPreviewImageProps = { location: StaticLocationPreview | LiveLocationPreview; }; -const GeolocationPreviewImage = () => ( -
- -
-); +const GeolocationPreviewImage = () => { + const { IconLocation } = useComponentContextIcons(); + + return ( +
+ +
+ ); +}; export type GeolocationPreviewProps = { location: StaticLocationPreview | LiveLocationPreview; diff --git a/src/components/MessageComposer/AttachmentPreviewList/MediaAttachmentPreview.tsx b/src/components/MessageComposer/AttachmentPreviewList/MediaAttachmentPreview.tsx index 951727d04..70c4cd814 100644 --- a/src/components/MessageComposer/AttachmentPreviewList/MediaAttachmentPreview.tsx +++ b/src/components/MessageComposer/AttachmentPreviewList/MediaAttachmentPreview.tsx @@ -4,7 +4,11 @@ import { type LocalImageAttachment, type LocalVideoAttachment, } from 'stream-chat'; -import { useComponentContext, useTranslationContext } from '../../../context'; +import { + useComponentContext, + useComponentContextIcons, + useTranslationContext, +} from '../../../context'; import { BaseImage as DefaultBaseImage } from '../../BaseImage'; import React, { type KeyboardEvent, @@ -14,7 +18,6 @@ import React, { useState, } from 'react'; import clsx from 'clsx'; -import { IconExclamationMark, IconRetry } from '../../Icons'; import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton'; import { Button } from '../../Button'; import { UploadProgressIndicator } from '../../Loading/UploadProgressIndicator'; @@ -34,6 +37,7 @@ export const MediaAttachmentPreview = ({ openPreview, removeAttachments, }: MediaAttachmentPreviewProps) => { + const { IconExclamationMark, IconRetry } = useComponentContextIcons(); const { t } = useTranslationContext(); const { BaseImage = DefaultBaseImage } = useComponentContext(); const [thumbnailPreviewError, setThumbnailPreviewError] = useState(false); diff --git a/src/components/MessageComposer/AttachmentPreviewList/UnsupportedAttachmentPreview.tsx b/src/components/MessageComposer/AttachmentPreviewList/UnsupportedAttachmentPreview.tsx index 857efea57..4e16ed5d2 100644 --- a/src/components/MessageComposer/AttachmentPreviewList/UnsupportedAttachmentPreview.tsx +++ b/src/components/MessageComposer/AttachmentPreviewList/UnsupportedAttachmentPreview.tsx @@ -1,7 +1,6 @@ import React from 'react'; import type { AnyLocalAttachment, LocalUploadAttachment } from 'stream-chat'; -import { IconUnsupportedAttachment } from '../../Icons'; -import { useTranslationContext } from '../../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../../context'; import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton'; export type UnsupportedAttachmentPreviewProps< @@ -18,6 +17,7 @@ export const UnsupportedAttachmentPreview = ({ attachment, removeAttachments, }: UnsupportedAttachmentPreviewProps) => { + const { IconUnsupportedAttachment } = useComponentContextIcons(); const { t } = useTranslationContext(); const { id } = attachment.localMetadata ?? {}; diff --git a/src/components/MessageComposer/AttachmentSelector/AttachmentSelector.tsx b/src/components/MessageComposer/AttachmentSelector/AttachmentSelector.tsx index 177f40c3e..0910ac614 100644 --- a/src/components/MessageComposer/AttachmentSelector/AttachmentSelector.tsx +++ b/src/components/MessageComposer/AttachmentSelector/AttachmentSelector.tsx @@ -27,7 +27,12 @@ import { ShareLocationDialog as DefaultLocationDialog } from '../../Location'; import { PollCreationDialog as DefaultPollCreationDialog } from '../../Poll'; import { Portal } from '../../Portal/Portal'; import { UploadFileInput } from '../../ReactFileUtilities'; -import { useChannel, useComponentContext, useTranslationContext } from '../../../context'; +import { + useChannel, + useComponentContext, + useComponentContextIcons, + useTranslationContext, +} from '../../../context'; import { AttachmentSelectorContextProvider, useAttachmentSelectorContext, @@ -38,13 +43,6 @@ import { useStateStore } from '../../../store'; import type { TextComposerState } from 'stream-chat'; import clsx from 'clsx'; import { Button, type ButtonProps } from '../../Button'; -import { - IconAttachment, - IconCommand, - IconLocation, - IconPlus, - IconPoll, -} from '../../Icons'; import { useIsCooldownActive } from '../hooks/useIsCooldownActive'; import { CommandsMenu, @@ -62,6 +60,7 @@ const textComposerStateSelector = ({ command }: TextComposerState) => ({ command const AttachmentSelectorMenuInitButtonIcon = ({ className }: { className?: string }) => { const { AttachmentSelectorInitiationButtonContents } = useComponentContext(); + const { IconPlus } = useComponentContextIcons(); if (AttachmentSelectorInitiationButtonContents) { return ( @@ -175,6 +174,7 @@ export type AttachmentSelectorActionProps = { export const DefaultAttachmentSelectorComponents = { Command({ submenuHeader, submenuItems }: AttachmentSelectorActionProps) { const { t } = useTranslationContext(); + const { IconCommand } = useComponentContextIcons(); const { openSubmenu } = useContextMenuContext(); const commands = useMessageComposerCommands(); const hasEnabledCommands = commands.some(({ enabled }) => enabled); @@ -202,6 +202,7 @@ export const DefaultAttachmentSelectorComponents = { }, File() { const { t } = useTranslationContext(); + const { IconAttachment } = useComponentContextIcons(); const { fileInput } = useAttachmentSelectorContext(); const { closeMenu } = useContextMenuContext(); @@ -220,6 +221,7 @@ export const DefaultAttachmentSelectorComponents = { }, Location({ openModalForAction }: AttachmentSelectorActionProps) { const { t } = useTranslationContext(); + const { IconLocation } = useComponentContextIcons(); const { closeMenu } = useContextMenuContext(); return ( = { - ban: IconUserRemove, - flag: IconFlag, - giphy: IconGiphy, - mute: IconMute, - unban: IconUserAdd, - unmute: IconAudio, -}; - export const CommandsMenuClassName = 'str-chat__context-menu--commands'; export const CommandsSubmenuHeader = () => { const { t } = useTranslationContext(); const { returnToParentMenu } = useContextMenuContext(); + const { IconChevronLeft } = useComponentContextIcons(); return ( = { + ban: IconUserRemove, + flag: IconFlag, + giphy: IconGiphy, + mute: IconMute, + unban: IconUserAdd, + unmute: IconAudio, + }; + return ( ({ useContextMenuContext: () => ({ closeMenu, returnToParentMenu }), })); -vi.mock('../../../../context', () => ({ - useMessageComposerContext: () => ({ textareaRef: { current: null } }), - useTranslationContext: () => ({ t }), -})); +vi.mock('../../../../context', async (importOriginal) => { + const actual = await importOriginal(); + return { + // The real hook: with no provider it returns the SDK icons, which is what these + // assertions are written against. + useComponentContextIcons: actual.useComponentContextIcons, + useMessageComposerContext: () => ({ textareaRef: { current: null } }), + useTranslationContext: () => ({ t }), + }; +}); vi.mock('../../hooks', () => ({ useMessageComposerCommands: () => commandsMock.value, diff --git a/src/components/MessageComposer/CommandChip.tsx b/src/components/MessageComposer/CommandChip.tsx index 33d4f6d17..440640fa4 100644 --- a/src/components/MessageComposer/CommandChip.tsx +++ b/src/components/MessageComposer/CommandChip.tsx @@ -1,13 +1,17 @@ import { useMessageComposerController } from './hooks'; import type { TextComposerState } from 'stream-chat'; -import { IconBolt, IconXmark } from '../Icons'; -import { useMessageComposerContext, useTranslationContext } from '../../context'; +import { + useComponentContextIcons, + useMessageComposerContext, + useTranslationContext, +} from '../../context'; export type CommandChipProps = { command?: TextComposerState['command']; }; export const CommandChip = ({ command }: CommandChipProps) => { + const { IconBolt, IconXmark } = useComponentContextIcons(); const { textComposer } = useMessageComposerController(); const { textareaRef } = useMessageComposerContext(); const { t } = useTranslationContext(); diff --git a/src/components/MessageComposer/LinkPreviewList.tsx b/src/components/MessageComposer/LinkPreviewList.tsx index 743d8e7fe..bfe57e862 100644 --- a/src/components/MessageComposer/LinkPreviewList.tsx +++ b/src/components/MessageComposer/LinkPreviewList.tsx @@ -8,7 +8,7 @@ import { useEnterLeaveHandlers } from '../Tooltip/hooks'; import { useMessageComposerController } from './hooks'; import { BaseImage } from '../BaseImage'; import { RemoveAttachmentPreviewButton } from './RemoveAttachmentPreviewButton'; -import { IconLink } from '../Icons'; +import { useComponentContextIcons } from '../../context'; export type LinkPreviewListProps = { displayLinkCount?: number; @@ -46,6 +46,7 @@ type LinkPreviewProps = { }; export const LinkPreviewCard = ({ linkPreview }: LinkPreviewProps) => { + const { IconLink } = useComponentContextIcons(); const { linkPreviewsManager } = useMessageComposerController(); const { handleEnter, handleLeave, tooltipVisible } = useEnterLeaveHandlers(); diff --git a/src/components/MessageComposer/MessageComposerActions.tsx b/src/components/MessageComposer/MessageComposerActions.tsx index b1498fb34..7aa56aa09 100644 --- a/src/components/MessageComposer/MessageComposerActions.tsx +++ b/src/components/MessageComposer/MessageComposerActions.tsx @@ -5,6 +5,7 @@ import { SendButton as DefaultSendButton } from './SendButton'; import { useChannel, useComponentContext, + useComponentContextIcons, useMessageComposerContext, } from '../../context'; import { useAIState } from '../AIStateIndicator'; @@ -19,7 +20,6 @@ import { useIsCooldownActive } from './hooks/useIsCooldownActive'; import { AIStates } from 'stream-chat'; import type { AIState, MessageComposerState, TextComposerState } from 'stream-chat'; import { useStateStore } from '../../store'; -import { IconCheckmark, IconSend } from '../Icons'; import { useInertWhenHidden } from '../Accessibility'; // `AIStates` is imported from its owner rather than through the `../AIStateIndicator` barrel: that @@ -39,6 +39,7 @@ const textComposerStateSelector = ({ command, text }: TextComposerState) => ({ }); export const MessageComposerActions = () => { + const { IconCheckmark, IconSend } = useComponentContextIcons(); const channel = useChannel(); const { hideSendButton } = useMessageComposerContext(); const messageComposer = useMessageComposerController(); diff --git a/src/components/MessageComposer/QuotedMessagePreview.tsx b/src/components/MessageComposer/QuotedMessagePreview.tsx index 8e46f99b5..7b9364a1f 100644 --- a/src/components/MessageComposer/QuotedMessagePreview.tsx +++ b/src/components/MessageComposer/QuotedMessagePreview.tsx @@ -35,23 +35,14 @@ import { import { useAttachmentContext } from '../../context/AttachmentContext'; import type { MessageContextValue } from '../../context'; import { RemoveAttachmentPreviewButton } from './RemoveAttachmentPreviewButton'; -import { - IconCamera, - IconFile, - IconLink, - IconLocation, - IconNoSign, - IconPlayFill, - IconPoll, - IconVideo, - IconVoice, -} from '../Icons'; import clsx from 'clsx'; import { BaseImage } from '../BaseImage'; import { FileIcon } from '../FileIcon'; import { QuotedMessageIndicator } from './QuotedMessageIndicator'; import { getRenderTextMentionEntities } from '../Message/renderText/rehypePlugins'; import { isDeletedMessage } from '../MessageList'; +import { useComponentContextIcons } from '../../context'; +import type { IconSlots } from '../Icons/slots'; const messageComposerStateStoreSelector = (state: MessageComposerState) => ({ quotedMessage: state.quotedMessage, @@ -174,12 +165,26 @@ type PreviewType = const getAttachmentIconWithType = ( quotedMessage: LocalMessage | MessageResponse | null, giphyVersionName: GiphyVersions, + // Icons arrive as an argument rather than from the hook: this is a plain helper, called from a + // `useMemo` inside the component, so it is not a place a hook may run. + icons: Required, ): { groupedAttachments: GroupedAttachments; Icon: ComponentType; PreviewImage: ReactElement | null; previewType: PreviewType | null; } => { + const { + IconCamera, + IconFile, + IconLink, + IconLocation, + IconNoSign, + IconPlayFill, + IconPoll, + IconVideo, + IconVoice, + } = icons; const groupedAttachments = getGroupedAttachments(quotedMessage); const result = { groupedAttachments, @@ -341,6 +346,7 @@ export const QuotedMessagePreviewUI = ({ // MERGE-RECONCILE: `giphyVersion` was read from the deleted ChannelStateContext; // migrated to the PR's source (useAttachmentContext().giphyVersion — same as Giphy.tsx). const { giphyVersion: giphyVersionName = 'fixed_height' } = useAttachmentContext(); + const icons = useComponentContextIcons(); const quotedMessageText = useMemo( () => @@ -374,7 +380,7 @@ export const QuotedMessagePreviewUI = ({ Icon: AttachmentIcon, PreviewImage, previewType, - } = getAttachmentIconWithType(quotedMessage, giphyVersionName); + } = getAttachmentIconWithType(quotedMessage, giphyVersionName, icons); let renderedText: ReactNode | undefined; @@ -444,6 +450,7 @@ export const QuotedMessagePreviewUI = ({ }; }, [ giphyVersionName, + icons, quotedMessage, quotedMessageMentionEntities, quotedMessageText, diff --git a/src/components/MessageComposer/RemoveAttachmentPreviewButton.tsx b/src/components/MessageComposer/RemoveAttachmentPreviewButton.tsx index 234b75ca7..34e2b2c7f 100644 --- a/src/components/MessageComposer/RemoveAttachmentPreviewButton.tsx +++ b/src/components/MessageComposer/RemoveAttachmentPreviewButton.tsx @@ -1,8 +1,7 @@ import clsx from 'clsx'; -import { IconXmarkSmall } from '../Icons'; import { Button } from '../Button'; import React, { type ComponentProps } from 'react'; -import { useTranslationContext } from '../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../context'; import type { AttachmentLoadingState } from 'stream-chat'; export const RemoveAttachmentPreviewButton = ({ @@ -12,6 +11,7 @@ export const RemoveAttachmentPreviewButton = ({ }: ComponentProps<'button'> & { uploadState?: AttachmentLoadingState; }) => { + const { IconXmarkSmall } = useComponentContextIcons(); const { t } = useTranslationContext(); return ( -); +}: ComponentProps<'button'>) => { + const { IconXmark } = useComponentContextIcons(); + + return ( + + ); +}; diff --git a/src/components/Notifications/Notification.tsx b/src/components/Notifications/Notification.tsx index 2707c247e..e0c4b87e4 100644 --- a/src/components/Notifications/Notification.tsx +++ b/src/components/Notifications/Notification.tsx @@ -3,13 +3,7 @@ import clsx from 'clsx'; import type { NotificationSeverity } from 'stream-chat'; import { type Notification as NotificationType } from 'stream-chat'; -import { - IconCheckmark, - IconExclamationMark, - IconExclamationTriangleFill, - IconRefresh, - IconXmark, -} from '../../components/Icons'; +import { useComponentContextIcons } from '../../context'; import { useTranslationContext } from '../../context/TranslationContext'; import { Button } from '../Button'; import { useNotificationApi } from './hooks/useNotificationApi'; @@ -21,15 +15,18 @@ export type NotificationIconProps = { notification: NotificationType; }; -const IconsBySeverity: Record = { - error: IconExclamationMark, - info: null, - loading: IconRefresh, - success: IconCheckmark, - warning: IconExclamationTriangleFill, -}; - const DefaultNotificationIcon = ({ notification }: NotificationIconProps) => { + const { IconCheckmark, IconExclamationMark, IconExclamationTriangleFill, IconRefresh } = + useComponentContextIcons(); + + const IconsBySeverity: Record = { + error: IconExclamationMark, + info: null, + loading: IconRefresh, + success: IconCheckmark, + warning: IconExclamationTriangleFill, + }; + if (!notification.severity) return null; const Icon = IconsBySeverity[notification.severity] ?? null; @@ -74,6 +71,7 @@ export const Notification = forwardRef( ) => { const { removeNotification } = useNotificationApi(); const { t } = useTranslationContext(); + const { IconXmark } = useComponentContextIcons(); const displayMessage = t('translationBuilderTopic.notification', { notification, diff --git a/src/components/Poll/PollActions/PollResults/PollOptionWithVotesHeader.tsx b/src/components/Poll/PollActions/PollResults/PollOptionWithVotesHeader.tsx index af4d42b26..a81e2052f 100644 --- a/src/components/Poll/PollActions/PollResults/PollOptionWithVotesHeader.tsx +++ b/src/components/Poll/PollActions/PollResults/PollOptionWithVotesHeader.tsx @@ -1,8 +1,11 @@ import React from 'react'; import { useStateStore } from '../../../../store'; -import { usePollContext, useTranslationContext } from '../../../../context'; +import { + useComponentContextIcons, + usePollContext, + useTranslationContext, +} from '../../../../context'; import type { PollOptionResponseData, PollState } from 'stream-chat'; -import { IconTrophy } from '../../../Icons'; type PollStateSelectorReturnValue = { maxVotedOptionIds: string[]; @@ -20,6 +23,7 @@ export type PollResultOptionVoteCounterProps = { export const PollResultOptionVoteCounter = ({ optionId, }: PollResultOptionVoteCounterProps) => { + const { IconTrophy } = useComponentContextIcons(); const { t } = useTranslationContext(); const { poll } = usePollContext(); const { maxVotedOptionIds, vote_counts_by_option } = useStateStore( diff --git a/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx b/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx index d2ecba5d4..570ca0aab 100644 --- a/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx +++ b/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx @@ -10,7 +10,7 @@ import type { PollComposerState, PollComposerValidationCode, } from 'stream-chat'; -import { IconMinusCircle } from '../../Icons'; +import { useComponentContextIcons } from '../../../context'; import { Button, type ButtonProps } from '../../Button'; import { TextInputFieldSet } from '../../Form/TextInputFieldSet'; import { VisuallyHidden } from '../../VisuallyHidden'; @@ -308,15 +308,19 @@ export const OptionFieldSet = () => { ); }; -const RemoveOptionButton = ({ className, ...props }: ButtonProps) => ( - -); +const RemoveOptionButton = ({ className, ...props }: ButtonProps) => { + const { IconMinusCircle } = useComponentContextIcons(); + + return ( + + ); +}; diff --git a/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx b/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx index 46be807d0..89de0588d 100644 --- a/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx +++ b/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx @@ -2,9 +2,12 @@ import React from 'react'; import { flushSync } from 'react-dom'; import { useCanCreatePoll } from '../../MessageComposer/hooks/useCanCreatePoll'; import { useMessageComposerController } from '../../MessageComposer/hooks/useMessageComposerController'; -import { useMessageComposerContext, useTranslationContext } from '../../../context'; +import { + useComponentContextIcons, + useMessageComposerContext, + useTranslationContext, +} from '../../../context'; import clsx from 'clsx'; -import { IconSend } from '../../Icons'; import { Prompt } from '../../Dialog'; import { useSendMessageFn } from '../../MessageComposer/hooks/useSendMessageFn'; import { useNotificationApi } from '../../Notifications'; @@ -16,6 +19,7 @@ export type PollCreationDialogControlsProps = { export const PollCreationDialogControls = ({ close, }: PollCreationDialogControlsProps) => { + const { IconSend } = useComponentContextIcons(); const { t } = useTranslationContext(); const { textareaRef } = useMessageComposerContext(); const messageComposer = useMessageComposerController(); diff --git a/src/components/Poll/PollCreationDialog/PollOptionReorderHandle.tsx b/src/components/Poll/PollCreationDialog/PollOptionReorderHandle.tsx index f2fdecaaa..bd68bd3df 100644 --- a/src/components/Poll/PollCreationDialog/PollOptionReorderHandle.tsx +++ b/src/components/Poll/PollCreationDialog/PollOptionReorderHandle.tsx @@ -2,9 +2,8 @@ import type { KeyboardEvent as ReactKeyboardEvent } from 'react'; import React, { useEffect, useRef } from 'react'; import type { PollComposerOption } from 'stream-chat'; -import { IconReorder } from '../../Icons'; import { useAriaLiveAnnouncer } from '../../Accessibility'; -import { useTranslationContext } from '../../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../../context'; type PollOptionReorderHandleProps = { index: number; @@ -30,6 +29,7 @@ export const PollOptionReorderHandle = ({ registerRef, totalOptionCount, }: PollOptionReorderHandleProps) => { + const { IconReorder } = useComponentContextIcons(); const { t } = useTranslationContext(); const announce = useAriaLiveAnnouncer(); const hasAnnouncedFocusRef = useRef(false); diff --git a/src/components/Reactions/MessageReactionsDetail.tsx b/src/components/Reactions/MessageReactionsDetail.tsx index c37a40ba0..2266ed995 100644 --- a/src/components/Reactions/MessageReactionsDetail.tsx +++ b/src/components/Reactions/MessageReactionsDetail.tsx @@ -10,12 +10,12 @@ import type { MessageContextValue } from '../../context'; import { useChatContext, useComponentContext, + useComponentContextIcons, useMessageContext, useTranslationContext, } from '../../context'; import { defaultReactionOptions, getHasExtendedReactions } from './reactionOptions'; import type { useProcessReactions } from './hooks/useProcessReactions'; -import { IconEmojiAdd } from '../Icons'; import { ReactionSelector, type ReactionSelectorProps } from './ReactionSelector'; export type MessageReactionsDetailProps = Partial< @@ -65,6 +65,7 @@ export const MessageReactionsDetail: MessageReactionsDetailInterface = ({ selectedReactionType, totalReactionCount, }) => { + const { IconEmojiAdd } = useComponentContextIcons(); const [extendedReactionListOpen, setExtendedReactionListOpen] = useState(false); const { client } = useChatContext(); const { diff --git a/src/components/Reactions/ReactionSelector.tsx b/src/components/Reactions/ReactionSelector.tsx index 0aaa65763..90b07f063 100644 --- a/src/components/Reactions/ReactionSelector.tsx +++ b/src/components/Reactions/ReactionSelector.tsx @@ -7,9 +7,9 @@ import { useComponentContext } from '../../context/ComponentContext'; import { useMessageContext } from '../../context/MessageContext'; import { useTranslationContext } from '../../context/TranslationContext'; import { Button } from '../Button'; -import { IconPlus } from '../Icons'; import type { ReactionResponse } from 'stream-chat'; +import { useComponentContextIcons } from '../../context'; export type ReactionSelectorProps = { /** Override dialog id used by the selector popover. */ @@ -33,6 +33,7 @@ interface ReactionSelectorInterface { const stableOwnReactions: ReactionResponse[] = []; export const ReactionSelector: ReactionSelectorInterface = (props) => { + const { IconPlus } = useComponentContextIcons(); const { dialogId: propDialogId, handleReaction: propHandleReaction, diff --git a/src/components/Reactions/ReactionSelectorWithButton.tsx b/src/components/Reactions/ReactionSelectorWithButton.tsx index 3ad3fdcc5..64e800869 100644 --- a/src/components/Reactions/ReactionSelectorWithButton.tsx +++ b/src/components/Reactions/ReactionSelectorWithButton.tsx @@ -4,6 +4,7 @@ import { ReactionSelector as DefaultReactionSelector } from './ReactionSelector' import { DialogAnchor, useDialogIsOpen, useDialogOnNearestManager } from '../Dialog'; import { useComponentContext, + useComponentContextIcons, useMessageContext, useTranslationContext, } from '../../context'; @@ -12,8 +13,8 @@ import type { IconProps } from '../../types/types'; import { QuickMessageActionsButton } from '../MessageActions'; type ReactionSelectorWithButtonProps = { - /* Custom component rendering the icon used in a button invoking reactions selector for a given message. */ - ReactionIcon: React.ComponentType; + /* Custom component rendering the icon used in a button invoking reactions selector for a given message. Defaults to the `icons.IconEmoji` slot on `ComponentContext`. */ + ReactionIcon?: React.ComponentType; }; /** @@ -26,6 +27,9 @@ export const ReactionSelectorWithButton = ({ const { t } = useTranslationContext(); const { isMyMessage, message, threadList } = useMessageContext(); const { ReactionSelector = DefaultReactionSelector } = useComponentContext(); + const { IconEmoji } = useComponentContextIcons(); + // The prop still wins: it targets one message's reaction button, the slot rebrands all of them. + const ResolvedReactionIcon = ReactionIcon ?? IconEmoji; const buttonRef = useRef>(null); // MUST match the id `MessageActions` derives via `ReactionSelector.getDialogId` — it // uses that to keep `.str-chat__message-options--active` applied while the reaction @@ -60,7 +64,7 @@ export const ReactionSelectorWithButton = ({ onClick={() => dialog?.toggle()} ref={buttonRef} > - + ); diff --git a/src/components/Reactions/__tests__/ReactionSelectorWithButton.test.tsx b/src/components/Reactions/__tests__/ReactionSelectorWithButton.test.tsx index a2ea1a239..86f81b875 100644 --- a/src/components/Reactions/__tests__/ReactionSelectorWithButton.test.tsx +++ b/src/components/Reactions/__tests__/ReactionSelectorWithButton.test.tsx @@ -13,35 +13,41 @@ import { ReactionSelector } from '../ReactionSelector'; const capturedDialogIds: string[] = []; -vi.mock('../../../context', () => ({ - useComponentContext: () => ({}), - useMessageContext: () => ({ - isMyMessage: () => false, - message: { id: 'message-1' }, - threadList: false, - }), - useTranslationContext: () => ({ - t: (key: string, second?: unknown, third?: unknown) => { - const defaultValue = typeof second === 'string' ? second : undefined; - const options = ((typeof second === 'object' ? second : third) ?? {}) as Record< - string, - unknown - >; - let template = defaultValue; - if (template === undefined && typeof options.count === 'number') { - template = ( - options.count === 1 ? options.defaultValue_one : options.defaultValue_other - ) as string | undefined; - } - template ??= options.defaultValue as string | undefined; - template ??= key; - return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (whole, name: string) => { - const value = options[name]; - return value === undefined || value === null ? whole : String(value); - }); - }, - }), -})); +vi.mock('../../../context', async (importOriginal) => { + const actual = await importOriginal(); + return { + useComponentContext: () => ({}), + // The real hook: with no provider it returns the SDK icons, which is what these + // assertions are written against. + useComponentContextIcons: actual.useComponentContextIcons, + useMessageContext: () => ({ + isMyMessage: () => false, + message: { id: 'message-1' }, + threadList: false, + }), + useTranslationContext: () => ({ + t: (key: string, second?: unknown, third?: unknown) => { + const defaultValue = typeof second === 'string' ? second : undefined; + const options = ((typeof second === 'object' ? second : third) ?? {}) as Record< + string, + unknown + >; + let template = defaultValue; + if (template === undefined && typeof options.count === 'number') { + template = ( + options.count === 1 ? options.defaultValue_one : options.defaultValue_other + ) as string | undefined; + } + template ??= options.defaultValue as string | undefined; + template ??= key; + return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (whole, name: string) => { + const value = options[name]; + return value === undefined || value === null ? whole : String(value); + }); + }, + }), + }; +}); vi.mock('../../Dialog', () => ({ DialogAnchor: () => null, diff --git a/src/components/Search/SearchBar/SearchBar.tsx b/src/components/Search/SearchBar/SearchBar.tsx index 56283d5ff..47e6e4eb3 100644 --- a/src/components/Search/SearchBar/SearchBar.tsx +++ b/src/components/Search/SearchBar/SearchBar.tsx @@ -4,9 +4,9 @@ import React, { useEffect, useRef, useState } from 'react'; import { useSearchContext } from '../SearchContext'; import { useSearchQueriesInProgress } from '../hooks'; import { useInteractionAnnouncements } from '../../Accessibility'; -import { useTranslationContext } from '../../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../../context'; import { useStateStore } from '../../../store'; -import { Button, IconSearch, IconXCircle } from '../../../components'; +import { Button } from '../../../components'; import type { SearchControllerState } from 'stream-chat'; @@ -17,6 +17,7 @@ const searchControllerStateSelector = (nextValue: SearchControllerState) => ({ export const SearchBar = () => { const { t } = useTranslationContext(); + const { IconSearch, IconXCircle } = useComponentContextIcons(); const { announceInteraction } = useInteractionAnnouncements(); const { containerRef, diff --git a/src/components/Search/__tests__/Search.test.tsx b/src/components/Search/__tests__/Search.test.tsx index b2d6e5db3..22c0a4e8c 100644 --- a/src/components/Search/__tests__/Search.test.tsx +++ b/src/components/Search/__tests__/Search.test.tsx @@ -7,8 +7,10 @@ import { Search } from '../Search'; import { useChatContext, useComponentContext, + useComponentContextIcons, useTranslationContext, } from '../../../context'; +import * as DEFAULT_ICONS from '../../Icons/icons'; import type { ChatContextValue, ComponentContextValue, @@ -72,6 +74,9 @@ describe('Search', () => { searchSourceTypes: [], sources: [], }); + // The module is auto-mocked, so the icon hook would return undefined; hand back the real + // icons rather than stubs, so these tests keep asserting against what users actually see. + vi.mocked(useComponentContextIcons).mockReturnValue(DEFAULT_ICONS); }); it('renders search container with default built-in components', () => { diff --git a/src/components/Search/__tests__/SearchBar.test.tsx b/src/components/Search/__tests__/SearchBar.test.tsx index 4699bbb40..f340d96b3 100644 --- a/src/components/Search/__tests__/SearchBar.test.tsx +++ b/src/components/Search/__tests__/SearchBar.test.tsx @@ -7,7 +7,8 @@ import { useSearchContext } from '../SearchContext'; import type { SearchContextValue } from '../SearchContext'; import { useSearchQueriesInProgress } from '../hooks'; import type { TranslationContextValue } from '../../../context'; -import { useTranslationContext } from '../../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../../context'; +import * as DEFAULT_ICONS from '../../Icons/icons'; import { useStateStore } from '../../../store'; import { axe } from '../../../../axe-helper'; import { mockT } from '../../../mock-builders/translator'; @@ -59,6 +60,9 @@ describe('SearchBar', () => { isActive: false, searchQuery: '', }); + // The module is auto-mocked, so the icon hook would return undefined; hand back the real + // icons rather than stubs, so these tests keep asserting against what users actually see. + vi.mocked(useComponentContextIcons).mockReturnValue(DEFAULT_ICONS); }); it('renders with default state', () => { diff --git a/src/components/SummarizedMessagePreview/SummarizedMessagePreview.tsx b/src/components/SummarizedMessagePreview/SummarizedMessagePreview.tsx index 9944918c6..36ed4f4f5 100644 --- a/src/components/SummarizedMessagePreview/SummarizedMessagePreview.tsx +++ b/src/components/SummarizedMessagePreview/SummarizedMessagePreview.tsx @@ -6,21 +6,7 @@ import { useLatestMessagePreview, type UseLatestMessagePreviewParams, } from './hooks/useLatestMessagePreview'; -import { - IconCamera, - IconCheckmark1Small, - IconChecks, - IconClock, - IconExclamationCircleFill, - IconFile, - IconGiphy, - IconLink, - IconLocation, - IconNoSign, - IconUnsupportedAttachment, - IconVideo, - IconVoice, -} from '../Icons'; +import { useComponentContextIcons } from '../../context'; /** * Props for {@link SummarizedMessagePreview}. Override the component via `ComponentContext` @@ -29,33 +15,50 @@ import { */ export type SummarizedMessagePreviewProps = UseLatestMessagePreviewParams; -const deliveryStatusIconMap: Record = { - delivered: IconChecks, - read: IconChecks, - sending: IconClock, - sent: IconCheckmark1Small, -}; - -const contentTypeIconMap: Partial< - Record -> = { - deleted: IconNoSign, - error: IconExclamationCircleFill, - file: IconFile, - giphy: IconGiphy, - image: IconCamera, - link: IconLink, - location: IconLocation, - unsupported: IconUnsupportedAttachment, - video: IconVideo, - voice: IconVoice, -}; - export const SummarizedMessagePreview = ({ latestMessage, messageDeliveryStatus, participantCount, }: UseLatestMessagePreviewParams) => { + const { + IconCamera, + IconCheckmark1Small, + IconChecks, + IconClock, + IconExclamationCircleFill, + IconFile, + IconGiphy, + IconLink, + IconLocation, + IconNoSign, + IconUnsupportedAttachment, + IconVideo, + IconVoice, + } = useComponentContextIcons(); + + const deliveryStatusIconMap: Record = + { + delivered: IconChecks, + read: IconChecks, + sending: IconClock, + sent: IconCheckmark1Small, + }; + + const contentTypeIconMap: Partial< + Record + > = { + deleted: IconNoSign, + error: IconExclamationCircleFill, + file: IconFile, + giphy: IconGiphy, + image: IconCamera, + link: IconLink, + location: IconLocation, + unsupported: IconUnsupportedAttachment, + video: IconVideo, + voice: IconVoice, + }; + const { deliveryStatus, senderName, text, type } = useLatestMessagePreview({ latestMessage, messageDeliveryStatus, diff --git a/src/components/TextareaComposer/SuggestionList/MentionItem/BroadcastMentionItem.tsx b/src/components/TextareaComposer/SuggestionList/MentionItem/BroadcastMentionItem.tsx index f9e0aedca..7e4dc26cf 100644 --- a/src/components/TextareaComposer/SuggestionList/MentionItem/BroadcastMentionItem.tsx +++ b/src/components/TextareaComposer/SuggestionList/MentionItem/BroadcastMentionItem.tsx @@ -1,9 +1,8 @@ import clsx from 'clsx'; import React from 'react'; import type { ChannelMentionSuggestion, HereMentionSuggestion } from 'stream-chat'; -import { IconMegaphone } from '../../../Icons'; import { ListItemLayout } from '../../../ListItemLayout'; -import { useTranslationContext } from '../../../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../../../context'; import { MentionSuggestionTitle } from './MentionSuggestionTitle'; import type { MentionItemComponentProps } from './types'; @@ -16,6 +15,7 @@ export const BroadcastMentionItem = ({ focused, ...buttonProps }: BroadcastMentionItemProps) => { + const { IconMegaphone } = useComponentContextIcons(); const { t } = useTranslationContext(); const description = entity.mentionType === 'channel' diff --git a/src/components/TextareaComposer/SuggestionList/MentionItem/RoleItem.tsx b/src/components/TextareaComposer/SuggestionList/MentionItem/RoleItem.tsx index 3d5604bd4..3c83727bf 100644 --- a/src/components/TextareaComposer/SuggestionList/MentionItem/RoleItem.tsx +++ b/src/components/TextareaComposer/SuggestionList/MentionItem/RoleItem.tsx @@ -1,9 +1,8 @@ import clsx from 'clsx'; import React from 'react'; import type { RoleMentionSuggestion } from 'stream-chat'; -import { IconShield } from '../../../Icons'; import { ListItemLayout } from '../../../ListItemLayout'; -import { useTranslationContext } from '../../../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../../../context'; import { MentionSuggestionTitle } from './MentionSuggestionTitle'; import type { MentionItemComponentProps } from './types'; import { TokenizedSuggestionParts } from '../TokenizedSuggestionParts'; @@ -11,6 +10,7 @@ import { TokenizedSuggestionParts } from '../TokenizedSuggestionParts'; export type RoleItemProps = MentionItemComponentProps; export const RoleItem = ({ entity, focused, ...buttonProps }: RoleItemProps) => { + const { IconShield } = useComponentContextIcons(); void focused; const { t } = useTranslationContext(); const role = entity.name; diff --git a/src/components/TextareaComposer/SuggestionList/MentionItem/UserGroupItem.tsx b/src/components/TextareaComposer/SuggestionList/MentionItem/UserGroupItem.tsx index cad38aca7..8b2b970c9 100644 --- a/src/components/TextareaComposer/SuggestionList/MentionItem/UserGroupItem.tsx +++ b/src/components/TextareaComposer/SuggestionList/MentionItem/UserGroupItem.tsx @@ -1,11 +1,11 @@ import clsx from 'clsx'; import React from 'react'; import type { UserGroupMentionSuggestion } from 'stream-chat'; -import { IconUsers } from '../../../Icons'; import { ListItemLayout } from '../../../ListItemLayout'; import { MentionSuggestionTitle } from './MentionSuggestionTitle'; import type { MentionItemComponentProps } from './types'; import { TokenizedSuggestionParts } from '../TokenizedSuggestionParts'; +import { useComponentContextIcons } from '../../../../context'; export type UserGroupItemProps = MentionItemComponentProps; @@ -14,6 +14,7 @@ export const UserGroupItem = ({ focused, ...buttonProps }: UserGroupItemProps) => { + const { IconUsers } = useComponentContextIcons(); void focused; return ( diff --git a/src/components/TextareaComposer/__tests__/CommandItem.test.tsx b/src/components/TextareaComposer/__tests__/CommandItem.test.tsx index df239d7db..bac43363d 100644 --- a/src/components/TextareaComposer/__tests__/CommandItem.test.tsx +++ b/src/components/TextareaComposer/__tests__/CommandItem.test.tsx @@ -21,29 +21,35 @@ vi.mock('../../MessageComposer/hooks', () => ({ }), })); -vi.mock('../../../context', () => ({ - useTranslationContext: () => ({ - t: (key: string, second?: unknown, third?: unknown) => { - const defaultValue = typeof second === 'string' ? second : undefined; - const options = ((typeof second === 'object' ? second : third) ?? {}) as Record< - string, - unknown - >; - let template = defaultValue; - if (template === undefined && typeof options.count === 'number') { - template = ( - options.count === 1 ? options.defaultValue_one : options.defaultValue_other - ) as string | undefined; - } - template ??= options.defaultValue as string | undefined; - template ??= key; - return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (whole, name: string) => { - const value = options[name]; - return value === undefined || value === null ? whole : String(value); - }); - }, - }), -})); +vi.mock('../../../context', async (importOriginal) => { + const actual = await importOriginal(); + return { + // The real hook: with no provider it returns the SDK icons, which is what these + // assertions are written against. + useComponentContextIcons: actual.useComponentContextIcons, + useTranslationContext: () => ({ + t: (key: string, second?: unknown, third?: unknown) => { + const defaultValue = typeof second === 'string' ? second : undefined; + const options = ((typeof second === 'object' ? second : third) ?? {}) as Record< + string, + unknown + >; + let template = defaultValue; + if (template === undefined && typeof options.count === 'number') { + template = ( + options.count === 1 ? options.defaultValue_one : options.defaultValue_other + ) as string | undefined; + } + template ??= options.defaultValue as string | undefined; + template ??= key; + return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (whole, name: string) => { + const value = options[name]; + return value === undefined || value === null ? whole : String(value); + }); + }, + }), + }; +}); afterEach(cleanup); diff --git a/src/components/Thread/ThreadHeader.tsx b/src/components/Thread/ThreadHeader.tsx index 7bc165de6..8c9b6ec88 100644 --- a/src/components/Thread/ThreadHeader.tsx +++ b/src/components/Thread/ThreadHeader.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { useChannel } from '../../context'; +import { useChannel, useComponentContextIcons } from '../../context'; import { useTranslationContext } from '../../context/TranslationContext'; import { useStateStore } from '../../store'; import { useChannelPreviewInfo } from '../ChannelListItem/hooks/useChannelPreviewInfo'; @@ -13,7 +13,6 @@ import { useComponentContext } from '../../context/ComponentContext'; import type { EventPayload, LocalMessage } from 'stream-chat'; import type { TextComposerState, ThreadState } from 'stream-chat'; import { Button } from '../Button'; -import { IconXmark } from '../Icons'; import { useWorkspaceNavigation } from '../../context'; import type { ChannelConfig } from 'stream-chat'; @@ -85,6 +84,7 @@ export type ThreadHeaderProps = { }; export const ThreadHeader = ({ overrideTitle }: ThreadHeaderProps) => { + const { IconXmark } = useComponentContextIcons(); const { t } = useTranslationContext(); const channel = useChannel(); const { HeaderStartContent } = useComponentContext(); diff --git a/src/components/Threads/ThreadList/ThreadListEmptyPlaceholder.tsx b/src/components/Threads/ThreadList/ThreadListEmptyPlaceholder.tsx index e8d9ca67e..9a8e1b633 100644 --- a/src/components/Threads/ThreadList/ThreadListEmptyPlaceholder.tsx +++ b/src/components/Threads/ThreadList/ThreadListEmptyPlaceholder.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import { useTranslationContext } from '../../../context'; -import { IconMessageBubbles } from '../../Icons'; +import { useComponentContextIcons, useTranslationContext } from '../../../context'; export const ThreadListEmptyPlaceholder = () => { + const { IconMessageBubbles } = useComponentContextIcons(); const { t } = useTranslationContext(); return ( diff --git a/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx b/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx index cc8286116..f03c5e367 100644 --- a/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx +++ b/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx @@ -3,8 +3,11 @@ import clsx from 'clsx'; import type { ThreadManagerState } from 'stream-chat'; -import { IconRefresh } from '../../Icons'; -import { useChatContext, useTranslationContext } from '../../../context'; +import { + useChatContext, + useComponentContextIcons, + useTranslationContext, +} from '../../../context'; import { useStateStore } from '../../../store'; import { LoadingIndicator } from '../../Loading'; @@ -14,6 +17,7 @@ const selector = (nextValue: ThreadManagerState) => ({ }); export const ThreadListUnseenThreadsBanner = () => { + const { IconRefresh } = useComponentContextIcons(); const { client } = useChatContext(); const { t } = useTranslationContext(); const { isLoading, unseenThreadIds } = useStateStore(client.threads.state, selector); diff --git a/src/components/VideoPlayer/VideoThumbnail.tsx b/src/components/VideoPlayer/VideoThumbnail.tsx index 8b0471071..2674ee382 100644 --- a/src/components/VideoPlayer/VideoThumbnail.tsx +++ b/src/components/VideoPlayer/VideoThumbnail.tsx @@ -1,9 +1,8 @@ import { BaseImage, type BaseImageProps } from '../BaseImage'; import { Button } from '../Button'; import clsx from 'clsx'; -import { IconPlayFill } from '../Icons'; import React from 'react'; -import { useTranslationContext } from '../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../context'; export type VideoThumbnailProps = BaseImageProps & { onPlay?: () => void; @@ -14,6 +13,7 @@ export const VideoThumbnail = ({ onPlay, ...imageProps }: VideoThumbnailProps) => { + const { IconPlayFill } = useComponentContextIcons(); const { t } = useTranslationContext(); return ( diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index 0ea7b66b3..0ee07bdbc 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -81,6 +81,7 @@ import type { UploadedSizeIndicatorProps } from '../components/Loading/UploadedS import type { NotificationAnnouncerProps } from '../components/Accessibility'; import type { SummarizedMessagePreviewProps } from '../components/SummarizedMessagePreview'; import type { TypingIndicatorProps } from '../components/TypingIndicator'; +import type { IconSlots } from '../components/Icons/slots'; export type ComponentContextValue = { /** Custom UI component rendered when a paginated list (e.g. the channel list) is empty. */ @@ -167,6 +168,8 @@ export type ComponentContextValue = { GiphyPreviewMessage?: React.ComponentType; /** Custom UI component to render at the top of the `MessageList` */ HeaderComponent?: React.ComponentType; + /** Overrides for the SDK's own icons, keyed by icon name. Merged per slot with the defaults, so overriding one icon leaves the rest intact. Read through [useComponentContextIcons](https://github.com/GetStream/stream-chat-react/blob/master/src/context/useComponentContextIcons.ts) */ + icons?: IconSlots; /** Custom UI component handling how the message composer is rendered, defaults to and accepts the same props as [MessageComposerUI](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageComposer/MessageComposerUI.tsx) */ MessageComposerUI?: React.ComponentType; /** Custom component to render link previews in message composer **/ diff --git a/src/context/WithComponents.tsx b/src/context/WithComponents.tsx index 621d6fcb0..8ac2d4db0 100644 --- a/src/context/WithComponents.tsx +++ b/src/context/WithComponents.tsx @@ -14,7 +14,13 @@ export function WithComponents({ // `useComponentContext()` consumer below it, which would defeat the per-message // memoization in `areMessagePropsEqual`. const actualOverrides: ComponentContextValue = useMemo( - () => ({ ...parentOverrides, ...overrides }), + () => ({ + ...parentOverrides, + ...overrides, + // `icons` merges per slot rather than being replaced wholesale: a nested provider that + // rebrands one icon must not clear the ones an ancestor supplied. + icons: { ...parentOverrides?.icons, ...overrides?.icons }, + }), [parentOverrides, overrides], ); return ( diff --git a/src/context/__tests__/iconSlotIntegration.test.tsx b/src/context/__tests__/iconSlotIntegration.test.tsx new file mode 100644 index 000000000..85978ed05 --- /dev/null +++ b/src/context/__tests__/iconSlotIntegration.test.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { WithComponents } from '../WithComponents'; +import { ErrorBadge } from '../../components/Badge/Badge'; +import { LoadingIndicator } from '../../components/Loading/LoadingIndicator'; +import { CloseButtonOnModalOverlay } from '../../components/Modal/CloseButtonOnModalOverlay'; +import { ChannelDetailEmptyList } from '../../plugins/ChannelDetail/ChannelDetailEmptyList'; + +/** + * R1 proves the hook merges correctly. This proves the other half: that converted call sites + * actually read through it, rather than still importing an icon directly. + */ +describe('icon slots reach converted call sites', () => { + const Custom = () => ; + + it('renders the SDK icon with no override', () => { + render(); + + expect(screen.getByTestId('loading')).toHaveClass('str-chat__loading-indicator'); + expect(screen.queryByTestId('custom-icon')).not.toBeInTheDocument(); + }); + + it.each([ + ['LoadingIndicator', 'IconLoading', ], + ['ErrorBadge', 'IconExclamationMarkFill', ], + ['CloseButtonOnModalOverlay', 'IconXmark', ], + // a v15-only plugin component, converted in R3 + ['ChannelDetailEmptyList', 'IconSearch', ], + ])('%s honours an %s override', (_name, slot, element) => { + render( + + {element} + , + ); + + expect(screen.getByTestId('custom-icon')).toBeInTheDocument(); + }); + + it('an override for one slot does not leak into a component using another', () => { + render( + + + , + ); + + expect(screen.queryByTestId('custom-icon')).not.toBeInTheDocument(); + }); +}); diff --git a/src/context/__tests__/useComponentContextIcons.test.tsx b/src/context/__tests__/useComponentContextIcons.test.tsx new file mode 100644 index 000000000..f116ac31d --- /dev/null +++ b/src/context/__tests__/useComponentContextIcons.test.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import { render, renderHook, screen } from '@testing-library/react'; +import type { PropsWithChildren } from 'react'; + +import { useComponentContextIcons } from '../useComponentContextIcons'; +import { WithComponents } from '../WithComponents'; +import * as DEFAULT_ICONS from '../../components/Icons/icons'; +import type { IconSlots } from '../../components/Icons/slots'; + +const CustomFlag = () => ; +const CustomSend = () => ; + +const withIcons = (icons: IconSlots) => + function Wrapper({ children }: PropsWithChildren) { + return {children}; + }; + +describe('useComponentContextIcons', () => { + it('returns the SDK icons when nothing is overridden', () => { + const { result } = renderHook(() => useComponentContextIcons()); + + expect(result.current.IconFlag).toBe(DEFAULT_ICONS.IconFlag); + expect(result.current.IconSend).toBe(DEFAULT_ICONS.IconSend); + }); + + it('covers every icon the SDK exports, so call sites can destructure without fallbacks', () => { + const { result } = renderHook(() => useComponentContextIcons()); + + const exported = Object.keys(DEFAULT_ICONS).sort(); + expect(Object.keys(result.current).sort()).toEqual(exported); + expect(Object.values(result.current).every(Boolean)).toBe(true); + }); + + it('overriding one icon leaves its siblings intact', () => { + const { result } = renderHook(() => useComponentContextIcons(), { + wrapper: withIcons({ IconFlag: CustomFlag }), + }); + + expect(result.current.IconFlag).toBe(CustomFlag); + expect(result.current.IconSend).toBe(DEFAULT_ICONS.IconSend); + }); + + it('a nested provider merges with an ancestor rather than clearing it', () => { + const wrapper = ({ children }: PropsWithChildren) => ( + + + {children} + + + ); + + const { result } = renderHook(() => useComponentContextIcons(), { wrapper }); + + expect(result.current.IconFlag).toBe(CustomFlag); + expect(result.current.IconSend).toBe(CustomSend); + }); + + it('a nested provider wins for a slot the ancestor also set', () => { + const wrapper = ({ children }: PropsWithChildren) => ( + + + {children} + + + ); + + const { result } = renderHook(() => useComponentContextIcons(), { wrapper }); + + expect(result.current.IconFlag).toBe(CustomSend); + }); + + it('a slot explicitly set to undefined falls back to the SDK icon', () => { + const { result } = renderHook(() => useComponentContextIcons(), { + wrapper: withIcons({ IconFlag: undefined }), + }); + + expect(result.current.IconFlag).toBe(DEFAULT_ICONS.IconFlag); + }); + + it('keeps each icon component stable across re-renders, so icon subtrees are not remounted', () => { + // What React compares when deciding to remount is the element type — the icon component + // itself — not the object holding it. That stays stable even when a consumer passes + // `overrides` inline and the containing object is rebuilt. + const { rerender, result } = renderHook(() => useComponentContextIcons(), { + wrapper: withIcons({ IconFlag: CustomFlag }), + }); + + const { IconFlag: firstFlag, IconSend: firstSend } = result.current; + rerender(); + + expect(result.current.IconFlag).toBe(firstFlag); + expect(result.current.IconSend).toBe(firstSend); + }); + + it('returns the same object across re-renders when the consumer memoizes its overrides', () => { + const overrides = { icons: { IconFlag: CustomFlag } }; + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + const { rerender, result } = renderHook(() => useComponentContextIcons(), { + wrapper, + }); + + const first = result.current; + rerender(); + + expect(result.current).toBe(first); + }); + + it('picks up an override swapped at runtime', () => { + // The reason this hook keys its memo on `icons` rather than on `[]`: a consumer that swaps an + // icon after mount must see the new one. Keyed on `[]` this assertion fails. + const Probe = () => { + const { IconFlag } = useComponentContextIcons(); + return ; + }; + const Tree = ({ icons }: { icons: IconSlots }) => ( + + + + ); + + const { rerender } = render(); + expect(screen.getByTestId('custom-flag')).toBeInTheDocument(); + + rerender(); + + expect(screen.queryByTestId('custom-flag')).not.toBeInTheDocument(); + expect(screen.getByTestId('custom-send')).toBeInTheDocument(); + }); + + it('a non-component override does not overwrite the SDK icon', () => { + const { result } = renderHook(() => useComponentContextIcons(), { + // a consumer computing overrides dynamically can hand us a hole + wrapper: withIcons({ IconFlag: null as unknown as IconSlots['IconFlag'] }), + }); + + expect(result.current.IconFlag).toBe(DEFAULT_ICONS.IconFlag); + }); +}); diff --git a/src/context/index.ts b/src/context/index.ts index 803a0dc71..73a783e47 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -14,6 +14,7 @@ export * from './ModalContext'; export * from './PollContext'; export * from './TranslationContext'; export * from './useChannel'; +export * from './useComponentContextIcons'; export * from './VirtualizedMessageListContext'; export * from './WithComponents'; export * from './WorkspaceNavigationContext'; diff --git a/src/context/useComponentContextIcons.ts b/src/context/useComponentContextIcons.ts new file mode 100644 index 000000000..6bec3739a --- /dev/null +++ b/src/context/useComponentContextIcons.ts @@ -0,0 +1,32 @@ +import { useMemo } from 'react'; + +import { useComponentContext } from './ComponentContext'; +import * as DEFAULT_ICONS from '../components/Icons/icons'; +import type { IconSlots } from '../components/Icons/slots'; + +/** + * Reads the `icons` override from `ComponentContext` and merges it over the SDK's own icons. Every + * slot is guaranteed defined, so callers destructure without fallbacks: + * + * ```tsx + * const { IconFlag } = useComponentContextIcons(); + * ``` + * + * Overrides supplied via `` win per slot; slots the + * consumer did not provide fall back to the SDK icon. + */ +export const useComponentContextIcons = (): Required => { + const { icons } = useComponentContext(); + + // Keyed on `icons`, not on `[]`: `WithComponents` already memoizes the merged context value, so + // this identity is stable across renders and an override swapped at runtime is still picked up. + // Entries are filtered because a slot explicitly set to `undefined` must fall back to the SDK + // icon rather than spread a hole over it. + return useMemo( + () => ({ + ...DEFAULT_ICONS, + ...Object.fromEntries(Object.entries(icons ?? {}).filter(([, Icon]) => !!Icon)), + }), + [icons], + ); +}; diff --git a/src/plugins/ChannelDetail/ChannelDetail.tsx b/src/plugins/ChannelDetail/ChannelDetail.tsx index 05c2f3614..665e737e1 100644 --- a/src/plugins/ChannelDetail/ChannelDetail.tsx +++ b/src/plugins/ChannelDetail/ChannelDetail.tsx @@ -18,33 +18,37 @@ import { ChannelMediaView } from './Views/ChannelMediaView'; import { ChannelMembersView } from './Views/ChannelMembersView'; import { PinnedMessagesView } from './Views/PinnedMessagesView'; import { Prompt } from '../../components/Dialog'; -import { - IconFolder, - IconImage, - IconInfo, - IconPin, - IconUser, -} from '../../components/Icons'; - -const ChannelManagementNavButtonIcon = () => ( - -); +import { useComponentContextIcons } from '../../context'; -const ChannelMembersNavButtonIcon = () => ( - -); +const ChannelManagementNavButtonIcon = () => { + const { IconInfo } = useComponentContextIcons(); -const PinnedMessagesNavButtonIcon = () => ( - -); + return ; +}; -const ChannelMediaNavButtonIcon = () => ( - -); +const ChannelMembersNavButtonIcon = () => { + const { IconUser } = useComponentContextIcons(); -const ChannelFilesNavButtonIcon = () => ( - -); + return ; +}; + +const PinnedMessagesNavButtonIcon = () => { + const { IconPin } = useComponentContextIcons(); + + return ; +}; + +const ChannelMediaNavButtonIcon = () => { + const { IconImage } = useComponentContextIcons(); + + return ; +}; + +const ChannelFilesNavButtonIcon = () => { + const { IconFolder } = useComponentContextIcons(); + + return ; +}; export const ChannelManagementNavButton = (props: SectionNavigatorNavButtonProps) => ( ( -
- -
{children}
-
-); +export const ChannelDetailEmptyList = ({ children }: PropsWithChildrenOnly) => { + const { IconSearch } = useComponentContextIcons(); + + return ( +
+ +
{children}
+
+ ); +}; diff --git a/src/plugins/ChannelDetail/ChannelDetailSearchInput.tsx b/src/plugins/ChannelDetail/ChannelDetailSearchInput.tsx index cd2ebc126..9e988b644 100644 --- a/src/plugins/ChannelDetail/ChannelDetailSearchInput.tsx +++ b/src/plugins/ChannelDetail/ChannelDetailSearchInput.tsx @@ -1,8 +1,7 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { useTranslationContext } from '../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../context'; import { TextInput } from '../../components/Form'; -import { IconSearch } from '../../components/Icons'; export type ChannelDetailSearchInputProps = { autoFocus?: boolean; @@ -12,6 +11,7 @@ export type ChannelDetailSearchInputProps = { export const ChannelDetailSearchInput = React.memo( ({ autoFocus, onSearchChange, resetKey }: ChannelDetailSearchInputProps) => { + const { IconSearch } = useComponentContextIcons(); const { t } = useTranslationContext(); const [searchInput, setSearchInput] = useState(''); diff --git a/src/plugins/ChannelDetail/SectionNavigator/SectionNavigatorHeader.tsx b/src/plugins/ChannelDetail/SectionNavigator/SectionNavigatorHeader.tsx index ae60f7f95..6c1431279 100644 --- a/src/plugins/ChannelDetail/SectionNavigator/SectionNavigatorHeader.tsx +++ b/src/plugins/ChannelDetail/SectionNavigator/SectionNavigatorHeader.tsx @@ -1,10 +1,9 @@ import React, { useMemo } from 'react'; import { SECTION_NAVIGATOR_LAYOUT, useSectionNavigatorContext } from './SectionNavigator'; -import { useTranslationContext } from '../../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../../context'; import { Button } from '../../../components/Button'; import { Prompt, type PromptHeaderProps } from '../../../components/Dialog'; -import { IconMenu } from '../../../components/Icons'; export type SectionNavigatorHeaderProps = Omit; @@ -24,6 +23,7 @@ export const SectionNavigatorHeader = (props: SectionNavigatorHeaderProps) => { if (props.goBack) return undefined; return function SectionNavigatorHeaderMenuButton() { + const { IconMenu } = useComponentContextIcons(); return (
); diff --git a/src/plugins/SlotLayout/ChatView.tsx b/src/plugins/SlotLayout/ChatView.tsx index 16de115ed..d9173edf2 100644 --- a/src/plugins/SlotLayout/ChatView.tsx +++ b/src/plugins/SlotLayout/ChatView.tsx @@ -12,16 +12,11 @@ import React, { import { useStableId } from '../../components/UtilityComponents/useStableId'; import { Button, type ButtonProps } from '../../components/Button'; -import { - IconMessageBubble, - IconMessageBubbleFill, - IconThread, - IconThreadFill, -} from '../../components/Icons'; import { UnreadCountBadge } from '../../components/Threads/UnreadCountBadge'; import { DialogManagerProvider, useChatContext, + useComponentContextIcons, useTranslationContext, } from '../../context'; import { useStateStore } from '../../store'; @@ -623,6 +618,7 @@ export type ChatViewSelectorItemProps = { export const ChatViewChannelsSelectorButton = ({ iconOnly = true, }: ChatViewSelectorItemProps) => { + const { IconMessageBubble, IconMessageBubbleFill } = useComponentContextIcons(); const { activeView, setActiveView } = useChatViewContext(); const { t } = useTranslationContext(); @@ -648,6 +644,7 @@ export const ChatViewChannelsSelectorButton = ({ export const ChatViewThreadsSelectorButton = ({ iconOnly = true, }: ChatViewSelectorItemProps) => { + const { IconThread, IconThreadFill } = useComponentContextIcons(); const { client } = useChatContext(); const { unreadThreadCount } = useStateStore( client.threads.state,