diff --git a/src/components/Attachment/Geolocation.tsx b/src/components/Attachment/Geolocation.tsx index e997bb259..a4a80483b 100644 --- a/src/components/Attachment/Geolocation.tsx +++ b/src/components/Attachment/Geolocation.tsx @@ -3,9 +3,12 @@ import { useEffect } from 'react'; import { useRef, useState } from 'react'; import React from 'react'; import type { Coords, SharedLocationResponse } from 'stream-chat'; -import { useChatContext, useTranslationContext } from '../../context'; +import { + useChatContext, + useComponentContextIcons, + useTranslationContext, +} from '../../context'; import { ExternalLinkIcon } from './icons'; -import { IconLocation } from '../Icons'; import { Button } from '../Button'; export type GeolocationMapProps = Coords; @@ -102,6 +105,7 @@ const DefaultGeolocationAttachmentMapPlaceholder = ({ location, }: GeolocationAttachmentMapPlaceholderProps) => { const { t } = useTranslationContext(); + const { IconLocation } = useComponentContextIcons(); return (
{ ); }; -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 365a8088d..6c473eb03 100644 --- a/src/components/Attachment/LinkPreview/Card.tsx +++ b/src/components/Attachment/LinkPreview/Card.tsx @@ -2,11 +2,11 @@ import React from 'react'; import { BaseImage } from '../../BaseImage'; import { SafeAnchor } from '../../SafeAnchor'; import { useChannelStateContext } from '../../../context/ChannelStateContext'; +import { useComponentContextIcons } from '../../../context'; 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'; @@ -64,6 +64,7 @@ type CardContentProps = RenderAttachmentProps['attachment']; const CardContent = (props: CardContentProps) => { const { og_scrape_url, text, title, title_link } = props; const url = title_link || og_scrape_url; + const { IconLink } = useComponentContextIcons(); return (
diff --git a/src/components/Attachment/LinkPreview/CardAudio.tsx b/src/components/Attachment/LinkPreview/CardAudio.tsx index 538edbe44..1d8cf6476 100644 --- a/src/components/Attachment/LinkPreview/CardAudio.tsx +++ b/src/components/Attachment/LinkPreview/CardAudio.tsx @@ -1,10 +1,9 @@ import { type AudioPlayerState, ProgressBar, useAudioPlayer } from '../../AudioPlayback'; -import { useMessageContext } from '../../../context'; +import { useComponentContextIcons, useMessageContext } from '../../../context'; import { useStateStore } from '../../../store'; import { PlayButton } from '../../Button'; import type { AudioProps } from '../Audio'; import React from 'react'; -import { IconLink } from '../../Icons'; import { SafeAnchor } from '../../SafeAnchor'; import type { CardProps } from './Card'; @@ -21,22 +20,25 @@ 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 3557c68be..05b8ff741 100644 --- a/src/components/Attachment/ModalGallery.tsx +++ b/src/components/Attachment/ModalGallery.tsx @@ -7,8 +7,11 @@ import { BaseImage as DefaultBaseImage } from '../BaseImage'; import { Gallery as DefaultGallery, GalleryUI } from '../Gallery'; import { LoadingIndicator } from '../Loading'; import { GlobalModal, type ModalCloseSource } from '../Modal'; -import { useComponentContext, useTranslationContext } from '../../context'; -import { IconRetry } from '../Icons'; +import { + useComponentContext, + useComponentContextIcons, + useTranslationContext, +} from '../../context'; import { VideoThumbnail } from '../VideoPlayer/VideoThumbnail'; const MAX_VISIBLE_THUMBNAILS = 4; @@ -144,6 +147,7 @@ const ThumbnailButton = ({ showOverlay, }: ThumbnailButtonProps) => { const { t } = useTranslationContext(); + const { IconRetry } = useComponentContextIcons(); const imageUrl = item.imageUrl; const [isLoadFailed, setIsLoadFailed] = useState(false); const [isImageLoading, setIsImageLoading] = useState(Boolean(imageUrl)); diff --git a/src/components/Attachment/UnsupportedAttachment.tsx b/src/components/Attachment/UnsupportedAttachment.tsx index b91fcb6d1..b9a67645d 100644 --- a/src/components/Attachment/UnsupportedAttachment.tsx +++ b/src/components/Attachment/UnsupportedAttachment.tsx @@ -1,13 +1,14 @@ 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('UnsupportedAttachment'); 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 d6dee8640..16f3d1777 100644 --- a/src/components/Attachment/__tests__/Giphy.test.tsx +++ b/src/components/Attachment/__tests__/Giphy.test.tsx @@ -11,21 +11,25 @@ const { channelStateMock } = vi.hoisted(() => ({ }, })); -vi.mock('../../../context', () => ({ - useChannelStateContext: () => channelStateMock, - useComponentContext: () => ({}), - useTranslationContext: () => ({ - t: (key, params) => - Object.keys(params ?? {}).reduce( - (acc, paramKey) => - acc.replace( - new RegExp(`\\{\\{\\s${paramKey}\\s\\}\\}`, 'g'), - String(params?.[paramKey]), - ), - key.replace(/^aria\//, ''), - ), - }), -})); +vi.mock('../../../context', async (importOriginal) => { + const actual = await importOriginal(); + return { + useChannelStateContext: () => channelStateMock, + useComponentContext: () => ({}), + useComponentContextIcons: actual.useComponentContextIcons, + useTranslationContext: () => ({ + t: (key, params) => + Object.keys(params ?? {}).reduce( + (acc, paramKey) => + acc.replace( + new RegExp(`\\{\\{\\s${paramKey}\\s\\}\\}`, 'g'), + String(params?.[paramKey]), + ), + key.replace(/^aria\//, ''), + ), + }), + }; +}); 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 73c150dff..8be36b534 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,8 @@ 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..34ac8eb67 100644 --- a/src/components/Avatar/Avatar.tsx +++ b/src/components/Avatar/Avatar.tsx @@ -6,10 +6,14 @@ 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 */ + /** + * Custom icon rendered when there is no image and no initials. + * @deprecated Use the `icons.IconUser` slot on `ComponentContext` (via ``) instead. + * Passing this prop still wins over the context slot for backwards compatibility. + */ FallbackIcon?: ComponentType>; /** URL of the avatar image */ imageUrl?: string; @@ -51,7 +55,7 @@ const getInitials = (name?: string) => { */ export const Avatar = ({ className, - FallbackIcon = IconUser, + FallbackIcon, imageUrl, initials: customInitials, isOnline, @@ -59,6 +63,8 @@ export const Avatar = ({ userName, ...rest }: AvatarProps) => { + const { IconUser } = useComponentContextIcons(); + const ResolvedFallbackIcon = FallbackIcon ?? IconUser; const [error, setError] = useState(false); useEffect(() => () => setError(false), [imageUrl]); @@ -113,7 +119,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..3a2c24869 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,11 @@ 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 05129ccbd..bff83df73 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 } from 'stream-chat'; import clsx from 'clsx'; @@ -10,13 +10,15 @@ export type MediaBadgeProps = { variant: 'video' | 'voice-recording' | string; }; -const MediaBadgeVariantToIcon: Record = { - video: IconVideoFill, - voiceRecording: IconMicrophoneSolid, -}; - export const MediaBadge = ({ attachment, variant }: MediaBadgeProps) => { - const Icon = MediaBadgeVariantToIcon[variant]; + const { IconMicrophoneSolid, IconVideoFill } = useComponentContextIcons(); + + const mediaBadgeVariantToIcon: Record = { + video: IconVideoFill, + voiceRecording: IconMicrophoneSolid, + }; + + const Icon = mediaBadgeVariantToIcon[variant]; return (
{ + const { IconImage } = useComponentContextIcons(); + const { t } = useTranslationContext(); return (
& { isPlaying: boolean; }; export const PlayButton = ({ className, isPlaying, ...props }: PlayButtonProps) => { + const { IconPauseFill, IconPlayFill } = useComponentContextIcons(); + const { t } = useTranslationContext(); return ( -); +}: BaseContextMenuButtonProps) => { + const { IconChevronRight } = useComponentContextIcons(); + const ResolvedSubmenuIcon = SubmenuIcon ?? IconChevronRight; + return ( + + ); +}; export type UserContextMenuButtonProps = Pick & ComponentProps<'button'>; @@ -671,6 +682,7 @@ export function ContextMenuContent({ ...props }: ContextMenuContentProps) { const { t } = useTranslationContext(); + const { IconChevronLeft } = useComponentContextIcons(); const resolvedBackLabel = backLabel ?? t('Back'); const { ['aria-describedby']: rootAriaDescribedBy, diff --git a/src/components/Dialog/components/Prompt.tsx b/src/components/Dialog/components/Prompt.tsx index 87eb2c576..ca523d4bd 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'>) => ( @@ -36,6 +39,7 @@ const PromptHeader = ({ }: PromptHeaderProps) => { const { t } = useTranslationContext(); const { dialogId } = useModalContext(); + const { IconArrowLeft, IconXmark } = useComponentContextIcons(); const { descriptionId: derivedDescriptionId, titleId: derivedTitleId } = useAriaIdentifiers(dialogId); const resolvedTitleId = titleId ?? derivedTitleId; diff --git a/src/components/Dialog/components/Viewer.tsx b/src/components/Dialog/components/Viewer.tsx index 70b5ec903..0948663b2 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'>) => ( @@ -32,6 +35,7 @@ const ViewerHeader = ({ }: ViewerHeaderProps) => { const { t } = useTranslationContext(); const { dialogId } = useModalContext(); + const { IconArrowLeft, IconXmark } = useComponentContextIcons(); const { descriptionId: derivedDescriptionId, titleId: derivedTitleId } = useAriaIdentifiers(dialogId); const resolvedTitleId = titleId ?? derivedTitleId; diff --git a/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx b/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx index 1f18a4faa..d9fb89c9f 100644 --- a/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx +++ b/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx @@ -1,7 +1,7 @@ import React from 'react'; +import { useComponentContextIcons } from '../../context'; import { useTranslationContext } from '../../context/TranslationContext'; -import { IconMessageBubble, IconMessageBubbles } from '../Icons'; export type EmptyStateIndicatorProps = { /** List Type: channel | message */ @@ -13,6 +13,7 @@ const UnMemoizedEmptyStateIndicator = (props: EmptyStateIndicatorProps) => { const { listType, messageText } = props; const { t } = useTranslationContext('EmptyStateIndicator'); + const { IconMessageBubble, IconMessageBubbles } = useComponentContextIcons(); if (listType === 'thread') return null; diff --git a/src/components/Form/NumericInput.tsx b/src/components/Form/NumericInput.tsx index e58e7f8b5..710aadb8b 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< @@ -52,6 +51,7 @@ export const NumericInput = forwardRef( const generatedId = useStableId(); const id = idProp ?? generatedId; const { t } = useTranslationContext(); + const { IconMinus, IconPlusSmall } = useComponentContextIcons(); const num = parseNumeric(value); const minDef = min ?? -Infinity; 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 (
{ + const { IconArrowDownCircle, IconXmark } = useComponentContextIcons(); + const { t } = useTranslationContext(); const { MessageTimestamp = DefaultMessageTimestamp } = useComponentContext('GalleryUI'); const { isMyMessage, message } = useMessageContext('GalleryUI'); diff --git a/src/components/Gallery/GalleryUI.tsx b/src/components/Gallery/GalleryUI.tsx index 4112fd415..76a3e146c 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,8 @@ 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..47ee77c42 100644 --- a/src/components/Icons/index.ts +++ b/src/components/Icons/index.ts @@ -1,2 +1,3 @@ export { createIcon } from './createIcon'; export * from './icons'; +export * from './slots'; diff --git a/src/components/Icons/slots.ts b/src/components/Icons/slots.ts new file mode 100644 index 000000000..f05d1f92c --- /dev/null +++ b/src/components/Icons/slots.ts @@ -0,0 +1,101 @@ +import type { ComponentPropsWithoutRef, ComponentType } from 'react'; + +export type IconComponent = ComponentType>; + +/** + * Names of icons that can be overridden via `ComponentContext.icons`. Enumerated from icons + * actually imported from `../components/Icons` anywhere in `src/`. Overrides are deep-merged + * with sibling entries by `WithComponents`, so a consumer can rebrand a single icon without + * clearing the others. + */ +export type IconSlots = Partial< + Record< + | 'IconArchive' + | 'IconArrowDown' + | 'IconArrowDownCircle' + | 'IconArrowLeft' + | 'IconArrowUp' + | 'IconArrowUpRight' + | 'IconAttachment' + | 'IconAudio' + | 'IconBell' + | 'IconBellOff' + | 'IconBolt' + | 'IconBookmark' + | 'IconBookmarkRemove' + | 'IconCamera' + | 'IconCheckmark' + | 'IconCheckmark1Small' + | 'IconChecks' + | 'IconChevronDown' + | 'IconChevronLeft' + | 'IconChevronRight' + | 'IconClock' + | 'IconCommand' + | 'IconCopy' + | 'IconDelete' + | 'IconDownload' + | 'IconEdit' + | 'IconEmoji' + | 'IconEmojiAdd' + | 'IconExclamationCircleFill' + | 'IconExclamationMark' + | 'IconExclamationMarkFill' + | 'IconExclamationTriangleFill' + | 'IconEyeFill' + | 'IconFile' + | 'IconFlag' + | 'IconFolder' + | 'IconGiphy' + | 'IconImage' + | 'IconInfo' + | 'IconLeave' + | 'IconLink' + | 'IconLoading' + | 'IconLocation' + | 'IconMegaphone' + | 'IconMenu' + | 'IconMessageBubble' + | 'IconMessageBubbleFill' + | 'IconMessageBubbles' + | 'IconMicrophoneSolid' + | 'IconMinus' + | 'IconMinusCircle' + | 'IconMore' + | 'IconMute' + | 'IconNoSign' + | 'IconNotification' + | 'IconPauseFill' + | 'IconPin' + | 'IconPlayFill' + | 'IconPlus' + | 'IconPlusSmall' + | 'IconPoll' + | 'IconQuote' + | 'IconRefresh' + | 'IconReorder' + | 'IconReply' + | 'IconRetry' + | 'IconSearch' + | 'IconSend' + | 'IconShield' + | 'IconThread' + | 'IconThreadFill' + | 'IconTranslate' + | 'IconTrophy' + | 'IconUnpin' + | 'IconUnsupportedAttachment' + | 'IconUpload' + | 'IconUser' + | 'IconUserAdd' + | 'IconUserCheck' + | 'IconUserRemove' + | 'IconUsers' + | 'IconVideo' + | 'IconVideoFill' + | 'IconVoice' + | 'IconXmark' + | 'IconXmarkSmall', + IconComponent + > +>; diff --git a/src/components/Loading/LoadingIndicator.tsx b/src/components/Loading/LoadingIndicator.tsx index 1ba631195..9bc81f7a0 100644 --- a/src/components/Loading/LoadingIndicator.tsx +++ b/src/components/Loading/LoadingIndicator.tsx @@ -1,8 +1,10 @@ import React, { type ComponentProps } from 'react'; -import { IconLoading } from '../Icons'; +import { useComponentContextIcons } from '../../context'; +import type { IconLoading as DefaultIconLoading } from '../Icons'; -export type LoadingIndicatorProps = ComponentProps; +export type LoadingIndicatorProps = ComponentProps; -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 5496147b9..6b2355a6a 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,8 @@ 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 +102,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 a3b96cb52..25f50f4cb 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 }, @@ -31,6 +35,8 @@ 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 03abcdf0f..82f15a270 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'; @@ -69,6 +69,7 @@ export const DefaultStartRecordingAudioButton = forwardRef< StartRecordingAudioButtonProps >(function StartRecordingAudioButton(props, ref) { const { t } = useTranslationContext(); + const { IconVoice } = useComponentContextIcons(); return ( -); +}: ComponentProps<'button'>) => { + const { IconXmark } = useComponentContextIcons(); + return ( + + ); +}; diff --git a/src/components/Notifications/Notification.tsx b/src/components/Notifications/Notification.tsx index 75270f2e6..2d8118f37 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,18 +15,20 @@ 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(); if (!notification.severity) return null; - const Icon = IconsBySeverity[notification.severity] ?? null; + const iconsBySeverity: Record = { + error: IconExclamationMark, + info: null, + loading: IconRefresh, + success: IconCheckmark, + warning: IconExclamationTriangleFill, + }; + + const Icon = iconsBySeverity[notification.severity] ?? null; return ( Icon && (
@@ -72,6 +68,8 @@ export const Notification = forwardRef( }: NotificationProps, ref, ) => { + const { IconXmark } = useComponentContextIcons(); + const { removeNotification } = useNotificationApi(); const { t } = useTranslationContext(); diff --git a/src/components/Poll/PollActions/PollResults/PollOptionWithVotesHeader.tsx b/src/components/Poll/PollActions/PollResults/PollOptionWithVotesHeader.tsx index 6e37c8bfc..9f8383450 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 { PollOption, PollState } from 'stream-chat'; -import { IconTrophy } from '../../../Icons'; type PollStateSelectorReturnValue = { maxVotedOptionIds: string[]; @@ -20,6 +23,8 @@ 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 e597ce70a..738999c16 100644 --- a/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx +++ b/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx @@ -1,11 +1,10 @@ import clsx from 'clsx'; import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { TextInput } from '../../Form/TextInput'; -import { useTranslationContext } from '../../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../../context'; import { useMessageComposerController } from '../../MessageComposer/hooks/useMessageComposerController'; import { useStateStore } from '../../../store'; import type { PollComposerOption, PollComposerState } from 'stream-chat'; -import { IconMinusCircle } from '../../Icons'; import { Button, type ButtonProps } from '../../Button'; import { TextInputFieldSet } from '../../Form/TextInputFieldSet'; import { VisuallyHidden } from '../../VisuallyHidden'; @@ -281,15 +280,18 @@ 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 b218f909e..6e1ad9bea 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 { useNotificationApi } from '../../Notifications'; @@ -15,6 +18,8 @@ export type PollCreationDialogControlsProps = { export const PollCreationDialogControls = ({ close, }: PollCreationDialogControlsProps) => { + const { IconSend } = useComponentContextIcons(); + const { t } = useTranslationContext('PollCreationDialogControls'); const { handleSubmit: handleSubmitMessage, textareaRef } = useMessageComposerContext(); const messageComposer = useMessageComposerController(); diff --git a/src/components/Poll/PollCreationDialog/PollOptionReorderHandle.tsx b/src/components/Poll/PollCreationDialog/PollOptionReorderHandle.tsx index edcc0a14d..9e0cd6bd5 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,8 @@ 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 7a713b674..6e50d4306 100644 --- a/src/components/Reactions/MessageReactionsDetail.tsx +++ b/src/components/Reactions/MessageReactionsDetail.tsx @@ -9,13 +9,13 @@ import type { MessageContextValue } from '../../context'; import { useChatContext, useComponentContext, + useComponentContextIcons, useMessageContext, useTranslationContext, } from '../../context'; import type { ReactionSort } from 'stream-chat'; 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< @@ -72,6 +72,7 @@ export const MessageReactionsDetail: MessageReactionsDetailInterface = ({ reactionOptions = defaultReactionOptions, ReactionSelectorExtendedList = ReactionSelector.ExtendedList, } = useComponentContext(MessageReactionsDetail.name); + const { IconEmojiAdd } = useComponentContextIcons(); const { t } = useTranslationContext(); const { diff --git a/src/components/Reactions/ReactionSelector.tsx b/src/components/Reactions/ReactionSelector.tsx index 2cda7962f..588295db5 100644 --- a/src/components/Reactions/ReactionSelector.tsx +++ b/src/components/Reactions/ReactionSelector.tsx @@ -7,10 +7,10 @@ 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. */ dialogId?: string; @@ -44,6 +44,7 @@ export const ReactionSelector: ReactionSelectorInterface = (props) => { reactionOptions = defaultReactionOptions, ReactionSelectorExtendedList = ReactionSelector.ExtendedList, } = useComponentContext('ReactionSelector'); + const { IconPlus } = useComponentContextIcons(); const { closeReactionSelectorOnClick, diff --git a/src/components/Reactions/ReactionSelectorWithButton.tsx b/src/components/Reactions/ReactionSelectorWithButton.tsx index d489c81d3..5c4ddfb65 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,12 @@ 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. + * @deprecated Use the `icons.IconEmoji` slot on `ComponentContext` (via ``) instead. + * Passing this prop still wins over the context slot for backwards compatibility. + */ + ReactionIcon?: React.ComponentType; }; /** @@ -25,8 +30,9 @@ export const ReactionSelectorWithButton = ({ }: ReactionSelectorWithButtonProps) => { const { t } = useTranslationContext('ReactionSelectorWithButton'); const { isMyMessage, message, threadList } = useMessageContext('MessageOptions'); - const { ReactionSelector = DefaultReactionSelector } = - useComponentContext('MessageOptions'); + const { ReactionSelector = DefaultReactionSelector } = useComponentContext(); + const { IconEmoji } = useComponentContextIcons(); + const ResolvedReactionIcon = ReactionIcon ?? IconEmoji; const buttonRef = useRef>(null); const dialogId = DefaultReactionSelector.getDialogId({ messageId: message.id, @@ -56,7 +62,7 @@ export const ReactionSelectorWithButton = ({ onClick={() => dialog?.toggle()} ref={buttonRef} > - + ); diff --git a/src/components/SummarizedMessagePreview/SummarizedMessagePreview.tsx b/src/components/SummarizedMessagePreview/SummarizedMessagePreview.tsx index acccac0f8..0057a336c 100644 --- a/src/components/SummarizedMessagePreview/SummarizedMessagePreview.tsx +++ b/src/components/SummarizedMessagePreview/SummarizedMessagePreview.tsx @@ -6,49 +6,52 @@ import { useLatestMessagePreview, type UseLatestMessagePreviewParams, } from './hooks/useLatestMessagePreview'; -import { - IconCamera, - IconCheckmark1Small, - IconChecks, - IconClock, - IconExclamationCircleFill, - IconFile, - IconGiphy, - IconLink, - IconLocation, - IconNoSign, - IconUnsupportedAttachment, - IconVideo, - IconVoice, -} from '../Icons'; - -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, -}; +import { useComponentContextIcons } from '../../context'; 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 d6f1e7105..bcb8ba4b4 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,8 @@ 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 0c3de993d..89558c067 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,8 @@ 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..a30419633 100644 --- a/src/components/TextareaComposer/SuggestionList/MentionItem/UserGroupItem.tsx +++ b/src/components/TextareaComposer/SuggestionList/MentionItem/UserGroupItem.tsx @@ -1,7 +1,7 @@ import clsx from 'clsx'; import React from 'react'; import type { UserGroupMentionSuggestion } from 'stream-chat'; -import { IconUsers } from '../../../Icons'; +import { useComponentContextIcons } from '../../../../context'; import { ListItemLayout } from '../../../ListItemLayout'; import { MentionSuggestionTitle } from './MentionSuggestionTitle'; import type { MentionItemComponentProps } from './types'; @@ -14,6 +14,8 @@ 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 5dffad064..3d456c21f 100644 --- a/src/components/TextareaComposer/__tests__/CommandItem.test.tsx +++ b/src/components/TextareaComposer/__tests__/CommandItem.test.tsx @@ -21,11 +21,16 @@ vi.mock('../../MessageComposer/hooks', () => ({ }), })); -vi.mock('../../../context', () => ({ - useTranslationContext: () => ({ - t: (key: string) => key, - }), -})); +vi.mock('../../../context', async (importOriginal) => { + const actual = await importOriginal(); + return { + useComponentContext: () => ({}), + useComponentContextIcons: actual.useComponentContextIcons, + useTranslationContext: () => ({ + t: (key: string) => key, + }), + }; +}); afterEach(cleanup); diff --git a/src/components/Thread/ThreadHeader.tsx b/src/components/Thread/ThreadHeader.tsx index f61303e62..8d70b39d4 100644 --- a/src/components/Thread/ThreadHeader.tsx +++ b/src/components/Thread/ThreadHeader.tsx @@ -13,9 +13,9 @@ import { useTypingContext } from '../../context/TypingContext'; import type { LocalMessage } from 'stream-chat'; import type { ThreadState } from 'stream-chat'; import { Button } from '../Button'; -import { IconXmark } from '../Icons'; import { useChatViewContext } from '../ChatView'; +import { useComponentContextIcons } from '../../context'; const threadStateSelector = ({ replyCount }: ThreadState) => ({ replyCount }); /** Fallback when channel has no display title: parent message author (name only). */ @@ -72,6 +72,8 @@ export type ThreadHeaderProps = { }; export const ThreadHeader = (props: ThreadHeaderProps) => { + const { IconXmark } = useComponentContextIcons(); + const { closeThread, overrideTitle, thread } = props; const { t } = useTranslationContext(); diff --git a/src/components/Threads/ThreadList/ThreadListEmptyPlaceholder.tsx b/src/components/Threads/ThreadList/ThreadListEmptyPlaceholder.tsx index a15dc0e52..b505755b9 100644 --- a/src/components/Threads/ThreadList/ThreadListEmptyPlaceholder.tsx +++ b/src/components/Threads/ThreadList/ThreadListEmptyPlaceholder.tsx @@ -1,9 +1,10 @@ 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('ThreadListEmptyPlaceholder'); return ( diff --git a/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx b/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx index 66a96d7aa..fce96b126 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,8 @@ 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 724fb2a3a..0dff287f7 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,8 @@ 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 d30f05082..d02d02167 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -72,6 +72,7 @@ import type { SuggestionListProps, } from '../components/TextareaComposer'; +import type { IconSlots } from '../components/Icons'; import type { PropsWithChildrenOnly } from '../types/types'; import type { StopAIGenerationButtonProps } from '../components/MessageComposer/StopAIGenerationButton'; import type { VideoPlayerProps } from '../components/VideoPlayer'; @@ -107,6 +108,8 @@ export type ComponentContextValue = { extractDisplayInfo?: (_: { user?: Partial; }) => NonNullable[number]; + /** Overrides for icons rendered across the SDK. Individual keys are deep-merged with parent overrides via `WithComponents`, so a consumer can rebrand a single icon without wiping out the others. Preferred over component-level icon props (which are `@deprecated`). */ + icons?: IconSlots; /** UI component to display a user's avatar, defaults to and accepts same props as: [Avatar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/Avatar.tsx) */ Avatar?: React.ComponentType; /** UI component to display a list of avatars stacked in a row, defaults to and accepts same props as: [AvatarStack](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/AvatarStack.tsx) */ diff --git a/src/context/WithComponents.tsx b/src/context/WithComponents.tsx index 4f67a0da7..33a89f540 100644 --- a/src/context/WithComponents.tsx +++ b/src/context/WithComponents.tsx @@ -9,7 +9,15 @@ export function WithComponents({ overrides, }: PropsWithChildren<{ overrides: Partial }>) { const parentOverrides = useContext(ComponentContext); - const actualOverrides: ComponentContextValue = { ...parentOverrides, ...overrides }; + const actualOverrides: ComponentContextValue = { + ...parentOverrides, + ...overrides, + icons: { + ...parentOverrides?.icons, + ...overrides?.icons, + }, + }; + return ( {children} diff --git a/src/context/index.ts b/src/context/index.ts index e1e070fd1..982a0e482 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -4,6 +4,7 @@ export * from './ChannelListContext'; export * from './ChannelStateContext'; export * from './ChatContext'; export * from './ComponentContext'; +export * from './useComponentContextIcons'; export * from './DialogManagerContext'; export * from './MessageContext'; export * from './MessageBounceContext'; diff --git a/src/context/useComponentContextIcons.ts b/src/context/useComponentContextIcons.ts new file mode 100644 index 000000000..05932900d --- /dev/null +++ b/src/context/useComponentContextIcons.ts @@ -0,0 +1,33 @@ +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 on top of + * `DEFAULT_ICONS`. Every returned icon is guaranteed defined, so callers can + * destructure without fallbacks: + * + * ```tsx + * const { IconFlag } = useComponentContextIcons(); + * ``` + * + * Overrides supplied via `` win + * over defaults on a per-slot basis; slots the consumer didn't provide fall + * back to the SDK's own icon. + */ +export const useComponentContextIcons = (): Required => { + const { icons } = useComponentContext(); + + return useMemo(() => { + const definedOverrides = Object.fromEntries( + Object.entries(icons ?? {}).filter(([, Icon]) => typeof Icon === 'function'), + ); + + return { ...DEFAULT_ICONS, ...definedOverrides }; + + // Component should be stable. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +}; diff --git a/src/plugins/ChannelDetail/ChannelDetail.tsx b/src/plugins/ChannelDetail/ChannelDetail.tsx index 05c2f3614..860319a1c 100644 --- a/src/plugins/ChannelDetail/ChannelDetail.tsx +++ b/src/plugins/ChannelDetail/ChannelDetail.tsx @@ -18,33 +18,32 @@ 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'; +import { useComponentContextIcons } from '../../context'; -const ChannelManagementNavButtonIcon = () => ( - -); +const ChannelManagementNavButtonIcon = () => { + const { IconInfo } = useComponentContextIcons(); + return ; +}; -const ChannelMembersNavButtonIcon = () => ( - -); +const ChannelMembersNavButtonIcon = () => { + const { IconUser } = useComponentContextIcons(); + return ; +}; -const PinnedMessagesNavButtonIcon = () => ( - -); +const PinnedMessagesNavButtonIcon = () => { + const { IconPin } = useComponentContextIcons(); + return ; +}; -const ChannelMediaNavButtonIcon = () => ( - -); +const ChannelMediaNavButtonIcon = () => { + const { IconImage } = useComponentContextIcons(); + return ; +}; -const ChannelFilesNavButtonIcon = () => ( - -); +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 ec591d60e..4fce944e7 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,8 @@ 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 ef4fc40cb..30584e007 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; @@ -16,6 +15,8 @@ export type SectionNavigatorHeaderProps = Omit { + const { IconMenu } = useComponentContextIcons(); + const { t } = useTranslationContext('SectionNavigatorHeader'); const { layout, openNavigation } = useSectionNavigatorContext(); @@ -38,7 +39,7 @@ export const SectionNavigatorHeader = (props: SectionNavigatorHeaderProps) => { ); }; - }, [layout, openNavigation, props.goBack, t]); + }, [IconMenu, layout, openNavigation, props.goBack, t]); return ; }; diff --git a/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesEmptyList.tsx b/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesEmptyList.tsx index 83669b3e7..4176cb268 100644 --- a/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesEmptyList.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesEmptyList.tsx @@ -1,7 +1,8 @@ -import { useTranslationContext } from '../../../../context'; -import { IconFolder } from '../../../../components/Icons'; +import { useComponentContextIcons, useTranslationContext } from '../../../../context'; export const ChannelFilesEmptyList = () => { + const { IconFolder } = useComponentContextIcons(); + const { t } = useTranslationContext('ChannelFilesEmptyList'); return ( diff --git a/src/plugins/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx index e11394011..1f20b6a8b 100644 --- a/src/plugins/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx @@ -6,9 +6,12 @@ import { fromPartial } from '@total-typescript/shoehorn'; import { useChatContext, + useComponentContext, + useComponentContextIcons, useModalContext, useTranslationContext, } from '../../../../../context'; +import * as DEFAULT_ICONS from '../../../../../components/Icons/icons'; import { useStateStore } from '../../../../../store'; import { ChannelDetailProvider } from '../../../ChannelDetailContext'; import { ChannelFilesView } from '../ChannelFilesView'; @@ -193,6 +196,9 @@ describe('ChannelFilesView', () => { tDateTimeParser: (input?: string | number | Date) => Dayjs(input), } as unknown as ReturnType); + vi.mocked(useComponentContext).mockReturnValue({}); + vi.mocked(useComponentContextIcons).mockReturnValue(DEFAULT_ICONS); + vi.mocked(useChatContext).mockReturnValue({ client: { userID: 'user-1' }, } as ReturnType); diff --git a/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx index 8c04cbeb3..53a7be227 100644 --- a/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx @@ -5,6 +5,7 @@ import type { Channel } from 'stream-chat'; import { useChatContext, useComponentContext, + useComponentContextIcons, useModalContext, useTranslationContext, } from '../../../../context'; @@ -14,13 +15,6 @@ import { useStateStore } from '../../../../store'; import { Alert } from '../../../../components/Dialog'; import { Button } from '../../../../components/Button'; import { Switch } from '../../../../components/Form'; -import { - IconAudio, - IconDelete, - IconLeave, - IconMute, - IconNoSign, -} from '../../../../components/Icons'; import { ListItemLayout } from '../../../../components/ListItemLayout'; import { GlobalModal } from '../../../../components/Modal'; import { useNotificationApi } from '../../../../components/Notifications'; @@ -45,21 +39,36 @@ const toError = (error: unknown) => const getDisplayName = (name?: string, fallback?: string) => name || fallback || ''; -const BlockUserActionIcon = () => ( - -); -const DeleteChatActionIcon = () => ( - -); -const MuteActionIcon = () => ( - -); -const MutedActionIcon = () => ( - -); -const LeaveChannelActionIcon = () => ( - -); +const BlockUserActionIcon = () => { + const { IconNoSign } = useComponentContextIcons(); + return ( + + ); +}; +const DeleteChatActionIcon = () => { + const { IconDelete } = useComponentContextIcons(); + return ( + + ); +}; +const MuteActionIcon = () => { + const { IconMute } = useComponentContextIcons(); + return ( + + ); +}; +const MutedActionIcon = () => { + const { IconAudio } = useComponentContextIcons(); + return ( + + ); +}; +const LeaveChannelActionIcon = () => { + const { IconLeave } = useComponentContextIcons(); + return ( + + ); +}; const channelManagementViewActionClassName = 'str-chat__channel-management-view-action'; diff --git a/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx index b4979b38f..0dd4b0a8d 100644 --- a/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx @@ -10,6 +10,7 @@ import React, { import { useChatContext, useComponentContext, + useComponentContextIcons, useModalContext, useTranslationContext, } from '../../../../context'; @@ -23,7 +24,6 @@ import { useChannelPreviewInfo, useIsUserMuted, } from '../../../../components/ChannelListItem'; -import { IconCheckmark, IconMute, IconPin } from '../../../../components/Icons'; import { useChannelMembershipState } from '../../../../components/ChannelList'; import { useIsChannelMuted } from '../../../../components/ChannelListItem/hooks/useIsChannelMuted'; import { useChannelHasMembersOnline } from '../../../../components/ChannelHeader/hooks/useChannelHasMembersOnline'; @@ -55,6 +55,8 @@ export type ChannelManagementInfoBodyProps = { export const ChannelManagementInfoBody = ({ actions, }: ChannelManagementInfoBodyProps) => { + const { IconMute, IconPin } = useComponentContextIcons(); + const { client } = useChatContext(); const { channel } = useChannelDetailContext(); const { Avatar = DefaultChannelAvatar } = useComponentContext(); @@ -313,6 +315,7 @@ const useChannelManagementEditForm = ({ }; export const ChannelManagementEditBody = (props: ChannelManagementEditBodyProps) => { + const { IconCheckmark } = useComponentContextIcons(); const { Avatar = DefaultChannelAvatar } = useComponentContext(); const { canSubmit, diff --git a/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx index a03128e78..8f2cf2b5b 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx @@ -1,7 +1,8 @@ -import { useTranslationContext } from '../../../../context'; -import { IconImage } from '../../../../components/Icons'; +import { useComponentContextIcons, useTranslationContext } from '../../../../context'; export const ChannelMediaEmptyList = () => { + const { IconImage } = useComponentContextIcons(); + const { t } = useTranslationContext('ChannelMediaEmptyList'); return ( diff --git a/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx index 5e1257e95..a1e2b583c 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useComponentContext, + useComponentContextIcons, useModalContext, useTranslationContext, } from '../../../../context'; @@ -16,12 +17,6 @@ import { } from '../../../../components/BaseImage'; import { Prompt } from '../../../../components/Dialog'; import { Gallery as DefaultGallery, GalleryUI } from '../../../../components/Gallery'; -import { - IconChevronLeft, - IconChevronRight, - IconImage, - IconVideoFill, -} from '../../../../components/Icons'; import { GlobalModal } from '../../../../components/Modal'; import { SectionNavigatorHeader, @@ -54,6 +49,7 @@ const ChannelMediaGridItem = ({ const { t } = useTranslationContext('ChannelMediaView'); const { Avatar = DefaultAvatar, extractDisplayInfo = defaultExtractDisplayInfo } = useComponentContext(); + const { IconImage, IconVideoFill } = useComponentContextIcons(); const displayName = getUserDisplayName(item.user); const mediaSrc = item.type === 'video' @@ -120,6 +116,7 @@ const ChannelMediaPagination = ({ previousDisabled, }: ChannelMediaPaginationProps) => { const { t } = useTranslationContext('ChannelMediaView'); + const { IconChevronLeft, IconChevronRight } = useComponentContextIcons(); return (
diff --git a/src/plugins/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx index 4ffc3e0e5..f08aa665e 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx @@ -6,9 +6,11 @@ import { fromPartial } from '@total-typescript/shoehorn'; import { useChatContext, useComponentContext, + useComponentContextIcons, useModalContext, useTranslationContext, } from '../../../../../context'; +import * as DEFAULT_ICONS from '../../../../../components/Icons/icons'; import { useStateStore } from '../../../../../store'; import { ChannelDetailProvider } from '../../../ChannelDetailContext'; import { ChannelMediaView } from '../ChannelMediaView'; @@ -137,6 +139,7 @@ describe('ChannelMediaView', () => { Modal: ({ children, open }: { children: React.ReactNode; open: boolean }) => open ?
{children}
: null, } as unknown as ReturnType); + vi.mocked(useComponentContextIcons).mockReturnValue(DEFAULT_ICONS); vi.mocked(useStateStore).mockReturnValue({ hasNext: false, diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx index afcc3ff38..27ed32528 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx @@ -15,6 +15,7 @@ import { useChannelListContext, useChatContext, useComponentContext, + useComponentContextIcons, useModalContext, useTranslationContext, } from '../../../../context'; @@ -23,13 +24,6 @@ import { useStateStore } from '../../../../store'; import { Alert } from '../../../../components/Dialog'; import { Button } from '../../../../components/Button'; import { Switch } from '../../../../components/Form'; -import { - IconAudio, - IconMessageBubble, - IconMute, - IconNoSign, - IconUserRemove, -} from '../../../../components/Icons'; import { ListItemLayout } from '../../../../components/ListItemLayout'; import { GlobalModal } from '../../../../components/Modal'; import { useNotificationApi } from '../../../../components/Notifications'; @@ -80,25 +74,38 @@ export const useChannelMemberActionContext = () => { const toError = (error: unknown) => error instanceof Error ? error : new Error('An unknown error occurred'); -const MemberMuteActionIcon = () => ( - -); +const MemberMuteActionIcon = () => { + const { IconMute } = useComponentContextIcons(); + return ( + + ); +}; -const MemberUnmuteActionIcon = () => ( - -); +const MemberUnmuteActionIcon = () => { + const { IconAudio } = useComponentContextIcons(); + return ( + + ); +}; -const SendDirectMessageActionIcon = () => ( - -); +const SendDirectMessageActionIcon = () => { + const { IconMessageBubble } = useComponentContextIcons(); + return ; +}; -const BlockUserActionIcon = () => ( - -); +const BlockUserActionIcon = () => { + const { IconNoSign } = useComponentContextIcons(); + return ( + + ); +}; -const RemoveUserActionIcon = () => ( - -); +const RemoveUserActionIcon = () => { + const { IconUserRemove } = useComponentContextIcons(); + return ( + + ); +}; const channelMemberDetailActionClassName = 'str-chat__channel-member-detail-action'; diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx index 522c6303e..6bdf72b18 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx @@ -6,9 +6,11 @@ import type { Channel, ChannelMemberResponse } from 'stream-chat'; import { useChatContext, useComponentContext, + useComponentContextIcons, useModalContext, useTranslationContext, } from '../../../../../context'; +import * as DEFAULT_ICONS from '../../../../../components/Icons/icons'; import { ChannelDetailProvider } from '../../../ChannelDetailContext'; import { ChannelMemberDetail } from '../ChannelMemberDetail'; @@ -88,6 +90,7 @@ describe('ChannelMemberDetail', () => { vi.mocked(useComponentContext).mockReturnValue( {} as ReturnType, ); + vi.mocked(useComponentContextIcons).mockReturnValue(DEFAULT_ICONS); }); it("renders the provided member's details", () => { diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx index 5a5fe77b0..b2e3099c1 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx @@ -4,13 +4,13 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useChatContext, useComponentContext, + useComponentContextIcons, useTranslationContext, } from '../../../../context'; import { useStateStore } from '../../../../store'; import { Avatar as DefaultAvatar } from '../../../../components/Avatar'; import { extractDisplayInfo as defaultExtractDisplayInfo } from '../../../../components/Avatar/utils'; import { Checkbox } from '../../../../components/Form'; -import { IconMute } from '../../../../components/Icons'; import { ListItemLayout } from '../../../../components/ListItemLayout'; import { VirtualizedList } from '../../VirtualizedList'; import { Prompt } from '../../../../components/Dialog'; @@ -38,9 +38,12 @@ const EMPTY_USERS: UserResponse[] = []; const computeUserItemKey = (_: number, user: UserResponse) => user.id; -const MuteIndicator = () => ( - -); +const MuteIndicator = () => { + const { IconMute } = useComponentContextIcons(); + return ( + + ); +}; const readOnlyRootProps = { className: 'str-chat__channel-detail__channel-members-view__list-item', diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx index 18f10ba5f..0c63ffcfd 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx @@ -4,11 +4,11 @@ import React, { useCallback, useMemo } from 'react'; import { useChatContext, useComponentContext, + useComponentContextIcons, useTranslationContext, } from '../../../../context'; import { Avatar as DefaultAvatar } from '../../../../components/Avatar'; import { extractDisplayInfo as defaultExtractDisplayInfo } from '../../../../components/Avatar/utils'; -import { IconMute } from '../../../../components/Icons'; import { ListItemLayout } from '../../../../components/ListItemLayout'; import { VirtualizedList } from '../../VirtualizedList'; import { Prompt } from '../../../../components/Dialog'; @@ -76,6 +76,7 @@ const ChannelMembersBrowseViewItem = ({ const TrailingSlot = useMemo( () => function MemberTrailingSlot() { + const { IconMute } = useComponentContextIcons(); return (
{roleTranslation ? ( diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx index e125f3c5d..b558867a5 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx @@ -1,7 +1,11 @@ import type { Channel } from 'stream-chat'; import React, { useMemo, useState } from 'react'; -import { useComponentContext, useTranslationContext } from '../../../../context'; +import { + useComponentContext, + useComponentContextIcons, + useTranslationContext, +} from '../../../../context'; import { Button } from '../../../../components/Button'; import { ContextMenu, @@ -15,7 +19,6 @@ import type { ChannelMembersHeaderActionsProps, ChannelMembersModeController, } from './ChannelMembersView'; -import { IconUserAdd } from '../../../../components/Icons'; export type ChannelMembersHeaderActionType = 'addMembers' | (string & {}); @@ -91,6 +94,7 @@ const AddMembersMenuAction = ({ modeController, }: ChannelMembersHeaderActionComponentProps) => { const { t } = useTranslationContext(); + const { IconUserAdd } = useComponentContextIcons(); if (modeController.mode !== 'browse') return null; diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx index 630656a72..6e382cc06 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx @@ -5,8 +5,10 @@ import type { UserResponse } from 'stream-chat'; import { useChatContext, useComponentContext, + useComponentContextIcons, useTranslationContext, } from '../../../../../context'; +import * as DEFAULT_ICONS from '../../../../../components/Icons/icons'; import { useStateStore } from '../../../../../store'; import { ChannelMembersAddView } from '../ChannelMembersAddView'; import { @@ -92,6 +94,8 @@ describe('ChannelMembersAddView', () => { options?.count ? `${key}:${options.count}` : key, } as ReturnType); + vi.mocked(useComponentContext).mockReturnValue({}); + vi.mocked(useChatContext).mockReturnValue({ client: { user: { id: 'user-1' } }, mutes: [], @@ -100,6 +104,7 @@ describe('ChannelMembersAddView', () => { vi.mocked(useComponentContext).mockReturnValue( {} as ReturnType, ); + vi.mocked(useComponentContextIcons).mockReturnValue(DEFAULT_ICONS); vi.mocked(useStateStore).mockReturnValue({ isLoading: false, diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx index 2c6cf0afd..519bf1dfa 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx @@ -5,8 +5,10 @@ import type { ChannelMemberResponse } from 'stream-chat'; import { useChatContext, useComponentContext, + useComponentContextIcons, useTranslationContext, } from '../../../../../context'; +import * as DEFAULT_ICONS from '../../../../../components/Icons/icons'; import { useStateStore } from '../../../../../store'; import { ChannelMembersBrowseView } from '../ChannelMembersBrowseView'; import { createChannel, emitChannelEvent, renderWithChannel } from './testUtils'; @@ -121,6 +123,7 @@ describe('ChannelMembersBrowseView', () => { return key; }, } as ReturnType); + vi.mocked(useComponentContext).mockReturnValue({}); vi.mocked(useChatContext).mockReturnValue({ mutes: [], } as ReturnType); @@ -128,6 +131,7 @@ describe('ChannelMembersBrowseView', () => { vi.mocked(useComponentContext).mockReturnValue( {} as ReturnType, ); + vi.mocked(useComponentContextIcons).mockReturnValue(DEFAULT_ICONS); vi.mocked(useStateStore).mockReturnValue({ isLoading: false, diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx index 29d170ef5..a05203330 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx @@ -3,9 +3,11 @@ import React from 'react'; import { useComponentContext, + useComponentContextIcons, useModalContext, useTranslationContext, } from '../../../../../context'; +import * as DEFAULT_ICONS from '../../../../../components/Icons/icons'; import { type ChannelMembersModeViewProps, ChannelMembersView, @@ -219,6 +221,7 @@ describe('ChannelMembersView', () => { vi.mocked(useComponentContext).mockReturnValue( {} as ReturnType, ); + vi.mocked(useComponentContextIcons).mockReturnValue(DEFAULT_ICONS); vi.mocked(useChannelMemberCount).mockReturnValue(2); }); diff --git a/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx b/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx index 210c9610a..9353e15fd 100644 --- a/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx +++ b/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx @@ -1,7 +1,8 @@ -import { IconPin } from '../../../../components/Icons'; -import { useTranslationContext } from '../../../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../../../context'; export const PinnedMessagesEmptyList = () => { + const { IconPin } = useComponentContextIcons(); + const { t } = useTranslationContext(); return ( diff --git a/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx b/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx index bb676b3cc..421f5fa5c 100644 --- a/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx +++ b/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx @@ -7,9 +7,11 @@ import { useChannelActionContext, useChatContext, useComponentContext, + useComponentContextIcons, useModalContext, useTranslationContext, } from '../../../../../context'; +import * as DEFAULT_ICONS from '../../../../../components/Icons/icons'; import { useStateStore } from '../../../../../store'; import { ChannelDetailProvider } from '../../../ChannelDetailContext'; import { PinnedMessagesView } from '../PinnedMessagesView'; @@ -211,6 +213,8 @@ describe('PinnedMessagesView', () => { tDateTimeParser: (input?: string | Date) => new Date(input ?? Date.now()), } as ReturnType); + vi.mocked(useComponentContext).mockReturnValue({}); + vi.mocked(useChatContext).mockReturnValue({ client: { userID: 'user-1' }, } as ReturnType); @@ -218,6 +222,7 @@ describe('PinnedMessagesView', () => { vi.mocked(useComponentContext).mockReturnValue( {} as ReturnType, ); + vi.mocked(useComponentContextIcons).mockReturnValue(DEFAULT_ICONS); vi.mocked(useModalContext).mockReturnValue({ close: vi.fn(), diff --git a/src/plugins/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx b/src/plugins/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx index 8618ff551..7a0a0897a 100644 --- a/src/plugins/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx +++ b/src/plugins/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx @@ -94,27 +94,31 @@ const mocks = vi.hoisted(() => { }; }); -vi.mock('../../../context', () => ({ - useChatContext: () => ({ - client: mocks.client, - mutes: mocks.mutes, - }), - useComponentContext: () => ({ - Modal: ({ - children, - open, - role, - }: { - children: React.ReactNode; - open: boolean; - role?: string; - }) => (open ?
{children}
: null), - }), - useModalContext: () => ({ close: mocks.close }), - useTranslationContext: () => ({ - t: mocks.useStableTranslationFunction ? mocks.t : (key: string) => mocks.t(key), - }), -})); +vi.mock('../../../context', async (importOriginal) => { + const actual = await importOriginal(); + return { + useChatContext: () => ({ + client: mocks.client, + mutes: mocks.mutes, + }), + useComponentContext: () => ({ + Modal: ({ + children, + open, + role, + }: { + children: React.ReactNode; + open: boolean; + role?: string; + }) => (open ?
{children}
: null), + }), + useComponentContextIcons: actual.useComponentContextIcons, + useModalContext: () => ({ close: mocks.close }), + useTranslationContext: () => ({ + t: mocks.useStableTranslationFunction ? mocks.t : (key: string) => mocks.t(key), + }), + }; +}); vi.mock('../../../components/Notifications', () => ({ useNotificationApi: () => ({ diff --git a/src/plugins/ChannelDetail/__tests__/ChannelManagementView.test.tsx b/src/plugins/ChannelDetail/__tests__/ChannelManagementView.test.tsx index a47c39c2b..7f2d83d44 100644 --- a/src/plugins/ChannelDetail/__tests__/ChannelManagementView.test.tsx +++ b/src/plugins/ChannelDetail/__tests__/ChannelManagementView.test.tsx @@ -28,19 +28,23 @@ const mocks = vi.hoisted(() => ({ mutes: [] as Mute[], })); -vi.mock('../../../context', () => ({ - useChatContext: () => ({ - client: { - user: { id: 'own-user' }, - }, - mutes: mocks.mutes, - }), - useComponentContext: () => ({ - Avatar: () =>
, - }), - useModalContext: () => ({ close: mocks.close }), - useTranslationContext: () => ({ t: (key: string) => key }), -})); +vi.mock('../../../context', async (importOriginal) => { + const actual = await importOriginal(); + return { + useChatContext: () => ({ + client: { + user: { id: 'own-user' }, + }, + mutes: mocks.mutes, + }), + useComponentContext: () => ({ + Avatar: () =>
, + }), + useComponentContextIcons: actual.useComponentContextIcons, + useModalContext: () => ({ close: mocks.close }), + useTranslationContext: () => ({ t: (key: string) => key }), + }; +}); vi.mock('../../../context/ChatContext', () => ({ useChatContext: () => ({ @@ -132,8 +136,8 @@ vi.mock('../../../components/Dialog', () => ({ }, })); -vi.mock('../../../components/Icons', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../components/Icons/icons', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, diff --git a/src/plugins/Emojis/EmojiPicker.tsx b/src/plugins/Emojis/EmojiPicker.tsx index 4972da647..b4c734efd 100644 --- a/src/plugins/Emojis/EmojiPicker.tsx +++ b/src/plugins/Emojis/EmojiPicker.tsx @@ -1,10 +1,13 @@ import React, { useEffect, useState } from 'react'; import PickerImport from '@emoji-mart/react'; -import { useMessageComposerContext, useTranslationContext } from '../../context'; +import { + useComponentContextIcons, + useMessageComposerContext, + useTranslationContext, +} from '../../context'; import { Button, - IconEmoji, type PopperLikePlacement, useMessageComposerController, } from '../../components'; @@ -22,6 +25,10 @@ const Picker = const isShadowRoot = (node: Node): node is ShadowRoot => !!(node as ShadowRoot).host; export type EmojiPickerProps = { + /** + * @deprecated Use the `icons.IconEmoji` slot on `ComponentContext` (via ``) instead. + * Passing this prop still wins over the context slot for backwards compatibility. + */ ButtonIconComponent?: React.ComponentType; buttonClassName?: string; pickerContainerClassName?: string; @@ -72,7 +79,9 @@ export const EmojiPicker = (props: EmojiPickerProps) => { const { pickerContainerClassName, wrapperClassName } = classNames; - const { ButtonIconComponent = IconEmoji } = props; + const { IconEmoji } = useComponentContextIcons(); + const ResolvedButtonIconComponent = props.ButtonIconComponent ?? IconEmoji; + const pickerStyle = props.pickerProps?.style as React.CSSProperties | undefined; useEffect(() => { @@ -134,7 +143,7 @@ export const EmojiPicker = (props: EmojiPickerProps) => { type='button' variant='secondary' > - {ButtonIconComponent && } + {ResolvedButtonIconComponent && }
);