diff --git a/src/components/Attachment/Giphy.tsx b/src/components/Attachment/Giphy.tsx index 4d9807e571..d7969a43f8 100644 --- a/src/components/Attachment/Giphy.tsx +++ b/src/components/Attachment/Giphy.tsx @@ -1,10 +1,14 @@ import type { Attachment } from 'stream-chat'; +import { BaseImage as DefaultBaseImage } from '../BaseImage'; import { toGalleryItemDescriptors } from '../Gallery'; import clsx from 'clsx'; -import { useChannelStateContext } from '../../context'; +import { + useChannelStateContext, + useComponentContext, + useTranslationContext, +} from '../../context'; import { IconGiphy } from '../Icons'; import { useMemo } from 'react'; -import { ImageComponent } from './Image'; export type GiphyAttachmentProps = { attachment: Attachment; @@ -12,17 +16,28 @@ export type GiphyAttachmentProps = { export const Giphy = ({ attachment }: GiphyAttachmentProps) => { const { giphyVersion: giphyVersionName } = useChannelStateContext(); + const { BaseImage = DefaultBaseImage } = useComponentContext(); + const { t } = useTranslationContext(); + const usesDefaultBaseImage = BaseImage === DefaultBaseImage; const imageDescriptors = useMemo( () => toGalleryItemDescriptors(attachment, { giphyVersionName }), [attachment, giphyVersionName], ); - if (!imageDescriptors) return null; + if (!imageDescriptors?.imageUrl) return null; + + const { alt, dimensions, imageUrl, title } = imageDescriptors; return (
- +
); diff --git a/src/components/Attachment/Image.tsx b/src/components/Attachment/Image.tsx index 6e2d59f2ac..17109cdd59 100644 --- a/src/components/Attachment/Image.tsx +++ b/src/components/Attachment/Image.tsx @@ -6,10 +6,10 @@ import type { GalleryItem } from '../Gallery/GalleryContext'; export type ImageProps = GalleryItem; /** - * Display image in with option to expand into modal gallery + * Display image with tap-to-expand modal gallery. */ -export const ImageComponent = (props: ImageProps) => { +export const ImageComponent = (galleryItem: ImageProps) => { const { ModalGallery = DefaultModalGallery } = useComponentContext(); - return ; + return ; }; diff --git a/src/components/Attachment/LinkPreview/Card.tsx b/src/components/Attachment/LinkPreview/Card.tsx index 1c36edadf7..607c113acf 100644 --- a/src/components/Attachment/LinkPreview/Card.tsx +++ b/src/components/Attachment/LinkPreview/Card.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { BaseImage } from '../../Gallery'; +import { BaseImage } from '../../BaseImage'; import { SafeAnchor } from '../../SafeAnchor'; import { useChannelStateContext } from '../../../context/ChannelStateContext'; diff --git a/src/components/Attachment/ModalGallery.tsx b/src/components/Attachment/ModalGallery.tsx index b90c60ffc9..eb10b6bee4 100644 --- a/src/components/Attachment/ModalGallery.tsx +++ b/src/components/Attachment/ModalGallery.tsx @@ -1,12 +1,10 @@ import React, { useCallback, useState } from 'react'; import clsx from 'clsx'; -import type { BaseImageProps, GalleryItem } from '../Gallery'; -import { - BaseImage as DefaultBaseImage, - Gallery as DefaultGallery, - GalleryUI, -} from '../Gallery'; +import type { BaseImageProps } from '../BaseImage'; +import type { GalleryItem } from '../Gallery/GalleryContext'; +import { BaseImage as DefaultBaseImage } from '../BaseImage'; +import { Gallery as DefaultGallery, GalleryUI } from '../Gallery'; import { LoadingIndicator } from '../Loading'; import { GlobalModal } from '../Modal'; import { useComponentContext, useTranslationContext } from '../../context'; diff --git a/src/components/Gallery/BaseImage.tsx b/src/components/BaseImage/BaseImage.tsx similarity index 100% rename from src/components/Gallery/BaseImage.tsx rename to src/components/BaseImage/BaseImage.tsx diff --git a/src/components/Gallery/ImagePlaceholder.tsx b/src/components/BaseImage/ImagePlaceholder.tsx similarity index 100% rename from src/components/Gallery/ImagePlaceholder.tsx rename to src/components/BaseImage/ImagePlaceholder.tsx diff --git a/src/components/Gallery/__tests__/BaseImage.test.js b/src/components/BaseImage/__tests__/BaseImage.test.js similarity index 100% rename from src/components/Gallery/__tests__/BaseImage.test.js rename to src/components/BaseImage/__tests__/BaseImage.test.js diff --git a/src/components/BaseImage/index.ts b/src/components/BaseImage/index.ts new file mode 100644 index 0000000000..3ac7591790 --- /dev/null +++ b/src/components/BaseImage/index.ts @@ -0,0 +1,3 @@ +export * from './BaseImage'; +export * from './ImagePlaceholder'; +export * from './toBaseImageDescriptors'; diff --git a/src/components/Gallery/styling/ImagePlaceholder.scss b/src/components/BaseImage/styling/ImagePlaceholder.scss similarity index 100% rename from src/components/Gallery/styling/ImagePlaceholder.scss rename to src/components/BaseImage/styling/ImagePlaceholder.scss diff --git a/src/components/BaseImage/styling/index.scss b/src/components/BaseImage/styling/index.scss new file mode 100644 index 0000000000..bfb914727a --- /dev/null +++ b/src/components/BaseImage/styling/index.scss @@ -0,0 +1 @@ +@use 'ImagePlaceholder'; diff --git a/src/components/BaseImage/toBaseImageDescriptors.ts b/src/components/BaseImage/toBaseImageDescriptors.ts new file mode 100644 index 0000000000..b68cbcbef8 --- /dev/null +++ b/src/components/BaseImage/toBaseImageDescriptors.ts @@ -0,0 +1,101 @@ +import { + type Attachment, + isGiphyAttachment, + isImageAttachment, + isLocalImageAttachment, + isLocalVideoAttachment, + isScrapedContent, + isVideoAttachment, + type LinkPreview, + type LocalImageAttachment, + type LocalVideoAttachment, +} from 'stream-chat'; +import type { Dimensions } from '../../types/types'; + +type AttachmentPreviewableInGallery = + | LocalImageAttachment + | LocalVideoAttachment + | LinkPreview + | Attachment; + +/** Fields shared with gallery items for image/video preview from an attachment. */ +export type BaseImageDescriptor = { + alt?: string; + dimensions?: Dimensions; + imageUrl?: string; + title?: string; + videoThumbnailUrl?: string; + videoUrl?: string; +}; + +/** + * Maps an attachment (or link preview) to image/video URLs and metadata for {@link BaseImage} or the gallery. + */ +export const toBaseImageDescriptors = ( + attachment: AttachmentPreviewableInGallery, + options: { giphyVersionName?: string } = {}, +): BaseImageDescriptor | void => { + if (isGiphyAttachment(attachment)) { + const giphyVersion = + options?.giphyVersionName && attachment.giphy + ? attachment.giphy[ + options.giphyVersionName as keyof NonNullable + ] + : undefined; + + return { + alt: giphyVersion?.url || attachment.thumb_url, + dimensions: giphyVersion + ? { + height: giphyVersion.height, + width: giphyVersion.width, + } + : undefined, + imageUrl: attachment.thumb_url, + title: attachment.title || attachment.thumb_url, + }; + } + + if (isScrapedContent(attachment)) { + const imageUrl = attachment.image_url || attachment.thumb_url; + return { + alt: attachment.title || imageUrl, + imageUrl, + title: attachment.title, + }; + } + + if (isLocalVideoAttachment(attachment)) { + return { + title: attachment.title, + videoThumbnailUrl: attachment.thumb_url ?? attachment.localMetadata.previewUri, + videoUrl: attachment.asset_url ?? attachment.localMetadata.previewUri, + }; + } + + if (isVideoAttachment(attachment)) { + return { + title: attachment.title, + videoThumbnailUrl: attachment.thumb_url, + videoUrl: attachment.asset_url, + }; + } + + if (isLocalImageAttachment(attachment)) { + const imageUrl = attachment.image_url || attachment.localMetadata.previewUri; + return { + alt: attachment.title || imageUrl, + imageUrl, + title: attachment.title, + }; + } + + if (isImageAttachment(attachment)) { + const imageUrl = attachment.image_url; + return { + alt: attachment.title || imageUrl, + imageUrl, + title: attachment.title, + }; + } +}; diff --git a/src/components/Gallery/GalleryContext.tsx b/src/components/Gallery/GalleryContext.tsx index 92c52b273c..221862b159 100644 --- a/src/components/Gallery/GalleryContext.tsx +++ b/src/components/Gallery/GalleryContext.tsx @@ -1,98 +1,8 @@ import { createContext, useContext } from 'react'; -import { - type Attachment, - isGiphyAttachment, - isImageAttachment, - isLocalImageAttachment, - isLocalVideoAttachment, - isScrapedContent, - isVideoAttachment, - type LinkPreview, - type LocalImageAttachment, - type LocalVideoAttachment, -} from 'stream-chat'; +import { toBaseImageDescriptors } from '../BaseImage'; +import type { BaseImageProps } from '../BaseImage'; import type { Dimensions } from '../../types/types'; -import type { BaseImageProps } from './BaseImage'; - -type AttachmentPreviewableInGallery = - | LocalImageAttachment - | LocalVideoAttachment - | LinkPreview - | Attachment; - -export const toGalleryItemDescriptors = ( - attachment: AttachmentPreviewableInGallery, - options: { giphyVersionName?: string } = {}, -): Pick< - GalleryItem, - 'alt' | 'dimensions' | 'imageUrl' | 'title' | 'videoThumbnailUrl' | 'videoUrl' -> | void => { - if (isGiphyAttachment(attachment)) { - const giphyVersion = - options?.giphyVersionName && attachment.giphy - ? attachment.giphy[ - options.giphyVersionName as keyof NonNullable - ] - : undefined; - - return { - alt: giphyVersion?.url || attachment.thumb_url, - dimensions: giphyVersion - ? { - height: giphyVersion.height, - width: giphyVersion.width, - } - : undefined, - imageUrl: attachment.thumb_url, - title: attachment.title || attachment.thumb_url, - }; - } - - if (isScrapedContent(attachment)) { - // LinkPreview + OGAttachment - const imageUrl = attachment.image_url || attachment.thumb_url; - return { - alt: attachment.title || imageUrl, - imageUrl, - title: attachment.title, - }; - } - - if (isLocalVideoAttachment(attachment)) { - return { - title: attachment.title, - videoThumbnailUrl: attachment.thumb_url ?? attachment.localMetadata.previewUri, - videoUrl: attachment.asset_url ?? attachment.localMetadata.previewUri, - }; - } - - if (isVideoAttachment(attachment)) { - return { - title: attachment.title, - videoThumbnailUrl: attachment.thumb_url, - videoUrl: attachment.asset_url, - }; - } - - if (isLocalImageAttachment(attachment)) { - const imageUrl = attachment.image_url || attachment.localMetadata.previewUri; - return { - alt: attachment.title || imageUrl, - imageUrl, - title: attachment.title, - }; - } - - if (isImageAttachment(attachment)) { - const imageUrl = attachment.image_url; - return { - alt: attachment.title || imageUrl, - imageUrl, - title: attachment.title, - }; - } -}; export type GalleryItem = Omit & { dimensions?: Dimensions; @@ -101,6 +11,17 @@ export type GalleryItem = Omit & { videoUrl?: string; }; +/** + * Maps an attachment (or link preview) to gallery item fields. + * Delegates to {@link toBaseImageDescriptors}. + */ +export const toGalleryItemDescriptors = ( + ...args: Parameters +): Pick< + GalleryItem, + 'alt' | 'dimensions' | 'imageUrl' | 'title' | 'videoThumbnailUrl' | 'videoUrl' +> | void => toBaseImageDescriptors(...args); + export type GalleryContextValue = { /** Whether clicking the empty gallery background should request close */ closeOnBackgroundClick: boolean; diff --git a/src/components/Gallery/GalleryUI.tsx b/src/components/Gallery/GalleryUI.tsx index 5ab36d61e1..cde465c595 100644 --- a/src/components/Gallery/GalleryUI.tsx +++ b/src/components/Gallery/GalleryUI.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; -import { BaseImage } from './BaseImage'; +import { BaseImage } from '../BaseImage'; import { GalleryHeader } from './GalleryHeader'; import { useGalleryContext } from './GalleryContext'; import { Button, type ButtonProps } from '../Button'; diff --git a/src/components/Gallery/index.tsx b/src/components/Gallery/index.tsx index fd4343b977..326558edfc 100644 --- a/src/components/Gallery/index.tsx +++ b/src/components/Gallery/index.tsx @@ -1,5 +1,3 @@ -export * from './BaseImage'; export * from './Gallery'; export * from './GalleryContext'; export * from './GalleryUI'; -export * from './ImagePlaceholder'; diff --git a/src/components/Gallery/styling/index.scss b/src/components/Gallery/styling/index.scss index 564f4632ff..cb9cca1fb1 100644 --- a/src/components/Gallery/styling/index.scss +++ b/src/components/Gallery/styling/index.scss @@ -1,3 +1,2 @@ @use 'BaseImage'; @use 'Gallery'; -@use 'ImagePlaceholder'; diff --git a/src/components/MessageInput/AttachmentPreviewList/MediaAttachmentPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/MediaAttachmentPreview.tsx index 8cead490ea..4f7f49b7bf 100644 --- a/src/components/MessageInput/AttachmentPreviewList/MediaAttachmentPreview.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/MediaAttachmentPreview.tsx @@ -5,7 +5,7 @@ import { type LocalVideoAttachment, } from 'stream-chat'; import { useComponentContext, useTranslationContext } from '../../../context'; -import { BaseImage as DefaultBaseImage } from '../../Gallery'; +import { BaseImage as DefaultBaseImage } from '../../BaseImage'; import React, { type KeyboardEvent, type MouseEvent, diff --git a/src/components/MessageInput/LinkPreviewList.tsx b/src/components/MessageInput/LinkPreviewList.tsx index 7dbc21ef68..5ce4344792 100644 --- a/src/components/MessageInput/LinkPreviewList.tsx +++ b/src/components/MessageInput/LinkPreviewList.tsx @@ -6,7 +6,7 @@ import { useStateStore } from '../../store'; import { PopperTooltip } from '../Tooltip'; import { useEnterLeaveHandlers } from '../Tooltip/hooks'; import { useMessageComposer } from './hooks'; -import { BaseImage } from '../Gallery'; +import { BaseImage } from '../BaseImage'; import { RemoveAttachmentPreviewButton } from './RemoveAttachmentPreviewButton'; import { IconChainLink } from '../Icons'; diff --git a/src/components/MessageInput/MessageComposerActions.tsx b/src/components/MessageInput/MessageComposerActions.tsx index c00c5518da..bdfb36d742 100644 --- a/src/components/MessageInput/MessageComposerActions.tsx +++ b/src/components/MessageInput/MessageComposerActions.tsx @@ -81,7 +81,7 @@ export const MessageComposerActions = () => { if (isCooldownActive) { content = ; - } else if (contentIsEmpty && !editedMessage && recordingEnabled) { + } else if (contentIsEmpty && !editedMessage && !command && recordingEnabled) { content = ; } diff --git a/src/components/MessageInput/MessageInputFlat.tsx b/src/components/MessageInput/MessageInputFlat.tsx index 318cf9f019..635e766608 100644 --- a/src/components/MessageInput/MessageInputFlat.tsx +++ b/src/components/MessageInput/MessageInputFlat.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import clsx from 'clsx'; import { AttachmentSelector as DefaultAttachmentSelector, @@ -29,6 +30,7 @@ import { LinkPreviewsManager, type LinkPreviewsManagerState, type MessageComposerState, + type TextComposerState, } from 'stream-chat'; import { CommandChip as DefaultCommandChip } from './CommandChip'; @@ -52,6 +54,8 @@ const linkPreviewsManagerStateSelector = (state: LinkPreviewsManagerState) => ({ ), }); +const textComposerCommandSelector = ({ command }: TextComposerState) => ({ command }); + const MessageComposerPreviews = () => { const { AttachmentPreviewList = DefaultAttachmentPreviewList, @@ -111,6 +115,11 @@ const MessageComposerPreviews = () => { export const MessageInputFlat = () => { const { message } = useMessageContext(); const { recordingController } = useMessageInputContext(); + const messageComposer = useMessageComposer(); + const { command } = useStateStore( + messageComposer.textComposer.state, + textComposerCommandSelector, + ); const { AdditionalMessageComposerActions = DefaultAdditionalMessageComposerActions, @@ -126,7 +135,11 @@ export const MessageInputFlat = () => { className='str-chat__message-composer-container' component='div' > -
+
{recordingController.recordingState ? ( ) : ( diff --git a/src/components/MessageInput/QuotedMessagePreview.tsx b/src/components/MessageInput/QuotedMessagePreview.tsx index 480882dffd..de16432e5c 100644 --- a/src/components/MessageInput/QuotedMessagePreview.tsx +++ b/src/components/MessageInput/QuotedMessagePreview.tsx @@ -15,8 +15,10 @@ import { useStateStore } from '../../store'; import { useMessageComposer } from './hooks'; import { type Attachment, + type GiphyVersions, isAudioAttachment, isFileAttachment, + isGiphyAttachment, isImageAttachment, isScrapedContent, isVideoAttachment, @@ -28,6 +30,7 @@ import { type SharedLocationResponse, type TranslationLanguages, } from 'stream-chat'; +import { useChannelStateContext } from '../../context/ChannelStateContext'; import type { MessageContextValue } from '../../context'; import { RemoveAttachmentPreviewButton } from './RemoveAttachmentPreviewButton'; import { @@ -41,7 +44,7 @@ import { IconVideoSolid, } from '../Icons'; import clsx from 'clsx'; -import { BaseImage } from '../Gallery'; +import { BaseImage } from '../BaseImage'; import { FileIcon } from '../FileIcon'; import { QuotedMessageIndicator } from './QuotedMessageIndicator'; @@ -56,9 +59,17 @@ export type QuotedMessagePreviewProps = { const NullAttachmentIcon = () => null; +const QUOTED_GIPHY_PREVIEW_LABEL = 'Giphy'; + type AttachmentType = 'documents' | 'images' | 'links' | 'videos' | 'voiceRecordings'; +/** Giphy GIFs: only native type (e.g. /giphy command) is recognized as Giphy. */ +const isQuotedGiphyAttachment = (attachment: Attachment) => isGiphyAttachment(attachment); + const getAttachmentType = (attachment: Attachment) => { + if (isQuotedGiphyAttachment(attachment)) { + return 'giphy'; + } if (isScrapedContent(attachment)) { return 'link'; } else if (isVideoAttachment(attachment, SUPPORTED_VIDEO_FORMATS)) { @@ -77,6 +88,7 @@ const getAttachmentType = (attachment: Attachment) => { }; type GroupedAttachments = Record & { + giphies: Attachment[]; locations: SharedLocationResponse[]; polls: PollResponse[]; total: number; @@ -85,6 +97,7 @@ type GroupedAttachments = Record & { const getGroupedAttachments = (quotedMessage: LocalMessage | null) => { const groupedAttachments = { documents: [], + giphies: [], images: [], links: [], locations: [], @@ -99,6 +112,10 @@ const getGroupedAttachments = (quotedMessage: LocalMessage | null) => { const result = quotedMessage.attachments.reduce( (count, attachment) => { switch (getAttachmentType(attachment)) { + case 'giphy': + count.giphies.push(attachment); + count.total += 1; + break; case 'link': count.links.push(attachment); count.total += 1; @@ -142,6 +159,7 @@ type PreviewType = | 'voice' | 'file' | 'image' + | 'giphy' | 'link' | 'location' | 'poll' @@ -150,6 +168,7 @@ type PreviewType = const getAttachmentIconWithType = ( quotedMessage: LocalMessage | null, + giphyVersionName: GiphyVersions, ): { groupedAttachments: GroupedAttachments; Icon: ComponentType; @@ -169,6 +188,31 @@ const getAttachmentIconWithType = ( if (groupedAttachments.locations.length > 0) // todo: we do not generate the location preview image return { ...result, Icon: IconMapPin, previewType: 'location' }; + if ( + groupedAttachments.giphies.length > 0 && + groupedAttachments.giphies.length === groupedAttachments.total + ) { + const giphyAttachment = groupedAttachments.giphies[0] as Attachment & { + giphy?: Record; + }; + const giphyVersion = + giphyAttachment.giphy?.[giphyVersionName as keyof NonNullable]; + const src = + giphyVersion?.url || giphyAttachment.thumb_url || giphyAttachment.image_url || ''; + return { + ...result, + Icon: IconFileBend, + PreviewImage: ( + + ), + previewType: 'giphy', + }; + } if ( groupedAttachments.documents.length === groupedAttachments.total && groupedAttachments.documents.length === 1 @@ -279,6 +323,8 @@ export const QuotedMessagePreviewUI = ({ }: QuotedMessagePreviewUIProps) => { const { client } = useChatContext(); const { t, userLanguage } = useTranslationContext(); + const { giphyVersion: giphyVersionName = 'fixed_height' } = + useChannelStateContext('QuotedMessagePreview'); const quotedMessageText = useMemo( () => @@ -295,7 +341,7 @@ export const QuotedMessagePreviewUI = ({ Icon: AttachmentIcon, PreviewImage, previewType, - } = getAttachmentIconWithType(quotedMessage); + } = getAttachmentIconWithType(quotedMessage, giphyVersionName); let renderedText: ReactNode | undefined; @@ -313,6 +359,8 @@ export const QuotedMessagePreviewUI = ({ duration: displayDuration(voiceRecording!.duration), }); } + } else if (previewType === 'giphy') { + renderedText = QUOTED_GIPHY_PREVIEW_LABEL; } else if (previewType === 'link') { renderedText = groupedAttachments.links[0].title; } else if (previewType === 'mixed') { @@ -345,7 +393,7 @@ export const QuotedMessagePreviewUI = ({ PreviewImage, renderedText, }; - }, [quotedMessage, quotedMessageText, renderText, t]); + }, [giphyVersionName, quotedMessage, quotedMessageText, renderText, t]); const isOwnMessage = client.user?.id === quotedMessage?.user?.id; diff --git a/src/components/MessageInput/styling/MessageComposer.scss b/src/components/MessageInput/styling/MessageComposer.scss index bb85f4145b..7638b118b4 100644 --- a/src/components/MessageInput/styling/MessageComposer.scss +++ b/src/components/MessageInput/styling/MessageComposer.scss @@ -36,6 +36,32 @@ --str-chat__cooldown-box-shadow: none; --str-chat__message-composer-max-width: 768px; --str-chat__message-composer-padding: var(--spacing-md); + --str-chat__message-composer-command-transition-duration: 280ms; + --str-chat__message-composer-command-ease: cubic-bezier(0.22, 1, 0.32, 1); + + @keyframes str-chat-command-chip-enter-ltr { + from { + opacity: 0; + transform: translate3d(-0.75rem, 0, 0); + } + + to { + opacity: 1; + transform: translate3d(0, 0, 0); + } + } + + @keyframes str-chat-command-chip-enter-rtl { + from { + opacity: 0; + transform: translate3d(0.75rem, 0, 0); + } + + to { + opacity: 1; + transform: translate3d(0, 0, 0); + } + } .str-chat__message-composer-container { width: 100%; @@ -59,12 +85,72 @@ gap: var(--spacing-xs); min-width: 0; flex-shrink: 1; + transition: gap var(--str-chat__message-composer-command-transition-duration) + var(--str-chat__message-composer-command-ease); + } + + /* Slot must be at least as wide as the + control (incl. border/outline) so the composer never overlaps it */ + .str-chat__message-composer > .str-chat__attachment-selector { + flex-shrink: 0; + flex-grow: 0; + width: var( + --str-chat__message-composer-attachment-slot-width, + calc(var(--button-visual-height-lg) + 2 * var(--spacing-xxs)) + ); + max-width: var( + --str-chat__message-composer-attachment-slot-width, + calc(var(--button-visual-height-lg) + 2 * var(--spacing-xxs)) + ); + box-sizing: border-box; + opacity: 1; + transform: scale(1); + transform-origin: center bottom; + transition: + width var(--str-chat__message-composer-command-transition-duration) + var(--str-chat__message-composer-command-ease), + max-width var(--str-chat__message-composer-command-transition-duration) + var(--str-chat__message-composer-command-ease), + opacity calc(var(--str-chat__message-composer-command-transition-duration) * 0.85) + ease-out, + transform var(--str-chat__message-composer-command-transition-duration) + var(--str-chat__message-composer-command-ease), + margin var(--str-chat__message-composer-command-transition-duration) + var(--str-chat__message-composer-command-ease); + overflow: hidden; + pointer-events: auto; + display: flex; + align-items: flex-end; + justify-content: center; + } + + .str-chat__message-composer--command-active { + gap: 0; + } + + .str-chat__message-composer--command-active > .str-chat__attachment-selector { + width: 0; + max-width: 0; + min-width: 0; + margin-inline-end: 0; + opacity: 0; + transform: scale(0.55); + pointer-events: none; + } + + .str-chat__message-composer--command-active .str-chat__command-chip { + animation: str-chat-command-chip-enter-ltr + var(--str-chat__message-composer-command-transition-duration) + var(--str-chat__message-composer-command-ease) both; + } + + [dir='rtl'] .str-chat__message-composer--command-active .str-chat__command-chip { + animation-name: str-chat-command-chip-enter-rtl; } .str-chat__message-composer-compose-area { display: flex; flex-direction: column; - width: 100%; + flex: 1 1 0; min-width: 0; @include utils.component-layer-overrides('message-input'); } @@ -110,6 +196,19 @@ align-items: center; } + .str-chat__message-composer__additional-actions { + flex-shrink: 0; + min-width: 0; + max-width: 6rem; + opacity: 1; + transition: + max-width var(--str-chat__message-composer-command-transition-duration) + var(--str-chat__message-composer-command-ease), + opacity calc(var(--str-chat__message-composer-command-transition-duration) * 0.85) + ease-out; + overflow: hidden; + } + .str-chat__textarea { flex: 1; position: relative; @@ -180,6 +279,28 @@ } } + .str-chat__message-composer--command-active + .str-chat__message-composer__additional-actions { + max-width: 0; + opacity: 0; + pointer-events: none; + } + + @media (prefers-reduced-motion: reduce) { + .str-chat__message-composer { + transition-duration: 0.01ms; + } + + .str-chat__message-composer > .str-chat__attachment-selector, + .str-chat__message-composer-controls .str-chat__message-composer__additional-actions { + transition-duration: 0.01ms; + } + + .str-chat__message-composer--command-active .str-chat__command-chip { + animation: none; + } + } + // todo: need designs? what kind of action buttons to use on modals? .str-chat__recording-permission-denied-notification { max-width: 100%; diff --git a/src/components/SummarizedMessagePreview/SummarizedMessagePreview.tsx b/src/components/SummarizedMessagePreview/SummarizedMessagePreview.tsx index d4a5f7c907..ba8f85e1c5 100644 --- a/src/components/SummarizedMessagePreview/SummarizedMessagePreview.tsx +++ b/src/components/SummarizedMessagePreview/SummarizedMessagePreview.tsx @@ -33,6 +33,7 @@ const contentTypeIconMap: Partial< deleted: IconCircleBanSign, error: IconExclamationCircle1, file: IconFileBend, + giphy: IconFileBend, image: IconCamera1, link: IconChainLink, location: IconMapPin, diff --git a/src/components/SummarizedMessagePreview/hooks/useLatestMessagePreview.ts b/src/components/SummarizedMessagePreview/hooks/useLatestMessagePreview.ts index 7478aff42e..58a8433168 100644 --- a/src/components/SummarizedMessagePreview/hooks/useLatestMessagePreview.ts +++ b/src/components/SummarizedMessagePreview/hooks/useLatestMessagePreview.ts @@ -16,6 +16,7 @@ export type ChannelPreviewMessageType = | 'error' | 'empty' | 'image' + | 'giphy' | 'video' | 'voice' | 'file' @@ -63,7 +64,8 @@ function getAttachmentContentType(attachment: Attachment): ChannelPreviewMessage if (!attachment) return 'text'; // TODO: add audio (non-voice) content type when supported by the design - if (attachment.type === 'image' || attachment.type === 'giphy') return 'image'; + if (attachment.type === 'giphy') return 'giphy'; + if (attachment.type === 'image') return 'image'; if (attachment.type === 'video') return 'video'; if (attachment.type === 'voiceRecording') return 'voice'; if (attachment.type === 'file') return 'file'; @@ -186,17 +188,23 @@ export const useLatestMessagePreview = ({ } let text = - // prioritize message text content if available - textContent || - // then fallback text of the single attachment if only one attachment is present and it's not a voice recording (fallback text is generic for voice recordings, so not useful in the preview) - (attachments.length === 1 && contentType !== 'voice' - ? firstAttachment.fallback || firstAttachment.title - : '') || - // then generic fallback text based on attachment type and count - getAttachmentFallbackText(contentType, attachments.length, t); + contentType === 'giphy' + ? 'Giphy' + : // prioritize message text content if available + textContent || + // then fallback text of the single attachment if only one attachment is present and it's not a voice recording (fallback text is generic for voice recordings, so not useful in the preview) + (attachments.length === 1 && contentType !== 'voice' + ? firstAttachment.fallback || firstAttachment.title + : '') || + // then generic fallback text based on attachment type and count + getAttachmentFallbackText(contentType, attachments.length, t); // attach duration for audio/video attachments if available - if (attachments.length === 1 && typeof firstAttachment.duration === 'number') { + if ( + contentType !== 'giphy' && + attachments.length === 1 && + typeof firstAttachment.duration === 'number' + ) { const minutes = Math.floor(firstAttachment.duration / 60); const seconds = Math.ceil(firstAttachment.duration) % 60; const durationString = `${minutes}:${seconds.toString().padStart(2, '0')}`; diff --git a/src/components/VideoPlayer/VideoThumbnail.tsx b/src/components/VideoPlayer/VideoThumbnail.tsx index 2ae5ec9a16..46adb57eac 100644 --- a/src/components/VideoPlayer/VideoThumbnail.tsx +++ b/src/components/VideoPlayer/VideoThumbnail.tsx @@ -1,4 +1,4 @@ -import { BaseImage, type BaseImageProps } from '../Gallery'; +import { BaseImage, type BaseImageProps } from '../BaseImage'; import { Button } from '../Button'; import clsx from 'clsx'; import { IconPlaySolid } from '../Icons'; diff --git a/src/components/index.ts b/src/components/index.ts index 69821d23f7..dbd281f9a2 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -3,6 +3,7 @@ export * from './Attachment'; export * from './AudioPlayback'; export * from './Avatar'; export * from './Badge'; +export * from './BaseImage'; export * from './Button'; export * from './Channel'; export * from './ChannelHeader'; diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index 365552d823..eb1fd945b2 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -96,11 +96,11 @@ export type ComponentContextValue = { Avatar?: React.ComponentType; /** UI component to display a list of avatars stacked in a row, defaults to and accepts same props as: [AvatarStack](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/AvatarStack.tsx) */ AvatarStack?: React.ComponentType; - /** Custom UI component to display elements resp. a fallback in case of load error, defaults to and accepts same props as: [BaseImage](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Gallery/BaseImage.tsx) */ + /** Custom UI component to display elements resp. a fallback in case of load error, defaults to and accepts same props as: [BaseImage](https://github.com/GetStream/stream-chat-react/blob/master/src/components/BaseImage/BaseImage.tsx) */ BaseImage?: React.ComponentType; /** Custom UI component to display the contents of callout dialog, accepts same props as: [DefaultCalloutDialog](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Dialog/base/Callout.tsx) */ CalloutDialog?: React.ComponentType; - /** Custom UI component shown instead of the image when it fails to load, defaults to and accepts same props as: [ImagePlaceholder](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Gallery/ImagePlaceholder.tsx) */ + /** Custom UI component shown instead of the image when it fails to load, defaults to and accepts same props as: [ImagePlaceholder](https://github.com/GetStream/stream-chat-react/blob/master/src/components/BaseImage/ImagePlaceholder.tsx) */ ImagePlaceholder?: React.ComponentType; /** Custom UI component to display set of action buttons within `ChannelPreviewMessenger` component, accepts same props as: [ChannelPreviewActionButtons](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ChannelList/ChannelPreviewActionButtons.tsx) */ ChannelPreviewActionButtons?: React.ComponentType; @@ -163,7 +163,7 @@ export type ComponentContextValue = { MessageTimestamp?: React.ComponentType; /** Custom UI component for viewing content in a modal, defaults to and accepts the same props as [Modal](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Modal/Modal.tsx) */ Modal?: React.ComponentType; - /** Custom UI component for viewing message's image and giphy attachments with option to expand into the Gallery on Modal, defaults to and accepts the same props as [ModalGallery](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Gallery/ModalGallery.tsx) */ + /** Custom UI component for viewing message's image attachments with option to expand into the Gallery on Modal, defaults to and accepts the same props as [ModalGallery](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Gallery/ModalGallery.tsx) */ ModalGallery?: React.ComponentType; /** Custom UI component to show "Also sent in channel" in thread message lists when message.show_in_channel is true, defaults to and accepts same props as: [MessageAlsoSentInChannelIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageAlsoSentInChannelIndicator.tsx) */ MessageAlsoSentInChannelIndicator?: React.ComponentType; diff --git a/src/styling/index.scss b/src/styling/index.scss index d54e704328..4ddc87efda 100644 --- a/src/styling/index.scss +++ b/src/styling/index.scss @@ -14,6 +14,7 @@ // Base components @use '../components/Badge/styling' as Badge; +@use '../components/BaseImage/styling' as BaseImage; @use '../components/Button/styling' as Button; @use '../components/Form/styling' as Form; @use '../components/Dialog/styling' as Dialog;