diff --git a/src/components/Attachment/AttachmentContainer.tsx b/src/components/Attachment/AttachmentContainer.tsx index 580bcf8a1f..c119cff28d 100644 --- a/src/components/Attachment/AttachmentContainer.tsx +++ b/src/components/Attachment/AttachmentContainer.tsx @@ -20,6 +20,7 @@ import { isVoiceRecordingAttachment, } from 'stream-chat'; +import { Audio as DefaultAudioAttachment } from './Audio'; import { AttachmentActions as DefaultAttachmentActions } from './AttachmentActions'; import { VoiceRecording as DefaultVoiceRecording } from './VoiceRecording'; import { type GalleryItem, toGalleryItemDescriptors } from '../Gallery'; @@ -275,7 +276,7 @@ export const OtherFilesContainer = ({ export const AudioContainer = ({ attachment, - Audio = DefaultFile, + Audio = DefaultAudioAttachment, }: RenderAttachmentProps) => (
diff --git a/src/components/Attachment/Audio.tsx b/src/components/Attachment/Audio.tsx index d73bc5597f..a8aca18af6 100644 --- a/src/components/Attachment/Audio.tsx +++ b/src/components/Attachment/Audio.tsx @@ -1,13 +1,15 @@ import React from 'react'; import type { Attachment } from 'stream-chat'; -import { DownloadButton, FileSizeIndicator, ProgressBar } from './components'; +import { FileSizeIndicator } from './components'; import type { AudioPlayerState } from '../AudioPlayback/AudioPlayer'; import { useAudioPlayer } from '../AudioPlayback/WithAudioPlayback'; import { useStateStore } from '../../store'; import { useMessageContext } from '../../context'; import type { AudioPlayer } from '../AudioPlayback/AudioPlayer'; import { PlayButton } from '../Button/PlayButton'; +import { FileIcon } from '../FileIcon'; +import { DurationDisplay, ProgressBar } from '../AudioPlayback'; type AudioAttachmentUIProps = { audioPlayer: AudioPlayer; @@ -18,7 +20,7 @@ const AudioAttachmentUI = ({ audioPlayer }: AudioAttachmentUIProps) => { const dataTestId = 'audio-widget'; const rootClassName = 'str-chat__message-attachment-audio-widget'; - const { isPlaying, progress } = + const { durationSeconds, isPlaying, progress, secondsElapsed } = useStateStore(audioPlayer?.state, audioPlayerStateSelector) ?? {}; return ( @@ -26,16 +28,34 @@ const AudioAttachmentUI = ({ audioPlayer }: AudioAttachmentUIProps) => {
-
+
{audioPlayer.title}
- + + {/**/}
- - + {durationSeconds ? ( + <> + + + + ) : ( + <> + + + + )}
@@ -47,13 +67,18 @@ export type AudioProps = { }; const audioPlayerStateSelector = (state: AudioPlayerState) => ({ + durationSeconds: state.durationSeconds, isPlaying: state.isPlaying, progress: state.progressPercent, + secondsElapsed: state.secondsElapsed, }); -const UnMemoizedAudio = (props: AudioProps) => { +/** + * Audio attachment with play/pause button and progress bar + */ +export const Audio = (props: AudioProps) => { const { - attachment: { asset_url, file_size, mime_type, title }, + attachment: { asset_url, duration, file_size, mime_type, title }, } = props; /** @@ -68,6 +93,7 @@ const UnMemoizedAudio = (props: AudioProps) => { const { message, threadList } = useMessageContext() ?? {}; const audioPlayer = useAudioPlayer({ + durationSeconds: duration, fileSize: file_size, mimeType: mime_type, requester: @@ -80,8 +106,3 @@ const UnMemoizedAudio = (props: AudioProps) => { return audioPlayer ? : null; }; - -/** - * Audio attachment with play/pause button and progress bar - */ -export const Audio = React.memo(UnMemoizedAudio) as typeof UnMemoizedAudio; diff --git a/src/components/Attachment/FileAttachment.tsx b/src/components/Attachment/FileAttachment.tsx index e8eeb1a2fb..fe2f8e19cf 100644 --- a/src/components/Attachment/FileAttachment.tsx +++ b/src/components/Attachment/FileAttachment.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useComponentContext } from '../../context/ComponentContext'; import { FileIcon } from '../FileIcon'; import type { Attachment } from 'stream-chat'; @@ -8,26 +9,33 @@ export type FileAttachmentProps = { attachment: Attachment; }; -const UnMemoizedFileAttachment = ({ attachment }: FileAttachmentProps) => ( -
- -
-
-
- {attachment.title} +export const FileAttachment = ({ attachment }: FileAttachmentProps) => { + const { AttachmentFileIcon } = useComponentContext(); + const FileIconComponent = AttachmentFileIcon ?? FileIcon; + return ( +
+ +
+
+
+ {attachment.title} +
+ {/**/} +
+
+
- {/**/} -
-
-
-
-); - -export const FileAttachment = React.memo( - UnMemoizedFileAttachment, -) as typeof UnMemoizedFileAttachment; + ); +}; diff --git a/src/components/Attachment/LinkPreview/CardAudio.tsx b/src/components/Attachment/LinkPreview/CardAudio.tsx index 2c92dfa26a..9401ba165b 100644 --- a/src/components/Attachment/LinkPreview/CardAudio.tsx +++ b/src/components/Attachment/LinkPreview/CardAudio.tsx @@ -1,8 +1,7 @@ -import { type AudioPlayerState, useAudioPlayer } from '../../AudioPlayback'; +import { type AudioPlayerState, ProgressBar, 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'; @@ -74,7 +73,7 @@ const AudioWidget = ({ mimeType, src }: { src: string; mimeType?: string }) => {
- +
); }; diff --git a/src/components/Attachment/VoiceRecording.tsx b/src/components/Attachment/VoiceRecording.tsx index f12c09ac32..852ff1fb5b 100644 --- a/src/components/Attachment/VoiceRecording.tsx +++ b/src/components/Attachment/VoiceRecording.tsx @@ -1,20 +1,25 @@ import React from 'react'; import type { Attachment } from 'stream-chat'; -import { FileSizeIndicator, PlaybackRateButton, WaveProgressBar } from './components'; +import { FileSizeIndicator } from './components'; import { FileIcon } from '../FileIcon'; import { useMessageContext, useTranslationContext } from '../../context'; -import { DurationDisplay } from '../AudioPlayback'; -import type { AudioPlayerState } from '../AudioPlayback/AudioPlayer'; -import { useAudioPlayer } from '../AudioPlayback/WithAudioPlayback'; +import { + type AudioPlayer, + type AudioPlayerState, + DurationDisplay, + PlaybackRateButton, + useAudioPlayer, + WaveProgressBar, +} from '../AudioPlayback'; import { useStateStore } from '../../store'; -import type { AudioPlayer } from '../AudioPlayback/AudioPlayer'; import { PlayButton } from '../Button'; const rootClassName = 'str-chat__message-attachment__voice-recording-widget'; const audioPlayerStateSelector = (state: AudioPlayerState) => ({ canPlayRecord: state.canPlayRecord, + durationSeconds: state.durationSeconds, isPlaying: state.isPlaying, playbackRate: state.currentPlaybackRate, progress: state.progressPercent, @@ -27,8 +32,14 @@ type VoiceRecordingPlayerUIProps = { // todo: finish creating a BaseAudioPlayer derived from VoiceRecordingPlayerUI and AudioAttachmentUI const VoiceRecordingPlayerUI = ({ audioPlayer }: VoiceRecordingPlayerUIProps) => { - const { canPlayRecord, isPlaying, playbackRate, progress, secondsElapsed } = - useStateStore(audioPlayer?.state, audioPlayerStateSelector) ?? {}; + const { + canPlayRecord, + durationSeconds, + isPlaying, + playbackRate, + progress, + secondsElapsed, + } = useStateStore(audioPlayer?.state, audioPlayerStateSelector) ?? {}; return (
@@ -36,9 +47,9 @@ const VoiceRecordingPlayerUI = ({ audioPlayer }: VoiceRecordingPlayerUIProps) =>
- {audioPlayer.durationSeconds ? ( + {durationSeconds ? ( { it('renders title and file size', () => { const { container, getByText } = renderComponent({ - og: audioAttachment, + og: { ...audioAttachment, duration: undefined }, }); expect(getByText(audioAttachment.title)).toBeInTheDocument(); @@ -99,6 +99,13 @@ describe('Audio', () => { expect(container.querySelector('img')).not.toBeInTheDocument(); }); + it('renders duration instead of file size when available', () => { + renderComponent({ og: { ...audioAttachment, duration: 43.007999420166016 } }); + + expect(screen.getByText('00:44')).toBeInTheDocument(); + expect(screen.queryByTestId('file-size-indicator')).not.toBeInTheDocument(); + }); + it('creates a playback Audio() with the right src only after clicked to play', async () => { renderComponent({ og: audioAttachment }); await clickToPlay(); diff --git a/src/components/Attachment/__tests__/WaveProgressBar.test.js b/src/components/Attachment/__tests__/WaveProgressBar.test.js index 85dcc78af3..b1573261dd 100644 --- a/src/components/Attachment/__tests__/WaveProgressBar.test.js +++ b/src/components/Attachment/__tests__/WaveProgressBar.test.js @@ -14,11 +14,12 @@ window.ResizeObserver = ResizeObserverMock; const getBoundingClientRect = jest .spyOn(HTMLDivElement.prototype, 'getBoundingClientRect') - .mockReturnValue({ width: 120 }); + .mockReturnValue({ width: 120, x: 0 }); describe('WaveProgressBar', () => { beforeEach(() => { ResizeObserverMock.observers = []; + getBoundingClientRect.mockReturnValue({ width: 120, x: 0 }); }); it('is not rendered if waveform data is missing', () => { @@ -27,7 +28,7 @@ describe('WaveProgressBar', () => { }); it('is not rendered if no space available', () => { - getBoundingClientRect.mockReturnValueOnce({ width: 0 }); + getBoundingClientRect.mockReturnValue({ width: 0, x: 0 }); render( { waveformData={originalSample} />, ); - expect(screen.queryByTestId(BAR_ROOT_TEST_ID)).not.toBeInTheDocument(); + expect(screen.getByTestId(BAR_ROOT_TEST_ID)).toBeInTheDocument(); + expect(screen.queryAllByTestId(AMPLITUDE_BAR_TEST_ID)).toHaveLength(0); }); it('renders with default number of bars', () => { @@ -110,15 +112,16 @@ describe('WaveProgressBar', () => { }); it('is rendered with zero progress by default if waveform data is available', () => { - const { container } = render( + render( , ); - expect(container).toMatchSnapshot(); + expect(screen.getAllByTestId(AMPLITUDE_BAR_TEST_ID)).toHaveLength(40); expect(screen.getByTestId(PROGRESS_INDICATOR_TEST_ID)).toBeInTheDocument(); + expect(screen.getByTestId(PROGRESS_INDICATOR_TEST_ID)).toHaveStyle('left: 0px'); }); it('is rendered with highlighted bars with non-zero progress', () => { @@ -132,8 +135,8 @@ describe('WaveProgressBar', () => { ); expect( container.querySelectorAll('.str-chat__wave-progress-bar__amplitude-bar--active'), - ).toHaveLength(1); + ).toHaveLength(8); expect(screen.queryByTestId(PROGRESS_INDICATOR_TEST_ID)).toBeInTheDocument(); - expect(screen.queryByTestId(PROGRESS_INDICATOR_TEST_ID)).toHaveStyle('left: 20%'); + expect(screen.queryByTestId(PROGRESS_INDICATOR_TEST_ID)).not.toHaveStyle('left: 0px'); }); }); diff --git a/src/components/Attachment/__tests__/__snapshots__/WaveProgressBar.test.js.snap b/src/components/Attachment/__tests__/__snapshots__/WaveProgressBar.test.js.snap deleted file mode 100644 index a488dbf340..0000000000 --- a/src/components/Attachment/__tests__/__snapshots__/WaveProgressBar.test.js.snap +++ /dev/null @@ -1,43 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`WaveProgressBar is rendered with zero progress by default if waveform data is available 1`] = ` -
-
-
-
-
-
-
-
-
-
-`; diff --git a/src/components/Attachment/components/ProgressBar.tsx b/src/components/Attachment/components/ProgressBar.tsx deleted file mode 100644 index 20a17b3d16..0000000000 --- a/src/components/Attachment/components/ProgressBar.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import clsx from 'clsx'; -import React from 'react'; - -export type ProgressBarProps = { - /** Progress expressed in fractional number value btw 0 and 100. */ - progress: number; -} & Pick, 'className' | 'onClick'>; - -export const ProgressBar = ({ className, onClick, progress }: ProgressBarProps) => ( -
-
-
-); diff --git a/src/components/Attachment/components/index.ts b/src/components/Attachment/components/index.ts index f22bba5ae7..f7a5f39257 100644 --- a/src/components/Attachment/components/index.ts +++ b/src/components/Attachment/components/index.ts @@ -1,5 +1,2 @@ export * from './DownloadButton'; export * from './FileSizeIndicator'; -export * from './ProgressBar'; -export * from './PlaybackRateButton'; -export * from '../../AudioPlayback/components/WaveProgressBar'; diff --git a/src/components/Attachment/styling/Attachment.scss b/src/components/Attachment/styling/Attachment.scss index 7929eb6964..44c71ca316 100644 --- a/src/components/Attachment/styling/Attachment.scss +++ b/src/components/Attachment/styling/Attachment.scss @@ -177,37 +177,6 @@ /* Box shadow applied to geolocation attachments */ --str-chat__geolocation-attachment-box-shadow: none; - /* Border radius applied audio widget */ - --str-chat__audio-attachment-widget-border-radius: calc( - var(--str-chat__message-bubble-border-radius) - var(--str-chat__attachment-margin) - ); - - /* Text color used in audio widget */ - --str-chat__audio-attachment-widget-color: var(--str-chat__text-color); - - /* Border radius applied audio widget */ - --str-chat__audio-attachment-widget-secondary-color: var( - --str-chat__text-low-emphasis-color - ); - - /* The text/icon color for low emphasis texts (for example file size) in audio widget */ - --str-chat__audio-attachment-widget-background-color: transparent; - - /* Top border of audio widget */ - --str-chat__audio-attachment-widget-border-block-start: none; - - /* Bottom border of audio widget */ - --str-chat__audio-attachment-widget-border-block-end: none; - - /* Left (right in RTL layout) border of audio widget */ - --str-chat__audio-attachment-widget-border-inline-start: none; - - /* Right (left in RTL layout) border of audio widget */ - --str-chat__audio-attachment-widget-border-inline-end: none; - - /* Box shadow applied to audio widget */ - --str-chat__audio-attachment-widget-box-shadow: none; - /* Border radius applied to audio recording widget */ --str-chat__voice-recording-attachment-widget-border-radius: calc( var(--str-chat__message-bubble-border-radius) - var(--str-chat__attachment-margin) @@ -530,53 +499,6 @@ } } - .str-chat__message-attachment-audio-widget { - @include utils.component-layer-overrides('audio-attachment-widget'); - - .str-chat__message-attachment-audio-widget--play-controls { - @include utils.flex-row-center(); - } - - .str-chat__message-attachment-audio-widget--progress-track { - max-width: calc(var(--str-chat__spacing-px) * 120); - } - - .str-chat__message-attachment-audio-widget--text-second-row { - display: flex; - align-items: center; - width: 100%; - grid-column-gap: var(--str-chat__spacing-5); - padding-top: var(--str-chat__spacing-2_5); - - .str-chat__message-attachment-file--item-size { - line-height: calc(var(--str-chat__spacing-px) * 14); - } - } - } - - .str-chat__message-attachment-audio-widget--progress-track { - position: relative; - height: calc(var(--str-chat__spacing-px) * 5); - flex: 1; - cursor: pointer; - background: linear-gradient( - to right, - var(--str-chat__primary-color) - var(--str-chat__message-attachment-audio-widget-progress), - var(--str-chat__disabled-color) - var(--str-chat__message-attachment-audio-widget-progress) - ); - border-radius: calc(var(--str-chat__spacing-px) * 5); - - .str-chat__message-attachment-audio-widget--progress-indicator { - position: absolute; - inset-inline-start: 0; - height: inherit; - background-color: var(--str-chat__primary-color); - border-radius: inherit; - } - } - .str-chat__message-attachment__voice-recording-widget { @include utils.component-layer-overrides('voice-recording-attachment-widget'); display: flex; @@ -626,32 +548,10 @@ .str-chat__message-attachment__voice-recording-widget__timer { min-width: calc(var(--str-chat__spacing-px) * 40); width: calc(var(--str-chat__spacing-px) * 40); - font-size: var(--typography-font-size-xs); - font-weight: var(--typography-font-weight-semi-bold); + font: var(--str-chat__metadata-emphasis-text); } } - .str-chat__message_attachment__playback-rate-button { - @include utils.flex-row-center; - @include utils.button-reset; - text-transform: none; - display: flex; - justify-content: center; - align-items: center; - gap: var(--spacing-none); - flex-shrink: 0; - width: 40px; - min-width: 40px; - min-height: 24px; - max-height: 24px; - padding: var(--spacing-xxs) var(--spacing-none); - border-radius: var(--button-radius-lg); - border: 1px solid var(--chat-border-on-chat-incoming); - cursor: pointer; - font-size: var(--typography-font-size-xs); - color: var(--control-playback-toggle-text); - } - .str-chat__message-attachment-with-actions.str-chat__message-attachment--giphy { } @@ -664,58 +564,6 @@ } } -.str-chat__message-attachment-audio-widget--play-button { - --str-chat-icon-height: calc(var(--str-chat__spacing-px) * 24); - @include utils.component-layer-overrides('audio-attachment-controls-button'); - @include utils.circle-fab-overrides('audio-attachment-controls-button'); - @include utils.flex-row-center(); - height: calc(var(--str-chat__spacing-px) * 36); - width: calc(var(--str-chat__spacing-px) * 36); - cursor: pointer; - - svg { - width: var(--str-chat__spacing-3); - } -} - -.str-chat__quoted-message-preview { - * { - cursor: pointer !important; - } - - .str-chat__message-attachment-card { - .str-chat__message-attachment-card--source-link, - .str-chat__message-attachment-card--content { - display: none; - } - } - - .str-chat__message-attachment__voice-recording-widget { - display: flex; - justify-content: space-between; - - .str-chat__wave-progress-bar__track { - display: none; - } - - .str-chat__message-attachment-audio-widget--play-button { - display: none; - } - } - - .str-chat__message-attachment-file--item-download { - display: none; - } -} - -.str-chat__message { - .str-chat__quoted-message-preview { - .str-chat__message-attachment-file--item { - padding: 0; - } - } -} - .str-chat__message.str-chat__message--has-single-attachment.str-chat__message--has-no-text { .str-chat__message-bubble { padding: 0; @@ -749,10 +597,6 @@ background-color: var(--chat-bg-outgoing); } } - - .str-chat__message_attachment__playback-rate-button { - border: 1px solid var(--chat-border-on-chat-outgoing); - } } .str-chat__li--single, diff --git a/src/components/Attachment/styling/Audio.scss b/src/components/Attachment/styling/Audio.scss new file mode 100644 index 0000000000..66a886773b --- /dev/null +++ b/src/components/Attachment/styling/Audio.scss @@ -0,0 +1,94 @@ +@use '../../../styling/utils'; + +.str-chat { + /* Border radius applied audio widget */ + --str-chat__audio-attachment-widget-border-radius: calc( + var(--str-chat__message-bubble-border-radius) - var(--str-chat__attachment-margin) + ); + + /* Text color used in audio widget */ + --str-chat__audio-attachment-widget-color: var(--str-chat__text-color); + + /* Border radius applied audio widget */ + --str-chat__audio-attachment-widget-secondary-color: var( + --str-chat__text-low-emphasis-color + ); + + /* The text/icon color for low emphasis texts (for example file size) in audio widget */ + --str-chat__audio-attachment-widget-background-color: transparent; + + /* Top border of audio widget */ + --str-chat__audio-attachment-widget-border-block-start: none; + + /* Bottom border of audio widget */ + --str-chat__audio-attachment-widget-border-block-end: none; + + /* Left (right in RTL layout) border of audio widget */ + --str-chat__audio-attachment-widget-border-inline-start: none; + + /* Right (left in RTL layout) border of audio widget */ + --str-chat__audio-attachment-widget-border-inline-end: none; + + /* Box shadow applied to audio widget */ + --str-chat__audio-attachment-widget-box-shadow: none; +} + +.str-chat__message-attachment-audio-widget { + @include utils.component-layer-overrides('audio-attachment-widget'); + flex: 1 1 auto; + min-width: 276px; + + .str-chat__message-attachment-audio-widget--play-controls { + @include utils.flex-row-center(); + } + + .str-chat__message-attachment-audio-widget--data { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--spacing-xxs); + } + + .str-chat__message-attachment-audio-widget--progress-track { + //max-width: calc(var(--str-chat__spacing-px) * 120); + } + + .str-chat__message-attachment-audio-widget--text-first-row { + display: flex; + justify-content: space-between; + align-items: start; + gap: var(--spacing-xs); + + svg { + flex-shrink: 0; + } + } + + .str-chat__message-attachment-audio-widget--title { + font: var(--str-chat__caption-emphasis-text); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + word-break: break-word; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + + .str-chat__duration-display { + width: 40px; + font: var(--str-chat__metadata-default-text); + color: var(--text-secondary); + } + + .str-chat__message-attachment-audio-widget--text-second-row { + display: flex; + align-items: center; + width: 100%; + gap: var(--spacing-xs); + + .str-chat__message-attachment-file--item-size { + line-height: calc(var(--str-chat__spacing-px) * 14); + } + } +} diff --git a/src/components/Attachment/styling/CardAudio.scss b/src/components/Attachment/styling/CardAudio.scss index 1c3967666f..a0d06c1a8a 100644 --- a/src/components/Attachment/styling/CardAudio.scss +++ b/src/components/Attachment/styling/CardAudio.scss @@ -1,4 +1,5 @@ -// todo: remove if CardAudio.tsx is removed +@use '../../../styling/utils'; + .str-chat__message-attachment-card--audio { .str-chat__message-attachment-card--content { padding: 0; diff --git a/src/components/Attachment/styling/index.scss b/src/components/Attachment/styling/index.scss index 200c292134..98f287d7b0 100644 --- a/src/components/Attachment/styling/index.scss +++ b/src/components/Attachment/styling/index.scss @@ -1,5 +1,7 @@ @use 'Attachment'; @use 'AttachmentActions'; +@use 'Audio'; +@use 'CardAudio'; @use 'Giphy'; @use 'LinkPreview'; @use 'ModalGallery'; diff --git a/src/components/AudioPlayback/AudioPlayer.ts b/src/components/AudioPlayback/AudioPlayer.ts index ce26a8ec82..d1388c5371 100644 --- a/src/components/AudioPlayback/AudioPlayer.ts +++ b/src/components/AudioPlayback/AudioPlayer.ts @@ -35,6 +35,8 @@ export type AudioPlayerState = { canPlayRecord: boolean; /** Current playback speed. Initiated with the first item of the playbackRates array. */ currentPlaybackRate: number; + /** Audio duration in seconds, from descriptor or loaded metadata. */ + durationSeconds?: number; /** The audio element ref */ elementRef: HTMLAudioElement | null; /** Signals whether the playback is in progress. */ @@ -87,6 +89,8 @@ export class AudioPlayer { private _disposed = false; private _pendingLoadedMeta?: { element: HTMLAudioElement; onLoaded: () => void }; private _elementIsReadyPromise?: Promise; + private _metadataProbe: HTMLAudioElement | null = null; + private _metadataProbePromise?: Promise; private _restoringPosition = false; private _removalTimeout: ReturnType | undefined = undefined; @@ -124,6 +128,7 @@ export class AudioPlayer { this.state = new StateStore({ canPlayRecord, currentPlaybackRate: playbackRates[0], + durationSeconds, elementRef: null, isPlaying: false, playbackError: null, @@ -133,6 +138,7 @@ export class AudioPlayer { }); this.plugins.forEach((p) => p.onInit?.({ player: this })); + this.preloadMetadata(); } private get plugins(): AudioPlayerPlugin[] { @@ -160,7 +166,7 @@ export class AudioPlayer { } get durationSeconds() { - return this._data.durationSeconds; + return this.state.getLatestValue().durationSeconds; } get fileSize() { @@ -199,6 +205,11 @@ export class AudioPlayer { return this._disposed; } + private setDurationSeconds = (durationSeconds?: number) => { + this._data.durationSeconds = durationSeconds; + this.state.partialNext({ durationSeconds }); + }; + private ensureElementRef(): HTMLAudioElement { if (this._disposed) { throw new Error('AudioPlayer is disposed'); @@ -235,7 +246,74 @@ export class AudioPlayer { ) { return; } - this._data.durationSeconds = duration; + this.setDurationSeconds(duration); + }; + + private clearMetadataProbe = () => { + const probe = this._metadataProbe; + this._metadataProbe = null; + this._metadataProbePromise = undefined; + + if (!probe) return; + + try { + probe.pause(); + } catch { + // ignore + } + + probe.removeAttribute('src'); + try { + probe.load(); + } catch { + // ignore + } + }; + + private preloadMetadata = () => { + if ( + this._disposed || + this.durationSeconds != null || + !this.src || + this._metadataProbePromise || + typeof document === 'undefined' + ) { + return; + } + + const probe = document.createElement('audio'); + probe.preload = 'metadata'; + this._metadataProbe = probe; + this._metadataProbePromise = new Promise((resolve) => { + const cleanup = () => { + probe.removeEventListener('loadedmetadata', handleLoadedMetadata); + probe.removeEventListener('error', handleError); + if (this._metadataProbe === probe) { + this.clearMetadataProbe(); + } else { + this._metadataProbePromise = undefined; + } + resolve(); + }; + + const handleLoadedMetadata = () => { + this.updateDurationFromElement(probe); + cleanup(); + }; + + const handleError = () => { + cleanup(); + }; + + probe.addEventListener('loadedmetadata', handleLoadedMetadata, { once: true }); + probe.addEventListener('error', handleError, { once: true }); + probe.src = this.src; + try { + probe.load(); + } catch { + cleanup(); + } + }); }; private clearPlaybackStartSafetyTimeout = () => { @@ -295,14 +373,29 @@ export class AudioPlayer { }; setDescriptor(descriptor: AudioPlayerDescriptor) { + const previousSrc = this.src; this._data = { ...this._data, ...descriptor }; - if (descriptor.src !== this.src && this.elementRef) { + if (descriptor.src !== previousSrc && this.elementRef) { this.elementRef.src = descriptor.src; } + if (descriptor.src && descriptor.src !== previousSrc) { + this.clearMetadataProbe(); + if (descriptor.durationSeconds == null) { + this.setDurationSeconds(undefined); + this.preloadMetadata(); + } else { + this.setDurationSeconds(descriptor.durationSeconds); + } + return; + } + if (descriptor.durationSeconds != null) { + this.setDurationSeconds(descriptor.durationSeconds); + } } private releaseElement({ resetState }: { resetState: boolean }) { this.clearPendingLoadedMeta(); + this.clearMetadataProbe(); this._restoringPosition = false; if (resetState) { this.stop(); @@ -345,6 +438,7 @@ export class AudioPlayer { this.releaseElement({ resetState: false }); } this.clearPendingLoadedMeta(); + this.clearMetadataProbe(); this._restoringPosition = false; this._elementIsReadyPromise = undefined; this.state.partialNext({ elementRef }); @@ -355,11 +449,9 @@ export class AudioPlayer { }; setSecondsElapsed = (secondsElapsed: number) => { + const duration = this.elementRef?.duration ?? this.durationSeconds; this.state.partialNext({ - progressPercent: - this.elementRef && secondsElapsed - ? (secondsElapsed / this.elementRef.duration) * 100 - : 0, + progressPercent: duration && secondsElapsed ? (secondsElapsed / duration) * 100 : 0, secondsElapsed, }); }; @@ -441,7 +533,6 @@ export class AudioPlayer { togglePlay = async () => (this.isPlaying ? this.pause() : await this.play()); increasePlaybackRate = () => { - if (!this.elementRef) return; let currentPlaybackRateIndex = this.state .getLatestValue() .playbackRates.findIndex((rate) => rate === this.currentPlaybackRate); @@ -454,7 +545,9 @@ export class AudioPlayer { : currentPlaybackRateIndex + 1; const currentPlaybackRate = this.playbackRates[nextIndex]; this.state.partialNext({ currentPlaybackRate }); - this.elementRef.playbackRate = currentPlaybackRate; + if (this.elementRef) { + this.elementRef.playbackRate = currentPlaybackRate; + } }; seek = throttle(async ({ clientX, currentTarget }) => { @@ -492,6 +585,7 @@ export class AudioPlayer { this._disposed = true; this.cancelScheduledRemoval(); this.clearPendingLoadedMeta(); + this.clearMetadataProbe(); this._restoringPosition = false; this.releaseElement({ resetState: true }); this.unsubscribeEventListeners?.(); diff --git a/src/components/AudioPlayback/__tests__/AudioPlayer.test.js b/src/components/AudioPlayback/__tests__/AudioPlayer.test.js index 0c180027c8..57a136f922 100644 --- a/src/components/AudioPlayback/__tests__/AudioPlayer.test.js +++ b/src/components/AudioPlayback/__tests__/AudioPlayer.test.js @@ -83,6 +83,23 @@ describe('AudioPlayer', () => { expect(player.src).toBe(SRC); expect(player.mimeType).toBe(MIME); expect(player.durationSeconds).toBe(100); + expect(player.state.getLatestValue().durationSeconds).toBe(100); + }); + + it('preloads metadata and updates duration before playback starts', () => { + const probe = document.createElement('audio'); + const createElementSpy = jest.spyOn(document, 'createElement').mockReturnValue(probe); + const durationSpy = jest.spyOn(probe, 'duration', 'get').mockReturnValue(42); + + const player = makePlayer({ durationSeconds: undefined }); + + probe.dispatchEvent(new Event('loadedmetadata')); + + expect(createElementSpy).toHaveBeenCalledWith('audio'); + expect(player.durationSeconds).toBe(42); + expect(player.state.getLatestValue().durationSeconds).toBe(42); + + durationSpy.mockRestore(); }); it('constructor marks not playable when mimeType unsupported', () => { @@ -225,6 +242,16 @@ describe('AudioPlayer', () => { expect(pauseSpy).not.toHaveBeenCalled(); }); + it('increasePlaybackRate() updates state even before the element is attached', () => { + const player = makePlayer({ playbackRates: [1, 1.5, 2] }); + + expect(player.elementRef).toBeNull(); + + player.increasePlaybackRate(); + + expect(player.currentPlaybackRate).toBe(1.5); + }); + it('stop() pauses, resets secondsElapsed and currentTime', () => { const player = makePlayer(); player.ensureElementRef(); diff --git a/src/components/Attachment/components/PlaybackRateButton.tsx b/src/components/AudioPlayback/components/PlaybackRateButton.tsx similarity index 57% rename from src/components/Attachment/components/PlaybackRateButton.tsx rename to src/components/AudioPlayback/components/PlaybackRateButton.tsx index 503b5aee34..9363dc6d58 100644 --- a/src/components/Attachment/components/PlaybackRateButton.tsx +++ b/src/components/AudioPlayback/components/PlaybackRateButton.tsx @@ -4,11 +4,15 @@ import clsx from 'clsx'; export type PlaybackRateButtonProps = React.ComponentProps<'button'>; -export const PlaybackRateButton = ({ children, onClick }: PlaybackRateButtonProps) => ( +export const PlaybackRateButton = ({ + children, + className, + ...rest +}: PlaybackRateButtonProps) => ( diff --git a/src/components/AudioPlayback/components/ProgressBar.tsx b/src/components/AudioPlayback/components/ProgressBar.tsx new file mode 100644 index 0000000000..b37fb28668 --- /dev/null +++ b/src/components/AudioPlayback/components/ProgressBar.tsx @@ -0,0 +1,51 @@ +import clsx from 'clsx'; +import React from 'react'; +import { useInteractiveProgressBar } from './useInteractiveProgressBar'; +import type { SeekFn as AudioPlayerSeekFn } from '../AudioPlayer'; + +type SeekParams = Parameters[0]; + +export type ProgressBarProps = { + /** Progress expressed in fractional number value btw 0 and 100. */ + progress: number; + seek: (params: SeekParams) => void; +} & Pick, 'className'>; + +export const ProgressBar = ({ className, progress, seek }: ProgressBarProps) => { + const { + handleDrag, + handleDragStart, + handleDragStop, + indicatorLeft, + setProgressIndicator, + setRoot, + } = useInteractiveProgressBar({ progress, seek }); + + return ( +
+
+
+ ); +}; diff --git a/src/components/AudioPlayback/components/WaveProgressBar.tsx b/src/components/AudioPlayback/components/WaveProgressBar.tsx index bf7bb14f26..90747d2d98 100644 --- a/src/components/AudioPlayback/components/WaveProgressBar.tsx +++ b/src/components/AudioPlayback/components/WaveProgressBar.tsx @@ -1,16 +1,9 @@ import throttle from 'lodash.throttle'; -import type { PointerEventHandler } from 'react'; -import React, { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from 'react'; +import React, { useLayoutEffect, useMemo, useRef, useState } from 'react'; import clsx from 'clsx'; import { resampleWaveformData } from '../../Attachment/audioSampling'; import type { SeekFn as AudioPlayerSeekFn } from '../AudioPlayer'; +import { useInteractiveProgressBar } from './useInteractiveProgressBar'; type SeekParams = Parameters[0]; @@ -35,67 +28,24 @@ export const WaveProgressBar = ({ seek, waveformData, }: WaveProgressBarProps) => { - const isDragging = useRef(false); const [trackAxisX, setTrackAxisX] = useState<{ barCount: number; barWidth: number; gap: number; }>(); - const [root, setRoot] = useState(null); - const [progressIndicator, setProgressIndicator] = useState(null); - const lastRootWidth = useRef(0); - const lastIndicatorWidth = useRef(0); + const lastAvailableTrackWidth = useRef(0); const minAmplitudeBarWidthRef = useRef(null); const lastMinAmplitudeBarWidthUsed = useRef(null); - - const handleDragStart: PointerEventHandler = (e) => { - e.preventDefault(); - if (!progressIndicator) return; - isDragging.current = true; - progressIndicator.style.cursor = 'grabbing'; - }; - - const handleDrag: PointerEventHandler = (e) => { - if (!isDragging.current) return; - // Due to throttling of seek, it is necessary to create a copy (snapshot) of the event. - // Otherwise, the event would be nullified at the point when the throttled function is executed. - seek({ ...e }); - }; - - const handleDragStop = useCallback(() => { - if (!progressIndicator) return; - isDragging.current = false; - progressIndicator.style.removeProperty('cursor'); - }, [progressIndicator]); - - const calculateIndicatorPosition = () => { - if (progress === 0 || !lastRootWidth || !progressIndicator) return 0; - const availableWidth = lastRootWidth.current - lastIndicatorWidth.current; - return availableWidth * (progress / 100) + 1; - }; - - const getAvailableTrackWidth = useCallback((trackRoot: HTMLDivElement | null) => { - if (!trackRoot) return 0; - 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 - paddingLeft - paddingRight - totalGapsWidth - siblingsWidth, - ); - }, []); + const { + availableTrackWidth, + handleDrag, + handleDragStart, + handleDragStop, + indicatorLeft, + root, + setProgressIndicator, + setRoot, + } = useInteractiveProgressBar({ progress, seek }); const getTrackAxisX = useMemo( () => @@ -103,8 +53,10 @@ export const WaveProgressBar = ({ const minAmplitudeBarWidth = minAmplitudeBarWidthRef.current; const hasMinWidthChanged = minAmplitudeBarWidth !== lastMinAmplitudeBarWidthUsed.current; - if (availableWidth === lastRootWidth.current && !hasMinWidthChanged) return; - lastRootWidth.current = availableWidth; + if (availableWidth === lastAvailableTrackWidth.current && !hasMinWidthChanged) { + return; + } + lastAvailableTrackWidth.current = availableWidth; lastMinAmplitudeBarWidthUsed.current = minAmplitudeBarWidth; const possibleAmpCount = Math.floor( availableWidth / (relativeAmplitudeGap + relativeAmplitudeBarWidth), @@ -135,36 +87,11 @@ export const WaveProgressBar = ({ [trackAxisX, waveformData], ); - useEffect(() => { - document.addEventListener('pointerup', handleDragStop); - return () => { - document.removeEventListener('pointerup', handleDragStop); - }; - }, [handleDragStop]); - - useEffect(() => { - if (!root || typeof ResizeObserver === 'undefined') return; - const observer = new ResizeObserver(([entry]) => { - const availableWidth = getAvailableTrackWidth(entry.target as HTMLDivElement); - getTrackAxisX(availableWidth || entry.contentRect.width); - }); - observer.observe(root); - - return () => { - observer.disconnect(); - }; - }, [getTrackAxisX, root, getAvailableTrackWidth]); - useLayoutEffect(() => { - if (root) { - const availableWidth = getAvailableTrackWidth(root); - getTrackAxisX(availableWidth); - } - - if (progressIndicator) { - lastIndicatorWidth.current = progressIndicator.getBoundingClientRect().width; + if (availableTrackWidth > 0) { + getTrackAxisX(availableTrackWidth); } - }, [getAvailableTrackWidth, getTrackAxisX, root, progressIndicator]); + }, [availableTrackWidth, getTrackAxisX]); useLayoutEffect(() => { if (!root || typeof window === 'undefined') return; @@ -177,11 +104,10 @@ export const WaveProgressBar = ({ if (!Number.isNaN(parsedMinWidth) && parsedMinWidth > 0) { minAmplitudeBarWidthRef.current = parsedMinWidth; } - const availableWidth = getAvailableTrackWidth(root); - if (availableWidth > 0) { - getTrackAxisX(availableWidth); + if (availableTrackWidth > 0) { + getTrackAxisX(availableTrackWidth); } - }, [getAvailableTrackWidth, getTrackAxisX, root, trackAxisX?.barCount]); + }, [availableTrackWidth, getTrackAxisX, root, trackAxisX?.barCount]); if (!waveformData.length || trackAxisX?.barCount === 0) return null; @@ -191,7 +117,6 @@ export const WaveProgressBar = ({
0, - // 'str-chat__wave-progress-bar__track--': isPlaying, })} data-testid='wave-progress-bar-track' onClick={seek} @@ -231,7 +156,7 @@ export const WaveProgressBar = ({ data-testid='wave-progress-bar-progress-indicator' ref={setProgressIndicator} style={{ - left: `${calculateIndicatorPosition()}px`, + left: `${indicatorLeft}px`, }} />
diff --git a/src/components/AudioPlayback/components/index.ts b/src/components/AudioPlayback/components/index.ts index 600786bccd..ab375753b8 100644 --- a/src/components/AudioPlayback/components/index.ts +++ b/src/components/AudioPlayback/components/index.ts @@ -1,2 +1,4 @@ export * from './DurationDisplay'; +export * from './PlaybackRateButton'; +export * from './ProgressBar'; export * from './WaveProgressBar'; diff --git a/src/components/AudioPlayback/components/useInteractiveProgressBar.ts b/src/components/AudioPlayback/components/useInteractiveProgressBar.ts new file mode 100644 index 0000000000..d21a1f439f --- /dev/null +++ b/src/components/AudioPlayback/components/useInteractiveProgressBar.ts @@ -0,0 +1,118 @@ +import type { PointerEventHandler } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import type { SeekFn as AudioPlayerSeekFn } from '../AudioPlayer'; + +type SeekParams = Parameters[0]; + +type UseInteractiveProgressBarParams = { + progress?: number; + seek: (params: SeekParams) => void; +}; + +const getAvailableTrackWidth = (trackRoot: HTMLDivElement | null) => { + if (!trackRoot) return 0; + + 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 - paddingLeft - paddingRight - totalGapsWidth - siblingsWidth, + ); +}; + +export const useInteractiveProgressBar = ({ + progress = 0, + seek, +}: UseInteractiveProgressBarParams) => { + const isDragging = useRef(false); + const [availableTrackWidth, setAvailableTrackWidth] = useState(0); + const [root, setRoot] = useState(null); + const [progressIndicator, setProgressIndicator] = useState(null); + const lastIndicatorWidth = useRef(0); + + const handleDragStart: PointerEventHandler = (e) => { + e.preventDefault(); + if (!progressIndicator) return; + + isDragging.current = true; + progressIndicator.style.cursor = 'grabbing'; + }; + + const handleDrag: PointerEventHandler = (e) => { + if (!isDragging.current) return; + // Snapshot the event because seek is throttled. + seek({ ...e }); + }; + + const handleDragStop = useCallback(() => { + if (!progressIndicator) return; + + isDragging.current = false; + progressIndicator.style.removeProperty('cursor'); + }, [progressIndicator]); + + useEffect(() => { + document.addEventListener('pointerup', handleDragStop); + + return () => { + document.removeEventListener('pointerup', handleDragStop); + }; + }, [handleDragStop]); + + useEffect(() => { + if (!root || typeof ResizeObserver === 'undefined') return; + + const observer = new ResizeObserver(([entry]) => { + const nextAvailableWidth = getAvailableTrackWidth(entry.target as HTMLDivElement); + setAvailableTrackWidth(nextAvailableWidth || entry.contentRect.width); + }); + + observer.observe(root); + + return () => { + observer.disconnect(); + }; + }, [root]); + + useLayoutEffect(() => { + if (root) { + setAvailableTrackWidth(getAvailableTrackWidth(root)); + } + + if (progressIndicator) { + lastIndicatorWidth.current = progressIndicator.getBoundingClientRect().width; + } + }, [progressIndicator, root]); + + const indicatorLeft = + progress === 0 || !progressIndicator + ? 0 + : Math.max(0, availableTrackWidth - lastIndicatorWidth.current) * (progress / 100) + + 1; + + return { + availableTrackWidth, + handleDrag, + handleDragStart, + handleDragStop, + indicatorLeft, + root, + setProgressIndicator, + setRoot, + }; +}; diff --git a/src/components/AudioPlayback/styling/DurationDisplay.scss b/src/components/AudioPlayback/styling/DurationDisplay.scss index 4cb5c57c4b..f4bed37f90 100644 --- a/src/components/AudioPlayback/styling/DurationDisplay.scss +++ b/src/components/AudioPlayback/styling/DurationDisplay.scss @@ -1,7 +1,5 @@ .str-chat { .str-chat__duration-display { - font-size: var(--typography-font-size-xs); - line-height: var(--typography-line-height-tight); letter-spacing: 0; min-width: 35px; width: 35px; diff --git a/src/components/AudioPlayback/styling/PlaybackRateButton.scss b/src/components/AudioPlayback/styling/PlaybackRateButton.scss new file mode 100644 index 0000000000..f2356d0aff --- /dev/null +++ b/src/components/AudioPlayback/styling/PlaybackRateButton.scss @@ -0,0 +1,47 @@ +@use '../../../styling/utils'; + +.str-chat .str-chat__button.str-chat__playback-rate-button { + text-transform: none; + display: flex; + justify-content: center; + align-items: center; + gap: var(--spacing-xs); + min-width: 40px; + min-height: 24px; + max-height: 24px; + padding: var(--button-padding-y-sm) var(--spacing-xs); + background-color: inherit; + border-radius: var(--button-radius-lg); + border: 1px solid var(--control-playback-toggle-border); + color: var(--control-playback-toggle-text, var(--text-primary)); + font: var(--str-chat__metadata-emphasis-text); + + &:not(:disabled):hover { + @include utils.overlay-after(var(--background-utility-hover)); + } + + &:not(:disabled):focus-visible { + // focused + @include utils.focusable; + outline-offset: 0; + } + + &:not(:disabled):active { + // pressed + @include utils.overlay-after(var(--background-utility-pressed)); + } + + &:disabled { + border-color: var(--border-utility-disabled); + color: var(--text-disabled); + cursor: default; + } +} + +.str-chat__message--me { + .str-chat__message-attachment { + .str-chat__playback-rate-button { + border: 1px solid var(--chat-border-on-chat-outgoing); + } + } +} diff --git a/src/components/AudioPlayback/styling/ProgressBar.scss b/src/components/AudioPlayback/styling/ProgressBar.scss new file mode 100644 index 0000000000..76a78ac238 --- /dev/null +++ b/src/components/AudioPlayback/styling/ProgressBar.scss @@ -0,0 +1,31 @@ +.str-chat { + .str-chat__message-attachment-audio-widget--progress-track { + position: relative; + height: calc(var(--str-chat__spacing-px) * 5); + flex: 1; + min-width: 0; + cursor: pointer; + background: linear-gradient( + to right, + var(--str-chat__primary-color) + var(--str-chat__message-attachment-audio-widget-progress), + var(--str-chat__disabled-color) + var(--str-chat__message-attachment-audio-widget-progress) + ); + border-radius: calc(var(--str-chat__spacing-px) * 5); + + .str-chat__message-attachment-audio-widget--progress-indicator { + position: absolute; + inset-inline-start: 0; + top: 50%; + transform: translateY(-50%); + height: calc(var(--str-chat__spacing-px) * 12); + width: calc(var(--str-chat__spacing-px) * 12); + border-radius: var(--radius-max); + border: 1px solid var(--control-playback-thumb-border-default); + background: var(--control-playback-thumb-bg-default); + box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.14); + cursor: grab; + } + } +} diff --git a/src/components/AudioPlayback/styling/index.scss b/src/components/AudioPlayback/styling/index.scss index 172acc6020..3ad6ef4dc9 100644 --- a/src/components/AudioPlayback/styling/index.scss +++ b/src/components/AudioPlayback/styling/index.scss @@ -1,2 +1,4 @@ @use 'DurationDisplay'; +@use 'PlaybackRateButton'; +@use 'ProgressBar'; @use 'WaveProgressBar'; diff --git a/src/components/Button/styling/PlayButton.scss b/src/components/Button/styling/PlayButton.scss new file mode 100644 index 0000000000..734f6c0bbe --- /dev/null +++ b/src/components/Button/styling/PlayButton.scss @@ -0,0 +1,6 @@ +.str-chat__button-play { + svg { + width: 20px; + height: 20px; + } +} diff --git a/src/components/Button/styling/index.scss b/src/components/Button/styling/index.scss index 7514bf3556..bc4a33f3cb 100644 --- a/src/components/Button/styling/index.scss +++ b/src/components/Button/styling/index.scss @@ -1 +1,2 @@ @use 'Button'; +@use 'PlayButton'; diff --git a/src/components/FileIcon/FileIcon.tsx b/src/components/FileIcon/FileIcon.tsx index 0bd7be835a..a84425eb96 100644 --- a/src/components/FileIcon/FileIcon.tsx +++ b/src/components/FileIcon/FileIcon.tsx @@ -1,11 +1,26 @@ -import React from 'react'; +import React, { useMemo } from 'react'; +import clsx from 'clsx'; import { iconMap } from './iconMap'; +import { FILE_ICON_NO_LABEL_CLASSNAME, mergeFileIconSizeConfig } from './FileIconSet'; import { mimeTypeToExtensionMap } from './mimeTypes'; +import type { FileIconSize } from './FileIconSet'; + +export type FileIconSizeConfigOverride = Partial< + Record< + FileIconSize, + Partial<{ width: number; height: number; labelX: number; labelY: number }> + > +>; export type FileIconProps = { className?: string; fileName?: string; + /** When true, label is not rendered and the icon graphic is centered (adds str-chat__file-icon--no-label). */ + hideLabel?: boolean; mimeType?: string; + /** Override dimensions/label position per size (sm, md, lg). */ + sizeConfig?: FileIconSizeConfigOverride; + size?: FileIconSize; }; export function mimeTypeToIcon(mimeType?: string) { @@ -41,9 +56,28 @@ const labelFromMimeType = ({ }; export const FileIcon = (props: FileIconProps) => { - const { fileName, mimeType, ...rest } = props; - + const { + className, + fileName, + hideLabel, + mimeType, + size = 'md', + sizeConfig: sizeConfigOverride, + ...rest + } = props; + const sizeConfig = useMemo( + () => mergeFileIconSizeConfig(sizeConfigOverride), + [sizeConfigOverride], + ); const Icon = mimeTypeToIcon(mimeType); - const label = labelFromMimeType({ fileName, mimeType }); - return ; + const label = hideLabel ? undefined : labelFromMimeType({ fileName, mimeType }); + return ( + + ); }; diff --git a/src/components/FileIcon/FileIconSet.tsx b/src/components/FileIcon/FileIconSet.tsx index 77c6a06c84..aa5e42c2d7 100644 --- a/src/components/FileIcon/FileIconSet.tsx +++ b/src/components/FileIcon/FileIconSet.tsx @@ -1,187 +1,354 @@ -import type { ComponentProps, ComponentPropsWithoutRef } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; import clsx from 'clsx'; -export type IconProps = { +export const BASE_FILE_ICON_CLASSNAME = 'str-chat__file-icon' as const; +export const FILE_ICON_GRAPHIC_CLASSNAME = 'str-chat__file-icon__graphic' as const; +/** Add this class (e.g. via className) when hiding the label with CSS to center the icon graphic. */ +export const FILE_ICON_NO_LABEL_CLASSNAME = 'str-chat__file-icon--no-label' as const; + +export type FileIconSize = 'sm' | 'md' | 'lg'; + +export type FileIconSizeConfigEntry = { + width: number; + height: number; + labelX: number; + labelY: number; +}; + +/** Rendered dimensions (px) and label position in viewBox coords for consistent spacing. */ +export const FILE_ICON_SIZE_CONFIG: Record = { + lg: { height: 48, labelX: 16, labelY: 36, width: 40 }, + md: { height: 40, labelX: 16, labelY: 35, width: 32 }, + sm: { height: 24, labelX: 16, labelY: 31.5, width: 19 }, +}; + +/** Merge partial overrides with default config. Use for Chat-level fileIconSizeConfig. */ +export const mergeFileIconSizeConfig = ( + overrides?: Partial>>, +): Record => { + if (!overrides) return FILE_ICON_SIZE_CONFIG; + return (['sm', 'md', 'lg'] as const).reduce( + (acc, size) => ({ + ...acc, + [size]: { ...FILE_ICON_SIZE_CONFIG[size], ...overrides[size] }, + }), + {} as Record, + ); +}; + +const VIEWBOX_WIDTH = 32; +const VIEWBOX_HEIGHT = 40; + +export type BaseFileIconProps = { label?: string; + /** Resolved size config (defaults from FILE_ICON_SIZE_CONFIG when omitted). Pass sizeConfig on FileIcon or use AttachmentFileIcon to override. */ + sizeConfig?: Record; + size?: FileIconSize; } & ComponentPropsWithoutRef<'svg'>; -const Svg = ({ className, ...props }: ComponentProps<'svg'>) => ( - -); +type SvgProps = Omit; -type FileIconLabelProps = { label?: string }; +const Svg = ({ className, size, sizeConfig, ...props }: SvgProps) => { + const config = sizeConfig ?? FILE_ICON_SIZE_CONFIG; + const dimensions = size ? config[size] : undefined; + return ( + + ); +}; -const FileIconLabel = ({ label }: FileIconLabelProps) => ( - - {label} - -); +type FileIconLabelProps = { + label?: string; + size?: FileIconSize; + sizeConfig?: Record; +}; -export const FilePdfIcon = ({ className, ...props }: Omit) => ( - - - - +const FileIconLabel = ({ label, size, sizeConfig }: FileIconLabelProps) => { + const configMap = sizeConfig ?? FILE_ICON_SIZE_CONFIG; + const config = size ? configMap[size] : { labelX: 16, labelY: 33 }; + return ( + + {label} + + ); +}; + +export const FilePdfIcon = ({ + className, + ...props +}: Omit) => ( + + + + + + + + + + + + - - - - - - ); -export const FileWordIcon = ({ className, label = 'doc', ...props }: IconProps) => ( - - - - - - - +export const FileWordIcon = ({ + className, + label = 'doc', + size, + sizeConfig, + ...props +}: BaseFileIconProps) => ( + + + + + + + + + ); -export const FilePowerPointIcon = ({ className, label = 'ppt', ...props }: IconProps) => ( - - - - - +export const FilePowerPointIcon = ({ + className, + label = 'ppt', + size, + sizeConfig, + ...props +}: BaseFileIconProps) => ( + + + + + + + ); -export const FileExcelIcon = ({ className = '', label = 'xls', ...props }: IconProps) => ( - - - - - +export const FileExcelIcon = ({ + className = '', + label = 'xls', + size, + sizeConfig, + ...props +}: BaseFileIconProps) => ( + + + + + + + ); -export const FileArchiveIcon = ({ className = '', label = '', ...props }: IconProps) => ( - - - - - +export const FileArchiveIcon = ({ + className = '', + label = '', + size, + sizeConfig, + ...props +}: BaseFileIconProps) => ( + + + + + + + ); -export const FileCodeIcon = ({ className = '', label = 'code', ...props }: IconProps) => ( - - - - - +export const FileCodeIcon = ({ + className = '', + label = 'code', + size, + sizeConfig, + ...props +}: BaseFileIconProps) => ( + + + + + + + ); export const FileAudioIcon = ({ className = '', label = 'audio', + size, + sizeConfig, ...props -}: IconProps) => ( - - - - - +}: BaseFileIconProps) => ( + + + + + + + ); // todo: can we remove this type of icon? missing design -export const FileVideoIcon = ({ className = '', ...props }: IconProps) => ( +export const FileVideoIcon = ({ className = '', ...props }: BaseFileIconProps) => ( ( ); -export const FileFallbackIcon = ({ className = '', ...props }: IconProps) => ( - - - - - - +export const FileFallbackIcon = ({ className = '', ...props }: BaseFileIconProps) => ( + + + + + + + + ); diff --git a/src/components/FileIcon/iconMap.ts b/src/components/FileIcon/iconMap.ts index 4764745ed1..d03846595e 100644 --- a/src/components/FileIcon/iconMap.ts +++ b/src/components/FileIcon/iconMap.ts @@ -8,7 +8,7 @@ import { wordMimeTypes, } from './mimeTypes'; import type { ComponentType } from 'react'; -import type { IconProps } from './FileIconSet'; +import type { BaseFileIconProps } from './FileIconSet'; type MimeTypeMappedComponent = | 'FilePdfIcon' @@ -86,13 +86,13 @@ function generateGeneralTypeToIconMap({ type IconMap = { standard: Record< SupportedMimeType | GeneralType | 'fallback', - ComponentType + ComponentType >; }; export const iconMap: IconMap = { standard: { - ...generateMimeTypeToIconMap({ + ...generateMimeTypeToIconMap({ FileArchiveIcon: fileIconSet.FileArchiveIcon, FileCodeIcon: fileIconSet.FileCodeIcon, FileExcelIcon: fileIconSet.FileExcelIcon, @@ -100,7 +100,7 @@ export const iconMap: IconMap = { FilePowerPointIcon: fileIconSet.FilePowerPointIcon, FileWordIcon: fileIconSet.FileWordIcon, }), - ...generateGeneralTypeToIconMap({ + ...generateGeneralTypeToIconMap({ FileAltIcon: fileIconSet.FileFallbackIcon, FileAudioIcon: fileIconSet.FileAudioIcon, FileVideoIcon: fileIconSet.FileVideoIcon, diff --git a/src/components/FileIcon/index.ts b/src/components/FileIcon/index.ts index f72cd523d9..b64b833a47 100644 --- a/src/components/FileIcon/index.ts +++ b/src/components/FileIcon/index.ts @@ -1 +1,3 @@ export { FileIcon } from './FileIcon'; +export type { FileIconProps, FileIconSizeConfigOverride } from './FileIcon'; +export { FILE_ICON_GRAPHIC_CLASSNAME, FILE_ICON_NO_LABEL_CLASSNAME } from './FileIconSet'; diff --git a/src/components/FileIcon/styling/FileIcon.scss b/src/components/FileIcon/styling/FileIcon.scss index ab7e221a48..08b43f88d9 100644 --- a/src/components/FileIcon/styling/FileIcon.scss +++ b/src/components/FileIcon/styling/FileIcon.scss @@ -1,16 +1,39 @@ .str-chat { .str-chat__file-icon { fill: none; - width: 32px; - height: 40px; .str-chat__file-icon__label { fill: white; - //font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial; - font-size: 8px; + font-size: 8px; // default (md) font-weight: 800; letter-spacing: 0.1px; text-anchor: middle; } + + /* Hide label via CSS: .str-chat__file-icon__label { display: none; } then add .str-chat__file-icon--no-label for centering */ + &.str-chat__file-icon--no-label .str-chat__file-icon__graphic { + transform: translate( + 0, + 6 + ); /* viewBox units: center graphic in (0 0 32 40) when label hidden */ + } + + &.str-chat__file-icon--size-sm .str-chat__file-icon__label { + font-size: 0; + height: 0; + } + + /* sm size hides label visually; center the graphic */ + &.str-chat__file-icon--size-sm .str-chat__file-icon__graphic { + transform: translate(0, 6); + } + + &.str-chat__file-icon--size-md .str-chat__file-icon__label { + font-size: 8px; + } + + &.str-chat__file-icon--size-lg .str-chat__file-icon__label { + font-size: 10px; + } } } diff --git a/src/components/MediaRecorder/AudioRecorder/AudioRecordingPlayback.tsx b/src/components/MediaRecorder/AudioRecorder/AudioRecordingPlayback.tsx index 1743f34e59..5ff50c214a 100644 --- a/src/components/MediaRecorder/AudioRecorder/AudioRecordingPlayback.tsx +++ b/src/components/MediaRecorder/AudioRecorder/AudioRecordingPlayback.tsx @@ -1,6 +1,5 @@ import React, { useEffect } from 'react'; -import { WaveProgressBar } from '../../Attachment'; -import { DurationDisplay } from '../../AudioPlayback'; +import { DurationDisplay, WaveProgressBar } from '../../AudioPlayback'; import type { AudioPlayerState } from '../../AudioPlayback/AudioPlayer'; import { useAudioPlayer } from '../../AudioPlayback/WithAudioPlayback'; import { useStateStore } from '../../../store'; diff --git a/src/components/MessageInput/AttachmentPreviewList/AttachmentPreviewList.tsx b/src/components/MessageInput/AttachmentPreviewList/AttachmentPreviewList.tsx index fb9271f0f1..8d56503cfc 100644 --- a/src/components/MessageInput/AttachmentPreviewList/AttachmentPreviewList.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/AttachmentPreviewList.tsx @@ -1,5 +1,4 @@ -import { type ComponentType, useMemo } from 'react'; -import React from 'react'; +import React, { type ComponentType, useMemo } from 'react'; import { isLocalAttachment, isLocalAudioAttachment, @@ -18,10 +17,7 @@ import { FileAttachmentPreview as DefaultFileAttachmentPreview, type FileAttachmentPreviewProps, } from './FileAttachmentPreview'; -import { - type AudioAttachmentPreviewProps, - AudioAttachmentPreview as DefaultAudioAttachmentPreview, -} from './AudioAttachmentPreview'; +import { type AudioAttachmentPreviewProps } from './AudioAttachmentPreview'; import { type ImageAttachmentPreviewProps } from './ImageAttachmentPreview'; import { useAttachmentsForPreview, useMessageComposer } from '../hooks'; import { @@ -34,7 +30,9 @@ import { } from './MediaAttachmentPreview'; export type AttachmentPreviewListProps = { - AudioAttachmentPreview?: ComponentType; + AudioAttachmentPreview?: + | ComponentType + | ComponentType; FileAttachmentPreview?: ComponentType; GeolocationPreview?: ComponentType; ImageAttachmentPreview?: ComponentType; @@ -43,7 +41,7 @@ export type AttachmentPreviewListProps = { }; export const AttachmentPreviewList = ({ - AudioAttachmentPreview = DefaultAudioAttachmentPreview, + AudioAttachmentPreview = DefaultFileAttachmentPreview, FileAttachmentPreview = DefaultFileAttachmentPreview, GeolocationPreview = DefaultGeolocationPreview, ImageAttachmentPreview = MediaAttachmentPreview, diff --git a/src/components/MessageInput/AttachmentPreviewList/AudioAttachmentPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/AudioAttachmentPreview.tsx index 10402ffa85..695f39e72e 100644 --- a/src/components/MessageInput/AttachmentPreviewList/AudioAttachmentPreview.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/AudioAttachmentPreview.tsx @@ -10,12 +10,16 @@ import clsx from 'clsx'; import { LoadingIndicatorIcon } from '../icons'; import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton'; import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot'; -import { FileSizeIndicator, PlaybackRateButton, WaveProgressBar } from '../../Attachment'; +import { FileSizeIndicator } from '../../Attachment'; import { IconExclamationCircle, IconExclamationTriangle } from '../../Icons'; import { PlayButton } from '../../Button'; -import { DurationDisplay } from '../../AudioPlayback'; -import type { AudioPlayerState } from '../../AudioPlayback/AudioPlayer'; -import { useAudioPlayer } from '../../AudioPlayback/WithAudioPlayback'; +import { + type AudioPlayerState, + DurationDisplay, + PlaybackRateButton, + useAudioPlayer, + WaveProgressBar, +} from '../../AudioPlayback'; import { useStateStore } from '../../../store'; export type AudioAttachmentPreviewProps> = diff --git a/src/components/MessageInput/AttachmentPreviewList/FileAttachmentPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/FileAttachmentPreview.tsx index 9f60f2c7b8..a9e23a9514 100644 --- a/src/components/MessageInput/AttachmentPreviewList/FileAttachmentPreview.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/FileAttachmentPreview.tsx @@ -3,7 +3,7 @@ import { useTranslationContext } from '../../../context'; import { FileIcon } from '../../FileIcon'; import { LoadingIndicatorIcon } from '../icons'; -import type { LocalFileAttachment } from 'stream-chat'; +import type { LocalAudioAttachment, LocalFileAttachment } from 'stream-chat'; import type { UploadAttachmentPreviewProps } from './types'; import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton'; import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot'; @@ -11,7 +11,9 @@ import { FileSizeIndicator } from '../../Attachment'; import { IconExclamationCircle, IconExclamationTriangle } from '../../Icons'; export type FileAttachmentPreviewProps = - UploadAttachmentPreviewProps>; + UploadAttachmentPreviewProps< + LocalFileAttachment | LocalAudioAttachment + >; export const FileAttachmentPreview = ({ attachment, diff --git a/src/components/MessageInput/styling/AttachmentPreview.scss b/src/components/MessageInput/styling/AttachmentPreview.scss index e48e831b94..f2d8968fd0 100644 --- a/src/components/MessageInput/styling/AttachmentPreview.scss +++ b/src/components/MessageInput/styling/AttachmentPreview.scss @@ -128,26 +128,6 @@ .str-chat__attachment-preview-file__data { padding-right: var(--spacing-xs); } - - .str-chat__message_attachment__playback-rate-button { - @include utils.button-reset; - text-transform: none; - 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, diff --git a/src/components/Reactions/styling/ReactionSelector.scss b/src/components/Reactions/styling/ReactionSelector.scss index 92a421f977..bd8288fc42 100644 --- a/src/components/Reactions/styling/ReactionSelector.scss +++ b/src/components/Reactions/styling/ReactionSelector.scss @@ -87,7 +87,7 @@ background-color: var(--background-utility-pressed); } &:not(:disabled):focus-visible { - outline: 2px solid var(--border-utility-focus); + outline: 2px solid var(--border-utility-focused); outline-offset: -2px; } &:not(:disabled)[aria-pressed='true'] { diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index c40033034e..3cf2f897fd 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -68,12 +68,15 @@ import type { PropsWithChildrenOnly } from '../types/types'; import type { StopAIGenerationButtonProps } from '../components/MessageInput/StopAIGenerationButton'; import type { VideoPlayerProps } from '../components/VideoPlayer'; import type { EditedMessagePreviewProps } from '../components/MessageInput/EditedMessagePreview'; +import type { FileIconProps } from '../components/FileIcon/FileIcon'; export type ComponentContextValue = { /** Custom UI component to display additional message composer action buttons left to the textarea, defaults to and accepts same props as: [AdditionalMessageComposerActions](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageComposer/MessageComposerActions.tsx) */ AdditionalMessageComposerActions?: React.ComponentType; /** Custom UI component to display a message attachment, defaults to and accepts same props as: [Attachment](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Attachment/Attachment.tsx) */ Attachment?: React.ComponentType; + /** Custom UI component for the file type icon shown on file attachments (e.g. PDF, doc). Accepts same props as [FileIcon](https://github.com/GetStream/stream-chat-react/blob/master/src/components/FileIcon/FileIcon.tsx) (e.g. mimeType, size, sizeConfig). Use this to override dimensions or provide a custom icon. */ + AttachmentFileIcon?: React.ComponentType; /** Custom UI component to display an attachment previews in MessageInput, defaults to and accepts same props as: [Attachment](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/AttachmentPreviewList.tsx) */ AttachmentPreviewList?: React.ComponentType; /** Custom UI component to control adding attachments to MessageInput, defaults to and accepts same props as: [AttachmentSelector](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/AttachmentSelector.tsx) */ diff --git a/src/styling/_utils.scss b/src/styling/_utils.scss index 614faed50d..62eeb2d6a7 100644 --- a/src/styling/_utils.scss +++ b/src/styling/_utils.scss @@ -64,7 +64,7 @@ } @mixin focusable { - outline: 2px solid var(--border-utility-focus); + outline: 2px solid var(--border-utility-focused); outline-offset: 2px; } diff --git a/src/styling/base.scss b/src/styling/base.scss index 3b5f697e32..9b7bf5666d 100644 --- a/src/styling/base.scss +++ b/src/styling/base.scss @@ -7,7 +7,7 @@ color: var(--text-primary); *:not(:disabled):focus-visible { - outline: 2px solid var(--border-utility-focus); + outline: 2px solid var(--border-utility-focused); outline-offset: 2px; } diff --git a/src/styling/variables.css b/src/styling/variables.css index 58110f720d..21b2481e69 100644 --- a/src/styling/variables.css +++ b/src/styling/variables.css @@ -415,7 +415,7 @@ --border-core-on-accent: #ffffff; /** Borders on accent backgrounds. */ --border-core-on-surface: #c0c8d2; --border-core-inverse: #ffffff; /** Border on inverse (dark) surface, e.g. presence ring. */ - --border-utility-focused: #c3d9ff; /** Focus ring or focus border. */ + --border-utility-focused: var(--blue-150); /** Focus ring or focus border. */ --border-utility-active: var(--accent-primary); /** Focus ring or focus border. */ --border-utility-selected: rgba(26, 27, 37, 0.15); /** Focus ring or focus border. */ --border-core-opacity-subtle: rgba(26, 27, 37, 0.1); /** Image frame border treatment. */