From 60b8f5133155fb45669eeefc2e9b977ed161ca1b Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 6 Feb 2026 15:27:38 +0100 Subject: [PATCH 1/5] fix: adjust message attachment grouping, add compact link previews --- src/components/Attachment/Attachment.tsx | 161 +++++------ .../Attachment/AttachmentContainer.tsx | 112 ++++++-- src/components/Attachment/Card.tsx | 233 ---------------- .../Attachment/LinkPreview/Card.tsx | 120 ++++++++ .../Attachment/LinkPreview/CardAudio.tsx | 104 +++++++ .../LinkPreview/UnableToRenderCard.tsx | 22 ++ .../Attachment/LinkPreview/index.ts | 1 + .../Attachment/__tests__/Card.test.js | 2 +- .../__snapshots__/Attachment.test.js.snap | 65 +---- src/components/Attachment/index.ts | 2 +- .../Attachment/styling/Attachment.scss | 133 +-------- .../Attachment/styling/CardAudio.scss | 13 + .../Attachment/styling/LinkPreview.scss | 117 ++++++++ src/components/Attachment/styling/index.scss | 3 +- src/components/Attachment/utils.tsx | 19 +- .../components/DurationDisplay.tsx | 2 +- src/components/Gallery/Image.tsx | 1 + src/components/Message/MessageErrorText.tsx | 16 +- src/components/Message/MessageSimple.tsx | 3 + src/components/Message/MessageText.tsx | 31 +-- src/components/Message/QuotedMessage.tsx | 3 +- .../Message/__tests__/MessageText.test.js | 4 +- .../Message/styling/DateSeparator.scss | 34 +++ src/components/Message/styling/Message.scss | 260 ++---------------- .../styling/MessageEditedTimestamp.scss | 13 + .../Message/styling/MessageStatus.scss | 53 ++++ .../Message/styling/MessageSystem.scss | 23 ++ .../Message/styling/QuotedMessage.scss | 14 + .../styling/UnreadMessageNotification.scss | 35 +++ .../styling/UnreadMessagesSeparator.scss | 15 + src/components/Message/styling/index.scss | 9 +- .../styling/MessageActions.scss | 47 ++++ src/components/SafeAnchor/SafeAnchor.tsx | 3 - src/styling/_utils.scss | 2 +- 34 files changed, 846 insertions(+), 829 deletions(-) delete mode 100644 src/components/Attachment/Card.tsx create mode 100644 src/components/Attachment/LinkPreview/Card.tsx create mode 100644 src/components/Attachment/LinkPreview/CardAudio.tsx create mode 100644 src/components/Attachment/LinkPreview/UnableToRenderCard.tsx create mode 100644 src/components/Attachment/LinkPreview/index.ts create mode 100644 src/components/Attachment/styling/CardAudio.scss create mode 100644 src/components/Attachment/styling/LinkPreview.scss create mode 100644 src/components/Message/styling/DateSeparator.scss create mode 100644 src/components/Message/styling/MessageEditedTimestamp.scss create mode 100644 src/components/Message/styling/MessageStatus.scss create mode 100644 src/components/Message/styling/MessageSystem.scss create mode 100644 src/components/Message/styling/QuotedMessage.scss create mode 100644 src/components/Message/styling/UnreadMessageNotification.scss create mode 100644 src/components/Message/styling/UnreadMessagesSeparator.scss diff --git a/src/components/Attachment/Attachment.tsx b/src/components/Attachment/Attachment.tsx index 11617ca60e..3edf90e136 100644 --- a/src/components/Attachment/Attachment.tsx +++ b/src/components/Attachment/Attachment.tsx @@ -10,15 +10,11 @@ import { } from 'stream-chat'; import { - AudioContainer, CardContainer, FileContainer, - GalleryContainer, GeolocationContainer, - ImageContainer, MediaContainer, UnsupportedAttachmentContainer, - VoiceRecordingContainer, } from './AttachmentContainer'; import { SUPPORTED_VIDEO_FORMATS } from './utils'; @@ -27,7 +23,7 @@ import type { SharedLocationResponse, Attachment as StreamAttachment } from 'str import type { AttachmentActionsProps } from './AttachmentActions'; import type { AudioProps } from './Audio'; import type { VoiceRecordingProps } from './VoiceRecording'; -import type { CardProps } from './Card'; +import type { CardProps } from './LinkPreview/Card'; import type { FileAttachmentProps } from './FileAttachment'; import type { GalleryProps, ImageProps } from '../Gallery'; import type { UnsupportedAttachmentProps } from './UnsupportedAttachment'; @@ -35,25 +31,11 @@ import type { ActionHandlerReturnType } from '../Message/hooks/useActionHandler' import type { GroupedRenderedAttachment } from './utils'; import type { GeolocationProps } from './Geolocation'; -const CONTAINER_MAP = { - audio: AudioContainer, - // todo: rename to linkPreview - card: CardContainer, - file: FileContainer, - media: MediaContainer, - unsupported: UnsupportedAttachmentContainer, - voiceRecording: VoiceRecordingContainer, -} as const; - export const ATTACHMENT_GROUPS_ORDER = [ - 'card', - 'gallery', - 'image', 'media', - 'audio', - 'voiceRecording', - 'file', + 'card', 'geolocation', + 'file', 'unsupported', ] as const; @@ -86,7 +68,7 @@ export type AttachmentProps = { }; /** - * A component used for rendering message attachments. By default, the component supports: AttachmentActions, Audio, Card, File, Gallery, Image, and Video + * A component used for rendering message attachments. */ export const Attachment = (props: AttachmentProps) => { const { attachments } = props; @@ -111,87 +93,68 @@ const renderGroupedAttachments = ({ attachments, ...rest }: AttachmentProps): GroupedRenderedAttachment => { - const uploadedImages: StreamAttachment[] = attachments.filter((attachment) => - isImageAttachment(attachment), + const mediaAttachments: StreamAttachment[] = []; + const containers = attachments.reduce( + (typeMap, attachment) => { + if (isSharedLocationResponse(attachment)) { + typeMap.geolocation.push( + , + ); + } else if (isScrapedContent(attachment)) { + typeMap.card.push( + , + ); + } else if ( + isImageAttachment(attachment) || + isVideoAttachment(attachment, SUPPORTED_VIDEO_FORMATS) + ) { + mediaAttachments.push(attachment); + } else if ( + isAudioAttachment(attachment) || + isVoiceRecordingAttachment(attachment) || + isFileAttachment(attachment, SUPPORTED_VIDEO_FORMATS) + ) { + typeMap.file.push( + , + ); + } else { + typeMap.unsupported.push( + , + ); + } + + return typeMap; + }, + { + card: [], + file: [], + geolocation: [], + media: [], + unsupported: [], + }, ); - const containers = attachments - .filter((attachment) => !isImageAttachment(attachment)) - .reduce( - (typeMap, attachment) => { - if (isSharedLocationResponse(attachment)) { - typeMap.geolocation.push( - , - ); - } else { - const attachmentType = getAttachmentType(attachment); - - const Container = CONTAINER_MAP[attachmentType]; - typeMap[attachmentType].push( - , - ); - } - - return typeMap; - }, - { - audio: [], - card: [], - file: [], - media: [], - unsupported: [], - // not used in reduce - // eslint-disable-next-line sort-keys - image: [], - // eslint-disable-next-line sort-keys - gallery: [], - geolocation: [], - voiceRecording: [], - }, + if (mediaAttachments.length) { + containers.media.push( + , ); - - if (uploadedImages.length > 1) { - containers['gallery'] = [ - , - ]; - } else if (uploadedImages.length === 1) { - containers['image'] = [ - , - ]; } return containers; }; - -export const getAttachmentType = ( - attachment: AttachmentProps['attachments'][number], -): keyof typeof CONTAINER_MAP => { - if (isScrapedContent(attachment)) { - return 'card'; - } else if (isVideoAttachment(attachment, SUPPORTED_VIDEO_FORMATS)) { - return 'media'; - } else if (isAudioAttachment(attachment)) { - return 'audio'; - } else if (isVoiceRecordingAttachment(attachment)) { - return 'voiceRecording'; - } else if (isFileAttachment(attachment, SUPPORTED_VIDEO_FORMATS)) { - return 'file'; - } - - return 'unsupported'; -}; diff --git a/src/components/Attachment/AttachmentContainer.tsx b/src/components/Attachment/AttachmentContainer.tsx index 8a55659130..c9032e48fd 100644 --- a/src/components/Attachment/AttachmentContainer.tsx +++ b/src/components/Attachment/AttachmentContainer.tsx @@ -4,7 +4,13 @@ import ReactPlayer from 'react-player'; import clsx from 'clsx'; import * as linkify from 'linkifyjs'; import type { Attachment, LocalAttachment, SharedLocationResponse } from 'stream-chat'; -import { isSharedLocationResponse } from 'stream-chat'; +import { + isAudioAttachment, + isFileAttachment, + isSharedLocationResponse, + isVideoAttachment, + isVoiceRecordingAttachment, +} from 'stream-chat'; import { AttachmentActions as DefaultAttachmentActions } from './AttachmentActions'; import { Audio as DefaultAudio } from './Audio'; @@ -14,7 +20,7 @@ import { Gallery as DefaultGallery, ImageComponent as DefaultImage, } from '../Gallery'; -import { Card as DefaultCard } from './Card'; +import { Card as DefaultCard } from './LinkPreview/Card'; import { FileAttachment as DefaultFile } from './FileAttachment'; import { Geolocation as DefaultGeolocation } from './Geolocation'; import { UnsupportedAttachment as DefaultUnsupportedAttachment } from './UnsupportedAttachment'; @@ -24,8 +30,13 @@ import type { GeolocationContainerProps, RenderAttachmentProps, RenderGalleryProps, + RenderMediaProps, +} from './utils'; +import { + isGalleryAttachmentType, + isSvgAttachment, + SUPPORTED_VIDEO_FORMATS, } from './utils'; -import { isGalleryAttachmentType, isSvgAttachment } from './utils'; import { useChannelStateContext } from '../../context/ChannelStateContext'; import type { ImageAttachmentConfiguration, @@ -109,6 +120,74 @@ function getCssDimensionsVariables(url: string) { return cssVars; } +export const MediaContainer = (props: RenderMediaProps) => { + const { attachments } = props; + const mediaAttachments = attachments; + if (!mediaAttachments.length) return null; + + if (mediaAttachments.length > 1) { + return ( + + ); + } + + const mediaAttachment = mediaAttachments[0]; + const { attachments: _attachments, ...rest } = props; // eslint-disable-line @typescript-eslint/no-unused-vars + const attachmentProps: RenderAttachmentProps = { + ...rest, + attachment: mediaAttachment, + }; + + if (isVideoAttachment(mediaAttachment, SUPPORTED_VIDEO_FORMATS)) { + return ; + } + + return ; +}; + +export const CardContainer = (props: RenderAttachmentProps) => { + const { attachment, Card = DefaultCard } = props; + const componentType = 'card'; + + if (attachment.actions && attachment.actions.length) { + return ( + +
+ + +
+
+ ); + } + + return ( + + + + ); +}; + +export const FileContainer = (props: RenderAttachmentProps) => { + const { attachment } = props; + + if (isVoiceRecordingAttachment(attachment)) { + return ; + } + + if (isAudioAttachment(attachment)) { + return ; + } + + if (!attachment.asset_url || !isFileAttachment(attachment, SUPPORTED_VIDEO_FORMATS)) { + return null; + } + + return ; +}; + export const GalleryContainer = ({ attachment, Gallery = DefaultGallery, @@ -189,29 +268,7 @@ export const ImageContainer = (props: RenderAttachmentProps) => { ); }; -export const CardContainer = (props: RenderAttachmentProps) => { - const { attachment, Card = DefaultCard } = props; - const componentType = 'card'; - - if (attachment.actions && attachment.actions.length) { - return ( - -
- - -
-
- ); - } - - return ( - - - - ); -}; - -export const FileContainer = ({ +export const OtherFilesContainer = ({ attachment, File = DefaultFile, }: RenderAttachmentProps) => { @@ -223,6 +280,7 @@ export const FileContainer = ({ ); }; + export const AudioContainer = ({ attachment, Audio = DefaultAudio, @@ -246,7 +304,7 @@ export const VoiceRecordingContainer = ({ ); -export const MediaContainer = (props: RenderAttachmentProps) => { +export const VideoContainer = (props: RenderAttachmentProps) => { const { attachment, Media = ReactPlayer } = props; const componentType = 'media'; const { shouldGenerateVideoThumbnail, videoAttachmentSizeHandler } = diff --git a/src/components/Attachment/Card.tsx b/src/components/Attachment/Card.tsx deleted file mode 100644 index 7bf8dc9778..0000000000 --- a/src/components/Attachment/Card.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import React from 'react'; -import clsx from 'clsx'; -import ReactPlayer from 'react-player'; - -import type { AudioProps } from './Audio'; -import { ImageComponent } from '../Gallery'; -import { SafeAnchor } from '../SafeAnchor'; -import { ProgressBar } from './components'; -import { useChannelStateContext } from '../../context/ChannelStateContext'; -import { useTranslationContext } from '../../context/TranslationContext'; - -import type { Attachment } from 'stream-chat'; -import type { RenderAttachmentProps } from './utils'; -import type { Dimensions } from '../../types/types'; -import { type AudioPlayerState, useAudioPlayer } from '../AudioPlayback'; -import { useStateStore } from '../../store'; -import { useMessageContext } from '../../context'; -import { PlayButton } from '../Button'; -import { IconChainLink } from '../Icons'; - -const getHostFromURL = (url?: string | null) => { - if (url !== undefined && url !== null) { - const [trimmedUrl] = url.replace(/^(?:https?:\/\/)?(?:www\.)?/i, '').split('/'); - - return trimmedUrl; - } - return null; -}; - -const UnableToRenderCard = ({ type }: { type?: CardProps['type'] }) => { - const { t } = useTranslationContext('Card'); - - return ( -
-
-
- {t('this content could not be displayed')} -
-
-
- ); -}; - -const SourceLink = ({ - author_name, - showUrl, - url, -}: Pick & { url: string; showUrl?: boolean }) => ( -
- - - {showUrl ? url : author_name || getHostFromURL(url)} - -
-); - -type CardHeaderProps = Pick< - CardProps, - 'asset_url' | 'title' | 'type' | 'image_url' | 'thumb_url' -> & { - dimensions: Dimensions; - image?: string; -}; - -const CardHeader = (props: CardHeaderProps) => { - const { asset_url, dimensions, image, image_url, thumb_url, title, type } = props; - - let visual = null; - if (asset_url && type === 'video') { - visual = ( - - ); - } else if (image) { - visual = ( - - ); - } - - return visual ? ( -
- {visual} -
- ) : null; -}; - -type CardContentProps = RenderAttachmentProps['attachment']; - -const CardContent = (props: CardContentProps) => { - const { author_name, og_scrape_url, text, title, title_link, type } = props; - const url = title_link || og_scrape_url; - - return ( -
- {type === 'audio' ? ( - - ) : ( - <> - {title && ( -
{title}
- )} - {text &&
{text}
} - {url && } - - )} -
- ); -}; - -const audioPlayerStateSelector = (state: AudioPlayerState) => ({ - isPlaying: state.isPlaying, - progress: state.progressPercent, -}); - -const AudioWidget = ({ mimeType, src }: { src: string; mimeType?: string }) => { - /** - * Introducing message context. This could be breaking change, therefore the fallback to {} is provided. - * If this component is used outside the message context, then there will be no audio player namespacing - * => scrolling away from the message in virtualized ML would create a new AudioPlayer instance. - * - * Edge case: the requester (message) has multiple attachments with the same assetURL - does not happen - * with the default SDK components, but can be done with custom API calls.In this case all the Audio - * widgets will share the state. - */ - const { message, threadList } = useMessageContext() ?? {}; - - const audioPlayer = useAudioPlayer({ - mimeType, - requester: - message?.id && - `${threadList ? (message.parent_id ?? message.id) : ''}${message.id}`, - src, - }); - - const { isPlaying, progress } = - useStateStore(audioPlayer?.state, audioPlayerStateSelector) ?? {}; - - if (!audioPlayer) return; - - return ( -
-
- -
- -
- ); -}; - -export const CardAudio = ({ - og: { asset_url, author_name, mime_type, og_scrape_url, text, title, title_link }, -}: AudioProps) => { - const url = title_link || og_scrape_url; - const dataTestId = 'card-audio-widget'; - const rootClassName = 'str-chat__message-attachment-card-audio-widget'; - return ( -
- {asset_url && } -
- {url && } - {title && ( -
{title}
- )} - {text && ( -
- {text} -
- )} -
-
- ); -}; - -export type CardProps = RenderAttachmentProps['attachment']; - -const UnMemoizedCard = (props: CardProps) => { - const { asset_url, giphy, image_url, thumb_url, title, title_link, type } = props; - const { giphyVersion: giphyVersionName } = useChannelStateContext('CardHeader'); - - let image = thumb_url || image_url; - const dimensions: { height?: string; width?: string } = {}; - - if (type === 'giphy' && typeof giphy !== 'undefined') { - const giphyVersion = - giphy[giphyVersionName as keyof NonNullable]; - image = giphyVersion.url; - dimensions.height = giphyVersion.height; - dimensions.width = giphyVersion.width; - } - - if (!title && !title_link && !asset_url && !image) { - return ; - } - - return ( -
- - -
- ); -}; - -/** - * Simple Card Layout for displaying links - */ -export const Card = React.memo(UnMemoizedCard) as typeof UnMemoizedCard; diff --git a/src/components/Attachment/LinkPreview/Card.tsx b/src/components/Attachment/LinkPreview/Card.tsx new file mode 100644 index 0000000000..680bb8f577 --- /dev/null +++ b/src/components/Attachment/LinkPreview/Card.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { BaseImage } from '../../Gallery'; +import { SafeAnchor } from '../../SafeAnchor'; +import { useChannelStateContext } from '../../../context/ChannelStateContext'; + +import type { Attachment } from 'stream-chat'; +import type { RenderAttachmentProps } from '../utils'; +import type { Dimensions } from '../../../types/types'; +import { IconChainLink } from '../../Icons'; +import { UnableToRenderCard } from './UnableToRenderCard'; +import clsx from 'clsx'; + +type CardRootProps = { + cardUrl: string | undefined; + children: React.ReactNode; + type?: CardProps['type']; +}; + +const CardRoot = ({ cardUrl, children, type }: CardRootProps) => { + const className = clsx( + `str-chat__message-attachment-card str-chat__message-attachment-card--${type}`, + ); + + return cardUrl ? ( + + {children} + + ) : ( +
{children}
+ ); +}; + +type CardHeaderProps = Pick & { + dimensions: Dimensions; + image?: string; +}; + +const CardHeader = (props: CardHeaderProps) => { + const { dimensions, image, image_url, thumb_url, title } = props; + + return image ? ( +
+ +
+ ) : null; +}; + +type CardContentProps = RenderAttachmentProps['attachment']; + +const CardContent = (props: CardContentProps) => { + const { og_scrape_url, text, title, title_link } = props; + const url = title_link || og_scrape_url; + + return ( +
+ {title &&
{title}
} + {text &&
{text}
} + {url && ( +
+ +
{url}
+
+ )} +
+ ); +}; + +export type CardProps = RenderAttachmentProps['attachment'] & { + compact?: boolean; +}; + +const UnMemoizedCard = (props: CardProps) => { + const { giphy, image_url, og_scrape_url, thumb_url, title, title_link, type } = props; + const { giphyVersion: giphyVersionName } = useChannelStateContext('CardHeader'); + const cardUrl = title_link || og_scrape_url; + + let image = thumb_url || image_url; + const dimensions: { height?: string; width?: string } = {}; + + if (type === 'giphy' && typeof giphy !== 'undefined') { + const giphyVersion = + giphy[giphyVersionName as keyof NonNullable]; + image = giphyVersion.url; + dimensions.height = giphyVersion.height; + dimensions.width = giphyVersion.width; + } + + if (!title && !cardUrl && !image) { + return ; + } + + return ( + + + + + ); +}; + +/** + * Simple Card Layout for displaying links + */ +export const Card = React.memo(UnMemoizedCard) as typeof UnMemoizedCard; diff --git a/src/components/Attachment/LinkPreview/CardAudio.tsx b/src/components/Attachment/LinkPreview/CardAudio.tsx new file mode 100644 index 0000000000..fcbf7a8daf --- /dev/null +++ b/src/components/Attachment/LinkPreview/CardAudio.tsx @@ -0,0 +1,104 @@ +import { type AudioPlayerState, useAudioPlayer } from '../../AudioPlayback'; +import { useMessageContext } from '../../../context'; +import { useStateStore } from '../../../store'; +import { PlayButton } from '../../Button'; +import { ProgressBar } from '../components'; +import type { AudioProps } from '../Audio'; +import React from 'react'; +import { IconChainLink } from '../../Icons'; +import { SafeAnchor } from '../../SafeAnchor'; +import type { CardProps } from './Card'; + +const getHostFromURL = (url?: string | null) => { + if (url !== undefined && url !== null) { + const [trimmedUrl] = url.replace(/^(?:https?:\/\/)?(?:www\.)?/i, '').split('/'); + + return trimmedUrl; + } + return null; +}; + +const SourceLink = ({ + author_name, + showUrl, + url, +}: Pick & { url: string; showUrl?: boolean }) => ( +
+ + + {showUrl ? url : author_name || getHostFromURL(url)} + +
+); + +const audioPlayerStateSelector = (state: AudioPlayerState) => ({ + isPlaying: state.isPlaying, + progress: state.progressPercent, +}); + +const AudioWidget = ({ mimeType, src }: { src: string; mimeType?: string }) => { + /** + * Introducing message context. This could be breaking change, therefore the fallback to {} is provided. + * If this component is used outside the message context, then there will be no audio player namespacing + * => scrolling away from the message in virtualized ML would create a new AudioPlayer instance. + * + * Edge case: the requester (message) has multiple attachments with the same assetURL - does not happen + * with the default SDK components, but can be done with custom API calls.In this case all the Audio + * widgets will share the state. + */ + const { message, threadList } = useMessageContext() ?? {}; + + const audioPlayer = useAudioPlayer({ + mimeType, + requester: + message?.id && + `${threadList ? (message.parent_id ?? message.id) : ''}${message.id}`, + src, + }); + + const { isPlaying, progress } = + useStateStore(audioPlayer?.state, audioPlayerStateSelector) ?? {}; + + if (!audioPlayer) return; + + return ( +
+
+ +
+ +
+ ); +}; + +export const CardAudio = ({ + og: { asset_url, author_name, mime_type, og_scrape_url, text, title, title_link }, +}: AudioProps) => { + const url = title_link || og_scrape_url; + const dataTestId = 'card-audio-widget'; + const rootClassName = 'str-chat__message-attachment-card-audio-widget'; + return ( +
+ {asset_url && } +
+ {url && } + {title && ( +
{title}
+ )} + {text && ( +
+ {text} +
+ )} +
+
+ ); +}; diff --git a/src/components/Attachment/LinkPreview/UnableToRenderCard.tsx b/src/components/Attachment/LinkPreview/UnableToRenderCard.tsx new file mode 100644 index 0000000000..2d17e3ce1e --- /dev/null +++ b/src/components/Attachment/LinkPreview/UnableToRenderCard.tsx @@ -0,0 +1,22 @@ +import type { Attachment } from 'stream-chat'; +import { useTranslationContext } from '../../../context'; +import clsx from 'clsx'; +import React from 'react'; + +export const UnableToRenderCard = ({ type }: { type?: Attachment['type'] }) => { + const { t } = useTranslationContext('Card'); + + return ( +
+
+
+ {t('this content could not be displayed')} +
+
+
+ ); +}; diff --git a/src/components/Attachment/LinkPreview/index.ts b/src/components/Attachment/LinkPreview/index.ts new file mode 100644 index 0000000000..ca0b060473 --- /dev/null +++ b/src/components/Attachment/LinkPreview/index.ts @@ -0,0 +1 @@ +export * from './Card'; diff --git a/src/components/Attachment/__tests__/Card.test.js b/src/components/Attachment/__tests__/Card.test.js index 28333a607e..4005b42a1c 100644 --- a/src/components/Attachment/__tests__/Card.test.js +++ b/src/components/Attachment/__tests__/Card.test.js @@ -2,7 +2,7 @@ import React from 'react'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; -import { Card } from '../Card'; +import { Card } from '../LinkPreview/Card'; import { ChannelActionProvider, diff --git a/src/components/Attachment/__tests__/__snapshots__/Attachment.test.js.snap b/src/components/Attachment/__tests__/__snapshots__/Attachment.test.js.snap index 339cf89613..f87197a067 100644 --- a/src/components/Attachment/__tests__/__snapshots__/Attachment.test.js.snap +++ b/src/components/Attachment/__tests__/__snapshots__/Attachment.test.js.snap @@ -13,30 +13,18 @@ exports[`attachment combines scraped & uploaded content should render all upload />
-
-
+ data-testid="file-attachment" + />
-
-
+ data-testid="file-attachment" + />
-
-
-
-
-
-
`; @@ -84,31 +58,25 @@ exports[`attachment combines scraped & uploaded content should render attachment class="str-chat__attachment-list" >
`; diff --git a/src/components/Attachment/index.ts b/src/components/Attachment/index.ts index 70509c8a3a..88dc7a4f6d 100644 --- a/src/components/Attachment/index.ts +++ b/src/components/Attachment/index.ts @@ -3,7 +3,7 @@ export * from './AttachmentActions'; export * from './AttachmentContainer'; export * from './Audio'; export * from './audioSampling'; -export * from './Card'; +export * from './LinkPreview/Card'; export * from './components'; export * from './FileAttachment'; export * from './Geolocation'; diff --git a/src/components/Attachment/styling/Attachment.scss b/src/components/Attachment/styling/Attachment.scss index d13014096e..45728514b9 100644 --- a/src/components/Attachment/styling/Attachment.scss +++ b/src/components/Attachment/styling/Attachment.scss @@ -324,18 +324,11 @@ /* The maximum height of videos, the default value is the mase as `--str-chat__attachment-max-width`. The rendered video can be smaller based on the aspect ratio */ --str-chat__video-height: var(--str-chat__attachment-max-width); - /* The height of scraped images, the default value is optimized for 1.91:1 aspect ratio */ - --str-chat__scraped-image-height: calc(var(--str-chat__attachment-max-width) * calc(1 / 1.91)); - - /* The height of scraped videos, the default value is optimized for 16:9 aspect ratio */ - --str-chat__scraped-video-height: calc(var(--str-chat__attachment-max-width) * calc(9 / 16)); - display: flex; flex-direction: column; align-items: stretch; gap: var(--spacing-xs); min-width: 0; - padding: var(--spacing-xs); @include utils.component-layer-overrides('attachment-list'); @@ -355,59 +348,8 @@ } } - .str-chat__message-attachment--card { - overflow: hidden; - border-radius: var(--message-bubble-radius-attachment); - line-height: var(--typography-line-height-tight); - - * { - color: var(--chat-text-incoming); - } - - .str-chat__message-attachment-card--header { - position: relative; - } - - .str-chat__message-attachment-card--title { - font-size: var(--typography-font-size-sm); - font-weight: var(--typography-font-weight-semi-bold); - } - - .str-chat__message-attachment-card--source-link, - .str-chat__message-attachment-card--text { - font-size: var(--typography-font-size-xs); - } - - .str-chat__message-attachment-card--title, - .str-chat__message-attachment-card--source-link .str-chat__message-attachment-card--url { - @include utils.ellipsis-text(); - } - - .str-chat__message-attachment-card--text { - @include utils.ellipsis-text-clamp-lines(); - } - - .str-chat__message-attachment-card--source-link { - display: flex; - align-items: center; - min-width: 0; - max-width: 100%; - gap: var(--spacing-xxs); - - .str-chat__message-attachment-card--url { - flex: 1; - min-width: 0; - - &:hover { - text-decoration: underline; - } - } - } - } - .str-chat__message-attachment--image, - .str-chat__message-attachment--video, - .str-chat__message-attachment-card--header { + .str-chat__message-attachment--video { width: auto; display: flex; justify-content: center; @@ -415,40 +357,6 @@ overflow: hidden; } - // Scraped images - .str-chat__message-attachment-card--header { - height: var(--str-chat__scraped-image-height); - - img { - object-fit: cover; - max-height: 100%; - max-width: 100%; - width: 100%; - height: 100%; - cursor: zoom-in; - } - } - - .str-chat__message-attachment-card--giphy { - .str-chat__message-attachment-card--header { - height: var(--str-chat__gif-height); - - img { - object-fit: contain; - max-height: 100%; - max-width: 100%; - cursor: default; - } - } - - // image enlargement available in React SDK only - .str-chat__message-attachment-card-react--header { - img { - cursor: zoom-in; - } - } - } - // Images uploaded from files .str-chat__message-attachment--image:not(.str-chat__message-attachment--card) > img { @include utils.clamped-height-from-original-image-dimensions( @@ -464,9 +372,10 @@ cursor: zoom-in; } - // Video files: uploaded from files and scraped - .str-chat__message-attachment--video:not(.str-chat__message-attachment--card), - .str-chat__message-attachment-card--video .str-chat__message-attachment-card--header { + // Video files: uploaded from files + + .str-chat__message-attachment-card--video .str-chat__message-attachment-card--header, // todo: remove if video previews are not supported + .str-chat__message-attachment--video:not(.str-chat__message-attachment--card) { $maxWidth: var(--str-chat__attachment-max-width); max-width: $maxWidth; display: flex; @@ -541,10 +450,6 @@ } } - .str-chat__message-attachment-card--video .str-chat__message-attachment-card--header { - height: var(--str-chat__scraped-video-height); - } - .str-chat__message-attachment--gallery { $max-width: var(--str-chat__attachment-max-width); @@ -926,34 +831,6 @@ font-size: var(--typography-font-size-xs); } - .str-chat__message-attachment-card { - width: 100%; - - .str-chat__message-attachment-card--content { - padding: var(--spacing-sm); - display: flex; - flex-direction: column; - gap: var(--spacing-xxs); - - .str-chat__message-attachment-card--title { - @include utils.ellipsis-text; - } - } - } - - .str-chat__message-attachment-card--audio { - .str-chat__message-attachment-card--content { - padding: 0; - - .str-chat__message-attachment-card-audio-widget { - display: flex; - flex-direction: column; - width: 100%; - padding: var(--spacing-md); - } - } - } - .str-chat__message-attachment-actions { @include utils.component-layer-overrides('attachment-actions'); diff --git a/src/components/Attachment/styling/CardAudio.scss b/src/components/Attachment/styling/CardAudio.scss new file mode 100644 index 0000000000..a2bf5fb2b4 --- /dev/null +++ b/src/components/Attachment/styling/CardAudio.scss @@ -0,0 +1,13 @@ +// todo: remove if CardAudio.tsx is removed +.str-chat__message-attachment-card--audio { + .str-chat__message-attachment-card--content { + padding: 0; + + .str-chat__message-attachment-card-audio-widget { + display: flex; + flex-direction: column; + width: 100%; + padding: var(--spacing-md); + } + } +} \ No newline at end of file diff --git a/src/components/Attachment/styling/LinkPreview.scss b/src/components/Attachment/styling/LinkPreview.scss new file mode 100644 index 0000000000..b2aa4eb997 --- /dev/null +++ b/src/components/Attachment/styling/LinkPreview.scss @@ -0,0 +1,117 @@ +@use '../../../styling/utils'; + +.str-chat__message-attachment-card { + /* The height of scraped images, the default value is optimized for 1.91:1 aspect ratio */ + --str-chat__scraped-image-height: calc(var(--str-chat__attachment-max-width) * calc(1 / 1.91)); + + /* The height of scraped videos, the default value is optimized for 16:9 aspect ratio */ + --str-chat__scraped-video-height: calc(var(--str-chat__attachment-max-width) * calc(9 / 16)); + + width: 100%; + display: flex; + gap: var(--spacing-xs); + padding: var(--spacing-xs) var(--spacing-sm) var(--spacing-xs) var(--spacing-xs); + overflow: hidden; + border-radius: var(--message-bubble-radius-attachment); + line-height: var(--typography-line-height-tight); + + * { + color: var(--chat-text-incoming, #1A1B25); + } + + .str-chat__message-attachment-card--header { + position: relative; + width: auto; + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 0; + overflow: hidden; + + img { + border-radius: var(--message-bubble-radius-attachment-inline, 8px); + object-fit: cover; + width: 40px; + height: 40px; + } + } + + &.str-chat__message-attachment-card--video .str-chat__message-attachment-card--header { + height: var(--str-chat__scraped-video-height); + } + + .str-chat__message-attachment-card--content { + display: flex; + flex-direction: column; + gap: var(--spacing-xxs); + min-width: 0; + } + + .str-chat__message-attachment-card--title { + @include utils.ellipsis-text; + font-size: var(--typography-font-size-sm); + font-weight: var(--typography-font-weight-semi-bold); + } + + .str-chat__message-attachment-card--source-link, + .str-chat__message-attachment-card--text { + font-size: var(--typography-font-size-xs); + } + + .str-chat__message-attachment-card--title, + .str-chat__message-attachment-card--url { + @include utils.ellipsis-text(); + } + + .str-chat__message-attachment-card--text { + @include utils.ellipsis-text-clamp-lines(); + } + + .str-chat__message-attachment-card--source-link { + display: flex; + align-items: center; + min-width: 0; + max-width: 100%; + gap: var(--spacing-xxs); + + .str-chat__message-attachment-card--url { + flex: 1; + min-width: 0; + } + } +} + +.str-chat__message-attachment-card--giphy { + .str-chat__message-attachment-card--header { + height: var(--str-chat__gif-height); + + img { + object-fit: contain; + max-height: 100%; + max-width: 100%; + cursor: default; + } + } +} + + +.str-chat__message--has-single-attachment { + .str-chat__message-attachment-card { + display: block; + padding: 0; + + img { + height: var(--str-chat__scraped-image-height); + width: 100%; + border-radius: 0; + } + + .str-chat__message-attachment-card--content { + padding: var(--spacing-sm); + + .str-chat__message-attachment-card--text { + @include utils.ellipsis-text-clamp-lines(2); + } + } + } +} \ No newline at end of file diff --git a/src/components/Attachment/styling/index.scss b/src/components/Attachment/styling/index.scss index 9b7e704c36..5ee6f43481 100644 --- a/src/components/Attachment/styling/index.scss +++ b/src/components/Attachment/styling/index.scss @@ -1 +1,2 @@ -@use 'Attachment'; \ No newline at end of file +@use 'Attachment'; +@use 'LinkPreview'; \ No newline at end of file diff --git a/src/components/Attachment/utils.tsx b/src/components/Attachment/utils.tsx index 230fe3b625..ed96a5f8a1 100644 --- a/src/components/Attachment/utils.tsx +++ b/src/components/Attachment/utils.tsx @@ -9,9 +9,20 @@ export const SUPPORTED_VIDEO_FORMATS = [ 'video/quicktime', ]; -export type AttachmentComponentType = (typeof ATTACHMENT_GROUPS_ORDER)[number]; +export type AttachmentComponentType = + | 'card' + | 'gallery' + | 'image' + | 'media' + | 'audio' + | 'voiceRecording' + | 'file' + | 'geolocation' + | 'unsupported'; -export type GroupedRenderedAttachment = Record; +export type AttachmentContainerType = (typeof ATTACHMENT_GROUPS_ORDER)[number]; + +export type GroupedRenderedAttachment = Record; export type GalleryAttachment = { images: Attachment[]; @@ -26,6 +37,10 @@ export type RenderGalleryProps = Omit & { attachment: GalleryAttachment; }; +export type RenderMediaProps = Omit & { + attachments: Attachment[]; +}; + export type GeolocationContainerProps = Omit & { location: SharedLocationResponse; }; diff --git a/src/components/AudioPlayback/components/DurationDisplay.tsx b/src/components/AudioPlayback/components/DurationDisplay.tsx index bc9c379e89..26825de27f 100644 --- a/src/components/AudioPlayback/components/DurationDisplay.tsx +++ b/src/components/AudioPlayback/components/DurationDisplay.tsx @@ -43,7 +43,7 @@ export function DurationDisplay({ className, )} > - {secondsElapsed && ( + {!!secondsElapsed && ( {formattedSecondsElapsed} diff --git a/src/components/Gallery/Image.tsx b/src/components/Gallery/Image.tsx index fbf596a642..d71c487dbf 100644 --- a/src/components/Gallery/Image.tsx +++ b/src/components/Gallery/Image.tsx @@ -19,6 +19,7 @@ export type ImageProps = { innerRef?: MutableRefObject; previewUrl?: string; style?: CSSProperties; + withGalleryPreview?: boolean; } & ( | { /** The text fallback for the image */ diff --git a/src/components/Message/MessageErrorText.tsx b/src/components/Message/MessageErrorText.tsx index 4a35a8a805..267627c528 100644 --- a/src/components/Message/MessageErrorText.tsx +++ b/src/components/Message/MessageErrorText.tsx @@ -7,27 +7,19 @@ import type { LocalMessage } from 'stream-chat'; export interface MessageErrorTextProps { message: LocalMessage; - theme: string; } -export function MessageErrorText({ message, theme }: MessageErrorTextProps) { +const ROOT_CLASS_NAME = 'str-chat__message--error-message'; +export function MessageErrorText({ message }: MessageErrorTextProps) { const { t } = useTranslationContext('MessageText'); if (message.type === 'error' && !isMessageBounced(message)) { - return ( -
- {t('Error · Unsent')} -
- ); + return
{t('Error · Unsent')}
; } if (message.status === 'failed') { return ( -
+
{message.error?.status !== 403 ? t('Message Failed · Click to try again') : t('Message Failed · Unauthorized')} diff --git a/src/components/Message/MessageSimple.tsx b/src/components/Message/MessageSimple.tsx index 9324a6639f..4a5992159e 100644 --- a/src/components/Message/MessageSimple.tsx +++ b/src/components/Message/MessageSimple.tsx @@ -39,6 +39,7 @@ import { useChatContext, useTranslationContext } from '../../context'; import { MessageEditedTimestamp } from './MessageEditedTimestamp'; import type { MessageUIComponentProps } from './types'; +import { QuotedMessage as DefaultQuotedMessage } from './QuotedMessage'; type MessageSimpleWithContextProps = MessageContextValue; @@ -77,6 +78,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => { MessageStatus = DefaultMessageStatus, MessageTimestamp = DefaultMessageTimestamp, PinIndicator, + QuotedMessage = DefaultQuotedMessage, ReactionsList = DefaultReactionList, ReminderNotification = DefaultReminderNotification, StreamedMessageText = DefaultStreamedMessageText, @@ -191,6 +193,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
{poll && } + {message.quoted_message && } {finalAttachments?.length ? ( ) : null} diff --git a/src/components/Message/MessageText.tsx b/src/components/Message/MessageText.tsx index 7c60146bbe..44b0d0979a 100644 --- a/src/components/Message/MessageText.tsx +++ b/src/components/Message/MessageText.tsx @@ -1,19 +1,13 @@ import clsx from 'clsx'; import React, { useMemo } from 'react'; - -import { QuotedMessage as DefaultQuotedMessage } from './QuotedMessage'; import { isOnlyEmojis, messageHasAttachments } from './utils'; -import { - useComponentContext, - useMessageContext, - useTranslationContext, -} from '../../context'; +import type { MessageContextValue } from '../../context'; +import { useMessageContext, useTranslationContext } from '../../context'; import { renderText as defaultRenderText } from './renderText'; import { MessageErrorText } from './MessageErrorText'; import type { LocalMessage, TranslationLanguages } from 'stream-chat'; -import type { MessageContextValue } from '../../context'; export type MessageTextProps = { /* Replaces the CSS class name placed on the component's inner `div` container */ @@ -22,8 +16,6 @@ export type MessageTextProps = { customWrapperClass?: string; /* The `StreamChat` message object, which provides necessary data to the underlying UI components (overrides the value stored in `MessageContext`) */ message?: LocalMessage; - /* Theme string to be added to CSS class names */ - theme?: string; } & Pick; const UnMemoizedMessageTextComponent = (props: MessageTextProps) => { @@ -32,11 +24,8 @@ const UnMemoizedMessageTextComponent = (props: MessageTextProps) => { customWrapperClass = '', message: propMessage, renderText: propsRenderText, - theme = 'simple', } = props; - const { QuotedMessage = DefaultQuotedMessage } = useComponentContext('MessageText'); - const { message: contextMessage, onMentionsClickMessage, @@ -57,31 +46,27 @@ const UnMemoizedMessageTextComponent = (props: MessageTextProps) => { const messageText = useMemo( () => renderText(messageTextToRender, message.mentioned_users), - // eslint-disable-next-line react-hooks/exhaustive-deps - [message.mentioned_users, messageTextToRender], + [message.mentioned_users, messageTextToRender, renderText], ); const wrapperClass = customWrapperClass || 'str-chat__message-text'; - const innerClass = - customInnerClass || - `str-chat__message-text-inner str-chat__message-${theme}-text-inner`; + const innerClass = customInnerClass; - if (!messageTextToRender && !message.quoted_message) return null; + if (!messageTextToRender) return null; return (
- {message.quoted_message && } - + {unsafeHTML && message.html ? (
) : ( diff --git a/src/components/Message/QuotedMessage.tsx b/src/components/Message/QuotedMessage.tsx index dee5f34c4e..f173adb27b 100644 --- a/src/components/Message/QuotedMessage.tsx +++ b/src/components/Message/QuotedMessage.tsx @@ -2,7 +2,6 @@ import React from 'react'; import type { MessageContextValue } from '../../context/MessageContext'; import { useMessageContext } from '../../context/MessageContext'; import { useChannelActionContext } from '../../context/ChannelActionContext'; -import { renderText as defaultRenderText } from './renderText'; import { QuotedMessagePreviewUI } from '../MessageInput'; export type QuotedMessageProps = Pick; @@ -11,7 +10,7 @@ export const QuotedMessage = ({ renderText: propsRenderText }: QuotedMessageProp const { message, renderText: contextRenderText } = useMessageContext('QuotedMessage'); const { jumpToMessage } = useChannelActionContext('QuotedMessage'); - const renderText = propsRenderText ?? contextRenderText ?? defaultRenderText; + const renderText = propsRenderText ?? contextRenderText; const { quoted_message } = message; diff --git a/src/components/Message/__tests__/MessageText.test.js b/src/components/Message/__tests__/MessageText.test.js index 6df70b5071..22f48d92e1 100644 --- a/src/components/Message/__tests__/MessageText.test.js +++ b/src/components/Message/__tests__/MessageText.test.js @@ -136,7 +136,7 @@ describe('', () => { customProps: { message }, }); expect(getByTestId(messageTextTestId)).toHaveClass( - 'str-chat__message-simple-text-inner--has-attachment', + 'str-chat__message-simple-text--has-attachment', ); const results = await axe(container); expect(results).toHaveNoViolations(); @@ -148,7 +148,7 @@ describe('', () => { customProps: { message }, }); expect(getByTestId(messageTextTestId)).toHaveClass( - 'str-chat__message-simple-text-inner--is-emoji', + 'str-chat__message-text-inner--is-emoji', ); const results = await axe(container); expect(results).toHaveNoViolations(); diff --git a/src/components/Message/styling/DateSeparator.scss b/src/components/Message/styling/DateSeparator.scss new file mode 100644 index 0000000000..e4aefc118a --- /dev/null +++ b/src/components/Message/styling/DateSeparator.scss @@ -0,0 +1,34 @@ +@use '../../../styling/utils'; + +.str-chat { + --str-chat__date-separator-color: var(--str-chat__text-low-emphasis-color); + --str-chat__date-separator-line-color: var(--str-chat__disabled-color); + --str-chat__date-separator-border-radius: 0; + --str-chat__date-separator-background-color: transparent; + --str-chat__date-separator-border-block-start: none; + --str-chat__date-separator-border-block-end: none; + --str-chat__date-separator-border-inline-start: none; + --str-chat__date-separator-border-inline-end: none; + --str-chat__date-separator-box-shadow: none; +} + +.str-chat__date-separator { + display: flex; + padding: 2rem; + align-items: center; + @include utils.component-layer-overrides('date-separator'); + font: var(--str-chat__body-text); + + &-line { + flex: 1; + height: 1px; + background-color: var(--str-chat__date-separator-line-color); + border: none; + } + + > * { + &:not(:last-child) { + margin-right: 1rem; + } + } +} \ No newline at end of file diff --git a/src/components/Message/styling/Message.scss b/src/components/Message/styling/Message.scss index 46501179fb..1a879b1d2f 100644 --- a/src/components/Message/styling/Message.scss +++ b/src/components/Message/styling/Message.scss @@ -14,7 +14,6 @@ --str-chat__message-secondary-color: var(--str-chat__text-low-emphasis-color); --str-chat__message-link-color: var(--chat-text-link); --str-chat__message-mention-color: var(--str-chat__primary-color); - --str-chat__message-status-color: var(--str-chat__primary-color); --str-chat__message-replies-count-color: var(--str-chat__primary-color); --str-chat__message-background-color: transparent; --str-chat__message-highlighted-background-color: var(--str-chat__message-highlight-color); @@ -35,7 +34,6 @@ --str-chat__message-bubble-background-color: var(--chat-bg-incoming); --str-chat__own-message-bubble-color: var(--chat-text-message); --str-chat__own-message-bubble-background-color: var(--chat-bg-outgoing); - --str-chat__quoted-message-bubble-background-color: var(--str-chat__secondary-background-color); --str-chat__message-bubble-border-block-start: none; --str-chat__message-bubble-border-block-end: none; --str-chat__message-bubble-border-inline-start: none; @@ -60,25 +58,6 @@ --str-chat__blocked-message-border-inline-end: none; --str-chat__blocked-message-box-shadow: none; - --str-chat__system-message-border-radius: 0; - --str-chat__system-message-color: var(--str-chat__text-low-emphasis-color); - --str-chat__system-message-background-color: transparent; - --str-chat__system-message-border-block-start: none; - --str-chat__system-message-border-block-end: none; - --str-chat__system-message-border-inline-start: none; - --str-chat__system-message-border-inline-end: none; - --str-chat__system-message-box-shadow: none; - - --str-chat__date-separator-color: var(--str-chat__text-low-emphasis-color); - --str-chat__date-separator-line-color: var(--str-chat__disabled-color); - --str-chat__date-separator-border-radius: 0; - --str-chat__date-separator-background-color: transparent; - --str-chat__date-separator-border-block-start: none; - --str-chat__date-separator-border-block-end: none; - --str-chat__date-separator-border-inline-start: none; - --str-chat__date-separator-border-inline-end: none; - --str-chat__date-separator-box-shadow: none; - --str-chat__translation-notice-color: var(--str-chat__text-low-emphasis-color); --str-chat__translation-notice-active-background-color: var(--str-chat__tertiary-surface-color); @@ -98,17 +77,6 @@ --str-chat__message-with-attachment-max-width: calc(var(--str-chat__spacing-px) * 300); } -.str-chat__message.str-chat__message--is-emoji-only { - .str-chat__message-inner { - .str-chat__message-bubble { - background-color: transparent; - overflow: visible; - font-size: 64px; - line-height: 64px; - } - } -} - .str-chat__message { --str-chat-message-options-size: calc(3 * var(--str-chat__message-options-button-size)); @@ -118,6 +86,10 @@ .str-chat__message-bubble { max-width: var(--str-chat__message-max-width); + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + padding: var(--spacing-xs); } .str-chat__message-options { @@ -130,6 +102,16 @@ } } +.str-chat__message.str-chat__message--is-emoji-only { + .str-chat__message-inner { + .str-chat__message-bubble { + background-color: transparent; + overflow: visible; + font-size: 64px; + line-height: 64px; + } + } +} .str-chat__message.str-chat__message--has-attachment { --str-chat__message-max-width: var(--str-chat__message-with-attachment-max-width); @@ -160,7 +142,7 @@ @include utils.component-layer-overrides('message'); @mixin chat-bubble-spacing { - padding: var(--spacing-xs) var(--spacing-sm); + padding-inline: var(--spacing-sm); p { white-space: pre-line; @@ -240,51 +222,6 @@ column-gap: var(--str-chat__spacing-2); position: relative; - .str-chat__message-options { - grid-area: options; - align-items: flex-start; - justify-content: flex-end; - flex-direction: row-reverse; - width: var(--str-chat-message-options-size); - --str-chat-icon-color: var(--str-chat__message-options-color); - - .str-chat__message-actions-box-button, - .str-chat__message-reply-in-thread-button, - .str-chat__message-reactions-button { - // remove default button styles (React SDK uses button compared to div in Angular SDK) - @include utils.button-reset; - @include utils.flex-row-center; - cursor: pointer; - width: var(--str-chat__message-options-button-size); - height: var(--str-chat__message-options-button-size); - border-radius: var(--str-chat__message-options-border-radius); - color: var(--str-chat__message-options-color); - - .str-chat__message-action-icon path { - fill: var(--str-chat__message-options-color); - } - } - - .str-chat__message-actions-box-button:hover, - .str-chat__message-reply-in-thread-button:hover, - .str-chat__message-reactions-button:hover { - background-color: var(--str-chat__message-options-hover-background-color); - } - - .str-chat__message-actions-box-button:active, - .str-chat__message-reply-in-thread-button:active, - .str-chat__message-reactions-button:active { - .str-chat__message-action-icon path { - fill: var(--str-chat__message-options-active-color); - } - } - - .str-chat__message-actions-box-button, - .str-chat__message-actions-container { - position: relative; - } - } - .str-chat__message-reactions-host { grid-area: reactions; } @@ -371,11 +308,6 @@ content: '•'; margin-right: var(--str-chat__spacing-1); } - - .str-chat__message-edited-timestamp { - --str-chat__message-edited-timestamp-height: 1rem; - flex-basis: 100%; - } } &.str-chat__message--me .str-chat__message-metadata { @@ -383,54 +315,6 @@ text-align: right; } - .str-chat__message-status { - @include utils.flex-row-center; - column-gap: var(--str-chat__spacing-0_5); - position: relative; - --str-chat-icon-color: var(--str-chat__message-status-color); - color: var(--str-chat__message-status-color); - font: var(--str-chat__body-text); - - svg { - width: 16px; - height: 16px; - - path { - fill: var(--str-chat__message-status-color); - } - } - } - - .str-chat__message-status.str-chat__message-status-sent { - svg { - width: 12px; - height: 12px; - } - } - - .str-chat__message-status-sent { - svg { - width: 12px; - height: 12px; - } - } - - .str-chat__message-status-delivered { - svg { - width: 15px; - height: 15px; - } - } - - .str-chat__message-status-sent, - .str-chat__message-status-delivered { - svg { - path { - fill: var(--str-chat__text-low-emphasis-color); - } - } - } - .str-chat__message-replies-count-button-wrapper, .str-chat__message-is-thread-reply-button-wrapper { grid-area: replies; @@ -451,6 +335,8 @@ .str-chat__message--deleted-inner { @include chat-bubble-spacing; + // todo: once deleted message designs are ready remove this padding? + padding-block: var(--spacing-xs); @include utils.component-layer-overrides('deleted-message'); font: var(--str-chat__body2-text); } @@ -458,6 +344,8 @@ .str-chat__message--blocked-inner { @include chat-bubble-spacing; @include utils.component-layer-overrides('blocked-message'); + // todo: once blocked message designs are ready remove this padding? + padding-block: var(--spacing-xs); font: var(--str-chat__body2-text); } @@ -667,115 +555,7 @@ } } -.str-chat__date-separator { - display: flex; - padding: 2rem; - align-items: center; - @include utils.component-layer-overrides('date-separator'); - font: var(--str-chat__body-text); - - &-line { - flex: 1; - height: 1px; - background-color: var(--str-chat__date-separator-line-color); - border: none; - } - - > * { - &:not(:last-child) { - margin-right: 1rem; - } - } -} - -.str-chat__message--system { - width: 100%; - text-align: center; - @include utils.component-layer-overrides('system-message'); - font: var(--str-chat__caption-text); - - p { - margin: 0; - } -} - -.str-chat__unread-messages-separator-wrapper { - padding-block: var(--str-chat__spacing-2); - - .str-chat__unread-messages-separator { - @include utils.flex-row-center; - width: 100%; - padding: var(--str-chat__spacing-2); - background-color: var(--str-chat__secondary-surface-color); - color: var(--str-chat__text-low-emphasis-color); - text-transform: uppercase; - font: var(--str-chat__caption-strong-text); - } -} - -.str-chat__unread-messages-notification { - --str-chat-icon-color: var(--str-chat__grey50); - background-color: var(--str-chat__text-low-emphasis-color); - border-radius: 1.125rem; - position: absolute; - top: 0.75rem; - z-index: 2; - display: flex; - align-items: center; - overflow: clip; - - button { - padding-block: var(--str-chat__spacing-2); - height: 100%; - width: 100%; - white-space: nowrap; - cursor: pointer; - color: var(--str-chat__grey50); - border: none; - background-color: transparent; - } - - button:first-of-type { - padding-inline: 0.75rem 0.375rem; - font: var(--str-chat__caption-text); - } - - button:last-of-type { - padding-inline: 0.375rem 0.75rem; - - svg { - width: 0.875rem; - } - } -} - -.str-chat-angular__message-bubble { - /* transform: translate3d(0, 0, 0) fixes scrolling issues on iOS, see: https://stackoverflow.com/questions/50105780/elements-disappear-when-scrolling-in-safari-webkit-transform-fix-only-works-t/50144295#50144295 */ - transform: translate3d(0, 0, 0); - - &.str-chat-angular__message-bubble--attachment-modal-open { - /* transform: none is required when image carousel is open in order for the modal to be correctly positioned, see how the transform property changes the behavior of fixed positioned elements https://developer.mozilla.org/en-US/docs/Web/CSS/position */ - transform: none; - } -} - -.str-chat__message-edited-timestamp { - overflow: hidden; - transition: height 0.1s; - - &--open { - height: var(--str-chat__message-edited-timestamp-height, 1rem); - } - - &--collapsed { - height: 0; - } -} - -.str-chat__message-text--pointer-cursor { - cursor: pointer; -} - +// todo: not implemented in stream-chat-react .str-chat__message-with-touch-support { .str-chat__message-bubble { -webkit-touch-callout: none; diff --git a/src/components/Message/styling/MessageEditedTimestamp.scss b/src/components/Message/styling/MessageEditedTimestamp.scss new file mode 100644 index 0000000000..2f45b06f59 --- /dev/null +++ b/src/components/Message/styling/MessageEditedTimestamp.scss @@ -0,0 +1,13 @@ +.str-chat__message-edited-timestamp { + overflow: hidden; + transition: height 0.1s; + flex-basis: 100%; + + &--open { + height: var(--str-chat__message-edited-timestamp-height, 1rem); + } + + &--collapsed { + height: 0; + } +} \ No newline at end of file diff --git a/src/components/Message/styling/MessageStatus.scss b/src/components/Message/styling/MessageStatus.scss new file mode 100644 index 0000000000..381be101ff --- /dev/null +++ b/src/components/Message/styling/MessageStatus.scss @@ -0,0 +1,53 @@ +.str-chat { + --str-chat__message-status-color: var(--accent-primary); +} + +.str-chat__message-status { + display: flex; + align-items: center; + justify-content: center; + column-gap: var(--str-chat__spacing-0_5); + position: relative; + --str-chat-icon-color: var(--str-chat__message-status-color); + color: var(--str-chat__message-status-color); + font: var(--str-chat__body-text); + + svg { + width: 16px; + height: 16px; + + path { + fill: var(--str-chat__message-status-color); + } + } +} + +.str-chat__message-status.str-chat__message-status-sent { + svg { + width: 12px; + height: 12px; + } +} + +.str-chat__message-status-sent { + svg { + width: 12px; + height: 12px; + } +} + +.str-chat__message-status-delivered { + svg { + width: 15px; + height: 15px; + } +} + +.str-chat__message-status-sent, +.str-chat__message-status-delivered { + svg { + path { + fill: var(--str-chat__text-low-emphasis-color); + } + } +} \ No newline at end of file diff --git a/src/components/Message/styling/MessageSystem.scss b/src/components/Message/styling/MessageSystem.scss new file mode 100644 index 0000000000..4e10a43711 --- /dev/null +++ b/src/components/Message/styling/MessageSystem.scss @@ -0,0 +1,23 @@ +@use '../../../styling/utils'; + +.str-chat { + --str-chat__system-message-border-radius: 0; + --str-chat__system-message-color: var(--str-chat__text-low-emphasis-color); + --str-chat__system-message-background-color: transparent; + --str-chat__system-message-border-block-start: none; + --str-chat__system-message-border-block-end: none; + --str-chat__system-message-border-inline-start: none; + --str-chat__system-message-border-inline-end: none; + --str-chat__system-message-box-shadow: none; +} + +.str-chat__message--system { + width: 100%; + text-align: center; + @include utils.component-layer-overrides('system-message'); + font: var(--str-chat__caption-text); + + p { + margin: 0; + } +} \ No newline at end of file diff --git a/src/components/Message/styling/QuotedMessage.scss b/src/components/Message/styling/QuotedMessage.scss new file mode 100644 index 0000000000..191bca65e6 --- /dev/null +++ b/src/components/Message/styling/QuotedMessage.scss @@ -0,0 +1,14 @@ +.str-chat { + --str-chat__quoted-message-bubble-background-color: var(--str-chat__secondary-background-color); + + .str-chat__message { + .str-chat__quoted-message-preview { + background-color: var(--chat-bg-attachment-incoming); + } + .str-chat__quoted-message-preview--own { + background-color: var(--chat-bg-attachment-outgoing); + } + } +} + + diff --git a/src/components/Message/styling/UnreadMessageNotification.scss b/src/components/Message/styling/UnreadMessageNotification.scss new file mode 100644 index 0000000000..68b95b83ce --- /dev/null +++ b/src/components/Message/styling/UnreadMessageNotification.scss @@ -0,0 +1,35 @@ +.str-chat__unread-messages-notification { + --str-chat-icon-color: var(--str-chat__grey50); + background-color: var(--str-chat__text-low-emphasis-color); + border-radius: 1.125rem; + position: absolute; + top: 0.75rem; + z-index: 2; + display: flex; + align-items: center; + overflow: clip; + + button { + padding-block: var(--str-chat__spacing-2); + height: 100%; + width: 100%; + white-space: nowrap; + cursor: pointer; + color: var(--str-chat__grey50); + border: none; + background-color: transparent; + } + + button:first-of-type { + padding-inline: 0.75rem 0.375rem; + font: var(--str-chat__caption-text); + } + + button:last-of-type { + padding-inline: 0.375rem 0.75rem; + + svg { + width: 0.875rem; + } + } +} \ No newline at end of file diff --git a/src/components/Message/styling/UnreadMessagesSeparator.scss b/src/components/Message/styling/UnreadMessagesSeparator.scss new file mode 100644 index 0000000000..4738ba3589 --- /dev/null +++ b/src/components/Message/styling/UnreadMessagesSeparator.scss @@ -0,0 +1,15 @@ +.str-chat__unread-messages-separator-wrapper { + padding-block: var(--str-chat__spacing-2); + + .str-chat__unread-messages-separator { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + padding: var(--str-chat__spacing-2); + background-color: var(--str-chat__secondary-surface-color); + color: var(--str-chat__text-low-emphasis-color); + text-transform: uppercase; + font: var(--str-chat__caption-strong-text); + } +} \ No newline at end of file diff --git a/src/components/Message/styling/index.scss b/src/components/Message/styling/index.scss index 1731d47701..86a34bb35f 100644 --- a/src/components/Message/styling/index.scss +++ b/src/components/Message/styling/index.scss @@ -1 +1,8 @@ -@use "Message"; \ No newline at end of file +@use "DateSeparator"; +@use "Message"; +@use "MessageEditedTimestamp"; +@use "MessageStatus"; +@use "MessageSystem"; +@use "QuotedMessage"; +@use "UnreadMessageNotification"; +@use "UnreadMessagesSeparator"; \ No newline at end of file diff --git a/src/components/MessageActions/styling/MessageActions.scss b/src/components/MessageActions/styling/MessageActions.scss index e48bf8cc98..88303a5842 100644 --- a/src/components/MessageActions/styling/MessageActions.scss +++ b/src/components/MessageActions/styling/MessageActions.scss @@ -1,3 +1,50 @@ +@use '../../../styling/utils'; + .str-chat__message-actions-box { min-width: 180px; +} + +.str-chat__message-options { + grid-area: options; + align-items: flex-start; + justify-content: flex-end; + flex-direction: row-reverse; + width: var(--str-chat-message-options-size); + --str-chat-icon-color: var(--str-chat__message-options-color); + + .str-chat__message-actions-box-button, + .str-chat__message-reply-in-thread-button, + .str-chat__message-reactions-button { + // remove default button styles (React SDK uses button compared to div in Angular SDK) + @include utils.button-reset; + @include utils.flex-row-center; + cursor: pointer; + width: var(--str-chat__message-options-button-size); + height: var(--str-chat__message-options-button-size); + border-radius: var(--str-chat__message-options-border-radius); + color: var(--str-chat__message-options-color); + + .str-chat__message-action-icon path { + fill: var(--str-chat__message-options-color); + } + } + + .str-chat__message-actions-box-button:hover, + .str-chat__message-reply-in-thread-button:hover, + .str-chat__message-reactions-button:hover { + background-color: var(--str-chat__message-options-hover-background-color); + } + + .str-chat__message-actions-box-button:active, + .str-chat__message-reply-in-thread-button:active, + .str-chat__message-reactions-button:active { + .str-chat__message-action-icon path { + fill: var(--str-chat__message-options-active-color); + } + } + + .str-chat__message-actions-box-button, + .str-chat__message-actions-container { + position: relative; + } } \ No newline at end of file diff --git a/src/components/SafeAnchor/SafeAnchor.tsx b/src/components/SafeAnchor/SafeAnchor.tsx index 0cf2d49383..ea73da1d34 100644 --- a/src/components/SafeAnchor/SafeAnchor.tsx +++ b/src/components/SafeAnchor/SafeAnchor.tsx @@ -1,7 +1,6 @@ import type { PropsWithChildren } from 'react'; import React from 'react'; import { sanitizeUrl } from '@braintree/sanitize-url'; -import { useTranslationContext } from '../../context'; /** * Similar to a regular anchor tag, but it sanitizes the href value and prevents XSS @@ -21,12 +20,10 @@ export type SafeAnchorProps = { const UnMemoizedSafeAnchor = (props: PropsWithChildren) => { const { children, className, download, href, rel, target } = props; - const { t } = useTranslationContext('SafeAnchor'); if (!href) return null; const sanitized = sanitizeUrl(href); return ( Date: Fri, 6 Feb 2026 15:33:28 +0100 Subject: [PATCH 2/5] feat: display audio attachment using file attachment component --- src/components/Attachment/AttachmentContainer.tsx | 5 ++--- src/components/Attachment/Audio.tsx | 7 +++---- src/components/Attachment/LinkPreview/CardAudio.tsx | 10 +++++++++- src/components/Attachment/__tests__/Audio.test.js | 10 +++++----- .../__tests__/__snapshots__/Card.test.js.snap | 8 ++++---- 5 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/components/Attachment/AttachmentContainer.tsx b/src/components/Attachment/AttachmentContainer.tsx index c9032e48fd..e67c64e28d 100644 --- a/src/components/Attachment/AttachmentContainer.tsx +++ b/src/components/Attachment/AttachmentContainer.tsx @@ -13,7 +13,6 @@ import { } from 'stream-chat'; import { AttachmentActions as DefaultAttachmentActions } from './AttachmentActions'; -import { Audio as DefaultAudio } from './Audio'; import { VoiceRecording as DefaultVoiceRecording } from './VoiceRecording'; import { BaseImage, @@ -283,11 +282,11 @@ export const OtherFilesContainer = ({ export const AudioContainer = ({ attachment, - Audio = DefaultAudio, + Audio = DefaultFile, }: RenderAttachmentProps) => (
-
); diff --git a/src/components/Attachment/Audio.tsx b/src/components/Attachment/Audio.tsx index d8189340b5..4abb80d5d7 100644 --- a/src/components/Attachment/Audio.tsx +++ b/src/components/Attachment/Audio.tsx @@ -42,8 +42,7 @@ const AudioAttachmentUI = ({ audioPlayer }: AudioAttachmentUIProps) => { }; export type AudioProps = { - // fixme: rename og to attachment - og: Attachment; + attachment: Attachment; }; const audioPlayerStateSelector = (state: AudioPlayerState) => ({ @@ -53,7 +52,7 @@ const audioPlayerStateSelector = (state: AudioPlayerState) => ({ const UnMemoizedAudio = (props: AudioProps) => { const { - og: { asset_url, file_size, mime_type, title }, + attachment: { asset_url, file_size, mime_type, title }, } = props; /** @@ -75,7 +74,7 @@ const UnMemoizedAudio = (props: AudioProps) => { `${threadList ? (message.parent_id ?? message.id) : ''}${message.id}`, src: asset_url, title, - waveformData: props.og.waveform_data, + waveformData: props.attachment.waveform_data, }); return audioPlayer ? : null; diff --git a/src/components/Attachment/LinkPreview/CardAudio.tsx b/src/components/Attachment/LinkPreview/CardAudio.tsx index fcbf7a8daf..2c92dfa26a 100644 --- a/src/components/Attachment/LinkPreview/CardAudio.tsx +++ b/src/components/Attachment/LinkPreview/CardAudio.tsx @@ -80,7 +80,15 @@ const AudioWidget = ({ mimeType, src }: { src: string; mimeType?: string }) => { }; export const CardAudio = ({ - og: { asset_url, author_name, mime_type, og_scrape_url, text, title, title_link }, + attachment: { + asset_url, + author_name, + mime_type, + og_scrape_url, + text, + title, + title_link, + }, }: AudioProps) => { const url = title_link || og_scrape_url; const dataTestId = 'card-audio-widget'; diff --git a/src/components/Attachment/__tests__/Audio.test.js b/src/components/Attachment/__tests__/Audio.test.js index 20becd1474..0a79b5b7df 100644 --- a/src/components/Attachment/__tests__/Audio.test.js +++ b/src/components/Attachment/__tests__/Audio.test.js @@ -49,7 +49,7 @@ const renderComponent = ( ) => render( - , ); @@ -248,10 +248,10 @@ describe('Audio', () => { render( - - , ); @@ -270,10 +270,10 @@ describe('Audio', () => { render( - - , ); diff --git a/src/components/Attachment/__tests__/__snapshots__/Card.test.js.snap b/src/components/Attachment/__tests__/__snapshots__/Card.test.js.snap index 9318fbfdd5..609021e7c5 100644 --- a/src/components/Attachment/__tests__/__snapshots__/Card.test.js.snap +++ b/src/components/Attachment/__tests__/__snapshots__/Card.test.js.snap @@ -799,7 +799,7 @@ exports[`Card (15) should render image without title and with caption using og_s
`; -exports[`Card (16) should render audio widget with title & text in Card content and without Card header if attachment type is audio and og image URLs are not available 1`] = ` +exports[`Card (16) should render audio widget with title & text in Card content and without Card header if attachment type is audio and attachment image URLs are not available 1`] = `
`; -exports[`Card (17) should render video widget in header and title & text in Card content if attachment type is video and og image URLs are not available 1`] = ` +exports[`Card (17) should render video widget in header and title & text in Card content if attachment type is video and attachment image URLs are not available 1`] = `
`; -exports[`Card (18) should render card with title and text only and without the image in the header part of the Card if attachment type is image and og image URLs are not available 1`] = ` +exports[`Card (18) should render card with title and text only and without the image in the header part of the Card if attachment type is image and attachment image URLs are not available 1`] = `
`; -exports[`Card (27) should render content part with title and text only and without the header part of the Card if attachment type is audio and asset and neither og image URL is available 1`] = ` +exports[`Card (27) should render content part with title and text only and without the header part of the Card if attachment type is audio and asset and neither attachment image URL is available 1`] = `
Date: Sat, 7 Feb 2026 07:53:13 +0100 Subject: [PATCH 3/5] fix: prevent re-download from attachment preview --- .../AttachmentPreviewList/utils/AttachmentPreviewRoot.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MessageInput/AttachmentPreviewList/utils/AttachmentPreviewRoot.tsx b/src/components/MessageInput/AttachmentPreviewList/utils/AttachmentPreviewRoot.tsx index 22b22556a4..f26614fe30 100644 --- a/src/components/MessageInput/AttachmentPreviewList/utils/AttachmentPreviewRoot.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/utils/AttachmentPreviewRoot.tsx @@ -54,7 +54,7 @@ export const AttachmentPreviewRoot = ({ const url = attachment.asset_url || attachment.image_url || attachment.localMetadata.previewUri; - const canDownloadAttachment = !!url; + const canDownloadAttachment = false; //!!url; const canPreviewAttachment = (!!url && isImageAttachment(attachment)) || isVideoAttachment(attachment); From f256bffc291d027c2036376cccc27e1b8a117ec4 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 9 Feb 2026 07:22:32 +0100 Subject: [PATCH 4/5] feat: adjust the audio player UIs across the attachments --- src/components/Attachment/VoiceRecording.tsx | 42 ++++++-------- .../components/PlaybackRateButton.tsx | 8 ++- src/components/AudioPlayback/AudioPlayer.ts | 24 ++++++++ .../components/DurationDisplay.tsx | 43 +++++++++----- .../components/WaveProgressBar.tsx | 57 ++++++++++++++++--- .../styling/DurationDisplay.scss | 7 ++- .../styling/WaveProgressBar.scss | 33 ++++++++--- .../AudioRecorder/AudioRecorder.tsx | 13 ----- .../AudioRecorder/AudioRecordingPlayback.tsx | 17 ++++-- .../AudioRecorder/styling/AudioRecorder.scss | 25 ++++++-- .../AudioAttachmentPreview.tsx | 38 +++++++------ .../styling/AttachmentPreview.scss | 27 ++++++++- .../styling/AttachmentSelector.scss | 1 + .../MessageInput/styling/MessageComposer.scss | 1 + 14 files changed, 235 insertions(+), 101 deletions(-) diff --git a/src/components/Attachment/VoiceRecording.tsx b/src/components/Attachment/VoiceRecording.tsx index ab8115c9eb..9b2056e1e5 100644 --- a/src/components/Attachment/VoiceRecording.tsx +++ b/src/components/Attachment/VoiceRecording.tsx @@ -2,10 +2,13 @@ import React from 'react'; import type { Attachment } from 'stream-chat'; import { FileSizeIndicator, PlaybackRateButton, WaveProgressBar } from './components'; -import { displayDuration } from './utils'; import { FileIcon } from '../FileIcon'; import { useMessageContext, useTranslationContext } from '../../context'; -import { type AudioPlayerState, useAudioPlayer } from '../AudioPlayback/'; +import { + type AudioPlayerState, + DurationDisplay, + useAudioPlayer, +} from '../AudioPlayback/'; import { useStateStore } from '../../store'; import type { AudioPlayer } from '../AudioPlayback'; import { PlayButton } from '../Button'; @@ -29,24 +32,19 @@ const VoiceRecordingPlayerUI = ({ audioPlayer }: VoiceRecordingPlayerUIProps) => const { canPlayRecord, isPlaying, playbackRate, progress, secondsElapsed } = useStateStore(audioPlayer?.state, audioPlayerStateSelector) ?? {}; - const displayedDuration = secondsElapsed || audioPlayer.durationSeconds; - return (
- {/*todo: should we be really removing the title?*/} - {/**/} - {/* {audioPlayer.title}*/} - {/*
*/}
{audioPlayer.durationSeconds ? ( - displayDuration(displayedDuration) + ) : ( )}
disabled={!canPlayRecord} onClick={audioPlayer.increasePlaybackRate} > - {playbackRate?.toFixed(1)}x + x{playbackRate?.toString()}
@@ -127,19 +124,14 @@ export const QuotedVoiceRecording = ({ attachment }: QuotedVoiceRecordingProps) // const title = attachment.title || t('Voice message');
- {/*{title && (*/} - {/* */} - {/* {title}*/} - {/*
*/} - {/*)}*/}
{attachment.duration ? ( - displayDuration(attachment.duration) + ) : ( ; export const PlaybackRateButton = ({ children, onClick }: PlaybackRateButtonProps) => ( - + ); diff --git a/src/components/AudioPlayback/AudioPlayer.ts b/src/components/AudioPlayback/AudioPlayer.ts index 94ea3a119c..ce26a8ec82 100644 --- a/src/components/AudioPlayback/AudioPlayer.ts +++ b/src/components/AudioPlayback/AudioPlayer.ts @@ -225,6 +225,19 @@ export class AudioPlayer { }, 2000); }; + private updateDurationFromElement = (element: HTMLAudioElement) => { + const duration = element.duration; + if ( + typeof duration !== 'number' || + isNaN(duration) || + !isFinite(duration) || + duration <= 0 + ) { + return; + } + this._data.durationSeconds = duration; + }; + private clearPlaybackStartSafetyTimeout = () => { if (!this.elementRef) return; clearTimeout(this.playTimeout); @@ -518,6 +531,9 @@ export class AudioPlayer { if (!audioElement) return; const handleEnded = () => { + if (audioElement) { + this.updateDurationFromElement(audioElement); + } this.state.partialNext({ isPlaying: false, secondsElapsed: audioElement?.duration ?? this.durationSeconds ?? 0, @@ -565,14 +581,22 @@ export class AudioPlayer { this.setSecondsElapsed(t); }; + const handleLoadedMetadata = () => { + if (audioElement) { + this.updateDurationFromElement(audioElement); + } + }; + audioElement.addEventListener('ended', handleEnded); audioElement.addEventListener('error', handleError); + audioElement.addEventListener('loadedmetadata', handleLoadedMetadata); audioElement.addEventListener('timeupdate', handleTimeupdate); this.unsubscribeEventListeners = () => { audioElement.pause(); audioElement.removeEventListener('ended', handleEnded); audioElement.removeEventListener('error', handleError); + audioElement.removeEventListener('loadedmetadata', handleLoadedMetadata); audioElement.removeEventListener('timeupdate', handleTimeupdate); }; }; diff --git a/src/components/AudioPlayback/components/DurationDisplay.tsx b/src/components/AudioPlayback/components/DurationDisplay.tsx index 26825de27f..b79c1af0fd 100644 --- a/src/components/AudioPlayback/components/DurationDisplay.tsx +++ b/src/components/AudioPlayback/components/DurationDisplay.tsx @@ -10,16 +10,24 @@ type DurationDisplayProps = { duration?: number; /** Elapsed time in seconds */ secondsElapsed?: number; + /** Show remaining time instead of elapsed when possible */ + showRemaining?: boolean; }; -function formatTime(totalSeconds?: number) { +function formatTime(totalSeconds?: number, rounding: 'ceil' | 'floor' = 'ceil') { if (totalSeconds == null || Number.isNaN(totalSeconds) || totalSeconds < 0) { return null; } - const s = Math.floor(totalSeconds); - const minutes = Math.floor(s / 60); - const seconds = s % 60; - return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + const roundedSeconds = + rounding === 'floor' ? Math.floor(totalSeconds) : Math.ceil(totalSeconds); + const hours = Math.floor(roundedSeconds / 3600); + const minutes = Math.floor((roundedSeconds % 3600) / 60); + const seconds = roundedSeconds % 60; + const minSec = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart( + 2, + '0', + )}`; + return hours ? `${String(hours).padStart(2, '0')}:${minSec}` : minSec; } export function DurationDisplay({ @@ -27,29 +35,38 @@ export function DurationDisplay({ duration, isPlaying, secondsElapsed, + showRemaining = false, }: DurationDisplayProps) { + const remainingSeconds = + duration != null && secondsElapsed != null + ? Math.max(0, duration - secondsElapsed) + : undefined; const formattedDuration = formatTime(duration); const formattedSecondsElapsed = formatTime(secondsElapsed); + const formattedRemaining = formatTime(remainingSeconds); + + const shouldShowElapsed = + !!secondsElapsed && secondsElapsed > 0 && secondsElapsed < (duration || 0); + const canShowRemaining = showRemaining && duration != null && secondsElapsed != null; + const primaryValue = showRemaining ? formattedRemaining : formattedSecondsElapsed; + const showPrimary = (canShowRemaining || shouldShowElapsed) && !!primaryValue; + const showDuration = !showPrimary && !!formattedDuration; return (
0 && secondsElapsed < (duration || 0), + 'str-chat__duration-display--hasProgress': !!secondsElapsed, 'str-chat__duration-display--isPlaying': isPlaying, }, className, )} > - {!!secondsElapsed && ( - - {formattedSecondsElapsed} - + {showPrimary && ( + {primaryValue} )} - {!!(formattedDuration && formattedSecondsElapsed) && <> / } - {formattedDuration && ( + {showDuration && ( {formattedDuration} )}
diff --git a/src/components/AudioPlayback/components/WaveProgressBar.tsx b/src/components/AudioPlayback/components/WaveProgressBar.tsx index bcbfab6e62..4e8b6b83a7 100644 --- a/src/components/AudioPlayback/components/WaveProgressBar.tsx +++ b/src/components/AudioPlayback/components/WaveProgressBar.tsx @@ -17,8 +17,6 @@ type WaveProgressBarProps = { seek: SeekFn; /** The array of fractional number values between 0 and 1 representing the height of amplitudes */ waveformData: number[]; - /** Allows to specify the number of bars into which the original waveformData array should be resampled */ - amplitudesCount?: number; /** Progress expressed in fractional number value btw 0 and 100. */ progress?: number; /** Absolute gap width between bars in px (overrides computed gap var). */ @@ -29,7 +27,6 @@ type WaveProgressBarProps = { export const WaveProgressBar = ({ amplitudeBarGapWidthPx, - amplitudesCount = 40, progress = 0, relativeAmplitudeBarWidth = 2, relativeAmplitudeGap = 1, @@ -46,6 +43,8 @@ export const WaveProgressBar = ({ const [progressIndicator, setProgressIndicator] = useState(null); const lastRootWidth = useRef(0); const lastIndicatorWidth = useRef(0); + const minAmplitudeBarWidthRef = useRef(null); + const lastMinAmplitudeBarWidthUsed = useRef(null); const handleDragStart: PointerEventHandler = (e) => { e.preventDefault(); @@ -78,25 +77,45 @@ export const WaveProgressBar = ({ const parent = trackRoot.parentElement; if (!parent) return trackRoot.getBoundingClientRect().width; const parentWidth = parent.getBoundingClientRect().width; + const computedStyle = window.getComputedStyle(parent); + const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0; + const paddingRight = parseFloat(computedStyle.paddingRight) || 0; + const rawColumnGap = computedStyle.columnGap || computedStyle.gap; + const parsedColumnGap = parseFloat(rawColumnGap); + const columnGap = Number.isNaN(parsedColumnGap) ? 0 : parsedColumnGap; + const gapCount = Math.max(0, parent.children.length - 1); + const totalGapsWidth = columnGap * gapCount; const siblingsWidth = Array.from(parent.children).reduce((total, child) => { if (child === trackRoot) return total; return total + child.getBoundingClientRect().width; }, 0); - return Math.max(0, parentWidth - siblingsWidth); + return Math.max( + 0, + parentWidth - paddingLeft - paddingRight - totalGapsWidth - siblingsWidth, + ); }, []); const getTrackAxisX = useMemo( () => throttle((availableWidth: number) => { - if (availableWidth === lastRootWidth.current) return; + const minAmplitudeBarWidth = minAmplitudeBarWidthRef.current; + const hasMinWidthChanged = + minAmplitudeBarWidth !== lastMinAmplitudeBarWidthUsed.current; + if (availableWidth === lastRootWidth.current && !hasMinWidthChanged) return; lastRootWidth.current = availableWidth; + lastMinAmplitudeBarWidthUsed.current = minAmplitudeBarWidth; const possibleAmpCount = Math.floor( availableWidth / (relativeAmplitudeGap + relativeAmplitudeBarWidth), ); - const tooManyAmplitudesToRender = possibleAmpCount < amplitudesCount; - const barCount = tooManyAmplitudesToRender ? possibleAmpCount : amplitudesCount; const amplitudeBarWidthToGapRatio = relativeAmplitudeBarWidth / (relativeAmplitudeBarWidth + relativeAmplitudeGap); + const maxCountByMinWidth = + typeof minAmplitudeBarWidth === 'number' && minAmplitudeBarWidth > 0 + ? Math.floor( + (availableWidth * amplitudeBarWidthToGapRatio) / minAmplitudeBarWidth, + ) + : possibleAmpCount; + const barCount = Math.max(0, Math.min(possibleAmpCount, maxCountByMinWidth)); const barWidth = barCount && (availableWidth / barCount) * amplitudeBarWidthToGapRatio; @@ -106,7 +125,7 @@ export const WaveProgressBar = ({ gap: barWidth * (relativeAmplitudeGap / relativeAmplitudeBarWidth), }); }, 1), - [relativeAmplitudeBarWidth, relativeAmplitudeGap, amplitudesCount], + [relativeAmplitudeBarWidth, relativeAmplitudeGap], ); const resampledWaveformData = useMemo( @@ -145,13 +164,33 @@ export const WaveProgressBar = ({ } }, [getAvailableTrackWidth, getTrackAxisX, root, progressIndicator]); + useLayoutEffect(() => { + if (!root || typeof window === 'undefined') return; + const amplitudeBar = root.querySelector( + '.str-chat__wave-progress-bar__amplitude-bar', + ); + if (!amplitudeBar) return; + const computedStyle = window.getComputedStyle(amplitudeBar); + const parsedMinWidth = parseFloat(computedStyle.minWidth); + if (!Number.isNaN(parsedMinWidth) && parsedMinWidth > 0) { + minAmplitudeBarWidthRef.current = parsedMinWidth; + } + const availableWidth = getAvailableTrackWidth(root); + if (availableWidth > 0) { + getTrackAxisX(availableWidth); + } + }, [getAvailableTrackWidth, getTrackAxisX, root, trackAxisX?.barCount]); + if (!waveformData.length || trackAxisX?.barCount === 0) return null; const amplitudeGapWidth = amplitudeBarGapWidthPx ?? trackAxisX?.gap; return (
0, + // 'str-chat__wave-progress-bar__track--': isPlaying, + })} data-testid='wave-progress-bar-track' onClick={seek} onPointerDown={handleDragStart} diff --git a/src/components/AudioPlayback/styling/DurationDisplay.scss b/src/components/AudioPlayback/styling/DurationDisplay.scss index be66285a16..169367e92e 100644 --- a/src/components/AudioPlayback/styling/DurationDisplay.scss +++ b/src/components/AudioPlayback/styling/DurationDisplay.scss @@ -1,13 +1,16 @@ .str-chat { .str-chat__duration-display { font-size: var(--typography-font-size-xs); - line-height: var(typography-line-height-tight); + line-height: var(--typography-line-height-tight); letter-spacing: 0; min-width: 35px; width: 35px; + color: var(--text-primary); + white-space: nowrap; + text-align: center; } - &.str-chat__duration-display--hasProgress { + .str-chat__duration-display--hasProgress { .str-chat__duration-display__time-elapsed { color: var(--str-chat__primary-color); } diff --git a/src/components/AudioPlayback/styling/WaveProgressBar.scss b/src/components/AudioPlayback/styling/WaveProgressBar.scss index f946cfa0ab..e6cd41e6e1 100644 --- a/src/components/AudioPlayback/styling/WaveProgressBar.scss +++ b/src/components/AudioPlayback/styling/WaveProgressBar.scss @@ -5,16 +5,25 @@ /* The gap between amplitudes of the wave data of a voice recording */ --str-chat__voice-recording-amplitude-bar-gap-width: var(--str-chat__spacing-px); + .str-chat__message-attachment__voice-recording-widget__audio-state { + display: flex; + align-items: center; + gap: var(--spacing-xs); + padding-inline: var(--spacing-xs); + height: 100%; + } + .str-chat__wave-progress-bar__track { $min_amplitude_height: 2px; position: relative; flex: 1; - width: 160px; + width: 100%; height: 30px; display: flex; align-items: center; gap: var(--str-chat__voice-recording-amplitude-bar-gap-width); + .str-chat__wave-progress-bar__amplitude-bar { width: 2px; min-width: 2px; @@ -29,20 +38,26 @@ // todo: CSS use semantic variable instead of --base-white border: 2px solid var(--base-white); box-shadow: var(--light-elevation-3); - background: var(--accent-primary); + background: var(--accent-neutral); height: 14px; width: 14px; border-radius: var(--radius-max); cursor: grab; } } -} -.str-chat__wave-progress-bar__amplitude-bar { - background: var(--chat-waveform-bar); - border-radius: var(--radius-max); -} + .str-chat__wave-progress-bar__amplitude-bar { + background: var(--chat-waveform-bar); + border-radius: var(--radius-max); + } -.str-chat__wave-progress-bar__amplitude-bar--active { - background: var(--chat-waveform-bar-playing); + .str-chat__wave-progress-bar__amplitude-bar--active { + background: var(--chat-waveform-bar-playing); + } + + .str-chat__wave-progress-bar__track--playback-initiated { + .str-chat__wave-progress-bar__progress-indicator { + background: var(--accent-primary); + } + } } \ No newline at end of file diff --git a/src/components/MediaRecorder/AudioRecorder/AudioRecorder.tsx b/src/components/MediaRecorder/AudioRecorder/AudioRecorder.tsx index 3aaf038a1d..c6b8269d50 100644 --- a/src/components/MediaRecorder/AudioRecorder/AudioRecorder.tsx +++ b/src/components/MediaRecorder/AudioRecorder/AudioRecorder.tsx @@ -36,19 +36,6 @@ export const AudioRecorder = () => { ) : null} - {/*{state.stopped ? (*/} - {/* */} - {/* {isUploadingFile ? : }*/} - {/* */} - {/*) : (*/} - - {/*)}*/}
- //
); }; diff --git a/src/components/MediaRecorder/AudioRecorder/AudioRecordingPlayback.tsx b/src/components/MediaRecorder/AudioRecorder/AudioRecordingPlayback.tsx index fe2cd26561..68a5ead135 100644 --- a/src/components/MediaRecorder/AudioRecorder/AudioRecordingPlayback.tsx +++ b/src/components/MediaRecorder/AudioRecorder/AudioRecordingPlayback.tsx @@ -1,7 +1,10 @@ import React, { useEffect } from 'react'; -import { RecordingTimer } from './RecordingTimer'; import { WaveProgressBar } from '../../Attachment'; -import { type AudioPlayerState, useAudioPlayer } from '../../AudioPlayback'; +import { + type AudioPlayerState, + DurationDisplay, + useAudioPlayer, +} from '../../AudioPlayback'; import { useStateStore } from '../../../store'; import { IconPause, IconPlaySolid } from '../../Icons'; import { Button } from '../../Button'; @@ -66,10 +69,16 @@ export const AudioRecordingPlayback = ({ > {isPlaying ? : } - + = 3600, + })} + duration={durationSeconds} + isPlaying={!!isPlaying} + secondsElapsed={secondsElapsed} + />
; const audioPlayerStateSelector = (state: AudioPlayerState) => ({ + canPlayRecord: state.canPlayRecord, isPlaying: state.isPlaying, + playbackRate: state.currentPlaybackRate, progressPercent: state.progressPercent, secondsElapsed: state.secondsElapsed, }); @@ -54,9 +60,11 @@ export const AudioAttachmentPreview = ({ }; }, [audioPlayer]); - const { isPlaying, progressPercent, secondsElapsed } = + const { canPlayRecord, isPlaying, playbackRate, progressPercent, secondsElapsed } = useStateStore(audioPlayer?.state, audioPlayerStateSelector) ?? {}; + const resolvedDuration = audioPlayer?.durationSeconds ?? attachment.duration; + const hasWaveform = !!audioPlayer?.waveformData?.length; const hasSizeLimitError = uploadPermissionCheck?.reason === 'size_limit'; const hasFatalError = uploadState === 'blocked' || hasSizeLimitError; @@ -80,29 +88,22 @@ export const AudioAttachmentPreview = ({
- {attachment.title} + {isVoiceRecordingAttachment(attachment) ? t('Voice message') : attachment.title}
{uploadState === 'uploading' && } {showProgressControls ? ( <> - {!attachment.duration && !progressPercent && !isPlaying && ( + {!resolvedDuration && !progressPercent && !isPlaying && ( )} {hasWaveform ? ( <> ) : ( @@ -150,6 +151,11 @@ export const AudioAttachmentPreview = ({ )}
+ {audioPlayer && canPlayRecord && ( + + x{playbackRate?.toString()} + + )} { diff --git a/src/components/MessageInput/styling/AttachmentPreview.scss b/src/components/MessageInput/styling/AttachmentPreview.scss index 30855a9a12..cd33fc3149 100644 --- a/src/components/MessageInput/styling/AttachmentPreview.scss +++ b/src/components/MessageInput/styling/AttachmentPreview.scss @@ -88,6 +88,31 @@ max-width: 280px; } + .str-chat__attachment-preview-audio { + .str-chat__attachment-preview-file__data { + padding-right: var(--spacing-xs); + } + + .str-chat__message_attachment__playback-rate-button { + @include utils.button-reset; + display: flex; + min-width: 40px; + min-height: 24px; + max-height: 24px; + padding: var(--button-padding-y-sm, 6px) var(--spacing-xs, 8px); + justify-content: center; + align-items: center; + gap: var(--spacing-xs, 8px); + color: var(--control-playback-toggle-text, var(--text-primary)); + background-color: transparent; + border-radius: var(--button-radius-lg, 9999px); + border: 1px solid var(--control-playback-toggle-border, #D5DBE1); + font-size: var(--typography-font-size-xs, 12px); + font-weight: var(--typography-font-weight-semi-bold, 600); + line-height: var(--typography-line-height-tight, 16px); + } + } + .str-chat__attachment-preview-audio, .str-chat__attachment-preview-file, .str-chat__attachment-preview-voice-recording, @@ -209,7 +234,7 @@ .str-chat__attachment-preview-file__data { display: flex; align-items: center; - width: 190px; + width: 160px; gap: var(--spacing-xxs); color: var(--text-secondary); font-weight: var(--typography-font-weight-regular); diff --git a/src/components/MessageInput/styling/AttachmentSelector.scss b/src/components/MessageInput/styling/AttachmentSelector.scss index 9fd3439d02..8b780b414d 100644 --- a/src/components/MessageInput/styling/AttachmentSelector.scss +++ b/src/components/MessageInput/styling/AttachmentSelector.scss @@ -1,4 +1,5 @@ .str-chat { + // todo: find existing replacement for variable button-style-outline-text) --str-chat__attachment-selector-button-icon-color: var(--button-style-outline-text); --str-chat__attachment-selector-button-icon-color-hover: var(--text-secondary); --str-chat__attachment-selector-actions-menu-button-icon-color: var(--text-secondary); diff --git a/src/components/MessageInput/styling/MessageComposer.scss b/src/components/MessageInput/styling/MessageComposer.scss index db4f238c2b..b487c7c70d 100644 --- a/src/components/MessageInput/styling/MessageComposer.scss +++ b/src/components/MessageInput/styling/MessageComposer.scss @@ -55,6 +55,7 @@ padding: var(--spacing-xs); gap: var(--spacing-xs); min-width: 0; + flex-shrink: 1; } .str-chat__message-composer-compose-area { From cefc94118ec5e0c2fe9690867b51fde230dee8d5 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 9 Feb 2026 07:24:07 +0100 Subject: [PATCH 5/5] feat: add Giphy styling --- src/components/Attachment/Attachment.tsx | 37 ++++- .../Attachment/AttachmentActions.tsx | 48 +++++- .../Attachment/AttachmentContainer.tsx | 30 ++++ src/components/Attachment/Giphy.tsx | 43 +++++ .../Attachment/LinkPreview/Card.tsx | 2 +- .../Attachment/VisibilityDisclaimer.tsx | 13 ++ .../__tests__/AttachmentActions.test.js | 14 ++ .../__snapshots__/VoiceRecording.test.js.snap | 7 - .../Attachment/styling/Attachment.scss | 151 +++--------------- .../Attachment/styling/AttachmentActions.scss | 91 +++++++++++ src/components/Attachment/styling/Giphy.scss | 62 +++++++ .../Attachment/styling/LinkPreview.scss | 13 -- src/components/Attachment/styling/index.scss | 4 +- src/components/Attachment/utils.tsx | 1 + src/components/Button/Button.tsx | 17 +- src/components/Gallery/BaseImage.tsx | 1 + src/components/Icons/IconEyeOpen.tsx | 17 ++ src/components/Icons/index.ts | 1 + src/components/Icons/styling/IconEyeOpen.scss | 9 ++ src/components/Icons/styling/index.scss | 1 + src/components/Message/MessageSimple.tsx | 10 +- src/components/Message/styling/Message.scss | 46 +++++- src/components/Message/utils.tsx | 8 + src/i18n/de.json | 1 + src/i18n/en.json | 1 + src/i18n/es.json | 1 + src/i18n/fr.json | 1 + src/i18n/hi.json | 1 + src/i18n/it.json | 1 + src/i18n/ja.json | 10 ++ src/i18n/ko.json | 10 ++ src/i18n/nl.json | 1 + src/i18n/pt.json | 1 + src/i18n/ru.json | 1 + src/i18n/tr.json | 1 + src/styling/_global-theme-variables.scss | 2 +- src/styling/index.scss | 14 +- 37 files changed, 490 insertions(+), 182 deletions(-) create mode 100644 src/components/Attachment/Giphy.tsx create mode 100644 src/components/Attachment/VisibilityDisclaimer.tsx create mode 100644 src/components/Attachment/styling/AttachmentActions.scss create mode 100644 src/components/Attachment/styling/Giphy.scss create mode 100644 src/components/Icons/IconEyeOpen.tsx create mode 100644 src/components/Icons/styling/IconEyeOpen.scss diff --git a/src/components/Attachment/Attachment.tsx b/src/components/Attachment/Attachment.tsx index 3edf90e136..2c00832cd2 100644 --- a/src/components/Attachment/Attachment.tsx +++ b/src/components/Attachment/Attachment.tsx @@ -13,14 +13,19 @@ import { CardContainer, FileContainer, GeolocationContainer, + GiphyContainer, MediaContainer, UnsupportedAttachmentContainer, } from './AttachmentContainer'; import { SUPPORTED_VIDEO_FORMATS } from './utils'; +import { defaultAttachmentActionsDefaultFocus } from './AttachmentActions'; import type { ReactPlayerProps } from 'react-player'; import type { SharedLocationResponse, Attachment as StreamAttachment } from 'stream-chat'; -import type { AttachmentActionsProps } from './AttachmentActions'; +import type { + AttachmentActionsDefaultFocusByType, + AttachmentActionsProps, +} from './AttachmentActions'; import type { AudioProps } from './Audio'; import type { VoiceRecordingProps } from './VoiceRecording'; import type { CardProps } from './LinkPreview/Card'; @@ -30,9 +35,11 @@ import type { UnsupportedAttachmentProps } from './UnsupportedAttachment'; import type { ActionHandlerReturnType } from '../Message/hooks/useActionHandler'; import type { GroupedRenderedAttachment } from './utils'; import type { GeolocationProps } from './Geolocation'; +import type { GiphyAttachmentProps } from './Giphy'; export const ATTACHMENT_GROUPS_ORDER = [ 'media', + 'giphy', 'card', 'geolocation', 'file', @@ -44,6 +51,8 @@ export type AttachmentProps = { attachments: (StreamAttachment | SharedLocationResponse)[]; /** The handler function to call when an action is performed on an attachment, examples include canceling a \/giphy command or shuffling the results. */ actionHandler?: ActionHandlerReturnType; + /** Which action should be focused on initial render, by attachment type (match by action.value) */ + attachmentActionsDefaultFocus?: AttachmentActionsDefaultFocusByType; /** Custom UI component for displaying attachment actions, defaults to and accepts same props as: [AttachmentActions](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Attachment/AttachmentActions.tsx) */ AttachmentActions?: React.ComponentType; /** Custom UI component for displaying an audio type attachment, defaults to and accepts same props as: [Audio](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Attachment/Audio.tsx) */ @@ -55,6 +64,8 @@ export type AttachmentProps = { /** Custom UI component for displaying a gallery of image type attachments, defaults to and accepts same props as: [Gallery](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Gallery/Gallery.tsx) */ Gallery?: React.ComponentType; Geolocation?: React.ComponentType; + /** Custom UI component for displaying a Giphy image, defaults to and accepts same props as: [Giphy](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Attachment/Giphy.tsx) */ + Giphy?: React.ComponentType; /** Custom UI component for displaying an image type attachment, defaults to and accepts same props as: [Image](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Gallery/Image.tsx) */ Image?: React.ComponentType; /** Optional flag to signal that an attachment is a displayed as a part of a quoted message */ @@ -71,12 +82,21 @@ export type AttachmentProps = { * A component used for rendering message attachments. */ export const Attachment = (props: AttachmentProps) => { - const { attachments } = props; + const { + attachmentActionsDefaultFocus = defaultAttachmentActionsDefaultFocus, + attachments, + ...rest + } = props; const groupedAttachments = useMemo( - () => renderGroupedAttachments(props), + () => + renderGroupedAttachments({ + attachmentActionsDefaultFocus, + attachments, + ...rest, + }), // eslint-disable-next-line react-hooks/exhaustive-deps - [attachments], + [attachments, attachmentActionsDefaultFocus], ); return ( @@ -104,6 +124,14 @@ const renderGroupedAttachments = ({ location={attachment} />, ); + } else if (attachment.type === 'giphy') { + typeMap.card.push( + , + ); } else if (isScrapedContent(attachment)) { typeMap.card.push( , string> +>; + +export const defaultAttachmentActionsDefaultFocus: AttachmentActionsDefaultFocusByType = { + giphy: 'send', }; const UnMemoizedAttachmentActions = (props: AttachmentActionsProps) => { - const { actionHandler, actions, id, text } = props; + const { actionHandler, actions, defaultFocusedActionValue, id, text } = props; const { t } = useTranslationContext('UnMemoizedAttachmentActions'); + const buttonRefs = useRef>([]); const handleActionClick = ( event: React.MouseEvent, @@ -35,20 +48,43 @@ const UnMemoizedAttachmentActions = (props: AttachmentActionsProps) => { [t], ); + const focusIndex = useMemo(() => { + if (!defaultFocusedActionValue) return null; + const index = actions.findIndex( + (action) => action.value === defaultFocusedActionValue, + ); + return index >= 0 ? index : null; + }, [actions, defaultFocusedActionValue]); + + useEffect(() => { + if (focusIndex === null) return; + const button = buttonRefs.current[focusIndex]; + if (button && document.activeElement !== button) { + button.focus(); + } + }, [focusIndex]); + return (
{text} - {actions.map((action) => ( - + ))}
diff --git a/src/components/Attachment/AttachmentContainer.tsx b/src/components/Attachment/AttachmentContainer.tsx index e67c64e28d..c47e968f8a 100644 --- a/src/components/Attachment/AttachmentContainer.tsx +++ b/src/components/Attachment/AttachmentContainer.tsx @@ -21,6 +21,7 @@ import { } from '../Gallery'; import { Card as DefaultCard } from './LinkPreview/Card'; import { FileAttachment as DefaultFile } from './FileAttachment'; +import { Giphy as DefaultGiphy } from './Giphy'; import { Geolocation as DefaultGeolocation } from './Geolocation'; import { UnsupportedAttachment as DefaultUnsupportedAttachment } from './UnsupportedAttachment'; import type { @@ -43,6 +44,7 @@ import type { } from '../../types/types'; import { IconPlaySolid } from '../Icons'; import { Button } from '../Button'; +import { VisibilityDisclaimer } from './VisibilityDisclaimer'; export type AttachmentContainerProps = { attachment: Attachment | GalleryAttachment | SharedLocationResponse; @@ -86,14 +88,19 @@ export const AttachmentActionsContainer = ({ actionHandler, attachment, AttachmentActions = DefaultAttachmentActions, + attachmentActionsDefaultFocus, }: RenderAttachmentProps) => { if (!attachment.actions?.length) return null; + const defaultFocusedActionValue = + attachment.type && attachmentActionsDefaultFocus?.[attachment.type]; + return ( @@ -169,6 +176,29 @@ export const CardContainer = (props: RenderAttachmentProps) => { ); }; +export const GiphyContainer = (props: RenderAttachmentProps) => { + const { attachment, Giphy = DefaultGiphy } = props; + const componentType = 'giphy'; + + if (attachment.actions && attachment.actions.length) { + return ( + +
+ + + +
+
+ ); + } + + return ( + + + + ); +}; + export const FileContainer = (props: RenderAttachmentProps) => { const { attachment } = props; diff --git a/src/components/Attachment/Giphy.tsx b/src/components/Attachment/Giphy.tsx new file mode 100644 index 0000000000..944f05e545 --- /dev/null +++ b/src/components/Attachment/Giphy.tsx @@ -0,0 +1,43 @@ +import type { Attachment } from 'stream-chat'; +import { ImageComponent } from '../Gallery'; +import clsx from 'clsx'; +import { useChannelStateContext } from '../../context'; +import { IconGiphy } from '../Icons'; + +export type GiphyAttachmentProps = { + attachment: Attachment; +}; + +export const Giphy = ({ attachment }: GiphyAttachmentProps) => { + const { giphy, thumb_url, title } = attachment; + const { giphyVersion: giphyVersionName } = useChannelStateContext(); + + if (typeof giphy === 'undefined') return null; + + const giphyVersion = giphy[giphyVersionName as keyof NonNullable]; + + const fallback = giphyVersion.url || thumb_url; + const dimensions: { height?: string; width?: string } = { + height: giphyVersion.height, + width: giphyVersion.width, + }; + + return ( +
+ + +
+ ); +}; + +const GiphyBadge = () => ( +
+ + Giphy +
+); diff --git a/src/components/Attachment/LinkPreview/Card.tsx b/src/components/Attachment/LinkPreview/Card.tsx index 680bb8f577..1c36edadf7 100644 --- a/src/components/Attachment/LinkPreview/Card.tsx +++ b/src/components/Attachment/LinkPreview/Card.tsx @@ -88,7 +88,7 @@ export type CardProps = RenderAttachmentProps['attachment'] & { const UnMemoizedCard = (props: CardProps) => { const { giphy, image_url, og_scrape_url, thumb_url, title, title_link, type } = props; - const { giphyVersion: giphyVersionName } = useChannelStateContext('CardHeader'); + const { giphyVersion: giphyVersionName } = useChannelStateContext(''); const cardUrl = title_link || og_scrape_url; let image = thumb_url || image_url; diff --git a/src/components/Attachment/VisibilityDisclaimer.tsx b/src/components/Attachment/VisibilityDisclaimer.tsx new file mode 100644 index 0000000000..6b3813fdf5 --- /dev/null +++ b/src/components/Attachment/VisibilityDisclaimer.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { IconEyeOpen } from '../Icons'; +import { useTranslationContext } from '../../context'; + +export const VisibilityDisclaimer = () => { + const { t } = useTranslationContext(); + return ( +
+ + {t('Only visible to you')} +
+ ); +}; diff --git a/src/components/Attachment/__tests__/AttachmentActions.test.js b/src/components/Attachment/__tests__/AttachmentActions.test.js index d3074d2e0e..3d651635ff 100644 --- a/src/components/Attachment/__tests__/AttachmentActions.test.js +++ b/src/components/Attachment/__tests__/AttachmentActions.test.js @@ -51,4 +51,18 @@ describe('AttachmentActions', () => { expect(actionHandler).toHaveBeenCalledTimes(2); }); }); + + it('should focus default action by value', async () => { + const { getByTestId } = render( + getComponent({ + actions, + defaultFocusedActionValue: actions[1].value, + id: nanoid(), + }), + ); + + await waitFor(() => { + expect(getByTestId(actions[1].name)).toHaveFocus(); + }); + }); }); diff --git a/src/components/Attachment/__tests__/__snapshots__/VoiceRecording.test.js.snap b/src/components/Attachment/__tests__/__snapshots__/VoiceRecording.test.js.snap index 06b17237c5..931dc0eda0 100644 --- a/src/components/Attachment/__tests__/__snapshots__/VoiceRecording.test.js.snap +++ b/src/components/Attachment/__tests__/__snapshots__/VoiceRecording.test.js.snap @@ -9,13 +9,6 @@ exports[`QuotedVoiceRecording should render the component 1`] = `