From 60b8f5133155fb45669eeefc2e9b977ed161ca1b Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 6 Feb 2026 15:27:38 +0100 Subject: [PATCH 01/20] 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 02/20] 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 03/20] 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 04/20] 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 05/20] 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`] = ` - )}
+ ); }; diff --git a/src/components/MessageInput/styling/MessageComposer.scss b/src/components/MessageInput/styling/MessageComposer.scss index 4261c41e30..1c98c6c2ee 100644 --- a/src/components/MessageInput/styling/MessageComposer.scss +++ b/src/components/MessageInput/styling/MessageComposer.scss @@ -35,11 +35,16 @@ --str-chat__cooldown-border-inline-start: 0; --str-chat__cooldown-border-inline-end: 0; --str-chat__cooldown-box-shadow: none; + --str-chat__message-composer-max-width: 768px; .str-chat__message-composer-container { width: 100%; display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-xs); justify-content: center; + padding: var(--spacing-xs); min-width: 0; } /* @@ -57,8 +62,7 @@ display: flex; align-items: end; width: 100%; - max-width: 768px; - padding: var(--spacing-xs); + max-width: var(--str-chat__message-composer-max-width); gap: var(--spacing-xs); min-width: 0; flex-shrink: 1; @@ -195,7 +199,9 @@ .str-chat__send-to-channel-checkbox__container { width: 100%; display: flex; - padding: 0.5rem 0.75rem; + align-items: flex-start; + padding-inline: 0.75rem; + max-width: var(--str-chat__message-composer-max-width); .str-chat__send-to-channel-checkbox__field { display: flex; From bee7651d5686054e897d75bcf2131e594704b467 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 12 Feb 2026 11:15:33 +0100 Subject: [PATCH 08/20] fix(demo): make the demo app layout responsive in channel with thread view --- examples/vite/src/index.scss | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss index e5ad46e401..54a8f367ac 100644 --- a/examples/vite/src/index.scss +++ b/examples/vite/src/index.scss @@ -97,16 +97,26 @@ body { max-width: 360px; } - @media (max-width: 960px) { - .str-chat__channel-list { - flex-basis: 260px; - max-width: 260px; + .str-chat__chat-view__threads { + .str-chat__thread-container { + flex: 1 1 auto; + min-width: 360px; + max-width: none; } + } - .str-chat__thread-list-container, - .str-chat__thread-container { - flex-basis: 320px; - max-width: 320px; + @media (max-width: 1100px) { + .str-chat__container:has(.str-chat__thread-container) > .str-chat__main-panel { + width: 0; + min-width: 0; + flex: 0 0 0; + overflow: hidden; + } + + .str-chat__container:has(.str-chat__thread-container) > .str-chat__thread-container { + flex: 1 1 auto; + min-width: 360px; + max-width: none; } } From d05cbdc24c8a2f372953089bdbbca5cbf7352bd4 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 12 Feb 2026 12:03:47 +0100 Subject: [PATCH 09/20] feat: remove floating composer styles --- .../MessageInput/styling/MessageComposer.scss | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/components/MessageInput/styling/MessageComposer.scss b/src/components/MessageInput/styling/MessageComposer.scss index 1c98c6c2ee..65c8612d88 100644 --- a/src/components/MessageInput/styling/MessageComposer.scss +++ b/src/components/MessageInput/styling/MessageComposer.scss @@ -47,16 +47,6 @@ padding: var(--spacing-xs); min-width: 0; } - /* - Styles for floating like composer - */ - .str-chat__message-composer--floating { - position: fixed; - bottom: 0; - background-color: var(--base-transparent-0); - // todo: variable exists only in Figma, not added to tokens repo - box-shadow: var(--shadow-web-light-elevation-2); - } .str-chat__message-composer { display: flex; From 5af04790e5ea60291d2674dd6ac8156ed9f69534 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 13 Feb 2026 09:47:49 +0100 Subject: [PATCH 10/20] feat: add generic form component TextInput and other form component styles --- src/components/Form/TextInput.tsx | 103 +++++++++++++ src/components/Form/index.ts | 1 + src/components/Form/styling/DropDown.scss | 14 ++ src/components/Form/styling/FieldError.scss | 4 + src/components/Form/styling/Form.scss | 13 ++ src/components/Form/styling/SwitchField.scss | 74 +++++++++ src/components/Form/styling/TextInput.scss | 151 +++++++++++++++++++ src/components/Form/styling/index.scss | 5 + src/components/index.ts | 1 + 9 files changed, 366 insertions(+) create mode 100644 src/components/Form/TextInput.tsx create mode 100644 src/components/Form/index.ts create mode 100644 src/components/Form/styling/DropDown.scss create mode 100644 src/components/Form/styling/FieldError.scss create mode 100644 src/components/Form/styling/Form.scss create mode 100644 src/components/Form/styling/SwitchField.scss create mode 100644 src/components/Form/styling/TextInput.scss create mode 100644 src/components/Form/styling/index.scss diff --git a/src/components/Form/TextInput.tsx b/src/components/Form/TextInput.tsx new file mode 100644 index 0000000000..5a78e75650 --- /dev/null +++ b/src/components/Form/TextInput.tsx @@ -0,0 +1,103 @@ +import clsx from 'clsx'; +import React, { forwardRef } from 'react'; +import type { ComponentProps, ReactNode } from 'react'; +import { useStableId } from '../UtilityComponents/useStableId'; + +export type TextInputVariant = 'outline' | 'ghost'; + +export type TextInputProps = Omit, 'className'> & { + /** Optional label above the input */ + label?: string; + /** Optional leading content (e.g. icon) inside the input area */ + leading?: ReactNode; + /** Optional trailing content (e.g. clear button) inside the input area */ + trailing?: ReactNode; + /** Optional suffix text shown after the input value, inside the field */ + trailingText?: string; + /** Helper or error message below the input (string, icon, or other React content) */ + message?: ReactNode; + /** When true, shows error border and error styling for message */ + error?: boolean; + /** Visual variant: outline = border always visible, ghost = border only on focus */ + variant?: TextInputVariant; + /** Optional class name for the root wrapper */ + className?: string; +}; + +export const TextInput = forwardRef(function TextInput( + { + className, + disabled, + error = false, + id: idProp, + label, + leading, + message, + trailing, + trailingText, + variant = 'outline', + ...inputProps + }, + ref, +) { + const generatedId = useStableId(); + const id = idProp ?? generatedId; + const messageId = message ? `${id}-message` : undefined; + + return ( +
+ {!!label && ( + + )} +
+ {!!leading && ( + + {leading} + + )} + + {trailingText != null && ( + + {trailingText} + + )} + {!!trailing && ( + + {trailing} + + )} +
+ {!!message && ( +
+ {message} +
+ )} +
+ ); +}); diff --git a/src/components/Form/index.ts b/src/components/Form/index.ts new file mode 100644 index 0000000000..a7fcf6f39c --- /dev/null +++ b/src/components/Form/index.ts @@ -0,0 +1 @@ +export * from './TextInput'; diff --git a/src/components/Form/styling/DropDown.scss b/src/components/Form/styling/DropDown.scss new file mode 100644 index 0000000000..30707c0d79 --- /dev/null +++ b/src/components/Form/styling/DropDown.scss @@ -0,0 +1,14 @@ +@use '../../../styling/utils'; + +.str-chat__dropdown { + .str-chat__dropdown__open-button { + @include utils.button-reset; + width: 100%; + text-align: start; + cursor: pointer; + } + + .str-chat__dropdown__items { + background-color: var(--str-chat__background-color); + } +} diff --git a/src/components/Form/styling/FieldError.scss b/src/components/Form/styling/FieldError.scss new file mode 100644 index 0000000000..855c778d08 --- /dev/null +++ b/src/components/Form/styling/FieldError.scss @@ -0,0 +1,4 @@ +.str-chat__form-field-error { + font-size: 0.75rem; + color: var(--str-chat__danger-color); +} diff --git a/src/components/Form/styling/Form.scss b/src/components/Form/styling/Form.scss new file mode 100644 index 0000000000..57b4d1ac1d --- /dev/null +++ b/src/components/Form/styling/Form.scss @@ -0,0 +1,13 @@ +// hide spin buttons form input of type number +/* Chrome, Safari, Edge, Opera */ + +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* Firefox */ +input[type='number'] { + -moz-appearance: textfield; +} diff --git a/src/components/Form/styling/SwitchField.scss b/src/components/Form/styling/SwitchField.scss new file mode 100644 index 0000000000..897dee38cf --- /dev/null +++ b/src/components/Form/styling/SwitchField.scss @@ -0,0 +1,74 @@ +// fixme: duplicate mixin in poll theme +@mixin field-background { + background-color: var(--str-chat__tertiary-surface-color); + border-radius: 0.75rem; +} + +.str-chat__dialog__field { + display: flex; + flex-direction: column; + gap: 0.5rem; + + .str-chat__form-field-error { + margin-left: 0.5rem; + } +} + +.str-chat__form__switch-field { + @include field-background; + width: 100%; + padding: 1rem; + + input[type='checkbox'] { + display: none; + } + + label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + width: 100%; + } + + &, + label { + cursor: pointer; + } + + .str-chat__form__switch-field__switch { + display: flex; + align-items: center; + width: calc(var(--str-chat__spacing-px) * 52); + height: calc(var(--str-chat__spacing-px) * 32); + padding: 0.25rem; + cursor: pointer; + background-color: var(--str-chat__text-low-emphasis-color); + border-radius: 100px; + + .str-chat__form__switch-field__switch-handle { + height: 1.5rem; + width: 1.5rem; + border-radius: var(--str-chat__border-radius-circle); + background-color: var(--str-chat__disabled-color); + } + + &.str-chat__form__switch-field__switch--on { + justify-content: flex-end; + background-color: var(--str-chat__green600); + + .str-chat__form__switch-field__switch-handle { + background-color: var(--str-chat__background-color); + border-radius: var(--str-chat__border-radius-circle); + } + } + } +} + +.str-chat__form__switch-field--disabled { + .str-chat__form__switch-field--disabled, + .str-chat__form__switch-field--disabled .str-chat__form__switch-field__switch, + label { + cursor: not-allowed; + } +} diff --git a/src/components/Form/styling/TextInput.scss b/src/components/Form/styling/TextInput.scss new file mode 100644 index 0000000000..0601d1f4ca --- /dev/null +++ b/src/components/Form/styling/TextInput.scss @@ -0,0 +1,151 @@ +.str-chat__form-text-input { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + + &__label { + font-size: var(--typography-font-size-sm); + font-weight: var(--typography-font-weight-medium); + line-height: var(--typography-line-height-tight); + color: var(--text-primary); + } + + &__wrapper { + display: flex; + align-items: center; + gap: var(--spacing-xs); + min-height: var(--size-40); + padding: 0 var(--spacing-sm); + background-color: var(--background-elevation-elevation-0); + border-radius: var(--radius-md); + outline: none; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; + } + + // --------------------------------------------------------------------------- + // Outline variant — always 1px border on wrapper; + 2px focus ring on focus-within + // --------------------------------------------------------------------------- + &__wrapper--outline { + border: 1px solid var(--input-border-default); + box-shadow: none; + } + + &:focus-within &__wrapper--outline { + border: 1px solid var(--input-border-focus); + box-shadow: 0 0 0 2px var(--border-utility-focus, #c3d9ff); + } + + &--error &__wrapper--outline { + border: 1px solid var(--border-utility-error); + } + + @at-root .str-chat__form-text-input:focus-within.str-chat__form-text-input--error + .str-chat__form-text-input__wrapper--outline { + border: 1px solid var(--border-utility-error); + box-shadow: none; + } + + &--disabled &__wrapper--outline { + border: 1px solid var(--border-utility-disabled); + } + + // --------------------------------------------------------------------------- + // Ghost variant — no border on wrapper; 2px focus ring only on focus-within (and error) + // --------------------------------------------------------------------------- + &__wrapper--ghost { + border: none; + box-shadow: none; + } + + &:focus-within &__wrapper--ghost { + border: none; + box-shadow: 0 0 0 2px var(--border-utility-focus, #c3d9ff); + } + + &--error &__wrapper--ghost { + border: 1px solid var(--border-utility-error); + box-shadow: none; + } + + @at-root .str-chat__form-text-input:focus-within.str-chat__form-text-input--error + .str-chat__form-text-input__wrapper--ghost { + border: 1px solid var(--border-utility-error); + box-shadow: none; + } + + // --------------------------------------------------------------------------- + // Shared: leading, input, suffix, trailing, message + // --------------------------------------------------------------------------- + &__leading { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--input-text-icon); + } + + &__input { + flex: 1; + min-width: 0; + padding: var(--spacing-xs) 0; + font-size: var(--typography-font-size-sm); + font-weight: var(--typography-font-weight-regular); + line-height: var(--typography-line-height-normal); + color: var(--input-text-default); + background: transparent; + border: none; + outline: none; + box-shadow: none; + + &::placeholder { + color: var(--input-text-placeholder); + } + + .str-chat__form-text-input--disabled & { + color: var(--input-text-placeholder); + cursor: not-allowed; + + &::placeholder { + color: var(--input-text-placeholder); + } + } + } + + .str-chat__form-text-input__input:focus, + .str-chat__form-text-input__input:focus-visible { + outline: none; + box-shadow: none; + } + + &__suffix { + flex-shrink: 0; + font-size: var(--typography-font-size-sm); + line-height: var(--typography-line-height-normal); + color: var(--text-tertiary); + } + + &__trailing { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-right: calc(-1 * var(--space-4)); + color: var(--input-text-icon); + + .str-chat__form-text-input--error & { + color: var(--accent-error); + } + } + + &__message { + font-size: var(--typography-font-size-xs); + line-height: var(--typography-line-height-tight); + color: var(--text-tertiary); + + .str-chat__form-text-input--error & { + color: var(--accent-error); + } + } +} diff --git a/src/components/Form/styling/index.scss b/src/components/Form/styling/index.scss new file mode 100644 index 0000000000..493ff44e98 --- /dev/null +++ b/src/components/Form/styling/index.scss @@ -0,0 +1,5 @@ +@use 'DropDown'; +@use 'FieldError'; +@use 'Form'; +@use 'SwitchField'; +@use 'TextInput'; diff --git a/src/components/index.ts b/src/components/index.ts index b1d8782537..e8605f9424 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -15,6 +15,7 @@ export * from './Dialog'; export * from './EmptyStateIndicator'; export * from './EventComponent'; export * from './FileIcon'; +export * from './Form'; export * from './Gallery'; export * from './Icons'; export * from './InfiniteScrollPaginator'; From 496611c53911853a4c1d00a62fb6c9d89df46c99 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 13 Feb 2026 09:49:05 +0100 Subject: [PATCH 11/20] feat: add Channel and Thread styling --- examples/vite/src/stream-imports-layout.scss | 6 +- examples/vite/src/stream-imports-theme.scss | 4 +- src/components/Channel/styling/Channel.scss | 241 +++++++++++++++++++ src/components/Channel/styling/index.scss | 1 + src/components/Thread/styling/Thread.scss | 149 ++++++++++++ src/components/Thread/styling/index.scss | 1 + src/styling/_utils.scss | 99 ++++++++ src/styling/index.scss | 5 +- 8 files changed, 500 insertions(+), 6 deletions(-) create mode 100644 src/components/Channel/styling/Channel.scss create mode 100644 src/components/Channel/styling/index.scss create mode 100644 src/components/Thread/styling/Thread.scss create mode 100644 src/components/Thread/styling/index.scss diff --git a/examples/vite/src/stream-imports-layout.scss b/examples/vite/src/stream-imports-layout.scss index 489947052d..388903a36f 100644 --- a/examples/vite/src/stream-imports-layout.scss +++ b/examples/vite/src/stream-imports-layout.scss @@ -8,7 +8,7 @@ //@use 'stream-chat-react/dist/scss/v2/Autocomplete/Autocomplete-layout'; //@use 'stream-chat-react/dist/scss/v2/AudioRecorder/AudioRecorder-layout'; @use 'stream-chat-react/dist/scss/v2/BaseImage/BaseImage-layout'; -@use 'stream-chat-react/dist/scss/v2/Channel/Channel-layout'; +//@use 'stream-chat-react/dist/scss/v2/Channel/Channel-layout'; @use 'stream-chat-react/dist/scss/v2/ChannelHeader/ChannelHeader-layout'; @use 'stream-chat-react/dist/scss/v2/ChannelList/ChannelList-layout'; @use 'stream-chat-react/dist/scss/v2/ChannelPreview/ChannelPreview-layout'; @@ -18,8 +18,8 @@ //@use 'stream-chat-react/dist/scss/v2/Dialog/Dialog-layout'; @use 'stream-chat-react/dist/scss/v2/DragAndDropContainer/DragAndDropContainer-layout'; @use 'stream-chat-react/dist/scss/v2/DropzoneContainer/DropzoneContainer-layout'; // X -@use 'stream-chat-react/dist/scss/v2/EditMessageForm/EditMessageForm-layout'; -@use 'stream-chat-react/dist/scss/v2/Form/Form-layout'; +//@use 'stream-chat-react/dist/scss/v2/EditMessageForm/EditMessageForm-layout'; +//@use 'stream-chat-react/dist/scss/v2/Form/Form-layout'; @use 'stream-chat-react/dist/scss/v2/ImageCarousel/ImageCarousel-layout'; //@use 'stream-chat-react/dist/scss/v2/Icon/Icon-layout'; @use 'stream-chat-react/dist/scss/v2/InfiniteScrollPaginator/InfiniteScrollPaginator-layout'; diff --git a/examples/vite/src/stream-imports-theme.scss b/examples/vite/src/stream-imports-theme.scss index bb54b78709..f93d94dd71 100644 --- a/examples/vite/src/stream-imports-theme.scss +++ b/examples/vite/src/stream-imports-theme.scss @@ -8,7 +8,7 @@ //@use 'stream-chat-react/dist/scss/v2/AudioRecorder/AudioRecorder-theme'; //@use 'stream-chat-react/dist/scss/v2/Autocomplete/Autocomplete-theme'; @use 'stream-chat-react/dist/scss/v2/BaseImage/BaseImage-theme'; -@use 'stream-chat-react/dist/scss/v2/Channel/Channel-theme.scss'; +//@use 'stream-chat-react/dist/scss/v2/Channel/Channel-theme.scss'; @use 'stream-chat-react/dist/scss/v2/ChannelHeader/ChannelHeader-theme'; @use 'stream-chat-react/dist/scss/v2/ChannelList/ChannelList-theme'; @use 'stream-chat-react/dist/scss/v2/ChannelPreview/ChannelPreview-theme'; @@ -16,7 +16,7 @@ //@use 'stream-chat-react/dist/scss/v2/Dialog/Dialog-theme'; @use 'stream-chat-react/dist/scss/v2/DragAndDropContainer/DragAndDropContainer-theme'; @use 'stream-chat-react/dist/scss/v2/DropzoneContainer/DropzoneContainer-theme'; -@use 'stream-chat-react/dist/scss/v2/Form/Form-theme'; +//@use 'stream-chat-react/dist/scss/v2/Form/Form-theme'; //@use 'stream-chat-react/dist/scss/v2/Icon/Icon-theme'; @use 'stream-chat-react/dist/scss/v2/ImageCarousel/ImageCarousel-theme'; @use 'stream-chat-react/dist/scss/v2/LoadingIndicator/LoadingIndicator-theme'; diff --git a/src/components/Channel/styling/Channel.scss b/src/components/Channel/styling/Channel.scss new file mode 100644 index 0000000000..27aae65a27 --- /dev/null +++ b/src/components/Channel/styling/Channel.scss @@ -0,0 +1,241 @@ +@use '../../../styling/utils'; + +.str-chat__channel { + height: 100%; + display: flex; + flex-direction: column; + position: relative; + + .str-chat__container { + height: 100%; + display: flex; + + .str-chat__main-panel { + position: relative; + height: 100%; + display: flex; + flex-direction: column; + width: 100%; + min-width: 0; + } + } +} + +.str-chat__empty-channel { + --str-chat-icon-height: calc(var(--str-chat__spacing-px) * 136); + @include utils.empty-layout; + position: relative; + + .str-chat__empty-channel-notifications { + position: absolute; + inset-block-end: var(--str-chat__spacing-2); + } +} + +.str-chat__loading-channel { + $text-height: calc(var(--str-chat__spacing-px) * 16); + height: 100%; + display: flex; + flex-direction: column; + + .str-chat__loading-channel-header { + @include utils.header-layout; + + .str-chat__loading-channel-header-avatar { + flex-shrink: 0; + width: calc(var(--str-chat__spacing-px) * 40); + height: calc(var(--str-chat__spacing-px) * 40); + border-radius: var(--str-chat__avatar-border-radius); + } + + .str-chat__loading-channel-header-end { + @include utils.header-text-layout; + + .str-chat__loading-channel-header-name { + border-radius: var(--str-chat__border-radius-xs); + height: $text-height; + width: calc(var(--str-chat__spacing-px) * 170); + } + + .str-chat__loading-channel-header-info { + border-radius: var(--str-chat__border-radius-xs); + height: $text-height; + width: calc(var(--str-chat__spacing-px) * 66); + } + } + } + + .str-chat__loading-channel-message-list { + /* stylelint-disable */ + height: 100%; + @include utils.message-list-spacing; + + .str-chat__loading-channel-message { + display: flex; + width: 100%; + column-gap: var(--str-chat__spacing-2); + padding: var(--str-chat__spacing-4) 0; + + .str-chat__loading-channel-message-avatar { + flex-shrink: 0; + width: calc(var(--str-chat__spacing-px) * 49); + height: calc(var(--str-chat__spacing-px) * 49); + } + + .str-chat__loading-channel-message-end { + display: flex; + flex-direction: column; + width: 100%; + row-gap: var(--str-chat__spacing-2); + + .str-chat__loading-channel-message-last-row { + display: flex; + width: 100%; + column-gap: var(--str-chat__spacing-2); + } + } + + .str-chat__loading-channel-message-sender { + height: $text-height; + width: calc(var(--str-chat__spacing-px) * 66); + } + + .str-chat__loading-channel-message-text { + height: $text-height; + width: 100%; + } + + .str-chat__loading-channel-message-date { + height: $text-height; + width: calc(var(--str-chat__spacing-px) * 50); + } + + &:nth-of-type(2) { + flex-direction: row-reverse; + + .str-chat__loading-channel-message-sender { + align-self: end; + } + + .str-chat__loading-channel-message-last-row { + flex-direction: row-reverse; + } + } + } + } + + .str-chat__loading-channel-message-input-row { + display: flex; + column-gap: var(--str-chat__spacing-2); + padding: var(--str-chat__spacing-2); + + .str-chat__loading-channel-message-input { + width: 100%; + height: calc(var(--str-chat__spacing-px) * 36); + } + + .str-chat__loading-channel-message-send { + height: calc(var(--str-chat__spacing-px) * 36); + width: calc(var(--str-chat__spacing-px) * 36); + } + } +} + +.str-chat { + /* The border radius used for the borders of the component */ + --str-chat__channel-border-radius: 0; + + /* The text/icon color of the component */ + --str-chat__channel-color: var(--str-chat__text-color); + + /* The background color of the component */ + --str-chat__channel-background-color: var(--str-chat__background-color); + + /* Box shadow applied to the component */ + --str-chat__channel-box-shadow: none; + + /* Top border of the component */ + --str-chat__channel-border-block-start: none; + + /* Bottom border of the component */ + --str-chat__channel-border-block-end: none; + + /* Left (right in RTL layout) border of the component */ + --str-chat__channel-border-inline-start: none; + + /* Right (left in RTL layout) border of the component */ + --str-chat__channel-border-inline-end: none; + + /* The icon color used when no channel is selected */ + --str-chat__channel-empty-indicator-color: var(--str-chat__disabled-color); + + /* The text color used when no channel is selected */ + --str-chat__channel-empty-color: var(--str-chat__text-low-emphasis-color); + + /* The color of the loading indicator */ + --str-chat__channel-loading-state-color: var(--str-chat__disabled-color); +} + +.str-chat__channel { + @include utils.component-layer-overrides('channel'); +} + +.str-chat__empty-channel { + --str-chat-icon-color: var(--str-chat__channel-empty-color); + @include utils.component-layer-overrides('channel'); + @include utils.empty-theme('channel'); + + .str-chat__empty-channel-text { + color: var(--str-chat__channel-empty-color); + } +} + +.str-chat__loading-channel { + @include utils.loading-animation; + + .str-chat__loading-channel-header { + background-color: var(--str-chat__channel-header-background-color); + + .str-chat__loading-channel-header-avatar { + @include utils.loading-item-background('channel'); + border-radius: var(--str-chat__avatar-border-radius); + } + + .str-chat__loading-channel-header-name, + .str-chat__loading-channel-header-info { + @include utils.loading-item-background('channel'); + border-radius: var(--str-chat__border-radius-xs); + } + } + + .str-chat__loading-channel-message-list { + background-color: var(--str-chat__message-list-background-color); + + .str-chat__loading-channel-message-avatar { + @include utils.loading-item-background('channel'); + border-radius: var(--str-chat__avatar-border-radius); + } + + .str-chat__loading-channel-message-sender, + .str-chat__loading-channel-message-text, + .str-chat__loading-channel-message-date { + @include utils.loading-item-background('channel'); + border-radius: var(--str-chat__message-bubble-border-radius); + } + } + + .str-chat__loading-channel-message-input-row { + .str-chat__loading-channel-message-input, + .str-chat__loading-channel-message-send { + @include utils.loading-item-background('channel'); + } + + .str-chat__loading-channel-message-input { + border-radius: var(--str-chat__message-textarea-border-radius); + } + + .str-chat__loading-channel-message-send { + border-radius: var(--str-chat__message-send-border-radius); + } + } +} diff --git a/src/components/Channel/styling/index.scss b/src/components/Channel/styling/index.scss new file mode 100644 index 0000000000..efed3be8be --- /dev/null +++ b/src/components/Channel/styling/index.scss @@ -0,0 +1 @@ +@use 'Channel'; diff --git a/src/components/Thread/styling/Thread.scss b/src/components/Thread/styling/Thread.scss new file mode 100644 index 0000000000..54ab1fcca3 --- /dev/null +++ b/src/components/Thread/styling/Thread.scss @@ -0,0 +1,149 @@ +@use '../../../styling/utils'; + +// FIXME: figure out why does this "container" exist when __thread is completely valid +.str-chat__thread-container { + position: relative; + height: 100%; + display: flex; + flex-direction: column; + width: 100%; + + .str-chat__thread-header { + @include utils.header-layout; + + .str-chat__thread-header-details { + @include utils.header-text-layout; + + .str-chat__thread-header-name, + .str-chat__thread-header-reply-count, + .str-chat__thread-header-channel-name, + .str-chat__thread-header-subtitle, + .str-chat__thread-header-title { + @include utils.ellipsis-text; + } + + .str-chat__thread-header-subtitle { + @include utils.prevent-glitch-text-overflow; + } + } + + .str-chat__close-thread-button { + $icon-size: calc(var(--str-chat__spacing-px) * 21); + display: flex; + align-items: flex-start; + justify-content: flex-end; + width: calc(var(--str-chat__spacing-px) * 40); + height: calc(var(--str-chat__spacing-px) * 40); + cursor: pointer; + line-height: $icon-size; + font-size: $icon-size; + + svg { + height: $icon-size; + width: $icon-size; + } + } + } +} + +.str-chat__thread { + .str-chat__main-panel-inner { + height: auto; + } +} + +.str-chat__thread--virtualized { + .str-chat__main-panel-inner { + height: 100%; + + // the first message in virtualized thread has to be separated from the top by padding, not margin + .str-chat__virtual-list-message-wrapper:first-of-type { + padding-block-start: var(--str-chat__spacing-4); + } + } +} + +.str-chat__parent-message-li { + padding: var(--str-chat__spacing-2); +} + +.str-chat { + /* The border radius used for the borders of the component */ + --str-chat__thread-border-radius: 0; + + /* The text/icon color of the component */ + --str-chat__thread-color: var(--str-chat__text-color); + + /* The background color of the component */ + --str-chat__thread-background-color: var(--str-chat__secondary-background-color); + + /* Top border of the component */ + --str-chat__thread-border-block-start: none; + + /* Bottom border of the component */ + --str-chat__thread-border-block-end: none; + + /* Left (right in RTL layout) border of the component */ + --str-chat__thread-border-inline-start: 1px solid var(--str-chat__surface-color); + + /* Right (left in RTL layout) border of the component */ + --str-chat__thread-border-inline-end: none; + + /* Box shadow applied to the component */ + --str-chat__thread-box-shadow: none; + + /* The border radius used for the borders of the thread header */ + --str-chat__thread-header-border-radius: 0; + + /* The text/icon color of the thread header */ + --str-chat__thread-header-color: var(--str-chat__text-color); + + /* The background color of the thread header */ + --str-chat__thread-header-background-color: var(--str-chat__secondary-background-color); + + /* Top border of the thread header */ + --str-chat__thread-header-border-block-start: none; + + /* Bottom border of the thread header */ + --str-chat__thread-header-border-block-end: none; + + /* Left (right in RTL layout) border of the thread header */ + --str-chat__thread-header-border-inline-start: none; + + /* Right (left in RTL layout) border of the thread header */ + --str-chat__thread-header-border-inline-end: none; + + /* Box shadow applied to the thread header */ + --str-chat__thread-header-box-shadow: none; + + /* The text/icon color used to display less emphasized text in the channel header */ + --str-chat__thread-header-info-color: var(--str-chat__text-low-emphasis-color); +} + +.str-chat__thread-container { + @include utils.component-layer-overrides('thread'); + + .str-chat__thread-header { + @include utils.component-layer-overrides('thread-header'); + + .str-chat__thread-header-name, + .str-chat__thread-header-title { + font: var(--str-chat__subtitle-medium-text); + } + + .str-chat__thread-header-channel-name, + .str-chat__thread-header-subtitle { + font: var(--str-chat__body-text); + color: var(--str-chat__thread-header-info-color); + } + + .str-chat__close-thread-button { + background-color: transparent; + border: none; + + svg path { + fill: var(--str-chat__thread-color); + } + } + } +} diff --git a/src/components/Thread/styling/index.scss b/src/components/Thread/styling/index.scss new file mode 100644 index 0000000000..99a8b19082 --- /dev/null +++ b/src/components/Thread/styling/index.scss @@ -0,0 +1 @@ +@use 'Thread'; diff --git a/src/styling/_utils.scss b/src/styling/_utils.scss index 6527626a2e..07e390237d 100644 --- a/src/styling/_utils.scss +++ b/src/styling/_utils.scss @@ -114,6 +114,105 @@ } } +@mixin header-layout { + display: flex; + padding: var(--str-chat__spacing-2); + column-gap: var(--str-chat__spacing-4); + align-items: center; +} + +@mixin header-text-layout { + display: flex; + flex-direction: column; + overflow-y: hidden; // for Edge + overflow-x: hidden; // for ellipsis text + flex: 1; + row-gap: var(--str-chat__spacing-1_5); +} + +@mixin empty-layout { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--str-chat__spacing-4); + + svg { + width: calc(var(--str-chat__spacing-px) * 136); + height: calc(var(--str-chat__spacing-px) * 136); + } +} + +@mixin empty-theme($component-name) { + font: var(--str-chat__headline-text); + text-align: center; + + svg path { + fill: var(--str-chat__#{$component-name}-empty-indicator-color); + } +} + +@mixin message-list-spacing { + $spacing: var(--str-chat__spacing-2); + padding: 0 $spacing; + + // Need this trick to be able to apply full-width background color on hover to messages / full-width separator to thread header + .str-chat__li { + margin-inline: calc(-1 * #{$spacing}); + padding-inline: $spacing; + } + + .str-chat__parent-message-li { + margin-inline: calc(-1 * #{$spacing}); + } + + @media only screen and (min-device-width: 768px) { + $spacing: min(var(--str-chat__spacing-10), 4%); + + padding: 0 $spacing; + + .str-chat__li { + margin-inline: calc(-1 * #{$spacing}); + padding-inline: $spacing; + } + + .str-chat__parent-message-li { + margin-inline: calc(-1 * #{$spacing} - 2px); + } + } +} + +@mixin loading-item-background($component-name) { + background-image: linear-gradient( + -90deg, + var(--str-chat__#{$component-name}-loading-state-color) 0%, + var(--str-chat__#{$component-name}-loading-state-color) 100% + ); +} + +@mixin loading-animation { + animation: pulsate 1s linear 0s infinite alternate; + + &:nth-of-type(2) { + animation: pulsate 1s linear 0.3334s infinite alternate; + } + + &:last-of-type { + animation: pulsate 1s linear 0.6667s infinite alternate; + } + + @keyframes pulsate { + from { + opacity: 0.5; + } + + to { + opacity: 1; + } + } +} + @mixin component-layer-overrides($component-name, $important: '') { background: var(--str-chat__#{$component-name}-background-color) #{$important}; color: var(--str-chat__#{$component-name}-color) #{$important}; diff --git a/src/styling/index.scss b/src/styling/index.scss index dae20fb5df..ab73b32ae3 100644 --- a/src/styling/index.scss +++ b/src/styling/index.scss @@ -16,7 +16,9 @@ // Base components @use '../components/Button/styling/Button'; +@use '../components/Form/styling' as Form; @use '../components/Dialog/styling' as Dialog; +@use '../components/Modal/styling' as Modal; // Specific components @use '../components/Attachment/styling' as Attachment; @@ -24,6 +26,7 @@ @use '../components/Avatar/styling/Avatar' as Avatar; @use '../components/Avatar/styling/AvatarStack' as AvatarStack; @use '../components/Avatar/styling/GroupAvatar' as GroupAvatar; +@use '../components/Channel/styling' as Channel; @use '../components/ChatView/styling' as ChatView; @use '../components/DateSeparator/styling' as DateSeparator; @use '../components/Gallery/styling' as Gallery; @@ -31,11 +34,11 @@ @use '../components/Message/styling' as Message; @use '../components/MessageActions/styling' as MessageActions; @use '../components/MessageInput/styling' as MessageComposer; -@use '../components/Modal/styling' as Modal; @use '../components/Poll/styling' as Poll; @use '../components/Reactions/styling/ReactionList' as ReactionList; @use '../components/Reactions/styling/ReactionSelector' as ReactionSelector; @use '../components/TextareaComposer/styling' as TextareaComposer; +@use '../components/Thread/styling' as Thread; @use '../components/VideoPlayer/styling' as VideoPlayer; // Layers have to be kept the last From 71eb35ce626f06ff2171f737855d972d93b38354 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 13 Feb 2026 09:50:17 +0100 Subject: [PATCH 12/20] feat: update design tokens variables --- src/styling/variables.css | 96 +++++++++++++++++++++++++-------------- 1 file changed, 61 insertions(+), 35 deletions(-) diff --git a/src/styling/variables.css b/src/styling/variables.css index 8666429680..eb52ebbb8d 100644 --- a/src/styling/variables.css +++ b/src/styling/variables.css @@ -228,21 +228,27 @@ --button-padding-y-lg: 14px; --button-padding-y-md: 10px; --button-padding-y-sm: 6px; + --button-padding-y-xs: 4px; --button-padding-x-icon-only-lg: 14px; --button-padding-x-icon-only-md: 10px; --button-padding-x-icon-only-sm: 6px; + --button-padding-x-icon-only-xs: 4px; --button-padding-x-with-label-lg: 16px; --button-padding-x-with-label-md: 16px; --button-padding-x-with-label-sm: 16px; + --button-padding-x-with-label-xs: 12px; --background-core-hover: rgba(30, 37, 43, 0.05); /** Hover feedback overlay. */ --background-core-pressed: rgba(30, 37, 43, 0.1); /** Pressed feedback overlay. */ --background-core-selected: rgba(30, 37, 43, 0.15); /** Selected overlay. */ --background-core-scrim: rgba(0, 0, 0, 0.25); /** Dimmed overlay for modals. */ --background-core-overlay: rgba(255, 255, 255, 0.75); /** Selected overlay. */ + --background-core-overlay-light: rgba(255, 255, 255, 0.75); /** Selected overlay. */ + --background-core-overlay-dark: rgba(0, 0, 0, 0.25); /** Selected overlay. */ --border-core-opacity-10: rgba(0, 0, 0, 0.1); /** Image frame border treatment. */ --border-core-opacity-25: rgba(0, 0, 0, 0.25); /** Image frame border treatment. */ --system-bg-blur: rgba(255, 255, 255, 0.01); --system-scrollbar: rgba(0, 0, 0, 0.5); + --badge-bg-overlay: rgba(0, 0, 0, 0.75); --typography-font-family-sans: var(--font-family-geist); --typography-font-family-mono: var(--font-family-geist-mono); --typography-font-size-xxs: var(--font-size-size-10); /** Micro text such as timestamps or subtle metadata. */ @@ -286,6 +292,7 @@ --button-visual-height-sm: var(--size-32); --button-visual-height-md: var(--size-40); --button-visual-height-lg: var(--size-48); + --button-visual-height-xs: var(--size-24); /** * Minimum interactive hit target size. * @@ -313,40 +320,36 @@ --icon-stroke-default: var(--w150); --icon-stroke-emphasis: var(--w200); --background-core-disabled: var(--slate-100); /** Optional disabled background for inputs, buttons, or chips. */ - --background-core-surface: var(--slate-200); /** Standard section background. */ - --background-core-surface-subtle: var(--slate-100); /** Very light section background. */ - --background-core-surface-strong: var(--slate-300); /** Stronger section background. */ + --background-core-surface: var(--slate-100); /** Standard section background. */ + --background-core-surface-subtle: var(--slate-50); /** Very light section background. */ + --background-core-surface-strong: var(--slate-150); /** Stronger section background. */ + --background-core-inverse: var(--slate-900); /** Inverse background used for elevated, transient, or high-attention UI surfaces that sit on top of the default app background. */ + --background-core-highlight: var(--yellow-50); --background-elevation-elevation-0: var(--base-white); /** Flat surfaces. */ --background-elevation-elevation-1: var(--base-white); /** Slightly elevated surfaces. */ --background-elevation-elevation-2: var(--base-white); /** Card-like elements. */ --background-elevation-elevation-3: var(--base-white); /** Popovers. */ --background-elevation-elevation-4: var(--base-white); /** Dialogs, modals. */ - --border-utility-disabled: var(--slate-200); /** Optional disabled background for inputs, buttons, or chips. */ + --border-utility-disabled: var(--slate-100); /** Optional disabled background for inputs, buttons, or chips. */ --border-core-default: var(--slate-150); /** Standard surface border. */ --border-core-subtle: var(--slate-100); /** Very light separators. */ - --border-core-strong: var(--slate-200); /** Stronger surface border. */ + --border-core-strong: var(--slate-300); /** Stronger surface border. */ --border-core-on-dark: var(--base-white); /** Used on dark backgrounds. */ --border-core-on-accent: var(--base-white); /** Borders on accent backgrounds. */ - --chat-bg-incoming: var(--slate-100); /** Incoming bubble background. */ - --chat-bg-attachment-incoming: var(--slate-150); /** Attachment card in incoming bubble. */ - --chat-border-outgoing: var(--base-transparent-0); - --chat-border-incoming: var(--base-transparent-0); - --chat-border-on-chat-incoming: var(--slate-400); + --border-core-on-surface: var(--slate-200); --chat-reply-indicator-incoming: var(--slate-400); /** Reply indicator shading for incoming. */ --chat-waveform-bar: var(--border-core-opacity-25); - --chat-poll-progress-track-incoming: var(--slate-600); - --chat-poll-progress-fill-incoming: var(--slate-300); - --chat-thread-connector-incoming: var(--slate-100); --system-text: var(--base-black); --control-radiocheck-bg: var(--base-transparent-0); - --control-progress-bar-track: var(--slate-500); - --control-progress-bar-fill: var(--slate-200); + --control-progress-bar-track: var(--slate-200); + --control-progress-bar-fill: var(--slate-500); --text-primary: var(--slate-900); /** Main text color. */ --text-secondary: var(--slate-700); /** Secondary metadata text. */ --text-tertiary: var(--slate-500); /** Lowest priority text. */ --text-inverse: var(--base-white); /** Text on dark or accent backgrounds. */ - --text-disabled: var(--slate-400); /** Disabled text. */ + --text-disabled: var(--slate-300); /** Disabled text. */ --text-on-accent: var(--base-white); /** Text on dark or accent backgrounds. */ + --text-on-dark: var(--base-white); /** Text on dark or accent backgrounds. */ --avatar-palette-bg-1: var(--blue-100); --avatar-palette-bg-2: var(--cyan-100); --avatar-palette-bg-3: var(--green-100); @@ -359,7 +362,7 @@ --avatar-palette-text-5: var(--yellow-800); --avatar-bg-placeholder: var(--slate-100); --avatar-text-placeholder: var(--slate-500); - --accent-success: var(--green-300); /** For success states and positive actions. */ + --accent-success: var(--green-400); /** For success states and positive actions. */ --accent-warning: var(--yellow-400); /** Warning or caution messages. */ --accent-error: var(--red-500); /** Destructive actions and error states. */ --accent-neutral: var(--slate-500); /** Neutral accent for low-priority badges. */ @@ -375,6 +378,9 @@ --brand-700: var(--blue-700); --brand-800: var(--blue-800); --brand-900: var(--blue-900); + --skeleton-gradient-default-base: var(--slate-50); /** Base color for the default skeleton loading gradient. Used as the background tone for placeholder surfaces. */ + --skeleton-gradient-default-highlight: var(--base-white); /** Highlight color for the default skeleton loading gradient. Used for the moving shimmer to indicate loading activity. */ + --skeleton-gradient-accent-highlight: var(--base-white); /** Highlight color for the accent skeleton loading gradient. Used for the shimmer on accent placeholders. */ --device-radius: var(--radius-md); --message-bubble-radius-group-top: var(--radius-2xl); --message-bubble-radius-group-middle: var(--radius-2xl); @@ -387,32 +393,48 @@ --composer-bg: var(--background-elevation-elevation-1); /** Composer container background. */ --button-primary-text-on-accent: var(--text-on-accent); --button-primary-border: var(--brand-200); + --button-primary-text-on-dark: var(--text-on-dark); + --button-primary-border-on-dark: var(--border-core-on-dark); --button-secondary-text: var(--text-primary); --button-secondary-bg-liquid-glass: var(--background-elevation-elevation-0); --button-secondary-border: var(--border-core-default); - --button-secondary-bg: var(--background-core-surface-subtle); + --button-secondary-bg: var(--background-core-surface); --button-secondary-text-on-accent: var(--text-primary); + --button-secondary-text-on-dark: var(--text-on-dark); + --button-secondary-border-on-dark: var(--border-core-on-dark); --button-destructive-text: var(--accent-error); --button-destructive-bg: var(--accent-error); --button-destructive-text-on-accent: var(--text-on-accent); --button-destructive-bg-liquid-glass: var(--background-elevation-elevation-0); --button-destructive-border: var(--accent-error); + --button-destructive-text-on-dark: var(--text-on-dark); + --button-destructive-border-on-dark: var(--text-on-dark); --background-core-app: var(--background-elevation-elevation-0); /** Global application background. */ - --border-utility-focus: var(--brand-300); /** Focus ring or focus border. */ + --border-utility-focus: var(--brand-150); /** Focus ring or focus border. */ --border-utility-error: var(--accent-error); /** Error state. */ --border-utility-warning: var(--accent-warning); /** Warning borders. */ --border-utility-success: var(--accent-success); /** Success borders. */ + --chat-bg-incoming: var(--background-core-surface); /** Incoming bubble background. */ --chat-bg-outgoing: var(--brand-100); /** Outgoing bubble background. */ + --chat-bg-attachment-incoming: var(--background-core-surface-strong); /** Attachment card in incoming bubble. */ --chat-bg-attachment-outgoing: var(--brand-150); /** Attachment card in outgoing bubble. */ --chat-bg-typing-indicator: var(--accent-neutral); /** Typing indicator chip. */ - --chat-text-message: var(--text-primary); /** Message body text. */ --chat-text-timestamp: var(--text-tertiary); /** Time labels. */ --chat-text-username: var(--text-secondary); /** Username label. */ --chat-text-reaction: var(--text-secondary); /** Reaction count text. */ --chat-text-system: var(--text-secondary); /** System messages like date separators. */ + --chat-text-incoming: var(--text-primary); /** Message body text. */ + --chat-text-outgoing: var(--brand-900); /** Message body text. */ + --chat-border-outgoing: var(--brand-100); + --chat-border-incoming: var(--border-core-subtle); --chat-border-on-chat-outgoing: var(--brand-300); + --chat-border-on-chat-incoming: var(--border-core-strong); --chat-reply-indicator-outgoing: var(--brand-400); /** Reply indicator shading for outgoing. */ - --chat-poll-progress-fill-outgoing: var(--brand-200); + --chat-poll-progress-track-incoming: var(--control-progress-bar-track); + --chat-poll-progress-track-outgoing: var(--brand-200); + --chat-poll-progress-fill-incoming: var(--control-progress-bar-fill); + --chat-thread-connector-incoming: var(--border-core-default); + --chat-thread-connector-outgoing: var(--brand-150); --input-border-default: var(--border-core-default); /** Default border of the chat input. Uses the standard border role from foundations. In high-contrast always black. */ --input-border-hover: var(--border-core-strong); /** Optional hover border when the input is hovered or highlighted. Slightly stronger than default. */ --input-text-default: var(--text-primary); /** Main text inside the chat input. */ @@ -420,7 +442,8 @@ --input-text-icon: var(--text-tertiary); /** Icons inside the input area (attach, emoji, camera, send when idle). Matches secondary text strength. */ --input-text-disabled: var(--text-disabled); /** Placeholder text for the input. Lower emphasis than main text. */ --input-send-icon-disabled: var(--text-disabled); /** Send icon when disabled (e.g. empty input). */ - --reaction-bg: var(--background-elevation-elevation-1); /** Reaction bar background. */ + --input-option-card-bg: var(--background-core-surface-subtle); + --reaction-bg: var(--background-elevation-elevation-3); /** Reaction bar background. */ --reaction-border: var(--border-core-default); /** Border around unselected reaction chips. Subtle in normal modes, strong in high-contrast for visibility. */ --reaction-text: var(--text-primary); /** Count label next to the emoji inside the reaction chip. Uses secondary text so it does not compete with message text. */ --reaction-emoji: var(--text-primary); /** Emoji color inside reaction chips. Uses primary text color so the emoji stays clearly legible. */ @@ -430,32 +453,34 @@ --badge-border: var(--border-core-on-dark); --badge-bg-error: var(--accent-error); --badge-bg-neutral: var(--accent-neutral); - --badge-text: var(--text-on-accent); - --badge-text-inverse: var(--text-primary); - --badge-bg-default: var(--background-elevation-elevation-1); - --badge-bg-inverse: var(--accent-black); + --badge-text: var(--text-primary); + --badge-text-inverse: var(--text-on-dark); + --badge-bg-default: var(--background-elevation-elevation-2); + --badge-bg-inverse: var(--background-core-inverse); + --badge-text-on-accent: var(--text-on-accent); --control-radiocheck-border: var(--border-core-default); - --control-radiocheck-icon-selected: var(--text-inverse); + --control-radiocheck-icon-selected: var(--text-on-dark); --control-remove-control-bg: var(--accent-black); --control-remove-control-icon: var(--text-on-accent); --control-remove-control-border: var(--border-core-on-dark); - --control-play-control-bg: var(--background-elevation-elevation-1); - --control-play-control-icon: var(--text-primary); - --control-play-control-border: var(--border-core-default); - --control-play-control-bg-inverse: var(--accent-black); - --control-play-control-icon-inverse: var(--text-on-accent); + --control-play-control-bg: var(--accent-black); + --control-play-control-icon: var(--text-on-accent); --control-toggle-switch-knob: var(--background-elevation-elevation-4); --control-toggle-switch-bg: var(--background-core-surface-strong); --control-toggle-switch-bg-disabled: var(--background-core-disabled); + --control-playback-toggle-border: var(--border-core-default); + --control-playback-toggle-text: var(--text-primary); --avatar-bg-default: var(--avatar-palette-bg-1); --avatar-text-default: var(--avatar-palette-text-1); --accent-primary: var(--brand-500); /** Main brand accent for interactive elements. */ + --chip-bg: var(--brand-100); + --chip-text: var(--brand-900); + --skeleton-gradient-accent-base: var(--brand-50); /** Base color for the accent skeleton loading gradient. Used on higher-contrast or accent surfaces (e.g. outgoing messages). */ --button-primary-bg: var(--accent-primary); --button-primary-text: var(--accent-primary); --border-utility-selected: var(--accent-primary); /** Focus ring or focus border. */ --chat-waveform-bar-playing: var(--accent-primary); - --chat-poll-progress-track-outgoing: var(--accent-primary); - --chat-thread-connector-outgoing: var(--chat-bg-outgoing); + --chat-poll-progress-fill-outgoing: var(--accent-primary); --input-send-icon: var(--accent-primary); /** Default send icon color in the input. Uses the brand accent. */ --system-caret: var(--accent-primary); --badge-bg-primary: var(--accent-primary); @@ -465,4 +490,5 @@ --chat-text-mention: var(--text-link); /** Mention styling. */ --chat-text-link: var(--text-link); /** Links inside message bubbles. */ --input-border-focus: var(--border-utility-selected); /** Focus border when the input is focused. Uses the shared focus state token (brand in normal modes, black in high-contrast). */ -} \ No newline at end of file + --input-border-selected: var(--border-utility-selected); /** Focus border when the input is focused. Uses the shared focus state token (brand in normal modes, black in high-contrast). */ +} From 3652daed32293e82abf7c3bf8eba288e6978c117 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 13 Feb 2026 09:52:50 +0100 Subject: [PATCH 13/20] refactor: rename person related icons to contain word person --- src/components/Avatar/Avatar.tsx | 4 ++-- src/components/Avatar/AvatarStack.tsx | 1 - src/components/Avatar/styling/Avatar.scss | 3 +-- src/components/Icons/{IconPeople.tsx => IconPerson.tsx} | 4 ++-- .../Icons/{IconPeopleAdd.tsx => IconPersonAdd.tsx} | 4 ++-- .../Icons/{IconPeopleRemove.tsx => IconPersonRemove.tsx} | 4 ++-- src/components/Icons/index.ts | 6 +++--- src/components/Icons/styling/IconClose.scss | 2 +- .../Icons/styling/{IconPeople.scss => IconPerson.scss} | 2 +- .../styling/{IconPeopleAdd.scss => IconPersonAdd.scss} | 2 +- .../{IconPeopleRemove.scss => IconPersonRemove.scss} | 2 +- src/components/Icons/styling/index.scss | 6 +++--- .../MessageInput/AttachmentSelector/CommandsMenu.tsx | 8 ++++---- 13 files changed, 23 insertions(+), 25 deletions(-) rename src/components/Icons/{IconPeople.tsx => IconPerson.tsx} (83%) rename src/components/Icons/{IconPeopleAdd.tsx => IconPersonAdd.tsx} (87%) rename src/components/Icons/{IconPeopleRemove.tsx => IconPersonRemove.tsx} (85%) rename src/components/Icons/styling/{IconPeople.scss => IconPerson.scss} (84%) rename src/components/Icons/styling/{IconPeopleAdd.scss => IconPersonAdd.scss} (84%) rename src/components/Icons/styling/{IconPeopleRemove.scss => IconPersonRemove.scss} (83%) diff --git a/src/components/Avatar/Avatar.tsx b/src/components/Avatar/Avatar.tsx index d0748cbad2..e2defda746 100644 --- a/src/components/Avatar/Avatar.tsx +++ b/src/components/Avatar/Avatar.tsx @@ -5,7 +5,7 @@ import React, { useMemo, useState, } from 'react'; -import { IconPeople } from '../Icons'; +import { IconPerson } from '../Icons'; export type AvatarProps = { /** URL of the avatar image */ @@ -99,7 +99,7 @@ export const Avatar = ({ {sizeAwareInitials}
)} - {!sizeAwareInitials.length && } + {!sizeAwareInitials.length && } )}
diff --git a/src/components/Avatar/AvatarStack.tsx b/src/components/Avatar/AvatarStack.tsx index 3b4a8c80a6..14c2feb4b3 100644 --- a/src/components/Avatar/AvatarStack.tsx +++ b/src/components/Avatar/AvatarStack.tsx @@ -1,4 +1,3 @@ -/* eslint-disable arrow-body-style */ import { type ComponentProps, type ElementType } from 'react'; import { useComponentContext } from '../../context'; import { type AvatarProps, Avatar as DefaultAvatar } from './Avatar'; diff --git a/src/components/Avatar/styling/Avatar.scss b/src/components/Avatar/styling/Avatar.scss index 45c53d6d3a..73457fd07f 100644 --- a/src/components/Avatar/styling/Avatar.scss +++ b/src/components/Avatar/styling/Avatar.scss @@ -26,7 +26,7 @@ height: 100%; } - .str-chat__icon--people { + .str-chat__icon--person { width: var(--avatar-icon-size); height: var(--avatar-icon-size); stroke-width: var(--avatar-icon-stroke-width); @@ -127,4 +127,3 @@ } } } - diff --git a/src/components/Icons/IconPeople.tsx b/src/components/Icons/IconPerson.tsx similarity index 83% rename from src/components/Icons/IconPeople.tsx rename to src/components/Icons/IconPerson.tsx index 89eac206e4..3881017ce7 100644 --- a/src/components/Icons/IconPeople.tsx +++ b/src/components/Icons/IconPerson.tsx @@ -2,10 +2,10 @@ import type { ComponentProps } from 'react'; import clsx from 'clsx'; import { BaseIcon } from './BaseIcon'; -export const IconPeople = ({ className, ...props }: ComponentProps<'svg'>) => ( +export const IconPerson = ({ className, ...props }: ComponentProps<'svg'>) => ( diff --git a/src/components/Icons/IconPeopleAdd.tsx b/src/components/Icons/IconPersonAdd.tsx similarity index 87% rename from src/components/Icons/IconPeopleAdd.tsx rename to src/components/Icons/IconPersonAdd.tsx index 1f9bf09f75..c36c8c9501 100644 --- a/src/components/Icons/IconPeopleAdd.tsx +++ b/src/components/Icons/IconPersonAdd.tsx @@ -2,10 +2,10 @@ import type { ComponentProps } from 'react'; import { BaseIcon } from './BaseIcon'; import clsx from 'clsx'; -export const IconPeopleAdd = ({ className, ...props }: ComponentProps<'svg'>) => ( +export const IconPersonAdd = ({ className, ...props }: ComponentProps<'svg'>) => ( diff --git a/src/components/Icons/IconPeopleRemove.tsx b/src/components/Icons/IconPersonRemove.tsx similarity index 85% rename from src/components/Icons/IconPeopleRemove.tsx rename to src/components/Icons/IconPersonRemove.tsx index 74c9c4cf46..3fb310d4c9 100644 --- a/src/components/Icons/IconPeopleRemove.tsx +++ b/src/components/Icons/IconPersonRemove.tsx @@ -2,10 +2,10 @@ import clsx from 'clsx'; import { BaseIcon } from './BaseIcon'; import type { ComponentProps } from 'react'; -export const IconPeopleRemove = ({ className, ...props }: ComponentProps<'svg'>) => ( +export const IconPersonRemove = ({ className, ...props }: ComponentProps<'svg'>) => ( diff --git a/src/components/Icons/index.ts b/src/components/Icons/index.ts index 9b1da8717d..8ad2d9e48d 100644 --- a/src/components/Icons/index.ts +++ b/src/components/Icons/index.ts @@ -23,9 +23,9 @@ export * from './IconMute'; export * from './IconPaperclip'; export * from './IconPaperPlane'; export * from './IconPause'; -export * from './IconPeople'; -export * from './IconPeopleAdd'; -export * from './IconPeopleRemove'; +export * from './IconPerson'; +export * from './IconPersonAdd'; +export * from './IconPersonRemove'; export * from './IconPlaySolid'; export * from './IconPlus'; export * from './IconPoll'; diff --git a/src/components/Icons/styling/IconClose.scss b/src/components/Icons/styling/IconClose.scss index b4a2b93660..8be7b72510 100644 --- a/src/components/Icons/styling/IconClose.scss +++ b/src/components/Icons/styling/IconClose.scss @@ -5,7 +5,7 @@ height: 8px; path { - stroke: var(--base-white); + stroke: currentColor; stroke-linecap: round; stroke-width: 1.5; } diff --git a/src/components/Icons/styling/IconPeople.scss b/src/components/Icons/styling/IconPerson.scss similarity index 84% rename from src/components/Icons/styling/IconPeople.scss rename to src/components/Icons/styling/IconPerson.scss index 0dfa069f8c..4a068604f8 100644 --- a/src/components/Icons/styling/IconPeople.scss +++ b/src/components/Icons/styling/IconPerson.scss @@ -1,4 +1,4 @@ -.str-chat__icon--people { +.str-chat__icon--person { width: 16px; height: 16px; stroke-width: 1.5; diff --git a/src/components/Icons/styling/IconPeopleAdd.scss b/src/components/Icons/styling/IconPersonAdd.scss similarity index 84% rename from src/components/Icons/styling/IconPeopleAdd.scss rename to src/components/Icons/styling/IconPersonAdd.scss index 217fcbe13c..acf8afa80f 100644 --- a/src/components/Icons/styling/IconPeopleAdd.scss +++ b/src/components/Icons/styling/IconPersonAdd.scss @@ -1,4 +1,4 @@ -.str-chat__icon--people-add { +.str-chat__icon--person-add { fill: none; width: 16px; height: 16px; diff --git a/src/components/Icons/styling/IconPeopleRemove.scss b/src/components/Icons/styling/IconPersonRemove.scss similarity index 83% rename from src/components/Icons/styling/IconPeopleRemove.scss rename to src/components/Icons/styling/IconPersonRemove.scss index ad5f928084..bfb3ce377d 100644 --- a/src/components/Icons/styling/IconPeopleRemove.scss +++ b/src/components/Icons/styling/IconPersonRemove.scss @@ -1,4 +1,4 @@ -.str-chat__icon--people-remove { +.str-chat__icon--person-remove { fill: none; width: 16px; height: 16px; diff --git a/src/components/Icons/styling/index.scss b/src/components/Icons/styling/index.scss index 6f49ddeb85..5d303a2854 100644 --- a/src/components/Icons/styling/index.scss +++ b/src/components/Icons/styling/index.scss @@ -24,9 +24,9 @@ @use 'IconPaperclip'; @use 'IconPaperPlane'; @use 'IconPause'; -@use 'IconPeople'; -@use 'IconPeopleAdd'; -@use 'IconPeopleRemove'; +@use 'IconPerson'; +@use 'IconPersonAdd'; +@use 'IconPersonRemove'; @use 'IconPlaySolid'; @use 'IconPlus'; @use 'IconPoll'; diff --git a/src/components/MessageInput/AttachmentSelector/CommandsMenu.tsx b/src/components/MessageInput/AttachmentSelector/CommandsMenu.tsx index 3024b7151c..47de9bec45 100644 --- a/src/components/MessageInput/AttachmentSelector/CommandsMenu.tsx +++ b/src/components/MessageInput/AttachmentSelector/CommandsMenu.tsx @@ -13,18 +13,18 @@ import { IconFlag, IconGiphy, IconMute, - IconPeopleAdd, - IconPeopleRemove, + IconPersonAdd, + IconPersonRemove, IconVolumeFull, } from '../../Icons'; import clsx from 'clsx'; const icons: Record = { - ban: IconPeopleRemove, + ban: IconPersonRemove, flag: IconFlag, giphy: IconGiphy, mute: IconMute, - unban: IconPeopleAdd, + unban: IconPersonAdd, unmute: IconVolumeFull, }; From 47b16a23257a0bec82b8eefdd42af5e78b6fa8c8 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 13 Feb 2026 09:53:39 +0100 Subject: [PATCH 14/20] fix: fix linter issues --- src/components/Attachment/styling/Giphy.scss | 2 -- src/components/DateSeparator/styling/DateSeparator.scss | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/Attachment/styling/Giphy.scss b/src/components/Attachment/styling/Giphy.scss index adaee6695a..acb4841b3e 100644 --- a/src/components/Attachment/styling/Giphy.scss +++ b/src/components/Attachment/styling/Giphy.scss @@ -39,8 +39,6 @@ background-color: var(--badge-bg-overlay, rgba(0, 0, 0, 0.75)); color: var(--badge-text-on-accent, #fff); - - /* metadata/emphasis */ 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); diff --git a/src/components/DateSeparator/styling/DateSeparator.scss b/src/components/DateSeparator/styling/DateSeparator.scss index e242b6f762..2b83e822fa 100644 --- a/src/components/DateSeparator/styling/DateSeparator.scss +++ b/src/components/DateSeparator/styling/DateSeparator.scss @@ -34,4 +34,4 @@ font-weight: var(--typography-font-weight-semi-bold); line-height: var(--typography-line-height-tight); } -} \ No newline at end of file +} From 3963ad1ee0df4c61654586e0085efee302cea434 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 13 Feb 2026 09:54:03 +0100 Subject: [PATCH 15/20] fix: hide only message avatar and not all the avatars in a message --- src/components/Message/styling/Message.scss | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/Message/styling/Message.scss b/src/components/Message/styling/Message.scss index ff8b049d6c..55479f8efb 100644 --- a/src/components/Message/styling/Message.scss +++ b/src/components/Message/styling/Message.scss @@ -667,11 +667,9 @@ // Avatar display in message list .str-chat__li--top, .str-chat__li--middle { - .str-chat__avatar { - // space above the message group - background: transparent; + .str-chat__message > .str-chat__avatar { + visibility: hidden; pointer-events: none; - color: transparent; } } From 29795a96e65929528a28f9e1ce2799fcd9608045 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 13 Feb 2026 09:54:31 +0100 Subject: [PATCH 16/20] feat: add color to remove attachment button icon --- .../MessageInput/styling/RemoveAttachmentPreviewButton.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/MessageInput/styling/RemoveAttachmentPreviewButton.scss b/src/components/MessageInput/styling/RemoveAttachmentPreviewButton.scss index c6bd563671..cbd2673a1a 100644 --- a/src/components/MessageInput/styling/RemoveAttachmentPreviewButton.scss +++ b/src/components/MessageInput/styling/RemoveAttachmentPreviewButton.scss @@ -8,4 +8,8 @@ border: 3px solid var(--str-chat__attachment-preview-close-icon-color); color: var(--str-chat__attachment-preview-close-icon-color); border-radius: var(--radius-max); + + svg { + color: var(--control-play-control-icon); + } } From 3abe9625b94483e06e8f3db1cea8796c5f03cb61 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 13 Feb 2026 09:55:01 +0100 Subject: [PATCH 17/20] feat: use AvatarStack for PollOptionSelector --- src/components/Poll/PollOptionSelector.tsx | 35 ++++++++++++---------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/src/components/Poll/PollOptionSelector.tsx b/src/components/Poll/PollOptionSelector.tsx index cdce442edc..15545da9b8 100644 --- a/src/components/Poll/PollOptionSelector.tsx +++ b/src/components/Poll/PollOptionSelector.tsx @@ -1,16 +1,17 @@ import clsx from 'clsx'; import debounce from 'lodash.debounce'; import React, { useMemo } from 'react'; +import type { PollOption, PollState, PollVote, VotingVisibility } from 'stream-chat'; import { isVoteAnswer } from 'stream-chat'; -import { Avatar } from '../Avatar'; +import { AvatarStack as DefaultAvatarStack } from '../Avatar'; import { useChannelStateContext, + useComponentContext, useMessageContext, usePollContext, useTranslationContext, } from '../../context'; import { useStateStore } from '../../store'; -import type { PollOption, PollState, PollVote, VotingVisibility } from 'stream-chat'; type AmountBarProps = { amount: number; @@ -69,7 +70,7 @@ export const PollOptionSelector = ({ const { t } = useTranslationContext(); const { channelCapabilities = {} } = useChannelStateContext('PollOptionsShortlist'); const { message } = useMessageContext(); - + const { AvatarStack = DefaultAvatarStack } = useComponentContext(); const { poll } = usePollContext(); const { is_closed, @@ -79,6 +80,7 @@ export const PollOptionSelector = ({ vote_counts_by_option, voting_visibility, } = useStateStore(poll.state, pollStateSelector); + const canCastVote = channelCapabilities['cast-poll-vote'] && !is_closed; const winningOptionCount = maxVotedOptionIds[0] ? vote_counts_by_option[maxVotedOptionIds[0]] @@ -96,6 +98,20 @@ export const PollOptionSelector = ({ [canCastVote, message.id, option.id, ownVotesByOptionId, poll], ); + const avatarDisplayInfo = useMemo( + () => + latest_votes_by_option?.[option.id] && + (latest_votes_by_option[option.id] as PollVote[]) + .filter((vote) => !!vote.user && !isVoteAnswer(vote)) + .slice(0, displayAvatarCount) + .map(({ user }) => ({ + id: user!.id, // eslint-disable-line @typescript-eslint/no-non-null-assertion + imageUrl: user!.image, // eslint-disable-line @typescript-eslint/no-non-null-assertion + userName: user!.name, // eslint-disable-line @typescript-eslint/no-non-null-assertion + })), + [displayAvatarCount, latest_votes_by_option, option.id], + ); + return (
{option.text}

{displayAvatarCount && voting_visibility === 'public' && (
- {latest_votes_by_option?.[option.id] && - (latest_votes_by_option[option.id] as PollVote[]) - .filter((vote) => !!vote.user && !isVoteAnswer(vote)) - .slice(0, displayAvatarCount) - .map(({ user }) => ( - - ))} +
)}
From ab6c62d5fb59346d38fd3b6640faa3ceba31fee6 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 13 Feb 2026 09:55:47 +0100 Subject: [PATCH 18/20] feat: add poll related theme styles --- src/components/Poll/styling/Poll.scss | 878 ++++++++---------- .../Poll/styling/PollCreationDialog.scss | 109 ++- src/components/Poll/styling/index.scss | 2 +- 3 files changed, 501 insertions(+), 488 deletions(-) diff --git a/src/components/Poll/styling/Poll.scss b/src/components/Poll/styling/Poll.scss index 3daccdc65c..5275ae37bf 100644 --- a/src/components/Poll/styling/Poll.scss +++ b/src/components/Poll/styling/Poll.scss @@ -1,619 +1,529 @@ @use '../../../styling/utils'; -.str-chat__poll { - $checkmark_size: 24px; - display: flex; - flex-direction: column; - gap: var(--spacing-xl); - padding: 0.75rem 0.675rem; - min-width: 260px; - max-width: 400px; - font-size: var(--typography-font-size-sm); - font-weight: var(--typography-font-weight-regular); - line-height: var(--typography-line-height-tight); - - button { - @include utils.button-reset; - cursor: pointer; - } - - .str-chat__checkmark { - grid-column: 1; - grid-row: span 2; - align-self: center; - //margin-right: 0.125rem; - height: $checkmark_size; - width: $checkmark_size; - } - - .str-chat__checkmark--checked { - height: calc($checkmark_size + 1px); - width: calc($checkmark_size + 1px); - background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Im00IDkuNC00LTRMMS40IDQgNCA2LjYgMTAuNiAwIDEyIDEuNGwtOCA4WiIvPjwvc3ZnPg=='); - background-repeat: no-repeat; - background-position: center; - background-size: 11px 10px; - } - - .str-chat__poll-header { - .str-chat__poll-title { - font-size: var(--typography-font-size-md); - font-weight: var(--typography-font-weight-semi-bold); - line-height: var(--typography-line-height-normal); - } - } - - .str-chat__poll-actions { +.str-chat { + .str-chat__poll { + $checkmark_size: 24px; display: flex; - align-items: center; - gap: var(--spacing-xs); - } -} - -.str-chat__poll-action { - width: 100%; - color: var(--str-chat__primary-color); - border: 1px solid var(--chat-border-on-chat-incoming); - font-size: var(--typography-font-size-sm); -} - -.str-chat__poll-results-modal, -.str-chat__poll-answer-list-modal, -.str-chat__add-poll-answer-modal, -.str-chat__suggest-poll-option-modal, -.str-chat__poll-options-modal { - button { - @include utils.button-reset; - cursor: pointer; - } -} + flex-direction: column; + gap: var(--spacing-xl); + padding: var(--spacing-sm); + min-width: 260px; + max-width: 400px; + font-size: var(--typography-font-size-sm); + font-weight: var(--typography-font-weight-regular); + line-height: var(--typography-line-height-tight); -.str-chat__poll-option-list--full, -.str-chat__modal__poll-results { - .str-chat__amount-bar { - display: none; - } -} + button { + @include utils.button-reset; + cursor: pointer; + } -.str-chat__poll-option { - cursor: pointer; + .str-chat__checkmark { + grid-column: 1; + grid-row: span 2; + align-self: center; + height: $checkmark_size; + width: $checkmark_size; + } + + .str-chat__checkmark--checked { + height: calc($checkmark_size + 1px); + width: calc($checkmark_size + 1px); + background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Im00IDkuNC00LTRMMS40IDQgNCA2LjYgMTAuNiAwIDEyIDEuNGwtOCA4WiIvPjwvc3ZnPg=='); + background-repeat: no-repeat; + background-position: center; + background-size: 11px 10px; + } + + .str-chat__poll-header { + .str-chat__poll-title { + font-size: var(--typography-font-size-md); + font-weight: var(--typography-font-weight-semi-bold); + line-height: var(--typography-line-height-normal); + } + } - &.str-chat__poll-option--full-vote-list { - cursor: default; - height: 100%; - padding: 0; + .str-chat__poll-actions { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-xs); + } } - .str-chat__poll-option-data { - flex: 1; - display: flex; - align-items: flex-start; - gap: var(--spacing-sm); + .str-chat__button.str-chat__poll-action { + width: 100%; + color: var(--str-chat__primary-color); + border: 1px solid var(--chat-border-on-chat-incoming); + font-size: var(--typography-font-size-sm); + padding: var(--button-padding-y-sm) var(--button-padding-x-with-label-sm); - p { - margin: 0; - flex: 1; + &.str-chat__poll-action--additional { + border: none; } + } - .str-chat__poll-option-voters { - --str-chat__avatar-size: 1.175rem; - display: flex; + .str-chat__poll-results-modal, + .str-chat__poll-answer-list-modal, + .str-chat__add-poll-answer-modal, + .str-chat__suggest-poll-option-modal, + .str-chat__poll-options-modal { + button { + @include utils.button-reset; + cursor: pointer; } + } - .str-chat__poll-option-vote-count { - font-size: var(--typography-font-size-xs); - line-height: var(--typography-line-height-tight); + .str-chat__poll-option-list--full, + .str-chat__modal__poll-results { + .str-chat__amount-bar { + display: none; } } -} -.str-chat__poll-option-list--full { .str-chat__poll-option { - display: flex; - flex-direction: row; - padding: 1rem 0.75rem; - - &:nth-of-type(1) { - padding-top: 1rem; - border-top-left-radius: var(--str-chat__border-radius-sm); - border-top-right-radius: var(--str-chat__border-radius-sm); - } + cursor: pointer; - &:last-child { - padding-bottom: 1rem; - border-bottom-left-radius: var(--str-chat__border-radius-sm); - border-bottom-right-radius: var(--str-chat__border-radius-sm); + &.str-chat__poll-option--full-vote-list { + cursor: default; + height: 100%; + padding: 0; } - } -} -.str-chat__poll-option-list:not(.str-chat__poll-option-list--full) { - display: flex; - flex-direction: column; - gap: 1.5rem; + .str-chat__poll-option-data { + flex: 1; + display: flex; + align-items: center; + gap: var(--spacing-sm); - .str-chat__poll-option { - display: grid; - grid-template-columns: auto 1fr; - grid-template-rows: 1fr auto; - column-gap: var(--spacing-sm); - row-gap: var(--spacing-xs); + p { + margin: 0; + flex: 1; + } - .str-chat__poll-option-data { - grid-column: 2 / 3; - grid-row: 1 / 2; - } + .str-chat__poll-option-voters { + --str-chat__avatar-size: 1.175rem; + display: flex; + } - .str-chat__poll-option__votes-bar { - grid-column: 2 / 3; - grid-row: 2 / 3; - height: 8px; - width: 100%; + .str-chat__poll-option-vote-count { + font-size: var(--typography-font-size-xs); + line-height: var(--typography-line-height-tight); + } } } -} -.str-chat__message--me { - .str-chat__poll { - .str-chat__poll-option-list:not(.str-chat__poll-option-list--full) { - .str-chat__poll-option__votes-bar { - background: linear-gradient( - to right, - var(--chat-poll-progress-fill-outgoing) var(--str-chat__amount-bar-fulfillment), - var(--chat-poll-progress-track-outgoing) var(--str-chat__amount-bar-fulfillment) - ); - } + .str-chat__poll-option-list--full { + .str-chat__poll-option { + display: flex; + flex-direction: row; + padding: 1rem 0.75rem; - .str-chat__poll-option__votes-bar--winner { - background: linear-gradient( - to right, - var(--chat-poll-progress-fill-outgoing) var(--str-chat__amount-bar-fulfillment), - var(--chat-poll-progress-track-outgoing) var(--str-chat__amount-bar-fulfillment) - ); + &:nth-of-type(1) { + padding-top: 1rem; + border-top-left-radius: var(--str-chat__border-radius-sm); + border-top-right-radius: var(--str-chat__border-radius-sm); } - } - .str-chat__poll-actions { - .str-chat__poll-action { - border: 1px solid var(--chat-border-on-chat-outgoing); + &:last-child { + padding-bottom: 1rem; + border-bottom-left-radius: var(--str-chat__border-radius-sm); + border-bottom-right-radius: var(--str-chat__border-radius-sm); } } } -} -.str-chat__modal__poll-results { - .str-chat__poll-option { + .str-chat__poll-option-list:not(.str-chat__poll-option-list--full) { display: flex; flex-direction: column; - } -} + gap: 1.5rem; -.str-chat-react__modal.str-chat__poll-action-modal { - .str-chat__modal__close-button { - display: none; - } -} - -.str-chat-react__modal.str-chat__poll-action-modal, -.str-chat__poll-actions .str-chat__modal { - .str-chat__modal__inner { - $content-offset-inline: 1rem; - padding: 0 0 0.5rem; - overflow: hidden; - max-width: 400px; - - .str-chat__tooltip { - max-width: 300px; - } - - .str-chat__modal__suggest-poll-option { - .str-chat__form-field-error { - height: 1rem; + .str-chat__poll-option { + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: 1fr auto; + column-gap: var(--spacing-sm); + row-gap: var(--spacing-xs); + + .str-chat__poll-option-data { + grid-column: 2 / 3; + grid-row: 1 / 2; } - .str-chat__dialog__controls { - padding-bottom: 0; + .str-chat__poll-option__votes-bar { + grid-column: 2 / 3; + grid-row: 2 / 3; + height: 8px; + width: 100%; } } + } - .str-chat__modal__poll-answer-list, - .str-chat__modal__poll-option-list, - .str-chat__modal__poll-results { - display: flex; - flex-direction: column; - width: 100%; - height: 100%; - min-height: 400px; - } + .str-chat__message--me { + .str-chat__poll { + .str-chat__poll-option-list:not(.str-chat__poll-option-list--full) { + .str-chat__poll-option__votes-bar { + background: linear-gradient( + to right, + var(--chat-poll-progress-fill-outgoing) + var(--str-chat__amount-bar-fulfillment), + var(--chat-poll-progress-track-outgoing) + var(--str-chat__amount-bar-fulfillment) + ); + } - .str-chat__modal__poll-answer-list, - .str-chat__poll-option--full-vote-list { - .str-chat__loading-indicator-placeholder { - display: flex; - justify-content: center; - align-items: center; - width: 100%; - height: 40px; + .str-chat__poll-option__votes-bar--winner { + background: linear-gradient( + to right, + var(--chat-poll-progress-fill-outgoing) + var(--str-chat__amount-bar-fulfillment), + var(--chat-poll-progress-track-outgoing) + var(--str-chat__amount-bar-fulfillment) + ); + } } - } - .str-chat__modal__poll-option-list__title, - .str-chat__modal__poll-results__title { - padding: 1.175rem 1rem; - } + .str-chat__poll-actions { + .str-chat__poll-action { + border: 1px solid var(--chat-border-on-chat-outgoing); - .str-chat__modal__poll-answer-list__body, - .str-chat__modal__poll-results__body { - display: flex; - flex-direction: column; - min-height: 0; - padding-bottom: 1rem; + &.str-chat__poll-action--additional { + border: none; + } + } + } } + } - .str-chat__modal__poll-results__body, - .str-chat__modal__poll-option-list__body, - .str-chat__poll-answer-list, - .str-chat__modal__poll-results__option-list { + .str-chat__modal__poll-results { + .str-chat__poll-option { display: flex; flex-direction: column; - flex: 1; - max-height: 100%; - min-height: 0; - } - - .str-chat__poll-answer-list { - padding-bottom: 0; - } - - .str-chat__modal__poll-results__body, - .str-chat__modal__poll-option-list__body, - .str-chat__poll-answer-list { - overflow-y: auto; - padding: 0 $content-offset-inline 1.25rem; - } - - .str-chat__poll-answer-list, - .str-chat__modal__poll-results__option-list { - gap: 0.5rem; - } - - .str-chat__modal__poll-results__body, - .str-chat__modal__poll-option-list__body { - gap: 2rem; } + } - .str-chat__poll-option__show-all-votes-button { - padding-bottom: 1rem; + .str-chat-react__modal.str-chat__poll-action-modal { + .str-chat__modal__close-button { + display: none; } + } - .str-chat__poll-answer { - display: flex; - flex-direction: column; - gap: 1rem; - padding: 0.75rem 1rem; + .str-chat-react__modal.str-chat__poll-action-modal, + .str-chat__poll-actions .str-chat__modal { + .str-chat__modal__inner { + $content-offset-inline: 1rem; + padding: 0 0 0.5rem; + overflow: hidden; + max-width: 400px; - .str-chat__poll-answer__text { - margin: 0; + .str-chat__tooltip { + max-width: 300px; } - } - - .str-chat__checkmark { - margin-right: 1rem; - } - .str-chat__poll-option__header { - display: flex; - align-items: flex-start; - gap: 0.25rem; - width: 100%; - padding: 0.75rem 1rem; + .str-chat__modal__suggest-poll-option { + .str-chat__form-field-error { + height: 1rem; + } - .str-chat__poll-option__option-text { - flex: 1; + .str-chat__dialog__controls { + padding-bottom: 0; + } } - } - .str-chat__poll-vote { - display: flex; - justify-content: space-between; - align-items: center; - gap: 0.5rem; - white-space: nowrap; - padding-block: 0.375rem; - - .str-chat__poll-vote__author { + .str-chat__modal__poll-answer-list, + .str-chat__modal__poll-option-list, + .str-chat__modal__poll-results { display: flex; - align-items: center; - gap: calc(var(--str-chat__spacing-px) * 5); - min-width: 0; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 400px; + } - .str-chat__poll-vote__author__name { - @include utils.ellipsis-text; - max-width: 130px; - min-width: 0; + .str-chat__modal__poll-answer-list, + .str-chat__poll-option--full-vote-list { + .str-chat__loading-indicator-placeholder { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 40px; } } - } - .str-chat__poll-result-option-vote-counter { - display: flex; - gap: 0.375rem; - - .str-chat__poll-result-winning-option-icon { - height: 1.25rem; - width: 1.25rem; - background-image: var(--str-chat__winning-poll-option-icon); + .str-chat__modal__poll-option-list__title, + .str-chat__modal__poll-results__title { + padding: 1.175rem 1rem; } - } - } -} -.str-chat__poll-vote-listing { - padding: 0 1rem 0.75rem; -} - -.str-chat__modal__poll-results--option-detail { - .str-chat__modal-header__title { - padding-inline: 1rem; - flex: 1; - } - - .str-chat__modal__poll-results__body { - padding-inline: 1rem; - } -} - -.str-chat__quoted-poll-preview { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - align-items: flex-start; -} - -.str-chat__modal.str-chat__create-poll-modal { - .str-chat__modal__close-button { - display: none; - } + .str-chat__modal__poll-answer-list__body, + .str-chat__modal__poll-results__body { + display: flex; + flex-direction: column; + min-height: 0; + padding-bottom: 1rem; + } - .str-chat__modal__inner { - padding: 0; - max-height: unset; - display: block; - } + .str-chat__modal__poll-results__body, + .str-chat__modal__poll-option-list__body, + .str-chat__poll-answer-list, + .str-chat__modal__poll-results__option-list { + display: flex; + flex-direction: column; + flex: 1; + max-height: 100%; + min-height: 0; + } - .str-chat__poll-creation-dialog { - height: 100%; - width: 100%; - display: flex; - flex-direction: column; + .str-chat__poll-answer-list { + padding-bottom: 0; + } - button { - @include utils.button-reset; - cursor: pointer; - } + .str-chat__modal__poll-results__body, + .str-chat__modal__poll-option-list__body, + .str-chat__poll-answer-list { + overflow-y: auto; + padding: 0 $content-offset-inline 1.25rem; + } - .str-chat__modal-header { - padding-block: 14px; + .str-chat__poll-answer-list, + .str-chat__modal__poll-results__option-list { + gap: 0.5rem; + } - .str-chat__modal-header__close-button { - background-image: var(--str-chat__close-icon); - background-repeat: no-repeat; + .str-chat__modal__poll-results__body, + .str-chat__modal__poll-option-list__body { + gap: 2rem; } - } - .str-chat__dialog__body { - flex: 1 1; - padding: 1rem; + .str-chat__poll-option__show-all-votes-button { + padding-bottom: 1rem; + } - form { + .str-chat__poll-answer { display: flex; flex-direction: column; - gap: 2rem; + gap: 1rem; + padding: 0.75rem 1rem; + + .str-chat__poll-answer__text { + margin: 0; + } } - } - .str-chat__form__input-fieldset { - margin: 0; - padding: 0; + .str-chat__checkmark { + margin-right: 1rem; + } - .str-chat__form__input-field { + .str-chat__poll-option__header { + display: flex; + align-items: flex-start; + gap: 0.25rem; width: 100%; - padding: 1rem; + padding: 0.75rem 1rem; - .str-chat__form__input-field__value { - width: 100%; - - .str-chat__form__input-field__error { - width: 100%; - } + .str-chat__poll-option__option-text { + flex: 1; } } - } - .str-chat__form__input-field--with-label { - .str-chat__form__input-field__value { - padding: 1rem; - } - } - - .str-chat__form__input-field__value input { - width: 100%; - } - - .str-chat__form__expandable-field { - padding: 1rem; - display: flex; - flex-direction: column; - gap: 1rem; + .str-chat__poll-vote { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + white-space: nowrap; + padding-block: 0.375rem; + + .str-chat__poll-vote__author { + display: flex; + align-items: center; + gap: calc(var(--str-chat__spacing-px) * 5); + min-width: 0; - .str-chat__form__switch-field { - padding: 0; + .str-chat__poll-vote__author__name { + @include utils.ellipsis-text; + max-width: 130px; + min-width: 0; + } + } } - .str-chat__form__input-field { - width: 100%; - - .str-chat__form__input-field__value { - padding: 0; + .str-chat__poll-result-option-vote-counter { + display: flex; + gap: 0.375rem; - .str-chat__form-field-error { - height: 1rem; - } + .str-chat__poll-result-winning-option-icon { + height: 1.25rem; + width: 1.25rem; + background-image: var(--str-chat__winning-poll-option-icon); } } } + } - .str-chat__form__input-fieldset__values { - display: flex; - flex-direction: column; - } + .str-chat__poll-vote-listing { + padding: 0 1rem 0.75rem; + } - .str-chat__form__field-label { - display: block; - margin-bottom: 0.5rem; + .str-chat__modal__poll-results--option-detail { + .str-chat__modal-header__title { + padding-inline: 1rem; + flex: 1; } - .str-chat__form__input-field--draggable { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; - - .str-chat__drag-handle { - height: 1rem; - width: 1rem; - } + .str-chat__modal__poll-results__body { + padding-inline: 1rem; } } -} -.str-chat__poll { - .str-chat__checkmark { - border-radius: var(--str-chat__border-radius-circle); - border: 1px solid var(--str-chat__disabled-color); + .str-chat__quoted-poll-preview { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: flex-start; } - .str-chat__checkmark--checked { - background-color: var(--str-chat__primary-color); - border: none; - } + .str-chat__modal.str-chat__create-poll-modal { + .str-chat__modal__close-button { + display: none; + } - .str-chat__poll-option-list { - .str-chat__poll-option { - &.str-chat__poll-option--votable:hover { - cursor: pointer; - } + .str-chat__modal__inner { + padding: 0; + max-height: unset; + display: block; + } + } - .str-chat__poll-option__votes-bar { - background: linear-gradient( - to right, - var(--chat-poll-progress-fill-incoming) var(--str-chat__amount-bar-fulfillment), - var(--chat-poll-progress-track-incoming) var(--str-chat__amount-bar-fulfillment) - ); - border-radius: calc(var(--str-chat__spacing-px) * 4); - } + .str-chat__poll { + .str-chat__checkmark { + border-radius: var(--str-chat__border-radius-circle); + border: 1px solid var(--str-chat__disabled-color); + } - .str-chat__poll-option__votes-bar--winner { - background: linear-gradient( - to right, - var(--chat-poll-progress-fill-incoming) var(--str-chat__amount-bar-fulfillment), - var(--str-chat__surface-color) var(--str-chat__amount-bar-fulfillment) - ); - } + .str-chat__checkmark--checked { + background-color: var(--str-chat__primary-color); + border: none; } - } -} -.str-chat__poll-option-list--full, -.str-chat__poll-answer, -.str-chat__modal__poll-option-list__title, -.str-chat__modal__poll-results .str-chat__modal__poll-results__title, -.str-chat__modal__poll-results .str-chat__poll-option { - border-radius: 0.75rem; -} + .str-chat__poll-option-list { + .str-chat__poll-option { + &.str-chat__poll-option--votable:hover { + cursor: pointer; + } -.str-chat__poll-option-list--full { - overflow: clip; -} + .str-chat__poll-option__votes-bar { + background: linear-gradient( + to right, + var(--chat-poll-progress-fill-incoming) + var(--str-chat__amount-bar-fulfillment), + var(--chat-poll-progress-track-incoming) + var(--str-chat__amount-bar-fulfillment) + ); + border-radius: calc(var(--str-chat__spacing-px) * 4); + } -.str-chat__poll--closed { - .str-chat__poll-option { - &:hover { - cursor: unset; + .str-chat__poll-option__votes-bar--winner { + background: linear-gradient( + to right, + var(--chat-poll-progress-fill-incoming) + var(--str-chat__amount-bar-fulfillment), + var(--str-chat__surface-color) var(--str-chat__amount-bar-fulfillment) + ); + } + } } } -} - -.str-chat__modal, .str-chat__poll-actions .str-chat__modal { - .str-chat__poll-answer__text, - .str-chat__modal__poll-option-list__title, - .str-chat__modal__poll-results__title { - font: var(--str-chat__subtitle-medium-text); - } .str-chat__poll-option-list--full, .str-chat__poll-answer, .str-chat__modal__poll-option-list__title, - .str-chat__modal__poll-results__title, - .str-chat__poll-option { - background-color: var(--str-chat__tertiary-surface-color); + .str-chat__modal__poll-results .str-chat__modal__poll-results__title, + .str-chat__modal__poll-results .str-chat__poll-option { + border-radius: 0.75rem; } .str-chat__poll-option-list--full { - .str-chat__poll-option-text { - font-weight: 500; - } + overflow: clip; + } - .str-chat__poll-option--votable:hover { - background-color: var(--str-chat__secondary-surface-color) + .str-chat__poll--closed { + .str-chat__poll-option { + &:hover { + cursor: unset; + } } } - .str-chat__poll-option { - .str-chat__poll-option__header { - font: var(--str-chat__subtitle-text); - - .str-chat__poll-option__option-text { - font: var(--str-chat__subtitle-medium-text); - } + .str-chat__modal, + .str-chat__poll-actions .str-chat__modal { + .str-chat__poll-answer__text, + .str-chat__modal__poll-option-list__title, + .str-chat__modal__poll-results__title { + font: var(--str-chat__subtitle-medium-text); } - .str-chat__poll-option__show-all-votes-button { - font: var(--str-chat__subtitle-text); - color: var(--str-chat__primary-color); + .str-chat__poll-option-list--full, + .str-chat__poll-answer, + .str-chat__modal__poll-option-list__title, + .str-chat__modal__poll-results__title, + .str-chat__poll-option { + background-color: var(--str-chat__tertiary-surface-color); } - } -} + .str-chat__poll-option-list--full { + .str-chat__poll-option-text { + font-weight: 500; + } -.str-chat__poll-vote { - .str-chat__poll-vote__author__name { - text-transform: capitalize; - } + .str-chat__poll-option--votable:hover { + background-color: var(--str-chat__secondary-surface-color); + } + } - .str-chat__poll-vote__timestamp { - color: var(--str-chat__text-low-emphasis-color); - } -} + .str-chat__poll-option { + .str-chat__poll-option__header { + font: var(--str-chat__subtitle-text); + .str-chat__poll-option__option-text { + font: var(--str-chat__subtitle-medium-text); + } + } -.str-chat__quoted-poll-preview { - font: var(--str-chat__body-medium-text); -} + .str-chat__poll-option__show-all-votes-button { + font: var(--str-chat__subtitle-text); + color: var(--str-chat__primary-color); + } + } + } + .str-chat__poll-vote { + .str-chat__poll-vote__author__name { + text-transform: capitalize; + } + .str-chat__poll-vote__timestamp { + color: var(--str-chat__text-low-emphasis-color); + } + } -@media only screen and (max-device-width: 768px) { - .str-chat__modal--open .str-chat__modal__inner { - width: 90%; + .str-chat__quoted-poll-preview { + font: var(--str-chat__body-medium-text); } - .str-chat__create-poll-modal, - .str-chat__poll-answer-list-modal, - .str-chat__poll-results-modal { - .str-chat__modal__inner { - height: 90%; - max-height: unset; + @media only screen and (max-device-width: 768px) { + .str-chat__modal--open .str-chat__modal__inner { + width: 90%; + } + + .str-chat__create-poll-modal, + .str-chat__poll-answer-list-modal, + .str-chat__poll-results-modal { + .str-chat__modal__inner { + height: 90%; + max-height: unset; + } } } -} \ No newline at end of file +} diff --git a/src/components/Poll/styling/PollCreationDialog.scss b/src/components/Poll/styling/PollCreationDialog.scss index 08e1e563e4..37869fc490 100644 --- a/src/components/Poll/styling/PollCreationDialog.scss +++ b/src/components/Poll/styling/PollCreationDialog.scss @@ -1,9 +1,88 @@ +@use '../../../styling/utils'; + @mixin field-background { background-color: var(--str-chat__tertiary-surface-color); border-radius: 0.75rem; } .str-chat__poll-creation-dialog { + .str-chat__modal-header { + padding-block: 14px; + + .str-chat__modal-header__close-button { + background-image: var(--str-chat__close-icon); + background-repeat: no-repeat; + } + } + + .str-chat__dialog__body { + flex: 1 1; + padding: 1rem; + + form { + display: flex; + flex-direction: column; + gap: 2rem; + } + } + + .str-chat__form__input-fieldset { + margin: 0; + padding: 0; + + .str-chat__form__input-field { + width: 100%; + padding: 1rem; + + .str-chat__form__input-field__value { + width: 100%; + + .str-chat__form__input-field__error { + width: 100%; + } + } + } + } + + .str-chat__form__input-field--with-label { + .str-chat__form__input-field__value { + padding: 1rem; + } + } + + .str-chat__form__input-field__value input { + width: 100%; + } + + .str-chat__form__input-fieldset__values { + display: flex; + flex-direction: column; + } + + .str-chat__form__field-label { + display: block; + margin-bottom: 0.5rem; + } + + .str-chat__form__input-field--draggable { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + + .str-chat__drag-handle { + height: 1rem; + width: 1rem; + } + } +} + +.str-chat__poll-creation-dialog { + display: flex; + flex-direction: column; + width: min(480px, 100vw); + height: min(640px, 100vh); + .str-chat__form__input-fieldset { border: none; } @@ -18,7 +97,8 @@ background: transparent; outline: none; - &, &::placeholder { + &, + &::placeholder { font: var(--str-chat__subtitle-text); } } @@ -40,7 +120,7 @@ border: 1px solid var(--str-chat__message-error-message-color); } - .str-chat__form__expandable-field .str-chat__form__input-field--has-error, { + .str-chat__form__expandable-field .str-chat__form__input-field--has-error { border: none; .str-chat__form__input-field__value { @@ -48,6 +128,29 @@ } } + .str-chat__form__expandable-field { + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; + + .str-chat__form__switch-field { + padding: 0; + } + + .str-chat__form__input-field { + width: 100%; + + .str-chat__form__input-field__value { + padding: 0; + + .str-chat__form-field-error { + height: 1rem; + } + } + } + } + .str-chat__form__input-field--with-label.str-chat__form__input-field--has-error { border: none; @@ -55,4 +158,4 @@ border: 1px solid var(--str-chat__message-error-message-color); } } -} \ No newline at end of file +} diff --git a/src/components/Poll/styling/index.scss b/src/components/Poll/styling/index.scss index 6132191917..d73bc5f194 100644 --- a/src/components/Poll/styling/index.scss +++ b/src/components/Poll/styling/index.scss @@ -1,2 +1,2 @@ @use 'Poll'; -@use 'PollCreationDialog'; \ No newline at end of file +@use 'PollCreationDialog'; From 2867eaa571e9354bed4ea1b414259052b53db52b Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 13 Feb 2026 09:56:10 +0100 Subject: [PATCH 19/20] feat: differ additional poll actions --- .../Poll/PollActions/PollAction.tsx | 7 +++ .../Poll/PollActions/PollActions.tsx | 47 ++++++++++--------- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/src/components/Poll/PollActions/PollAction.tsx b/src/components/Poll/PollActions/PollAction.tsx index a08bac9750..290415fd4f 100644 --- a/src/components/Poll/PollActions/PollAction.tsx +++ b/src/components/Poll/PollActions/PollAction.tsx @@ -10,6 +10,11 @@ export type PollActionProps = { closeModal: () => void; modalIsOpen: boolean; openModal: () => void; + /** + * Additional actions are shown based on the poll settings defined by the creator. + * Examples are "Suggest an option", "Add a comment", "View N comment(s)". + */ + isAdditionalAction?: boolean; modalClassName?: string; }; @@ -17,6 +22,7 @@ export const PollAction = ({ buttonText, children, closeModal, + isAdditionalAction, modalClassName, modalIsOpen, openModal, @@ -30,6 +36,7 @@ export const PollAction = ({ 'str-chat__button--outline', 'str-chat__button--secondary', 'str-chat__button--size-md', + { 'str-chat__poll-action--additional': isAdditionalAction }, )} onClick={openModal} > diff --git a/src/components/Poll/PollActions/PollActions.tsx b/src/components/Poll/PollActions/PollActions.tsx index e6e79ddd87..3df4cb5350 100644 --- a/src/components/Poll/PollActions/PollActions.tsx +++ b/src/components/Poll/PollActions/PollActions.tsx @@ -93,6 +93,28 @@ export const PollActions = ({ return (
+ {!is_closed && created_by_id === client.user?.id && ( + setModalOpen('end-vote')} + > + + + )} + + setModalOpen('view-results')} + > + + + {options.length > MAX_OPTIONS_DISPLAYED && ( setModalOpen('add-comment')} @@ -140,6 +164,7 @@ export const PollActions = ({ setModalOpen('view-comments')} @@ -150,28 +175,6 @@ export const PollActions = ({ /> )} - - setModalOpen('view-results')} - > - - - - {!is_closed && created_by_id === client.user?.id && ( - setModalOpen('end-vote')} - > - - - )}
); }; From bd8b85b6c59461b2d4a9bf75a4c26bc87abf0e10 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 13 Feb 2026 17:31:42 +0100 Subject: [PATCH 20/20] feat: add poll creation dialog styles --- src/components/Button/styling/Button.scss | 4 +- src/components/Dialog/styling/Dialog.scss | 8 +- src/components/Form/FieldError.tsx | 8 +- src/components/Form/NumericInput.tsx | 163 +++++++++++++++ src/components/Form/SwitchField.tsx | 174 +++++++++++++--- src/components/Form/TextInput.tsx | 58 +++++- src/components/Form/TextInputFieldSet.tsx | 29 +++ src/components/Form/index.ts | 2 + src/components/Form/styling/FieldError.scss | 5 +- src/components/Form/styling/Form.scss | 11 ++ src/components/Form/styling/NumericInput.scss | 95 +++++++++ src/components/Form/styling/SwitchField.scss | 187 ++++++++++++------ src/components/Form/styling/TextInput.scss | 23 ++- .../Form/styling/TextInputFieldset.scss | 45 +++++ src/components/Form/styling/index.scss | 2 + src/components/Icons/IconCheckmark2.tsx | 13 ++ src/components/Icons/IconCircleMinus.tsx | 13 ++ src/components/Icons/IconDotGrid2x3.tsx | 18 ++ src/components/Icons/index.ts | 3 + .../Icons/styling/IconCheckmark2.scss | 9 + .../Icons/styling/IconCircleMinus.scss | 9 + .../Icons/styling/IconDotGrid2x3.scss | 9 + .../Icons/styling/IconPaperPlane.scss | 2 + src/components/Icons/styling/index.scss | 3 + .../Location/ShareLocationDialog.tsx | 6 +- src/components/Modal/GlobalModal.tsx | 17 +- src/components/Modal/Modal.tsx | 16 +- src/components/Modal/ModalHeader.tsx | 17 +- .../Modal/__tests__/GlobalModal.test.js | 1 - src/components/Modal/__tests__/Modal.test.js | 2 - src/components/Modal/styling/Modal.scss | 98 +++------ .../MultipleAnswersField.tsx | 100 +++++----- .../Poll/PollCreationDialog/NameField.tsx | 65 +++--- .../PollCreationDialog/OptionFieldSet.tsx | 140 ++++++++----- .../PollCreationDialog/PollCreationDialog.tsx | 69 ++++--- .../PollCreationDialogControls.tsx | 28 ++- src/components/Poll/styling/Poll.scss | 21 +- .../Poll/styling/PollCreationDialog.scss | 130 ++++-------- src/i18n/de.json | 9 +- src/i18n/en.json | 9 +- src/i18n/es.json | 9 +- src/i18n/fr.json | 9 +- src/i18n/hi.json | 9 +- src/i18n/it.json | 9 +- src/i18n/ja.json | 9 +- src/i18n/ko.json | 9 +- src/i18n/nl.json | 9 +- src/i18n/pt.json | 9 +- src/i18n/ru.json | 9 +- src/i18n/tr.json | 9 +- src/styling/_global-theme-variables.scss | 33 ++++ 51 files changed, 1231 insertions(+), 513 deletions(-) create mode 100644 src/components/Form/NumericInput.tsx create mode 100644 src/components/Form/TextInputFieldSet.tsx create mode 100644 src/components/Form/styling/NumericInput.scss create mode 100644 src/components/Form/styling/TextInputFieldset.scss create mode 100644 src/components/Icons/IconCheckmark2.tsx create mode 100644 src/components/Icons/IconCircleMinus.tsx create mode 100644 src/components/Icons/IconDotGrid2x3.tsx create mode 100644 src/components/Icons/styling/IconCheckmark2.scss create mode 100644 src/components/Icons/styling/IconCircleMinus.scss create mode 100644 src/components/Icons/styling/IconDotGrid2x3.scss diff --git a/src/components/Button/styling/Button.scss b/src/components/Button/styling/Button.scss index ca8c472c78..68082c5a42 100644 --- a/src/components/Button/styling/Button.scss +++ b/src/components/Button/styling/Button.scss @@ -13,8 +13,8 @@ justify-content: center; gap: var(--spacing-xs); - line-height: var(--typography-line-height-normal); - font-weight: var(--typography-font-weight-semi-bold); + font: var(--str-chat__body-emphasis-text); + text-transform: capitalize; &.str-chat__button--solid { &.str-chat__button--primary { diff --git a/src/components/Dialog/styling/Dialog.scss b/src/components/Dialog/styling/Dialog.scss index 5411185408..ada37e2ff8 100644 --- a/src/components/Dialog/styling/Dialog.scss +++ b/src/components/Dialog/styling/Dialog.scss @@ -19,7 +19,7 @@ width: 100%; .str-chat__dialog__body { - padding: 2rem 1rem; + padding: 0 var(--spacing-xl); overflow-y: auto; .str-chat__dialog__title { @@ -30,11 +30,11 @@ .str-chat__dialog__controls { display: flex; justify-content: flex-end; - gap: 1.25rem; - padding: 2.25rem 1.25rem; + gap: var(--spacing-xs); + padding: var(--spacing-xl); .str-chat__dialog__controls-button { - @include utils.button-reset; + font: var(--str-chat__caption-emphasis-text); } } } diff --git a/src/components/Form/FieldError.tsx b/src/components/Form/FieldError.tsx index e7ff1b635c..39a547ee6f 100644 --- a/src/components/Form/FieldError.tsx +++ b/src/components/Form/FieldError.tsx @@ -1,10 +1,12 @@ import clsx from 'clsx'; -import type { ComponentProps } from 'react'; +import type { ComponentProps, ReactNode } from 'react'; import React from 'react'; -type FieldErrorProps = ComponentProps<'div'> & { - text?: string; +export type FieldErrorProps = ComponentProps<'div'> & { + /** Error message (string or custom content e.g. icon + text) */ + text?: ReactNode; }; + export const FieldError = ({ className, text, ...props }: FieldErrorProps) => (
{text} diff --git a/src/components/Form/NumericInput.tsx b/src/components/Form/NumericInput.tsx new file mode 100644 index 0000000000..61ebf8f3a3 --- /dev/null +++ b/src/components/Form/NumericInput.tsx @@ -0,0 +1,163 @@ +import clsx from 'clsx'; +import React, { forwardRef, useCallback } from 'react'; +import type { ChangeEvent, ComponentProps, KeyboardEvent } from 'react'; +import { useStableId } from '../UtilityComponents/useStableId'; +import { IconPlus } from '../Icons'; +import { Button } from '../Button'; + +export type NumericInputProps = Omit< + ComponentProps<'input'>, + 'className' | 'type' | 'value' | 'onChange' +> & { + value: string; + onChange: (e: ChangeEvent) => void; + /** Optional label above the input */ + label?: string; + /** Minimum value (stepper) */ + min?: number; + /** Maximum value (stepper) */ + max?: number; + /** Step for increment/decrement (default 1) */ + step?: number; + className?: string; +}; + +const parseNumeric = (s: string): number | null => { + const trimmed = s.trim(); + if (trimmed === '') return null; + const n = Number(trimmed); + return Number.isFinite(n) ? n : null; +}; + +const clamp = (n: number, min: number, max: number): number => + Math.min(Math.max(n, min), max); + +export const NumericInput = forwardRef( + function NumericInput( + { + className, + disabled = false, + id: idProp, + label, + max, + min, + onChange, + step = 1, + value, + ...inputProps + }, + ref, + ) { + const generatedId = useStableId(); + const id = idProp ?? generatedId; + + const num = parseNumeric(value); + const minDef = min ?? -Infinity; + const maxDef = max ?? Infinity; + const atMin = num !== null && num <= minDef; + const atMax = num !== null && num >= maxDef; + + const handleInputChange = useCallback( + (e: ChangeEvent) => { + const next = e.target.value; + if (next === '' || /^\d+$/.test(next)) { + onChange(e); + } + }, + [onChange], + ); + + const createChangeEvent = useCallback( + (newValue: string): ChangeEvent => + ({ + currentTarget: { value: newValue }, + target: { value: newValue }, + }) as ChangeEvent, + [], + ); + + const handleDecrement = useCallback(() => { + if (disabled || atMin) return; + const next = num !== null ? clamp(num - step, minDef, maxDef) : minDef; + onChange(createChangeEvent(String(next))); + }, [disabled, atMin, num, step, minDef, maxDef, onChange, createChangeEvent]); + + const handleIncrement = useCallback(() => { + if (disabled || atMax) return; + const next = num !== null ? clamp(num + step, minDef, maxDef) : minDef; + onChange(createChangeEvent(String(next))); + }, [disabled, atMax, num, step, minDef, maxDef, onChange, createChangeEvent]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + handleDecrement(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + handleIncrement(); + } + }, + [handleDecrement, handleIncrement], + ); + + return ( +
+ {!!label && ( + + )} +
+ + + +
+
+ ); + }, +); diff --git a/src/components/Form/SwitchField.tsx b/src/components/Form/SwitchField.tsx index 5641bc30f9..3c5f5ce1df 100644 --- a/src/components/Form/SwitchField.tsx +++ b/src/components/Form/SwitchField.tsx @@ -1,55 +1,165 @@ import clsx from 'clsx'; import type { ComponentProps, - ElementRef, KeyboardEventHandler, PropsWithChildren, + ReactNode, } from 'react'; import React, { useRef } from 'react'; -export type SwitchFieldProps = PropsWithChildren>; +export type SwitchFieldProps = Omit< + PropsWithChildren>, + 'children' +> & { + /** Main label content when title/description are not used */ + children?: ReactNode; + /** Optional description line below title */ + description?: string; + /** Class applied to the root div element of the SwitchField component */ + fieldClassName?: string; + /** Optional title line */ + title?: string; +}; + +export const SwitchField = ({ + children, + description, + fieldClassName, + title, + ...props +}: SwitchFieldProps) => { + const inputRef = useRef(null); -export const SwitchField = ({ children, ...props }: SwitchFieldProps) => { - const inputRef = useRef>(null); - const handleKeyUp: KeyboardEventHandler = (event) => { - if (![' ', 'Enter'].includes(event.key) || !inputRef.current) return; - event.preventDefault(); + const handleSwitchKeyDown: KeyboardEventHandler = (e) => { + if (![' ', 'Enter'].includes(e.key) || !inputRef.current) return; + e.preventDefault(); inputRef.current.click(); }; + const id = props.id; + return (
-