diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index bf36745609..0c8223cf04 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -19,6 +19,7 @@ import { ThreadList, useCreateChatClient, VirtualizedMessageList as MessageList, + // MessageList, Window, } from 'stream-chat-react'; import { createTextComposerEmojiMiddleware, EmojiPicker } from 'stream-chat-react/emojis'; @@ -29,6 +30,7 @@ import { humanId } from 'human-id'; init({ data }); const apiKey = import.meta.env.VITE_STREAM_API_KEY; +const token = import.meta.env.VITE_USER_TOKEN; if (!apiKey) { throw new Error('VITE_STREAM_API_KEY is not defined'); @@ -43,6 +45,7 @@ const isMessageAIGenerated = (message: LocalMessage) => !!message?.ai_generated; const useUser = () => { const userId = useMemo(() => { return ( + import.meta.env.VITE_USER_ID || new URLSearchParams(window.location.search).get('user_id') || localStorage.getItem('user_id') || humanId({ separator: '_', capitalize: false }) @@ -58,11 +61,13 @@ const useUser = () => { }, [userId]); const tokenProvider = useCallback(() => { - return fetch( - `https://pronto.getstream.io/api/auth/create-token?environment=shared-chat-redesign&user_id=${userId}`, - ) - .then((response) => response.json()) - .then((data) => data.token as string); + return token + ? Promise.resolve(token) + : fetch( + `https://pronto.getstream.io/api/auth/create-token?environment=shared-chat-redesign&user_id=${userId}`, + ) + .then((response) => response.json()) + .then((data) => data.token as string); }, [userId]); return { userId: userId, tokenProvider }; @@ -97,6 +102,11 @@ const App = () => { position: { before: 'stream-io/text-composer/mentions-middleware' }, unique: true, }); + + composer.updateConfig({ + linkPreviews: { enabled: true }, + location: { enabled: true }, + }); }); }, [chatClient]); @@ -120,7 +130,12 @@ const App = () => { - + diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss index 0cd8e333fa..58b67b7d50 100644 --- a/examples/vite/src/index.scss +++ b/examples/vite/src/index.scss @@ -1,10 +1,11 @@ -@layer stream, stream-overrides; +@layer stream, stream-new, stream-overrides; @import url('./stream-imports-theme.scss') layer(stream); @import url('./stream-imports-layout.scss') layer(stream); // v3 CSS import -@import url('stream-chat-react/dist/css/index.css'); +@import url('stream-chat-react/dist/css/index.css') layer(stream-new); +@import url('stream-chat-react/dist/css/emojis.css') layer(stream-new); :root { font-synthesis: none; diff --git a/examples/vite/src/stream-imports-layout.scss b/examples/vite/src/stream-imports-layout.scss index f0e79f3515..a46c118934 100644 --- a/examples/vite/src/stream-imports-layout.scss +++ b/examples/vite/src/stream-imports-layout.scss @@ -4,7 +4,7 @@ @use 'stream-chat-react/dist/scss/v2/Avatar/Avatar-layout'; @use 'stream-chat-react/dist/scss/v2/AttachmentList/AttachmentList-layout'; -@use 'stream-chat-react/dist/scss/v2/AttachmentPreviewList/AttachmentPreviewList-layout'; +//@use 'stream-chat-react/dist/scss/v2/AttachmentPreviewList/AttachmentPreviewList-layout'; // X @use 'stream-chat-react/dist/scss/v2/Autocomplete/Autocomplete-layout'; @use 'stream-chat-react/dist/scss/v2/AudioRecorder/AudioRecorder-layout'; @use 'stream-chat-react/dist/scss/v2/BaseImage/BaseImage-layout'; @@ -15,21 +15,21 @@ @use 'stream-chat-react/dist/scss/v2/ChannelSearch/ChannelSearch-layout'; @use 'stream-chat-react/dist/scss/v2/common/CTAButton/CTAButton-layout'; @use 'stream-chat-react/dist/scss/v2/common/CircleFAButton/CircleFAButton-layout'; -@use 'stream-chat-react/dist/scss/v2/Dialog/Dialog-layout'; +//@use 'stream-chat-react/dist/scss/v2/Dialog/Dialog-layout'; @use 'stream-chat-react/dist/scss/v2/DragAndDropContainer/DragAndDropContainer-layout'; -@use 'stream-chat-react/dist/scss/v2/DropzoneContainer/DropzoneContainer-layout'; +@use 'stream-chat-react/dist/scss/v2/DropzoneContainer/DropzoneContainer-layout'; // X @use 'stream-chat-react/dist/scss/v2/EditMessageForm/EditMessageForm-layout'; @use 'stream-chat-react/dist/scss/v2/Form/Form-layout'; @use 'stream-chat-react/dist/scss/v2/ImageCarousel/ImageCarousel-layout'; -@use 'stream-chat-react/dist/scss/v2/Icon/Icon-layout'; +//@use 'stream-chat-react/dist/scss/v2/Icon/Icon-layout'; @use 'stream-chat-react/dist/scss/v2/InfiniteScrollPaginator/InfiniteScrollPaginator-layout'; -@use 'stream-chat-react/dist/scss/v2/LinkPreview/LinkPreview-layout'; +//@use 'stream-chat-react/dist/scss/v2/LinkPreview/LinkPreview-layout'; // X @use 'stream-chat-react/dist/scss/v2/LoadingIndicator/LoadingIndicator-layout'; @use 'stream-chat-react/dist/scss/v2/Location/Location-layout'; @use 'stream-chat-react/dist/scss/v2/Message/Message-layout'; @use 'stream-chat-react/dist/scss/v2/MessageActionsBox/MessageActionsBox-layout'; @use 'stream-chat-react/dist/scss/v2/MessageBouncePrompt/MessageBouncePrompt-layout'; -@use 'stream-chat-react/dist/scss/v2/MessageInput/MessageInput-layout'; +//@use 'stream-chat-react/dist/scss/v2/MessageInput/MessageInput-layout'; // X @use 'stream-chat-react/dist/scss/v2/MessageList/MessageList-layout'; @use 'stream-chat-react/dist/scss/v2/MessageList/VirtualizedMessageList-layout'; @use 'stream-chat-react/dist/scss/v2/MessageReactions/MessageReactions-layout'; diff --git a/examples/vite/src/stream-imports-theme.scss b/examples/vite/src/stream-imports-theme.scss index d68ff407e2..186560dc5e 100644 --- a/examples/vite/src/stream-imports-theme.scss +++ b/examples/vite/src/stream-imports-theme.scss @@ -7,7 +7,6 @@ @use 'stream-chat-react/dist/scss/v2/common/CircleFAButton/CircleFAButton-theme'; @use 'stream-chat-react/dist/scss/v2/Avatar/Avatar-theme'; @use 'stream-chat-react/dist/scss/v2/AttachmentList/AttachmentList-theme'; -@use 'stream-chat-react/dist/scss/v2/AttachmentPreviewList/AttachmentPreviewList-theme'; @use 'stream-chat-react/dist/scss/v2/AudioRecorder/AudioRecorder-theme'; @use 'stream-chat-react/dist/scss/v2/Autocomplete/Autocomplete-theme'; @use 'stream-chat-react/dist/scss/v2/BaseImage/BaseImage-theme'; @@ -16,20 +15,17 @@ @use 'stream-chat-react/dist/scss/v2/ChannelList/ChannelList-theme'; @use 'stream-chat-react/dist/scss/v2/ChannelPreview/ChannelPreview-theme'; @use 'stream-chat-react/dist/scss/v2/ChannelSearch/ChannelSearch-theme'; -@use 'stream-chat-react/dist/scss/v2/Dialog/Dialog-theme'; +//@use 'stream-chat-react/dist/scss/v2/Dialog/Dialog-theme'; @use 'stream-chat-react/dist/scss/v2/DragAndDropContainer/DragAndDropContainer-theme'; @use 'stream-chat-react/dist/scss/v2/DropzoneContainer/DropzoneContainer-theme'; -@use 'stream-chat-react/dist/scss/v2/EditMessageForm/EditMessageForm-theme'; @use 'stream-chat-react/dist/scss/v2/Form/Form-theme'; -@use 'stream-chat-react/dist/scss/v2/Icon/Icon-theme'; +//@use 'stream-chat-react/dist/scss/v2/Icon/Icon-theme'; @use 'stream-chat-react/dist/scss/v2/ImageCarousel/ImageCarousel-theme'; -@use 'stream-chat-react/dist/scss/v2/LinkPreview/LinkPreview-theme'; @use 'stream-chat-react/dist/scss/v2/LoadingIndicator/LoadingIndicator-theme'; @use 'stream-chat-react/dist/scss/v2/Location/Location-theme'; @use 'stream-chat-react/dist/scss/v2/Message/Message-theme'; @use 'stream-chat-react/dist/scss/v2/MessageActionsBox/MessageActionsBox-theme'; @use 'stream-chat-react/dist/scss/v2/MessageBouncePrompt/MessageBouncePrompt-theme'; -@use 'stream-chat-react/dist/scss/v2/MessageInput/MessageInput-theme'; @use 'stream-chat-react/dist/scss/v2/MessageList/MessageList-theme'; @use 'stream-chat-react/dist/scss/v2/MessageList/VirtualizedMessageList-theme'; @use 'stream-chat-react/dist/scss/v2/MessageReactions/MessageReactions-theme'; diff --git a/examples/vite/src/vite-env.d.ts b/examples/vite/src/vite-env.d.ts index 0053e2bf3a..e24eae6382 100644 --- a/examples/vite/src/vite-env.d.ts +++ b/examples/vite/src/vite-env.d.ts @@ -1,5 +1,7 @@ interface ImportMetaEnv { readonly VITE_STREAM_API_KEY?: string; + readonly VITE_USER_ID?: string; + readonly VITE_USER_TOKEN?: string; } interface ImportMeta { diff --git a/examples/vite/yarn.lock b/examples/vite/yarn.lock index ae53236c15..a531301f48 100644 --- a/examples/vite/yarn.lock +++ b/examples/vite/yarn.lock @@ -2329,6 +2329,11 @@ minimatch@^9.0.4: dependencies: brace-expansion "^2.0.1" +modern-normalize@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/modern-normalize/-/modern-normalize-3.0.1.tgz#4e2dc8da282ab854d53d70d7155a8027f59fbed6" + integrity sha512-VqlMdYi59Uch6fnUPxnpijWUQe+TW6zeWCvyr6Mb7JibheHzSuAAoJi2c71ZwIaWKpECpGpYHoaaBp6rBRr+/g== + ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" diff --git a/package.json b/package.json index 884c1978d3..c26b780c01 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,7 @@ "emoji-mart": "^5.4.0", "react": "^19.0.0 || ^18.0.0 || ^17.0.0", "react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0", - "stream-chat": "^9.27.2" + "stream-chat": "^9.30.1" }, "peerDependenciesMeta": { "@breezystack/lamejs": { @@ -215,7 +215,7 @@ "react-dom": "^19.0.0", "sass": "^1.97.2", "semantic-release": "^25.0.2", - "stream-chat": "^9.27.2", + "stream-chat": "^9.30.1", "ts-jest": "^29.2.5", "typescript": "^5.4.5", "typescript-eslint": "^8.17.0", @@ -224,7 +224,7 @@ "scripts": { "clean": "rm -rf dist", "build": "yarn clean && concurrently './scripts/copy-css.sh' 'yarn build-translations' 'vite build' 'tsc --project tsconfig.lib.json' 'yarn build-styling'", - "build-styling": "sass src/styling/index.scss dist/css/index.css", + "build-styling": "sass src/styling/index.scss dist/css/index.css && sass src/plugins/Emojis/styling/index.scss dist/css/emojis.css", "build-translations": "i18next-cli extract", "coverage": "jest --collectCoverage && codecov", "lint": "yarn prettier --list-different && yarn eslint && yarn validate-translations", @@ -234,8 +234,8 @@ "prettier": "prettier '**/*.{js,mjs,ts,mts,jsx,tsx,md,json,yml}'", "prettier-fix": "yarn prettier --write", "fix-staged": "lint-staged --config .lintstagedrc.fix.json --concurrent 1", - "start": "tsc --watch --sourceMap", - "start:css": "sass --watch src/styling/index.scss dist/css/index.css", + "start": "tsc -p tsconfig.lib.json -w", + "start:css": "sass --watch src/styling:dist/css src/plugins/Emojis/styling:dist/css", "prepare": "husky install", "preversion": "yarn install", "test": "jest", diff --git a/src/components/Attachment/Attachment.tsx b/src/components/Attachment/Attachment.tsx index 0457228d26..11617ca60e 100644 --- a/src/components/Attachment/Attachment.tsx +++ b/src/components/Attachment/Attachment.tsx @@ -37,6 +37,7 @@ import type { GeolocationProps } from './Geolocation'; const CONTAINER_MAP = { audio: AudioContainer, + // todo: rename to linkPreview card: CardContainer, file: FileContainer, media: MediaContainer, @@ -177,7 +178,7 @@ const renderGroupedAttachments = ({ return containers; }; -const getAttachmentType = ( +export const getAttachmentType = ( attachment: AttachmentProps['attachments'][number], ): keyof typeof CONTAINER_MAP => { if (isScrapedContent(attachment)) { diff --git a/src/components/Attachment/Audio.tsx b/src/components/Attachment/Audio.tsx index 686cf70a45..d8189340b5 100644 --- a/src/components/Attachment/Audio.tsx +++ b/src/components/Attachment/Audio.tsx @@ -1,11 +1,12 @@ import React from 'react'; import type { Attachment } from 'stream-chat'; -import { DownloadButton, FileSizeIndicator, PlayButton, ProgressBar } from './components'; +import { DownloadButton, FileSizeIndicator, ProgressBar } from './components'; import { type AudioPlayerState, useAudioPlayer } from '../AudioPlayback'; import { useStateStore } from '../../store'; import { useMessageContext } from '../../context'; import type { AudioPlayer } from '../AudioPlayback/AudioPlayer'; +import { PlayButton } from '../Button/PlayButton'; type AudioAttachmentUIProps = { audioPlayer: AudioPlayer; diff --git a/src/components/Attachment/Card.tsx b/src/components/Attachment/Card.tsx index 2d0125bb17..542ed50121 100644 --- a/src/components/Attachment/Card.tsx +++ b/src/components/Attachment/Card.tsx @@ -5,7 +5,7 @@ import ReactPlayer from 'react-player'; import type { AudioProps } from './Audio'; import { ImageComponent } from '../Gallery'; import { SafeAnchor } from '../SafeAnchor'; -import { PlayButton, ProgressBar } from './components'; +import { ProgressBar } from './components'; import { useChannelStateContext } from '../../context/ChannelStateContext'; import { useTranslationContext } from '../../context/TranslationContext'; @@ -15,6 +15,7 @@ import type { Dimensions } from '../../types/types'; import { type AudioPlayerState, useAudioPlayer } from '../AudioPlayback'; import { useStateStore } from '../../store'; import { useMessageContext } from '../../context'; +import { PlayButton } from '../Button'; const getHostFromURL = (url?: string | null) => { if (url !== undefined && url !== null) { diff --git a/src/components/Attachment/FileAttachment.tsx b/src/components/Attachment/FileAttachment.tsx index 2e8537efdf..639d812de3 100644 --- a/src/components/Attachment/FileAttachment.tsx +++ b/src/components/Attachment/FileAttachment.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { FileIcon } from '../ReactFileUtilities'; +import { FileIcon } from '../FileIcon'; import type { Attachment } from 'stream-chat'; import { DownloadButton, FileSizeIndicator } from './components'; diff --git a/src/components/Attachment/UnsupportedAttachment.tsx b/src/components/Attachment/UnsupportedAttachment.tsx index 60883bd621..df7c7976de 100644 --- a/src/components/Attachment/UnsupportedAttachment.tsx +++ b/src/components/Attachment/UnsupportedAttachment.tsx @@ -1,7 +1,7 @@ import React from 'react'; import type { Attachment } from 'stream-chat'; -import { FileIcon } from '../ReactFileUtilities'; +import { FileIcon } from '../FileIcon'; import { useTranslationContext } from '../../context'; export type UnsupportedAttachmentProps = { diff --git a/src/components/Attachment/VoiceRecording.tsx b/src/components/Attachment/VoiceRecording.tsx index 2ce46ee314..c713cb951f 100644 --- a/src/components/Attachment/VoiceRecording.tsx +++ b/src/components/Attachment/VoiceRecording.tsx @@ -1,18 +1,14 @@ import React from 'react'; import type { Attachment } from 'stream-chat'; -import { - FileSizeIndicator, - PlaybackRateButton, - PlayButton, - WaveProgressBar, -} from './components'; +import { FileSizeIndicator, PlaybackRateButton, WaveProgressBar } from './components'; import { displayDuration } from './utils'; -import { FileIcon } from '../ReactFileUtilities'; +import { FileIcon } from '../FileIcon'; import { useMessageContext, useTranslationContext } from '../../context'; import { type AudioPlayerState, useAudioPlayer } 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'; @@ -73,7 +69,7 @@ const VoiceRecordingPlayerUI = ({ audioPlayer }: VoiceRecordingPlayerUIProps) => {playbackRate?.toFixed(1)}x ) : ( - + )} @@ -156,7 +152,7 @@ export const QuotedVoiceRecording = ({ attachment }: QuotedVoiceRecordingProps) - + ); }; diff --git a/src/components/Attachment/components/PlayButton.tsx b/src/components/Attachment/components/PlayButton.tsx deleted file mode 100644 index 67ea75ff73..0000000000 --- a/src/components/Attachment/components/PlayButton.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import { PauseIcon, PlayTriangleIcon } from '../icons'; - -type PlayButtonProps = { - isPlaying: boolean; - onClick: () => void; -}; - -export const PlayButton = ({ isPlaying, onClick }: PlayButtonProps) => ( - -); diff --git a/src/components/Attachment/components/index.ts b/src/components/Attachment/components/index.ts index bba0e369d2..f22bba5ae7 100644 --- a/src/components/Attachment/components/index.ts +++ b/src/components/Attachment/components/index.ts @@ -2,5 +2,4 @@ export * from './DownloadButton'; export * from './FileSizeIndicator'; export * from './ProgressBar'; export * from './PlaybackRateButton'; -export * from './PlayButton'; -export * from './WaveProgressBar'; +export * from '../../AudioPlayback/components/WaveProgressBar'; diff --git a/src/components/Attachment/utils.tsx b/src/components/Attachment/utils.tsx index a6ec70b1ef..230fe3b625 100644 --- a/src/components/Attachment/utils.tsx +++ b/src/components/Attachment/utils.tsx @@ -53,11 +53,10 @@ export const displayDuration = (totalSeconds?: number) => { const [hours, hoursLeftover] = divMod(totalSeconds, 3600); const [minutes, seconds] = divMod(hoursLeftover, 60); const roundedSeconds = Math.ceil(seconds); + const prependHrsZero = String(hours).padStart(2, '0'); + const prependMinZero = String(minutes).padStart(2, '0'); + const prependSecZero = String(roundedSeconds).padStart(2, '0'); + const minSec = `${prependMinZero}:${prependSecZero}`; - const prependHrsZero = hours.toString().length === 1 ? '0' : ''; - const prependMinZero = minutes.toString().length === 1 ? '0' : ''; - const prependSecZero = roundedSeconds.toString().length === 1 ? '0' : ''; - const minSec = `${prependMinZero}${minutes}:${prependSecZero}${roundedSeconds}`; - - return hours ? `${prependHrsZero}${hours}:` + minSec : minSec; + return hours ? `${prependHrsZero}:` + minSec : minSec; }; diff --git a/src/components/AudioPlayback/components/DurationDisplay.tsx b/src/components/AudioPlayback/components/DurationDisplay.tsx new file mode 100644 index 0000000000..72e49cbedb --- /dev/null +++ b/src/components/AudioPlayback/components/DurationDisplay.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import clsx from 'clsx'; + +type DurationDisplayProps = { + /** Whether audio is currently playing */ + isPlaying: boolean; + /** Optional className for styling */ + className?: string; + /** Total duration in seconds */ + duration?: number; + /** Elapsed time in seconds */ + secondsElapsed?: number; +}; + +function formatTime(totalSeconds?: number) { + if (totalSeconds == null || Number.isNaN(totalSeconds) || totalSeconds < 0) { + return null; + } + const s = Math.floor(totalSeconds); + const minutes = Math.floor(s / 60); + const seconds = s % 60; + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; +} + +export function DurationDisplay({ + className, + duration, + isPlaying, + secondsElapsed, +}: DurationDisplayProps) { + const formattedDuration = formatTime(duration); + const formattedSecondsElapsed = formatTime(secondsElapsed); + + return ( +
0 && secondsElapsed < (duration || 0), + 'str-chat__duration-display--isPlaying': isPlaying, + }, + className, + )} + > + {isPlaying && ( + + {formattedSecondsElapsed} + + )} + {!!(formattedDuration && formattedSecondsElapsed) && <> / } + {formattedDuration && ( + {formattedDuration} + )} +
+ ); +} diff --git a/src/components/Attachment/components/WaveProgressBar.tsx b/src/components/AudioPlayback/components/WaveProgressBar.tsx similarity index 97% rename from src/components/Attachment/components/WaveProgressBar.tsx rename to src/components/AudioPlayback/components/WaveProgressBar.tsx index e727afae9f..1ea3c162a8 100644 --- a/src/components/Attachment/components/WaveProgressBar.tsx +++ b/src/components/AudioPlayback/components/WaveProgressBar.tsx @@ -9,8 +9,8 @@ import React, { useState, } from 'react'; import clsx from 'clsx'; -import { resampleWaveformData } from '../audioSampling'; -import type { SeekFn } from '../hooks/useAudioController'; +import { resampleWaveformData } from '../../Attachment/audioSampling'; +import type { SeekFn } from '../../Attachment/hooks/useAudioController'; type WaveProgressBarProps = { /** Function that allows to change the track progress */ diff --git a/src/components/AudioPlayback/components/index.ts b/src/components/AudioPlayback/components/index.ts new file mode 100644 index 0000000000..600786bccd --- /dev/null +++ b/src/components/AudioPlayback/components/index.ts @@ -0,0 +1,2 @@ +export * from './DurationDisplay'; +export * from './WaveProgressBar'; diff --git a/src/components/AudioPlayback/index.ts b/src/components/AudioPlayback/index.ts index 566e397fb8..5bffc7665f 100644 --- a/src/components/AudioPlayback/index.ts +++ b/src/components/AudioPlayback/index.ts @@ -1,4 +1,5 @@ export * from './AudioPlayer'; +export * from './components'; export * from './plugins'; export { useActiveAudioPlayer, diff --git a/src/components/AudioPlayback/styling/DurationDisplay.scss b/src/components/AudioPlayback/styling/DurationDisplay.scss new file mode 100644 index 0000000000..5f47adf654 --- /dev/null +++ b/src/components/AudioPlayback/styling/DurationDisplay.scss @@ -0,0 +1,15 @@ +.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; + } + + &.str-chat__duration-display--hasProgress { + .str-chat__duration-display__time-elapsed { + color: var(--color-accent-primary); + } + } +} \ No newline at end of file diff --git a/src/components/AudioPlayback/styling/WaveProgressBar.scss b/src/components/AudioPlayback/styling/WaveProgressBar.scss new file mode 100644 index 0000000000..afac3c1617 --- /dev/null +++ b/src/components/AudioPlayback/styling/WaveProgressBar.scss @@ -0,0 +1,37 @@ +.str-chat { + .str-chat__wave-progress-bar__track { + $min_amplitude_height: 2px; + position: relative; + flex: 1; + width: 160px; + height: 20px; + display: flex; + align-items: center; + gap: var(--str-chat__voice-recording-amplitude-bar-gap-width); + + .str-chat__wave-progress-bar__amplitude-bar { + width: 2px; + min-width: 2px; + height: calc( + var(--str-chat__wave-progress-bar__amplitude-bar-height) + $min_amplitude_height + ); // variable set dynamically on element + background: var(--chat-waveform-bar); + border-radius: var(--radius-max); + } + + .str-chat__wave-progress-bar__amplitude-bar--active { + background: var(--chat-waveform-bar-playing); + } + + .str-chat__wave-progress-bar__progress-indicator { + position: absolute; + left: 0; + // todo: CSS use semantic variable instead of --base-white + border: 2px solid var(--base-white); + box-shadow: var(--light-elevation-3); + background: var(--color-accent-primary); + height: 14px; + width: 14px; + } + } +} \ No newline at end of file diff --git a/src/components/AudioPlayback/styling/index.scss b/src/components/AudioPlayback/styling/index.scss new file mode 100644 index 0000000000..8558688dda --- /dev/null +++ b/src/components/AudioPlayback/styling/index.scss @@ -0,0 +1,2 @@ +@use "DurationDisplay"; +@use "WaveProgressBar"; \ No newline at end of file diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index a0c82d2ce3..262d323954 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -1,6 +1,8 @@ import type { ComponentProps } from 'react'; import clsx from 'clsx'; -export const Button = ({ className, ...props }: ComponentProps<'button'>) => ( +export type ButtonProps = ComponentProps<'button'>; + +export const Button = ({ className, ...props }: ButtonProps) => ( +); diff --git a/src/components/Button/index.ts b/src/components/Button/index.ts index 8b166a86e4..c8179d9bf5 100644 --- a/src/components/Button/index.ts +++ b/src/components/Button/index.ts @@ -1 +1,2 @@ export * from './Button'; +export * from './PlayButton'; diff --git a/src/components/Button/styling/Button.scss b/src/components/Button/styling/Button.scss index 4c4f8a7b83..7e606e04c0 100644 --- a/src/components/Button/styling/Button.scss +++ b/src/components/Button/styling/Button.scss @@ -12,120 +12,131 @@ align-items: center; justify-content: center; gap: var(--spacing-xs); - //padding-inline: var(--spacing-md); - //padding-block: var(--spacing-sm); - /* - min-width / min-height on buttons: - - enforce a minimum tappable area - - follow accessibility & platform guidelines - - don’t affect layout unless the button would be too small - - are about usability, not design polish - */ - min-height: var(--button-visual-height-lg); - max-height: var(--button-visual-height-lg); line-height: var(--typography-line-height-normal); - border-radius: var(--button-radius-lg); - - font-weight: var(--font-weight-w600); + font-weight: var(--typography-font-weight-semibold); &.str-chat__button--solid { &.str-chat__button--primary { - background-color: var(--button-type-primary-bg); - color: var(--button-type-primary-text); - border-color: var(--button-type-primary-border); + background-color: var(--button-primary-bg); + color: var(--button-primary-text-on-accent); } &.str-chat__button--secondary { - background-color: var(--button-type-secondary-bg); - color: var(--button-type-secondary-text); - border-color: var(--button-type-secondary-border); + background-color: var(--button-secondary-bg); + color: var(--button-secondary-text-on-accent); } &.str-chat__button--destructive { - background-color: var(--button-type-destructive-bg); - color: var(--button-type-destructive-text); - border-color: var(--button-type-destructive-border); + background-color: var(--button-destructive-bg); + color: var(--button-destructive-text-on-accent); } &:disabled { - background-color: var(--state-bg-disabled); + background-color: var(--background-core-disabled); } } &.str-chat__button--ghost { - border-color: var(--button-style-ghost-border); - background-color: var(--button-style-ghost-bg); - &.str-chat__button--primary { - color: var(--button-style-ghost-text-primary); + color: var(--button-primary-text); } &.str-chat__button--secondary { - color: var(--button-style-ghost-text-secondary); + color: var(--button-secondary-text); } &.str-chat__button--destructive { - color: var(--button-type-destructive-text-inverse); + color: var(--button-destructive-text); } } + // todo: shouldn't we specify also background color? &.str-chat__button--outline { - // todo: designs not available - //&.str-chat__button--primary { - // background-color: var(--button-type-primary-bg); - // color: var(--button-type-primary-text); - // border-color: var(--button-type-primary-border); - //} + &.str-chat__button--primary { + color: var(--button-primary-text); + border-color: var(--button-primary-border); + } &.str-chat__button--secondary { - background-color: var(--button-style-outline-bg); - color: var(--button-style-outline-text); - border-color: var(--button-style-outline-border); + color: var(--button-secondary-text); + border-color: var(--button-secondary-border); } - // todo: designs not available &.str-chat__button--destructive { - //background-color: var(--button-type-destructive-bg); - color: var(--button-type-destructive-text-inverse); - //border-color: var(--button-type-destructive-border); + color: var(--button-destructive-text); + border-color: var(--button-destructive-border); } } - &.str-chat__button--solid, - &.str-chat__button--ghost { - &:disabled { - border: none; - } + &.str-chat__button--outline { + border-width: 1px; + border-style: solid; } &.str-chat__button--solid, &.str-chat__button--outline, &.str-chat__button--ghost { - border-width: 1px; - border-style: solid; - - &:not(:disabled):hover { - @include utils.overlay-after(0.05); + @include utils.overlay-after(var(--background-core-hover)); } + &[aria-expanded="true"], &:not(:disabled):active { // pressed - @include utils.overlay-after(0.10); + @include utils.overlay-after(var(--background-core-pressed)); } &:not(:disabled):focus-visible { // focused - outline: 2px solid var(--border-utility-focus); + @include utils.focusable; } &:disabled { - color: var(--state-text-disabled); + color: var(--text-disabled); cursor: default; } } + &.str-chat__button--outline:disabled { + border-color: var(--border-utility-disabled); + } + &.str-chat__button--floating { - box-shadow: var(--light-elevation-3); + box-shadow: var(--light-elevation-2); + + } + + &.str-chat__button--size-lg { + padding-block: var(--button-padding-y-lg); + padding-inline: var(--button-padding-x-with-label-lg); + border-radius: var(--button-radius-lg); + + &.str-chat__button--circular { + padding-inline: var(--button-padding-x-icon-only-lg); + } + } + + &.str-chat__button--size-md { + padding-block: var(--button-padding-y-md); + padding-inline: var(--button-padding-x-with-label-md); + border-radius: var(--button-radius-md); + + &.str-chat__button--circular { + padding-inline: var(--button-padding-x-icon-only-md); + } + } + + &.str-chat__button--size-sm { + padding-block: var(--button-padding-y-sm); + padding-inline: var(--button-padding-x-with-label-sm); + border-radius: var(--button-radius-md); + + &.str-chat__button--circular { + padding-inline: var(--button-padding-x-icon-only-sm); + } + } + + &.str-chat__button--circular { + border-radius: var(--button-radius-full); } } } diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 32fc8fe0fb..3c02eb46f2 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -112,7 +112,6 @@ type ChannelPropsForwardedToComponentContext = Pick< | 'EmojiPicker' | 'emojiSearchIndex' | 'EmptyStateIndicator' - | 'FileUploadIcon' | 'GiphyPreviewMessage' | 'HeaderComponent' | 'Input' @@ -1223,7 +1222,6 @@ const ChannelInner = ( EmojiPicker: props.EmojiPicker, emojiSearchIndex: props.emojiSearchIndex, EmptyStateIndicator: props.EmptyStateIndicator, - FileUploadIcon: props.FileUploadIcon, GiphyPreviewMessage: props.GiphyPreviewMessage, HeaderComponent: props.HeaderComponent, Input: props.Input, @@ -1293,7 +1291,6 @@ const ChannelInner = ( props.EmojiPicker, props.emojiSearchIndex, props.EmptyStateIndicator, - props.FileUploadIcon, props.GiphyPreviewMessage, props.HeaderComponent, props.Input, diff --git a/src/components/Dialog/DialogMenu.tsx b/src/components/Dialog/DialogMenu.tsx deleted file mode 100644 index 54e2527ce8..0000000000 --- a/src/components/Dialog/DialogMenu.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { ComponentProps } from 'react'; -import React from 'react'; -import clsx from 'clsx'; - -export type DialogMenuButtonProps = ComponentProps<'button'>; - -export const DialogMenuButton = ({ - children, - className, - ...props -}: DialogMenuButtonProps) => ( - -); diff --git a/src/components/Dialog/__tests__/DialogsManager.test.js b/src/components/Dialog/__tests__/DialogsManager.test.js index 64099c1562..f1cc21bd4d 100644 --- a/src/components/Dialog/__tests__/DialogsManager.test.js +++ b/src/components/Dialog/__tests__/DialogsManager.test.js @@ -1,4 +1,4 @@ -import { DialogManager } from '../DialogManager'; +import { DialogManager } from '../service/DialogManager'; import * as nanoid from 'nanoid'; const dialogId = 'dialogId'; diff --git a/src/components/Dialog/base/ContextMenu.tsx b/src/components/Dialog/base/ContextMenu.tsx new file mode 100644 index 0000000000..9076260106 --- /dev/null +++ b/src/components/Dialog/base/ContextMenu.tsx @@ -0,0 +1,6 @@ +import React, { type ComponentProps } from 'react'; +import clsx from 'clsx'; + +export const ContextMenu = ({ className, ...props }: ComponentProps<'div'>) => ( +
+); diff --git a/src/components/Dialog/ButtonWithSubmenu.tsx b/src/components/Dialog/base/ContextMenuButton.tsx similarity index 69% rename from src/components/Dialog/ButtonWithSubmenu.tsx rename to src/components/Dialog/base/ContextMenuButton.tsx index 74e18ba236..93d912cf19 100644 --- a/src/components/Dialog/ButtonWithSubmenu.tsx +++ b/src/components/Dialog/base/ContextMenuButton.tsx @@ -1,24 +1,56 @@ import clsx from 'clsx'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useDialogIsOpen, useDialogOnNearestManager } from './hooks'; -import { useDialogAnchor } from './DialogAnchor'; import type { ComponentProps, ComponentType } from 'react'; -import type { PopperLikePlacement } from './hooks'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { PopperLikePlacement } from '../hooks'; +import { useDialogIsOpen, useDialogOnNearestManager } from '../hooks'; +import { useDialogAnchor } from '../service'; +import { IconChevronRight } from '../../Icons'; + +export type BaseContextMenuButtonProps = { + hasSubMenu?: boolean; + Icon?: ComponentType>; + SubmenuIcon?: ComponentType>; +} & ComponentProps<'button'>; + +export const BaseContextMenuButton = ({ + children, + className, + hasSubMenu, + Icon, + SubmenuIcon = IconChevronRight, + ...props +}: BaseContextMenuButtonProps) => ( + +); -type ButtonWithSubmenu = ComponentProps<'button'> & { - children: React.ReactNode; - placement: PopperLikePlacement; +type ButtonWithSubmenuProps = { Submenu: ComponentType; submenuContainerProps?: ComponentProps<'div'>; + submenuPlacement?: PopperLikePlacement; }; -export const ButtonWithSubmenu = ({ + +const ContextMenuButtonWithSubmenu = ({ children, className, - placement, Submenu, submenuContainerProps, + submenuPlacement = 'right-start', ...buttonProps -}: ButtonWithSubmenu) => { +}: BaseContextMenuButtonProps & ButtonWithSubmenuProps) => { const buttonRef = useRef(null); const [dialogContainer, setDialogContainer] = useState(null); const keepSubmenuOpen = useRef(false); @@ -28,7 +60,7 @@ export const ButtonWithSubmenu = ({ const dialogIsOpen = useDialogIsOpen(dialogId, dialogManager?.id); const { setPopperElement, styles } = useDialogAnchor({ open: dialogIsOpen, - placement, + placement: submenuPlacement, referenceElement: buttonRef.current, }); @@ -75,11 +107,12 @@ export const ButtonWithSubmenu = ({ return ( <> - + {dialogIsOpen && (
{ @@ -134,3 +167,13 @@ export const ButtonWithSubmenu = ({ ); }; + +type ContextMenuButtonProps = BaseContextMenuButtonProps & + Partial; + +export const ContextMenuButton = (props: ContextMenuButtonProps) => + props.Submenu ? ( + + ) : ( + + ); diff --git a/src/components/Dialog/base/index.ts b/src/components/Dialog/base/index.ts new file mode 100644 index 0000000000..99070ecd1b --- /dev/null +++ b/src/components/Dialog/base/index.ts @@ -0,0 +1,2 @@ +export * from './ContextMenuButton'; +export * from './ContextMenu'; diff --git a/src/components/Dialog/hooks/useDialog.ts b/src/components/Dialog/hooks/useDialog.ts index 3c12ab7580..62cd700310 100644 --- a/src/components/Dialog/hooks/useDialog.ts +++ b/src/components/Dialog/hooks/useDialog.ts @@ -6,7 +6,10 @@ import { } from '../../../context'; import { useStateStore } from '../../../store'; -import type { DialogManagerState, GetOrCreateDialogParams } from '../DialogManager'; +import type { + DialogManagerState, + GetOrCreateDialogParams, +} from '../service/DialogManager'; export type UseDialogParams = GetOrCreateDialogParams & { dialogManagerId?: string; diff --git a/src/components/Dialog/index.ts b/src/components/Dialog/index.ts index 7e3fd2bf08..8a992d2dd7 100644 --- a/src/components/Dialog/index.ts +++ b/src/components/Dialog/index.ts @@ -1,5 +1,3 @@ -export * from './ButtonWithSubmenu'; -export * from './DialogAnchor'; -export * from './DialogManager'; -export * from './DialogPortal'; +export * from './base'; export * from './hooks'; +export * from './service'; diff --git a/src/components/Dialog/DialogAnchor.tsx b/src/components/Dialog/service/DialogAnchor.tsx similarity index 94% rename from src/components/Dialog/DialogAnchor.tsx rename to src/components/Dialog/service/DialogAnchor.tsx index ac8a3e1edb..0f7268b968 100644 --- a/src/components/Dialog/DialogAnchor.tsx +++ b/src/components/Dialog/service/DialogAnchor.tsx @@ -3,9 +3,9 @@ import type { ComponentProps, PropsWithChildren } from 'react'; import React, { useEffect, useState } from 'react'; import { FocusScope } from '@react-aria/focus'; import { DialogPortalEntry } from './DialogPortal'; -import { useDialog, useDialogIsOpen } from './hooks'; -import { usePopoverPosition } from './hooks/usePopoverPosition'; -import type { PopperLikePlacement } from './hooks'; +import { useDialog, useDialogIsOpen } from '../hooks'; +import { usePopoverPosition } from '../hooks/usePopoverPosition'; +import type { PopperLikePlacement } from '../hooks'; export interface DialogAnchorOptions { open: boolean; diff --git a/src/components/Dialog/DialogManager.ts b/src/components/Dialog/service/DialogManager.ts similarity index 100% rename from src/components/Dialog/DialogManager.ts rename to src/components/Dialog/service/DialogManager.ts diff --git a/src/components/Dialog/DialogPortal.tsx b/src/components/Dialog/service/DialogPortal.tsx similarity index 94% rename from src/components/Dialog/DialogPortal.tsx rename to src/components/Dialog/service/DialogPortal.tsx index 8274f2726f..546998b503 100644 --- a/src/components/Dialog/DialogPortal.tsx +++ b/src/components/Dialog/service/DialogPortal.tsx @@ -1,8 +1,8 @@ import type { PropsWithChildren } from 'react'; import React, { useCallback } from 'react'; -import { useDialogIsOpen, useOpenedDialogCount } from './hooks'; -import { Portal } from '../Portal/Portal'; -import { useDialogManager, useNearestDialogManagerContext } from '../../context'; +import { useDialogIsOpen, useOpenedDialogCount } from '../hooks'; +import { Portal } from '../../Portal/Portal'; +import { useDialogManager, useNearestDialogManagerContext } from '../../../context'; export const DialogPortalDestination = () => { const { dialogManager } = useNearestDialogManagerContext() ?? {}; diff --git a/src/components/Dialog/service/index.ts b/src/components/Dialog/service/index.ts new file mode 100644 index 0000000000..4f1a8c99dc --- /dev/null +++ b/src/components/Dialog/service/index.ts @@ -0,0 +1,3 @@ +export * from './DialogAnchor'; +export * from './DialogManager'; +export * from './DialogPortal'; diff --git a/src/components/Dialog/styling/ContextMenu.scss b/src/components/Dialog/styling/ContextMenu.scss new file mode 100644 index 0000000000..05837f2c56 --- /dev/null +++ b/src/components/Dialog/styling/ContextMenu.scss @@ -0,0 +1,41 @@ +@use '../../../styling/utils'; + +.str-chat { + .str-chat__context-menu { + display: flex; + flex-direction: column; + gap: var(--spacing-xxxs); + padding: var(--spacing-xxs); + background-color: var(--background-elevation-elevation-2); + box-shadow: var(--light-elevation-3); + border-radius: var(--radius-lg); + + .str-chat__context-menu__button { + @include utils.button-reset; + display: flex; + align-items: center; + gap: var(--spacing-xs); + padding: var(--spacing-xs); + cursor: pointer; + border-radius: var(--radius-md); + font-size: var(--typography-font-size-xs); + font-weight: var(--typography-font-weight-semi-bold); + line-height: var(--typography-line-height-tight); + + &:hover { + background-color: var(--background-core-hover); + } + + svg { + height: var(--icon-size-sm); + width: var(--icon-size-sm); + color: var(--text-secondary); + } + + .str-chat__context-menu__button__text { + flex: 1; + text-align: left; + } + } + } +} \ No newline at end of file diff --git a/src/components/Dialog/styling/Dialog.scss b/src/components/Dialog/styling/Dialog.scss new file mode 100644 index 0000000000..1f833f6743 --- /dev/null +++ b/src/components/Dialog/styling/Dialog.scss @@ -0,0 +1,7 @@ +.str-chat { + .str-chat__dialog-contents { + //background-color: var(--background-elevation-elevation-4); + //box-shadow: var(--light-elevation-3); + //border-radius: var(--radius-lg); + } +} \ No newline at end of file diff --git a/src/components/Dialog/styling/index.scss b/src/components/Dialog/styling/index.scss new file mode 100644 index 0000000000..d21468a5ac --- /dev/null +++ b/src/components/Dialog/styling/index.scss @@ -0,0 +1,2 @@ +@use "ContextMenu"; +@use "Dialog"; \ No newline at end of file diff --git a/src/components/FileIcon/FileIcon.tsx b/src/components/FileIcon/FileIcon.tsx new file mode 100644 index 0000000000..0bd7be835a --- /dev/null +++ b/src/components/FileIcon/FileIcon.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { iconMap } from './iconMap'; +import { mimeTypeToExtensionMap } from './mimeTypes'; + +export type FileIconProps = { + className?: string; + fileName?: string; + mimeType?: string; +}; + +export function mimeTypeToIcon(mimeType?: string) { + const theMap = iconMap['standard']; + + if (!mimeType) return theMap.fallback; + + const icon = theMap[mimeType]; + if (icon) return icon; + + if (mimeType.startsWith('audio/')) return theMap['audio/']; + if (mimeType.startsWith('video/')) return theMap['video/']; + if (mimeType.startsWith('image/')) return theMap['image/']; + if (mimeType.startsWith('text/')) return theMap['text/']; + + return theMap.fallback; +} + +const labelFromMimeType = ({ + fileName, + mimeType, +}: Pick) => { + let label; + + if (mimeType) { + label = mimeTypeToExtensionMap[mimeType]; + } + + if (!label && fileName) { + label = fileName.split('.').slice(-1)[0]; + } + return label; +}; + +export const FileIcon = (props: FileIconProps) => { + const { fileName, mimeType, ...rest } = props; + + const Icon = mimeTypeToIcon(mimeType); + const label = labelFromMimeType({ fileName, mimeType }); + return ; +}; diff --git a/src/components/FileIcon/FileIconSet.tsx b/src/components/FileIcon/FileIconSet.tsx new file mode 100644 index 0000000000..77c6a06c84 --- /dev/null +++ b/src/components/FileIcon/FileIconSet.tsx @@ -0,0 +1,226 @@ +import type { ComponentProps, ComponentPropsWithoutRef } from 'react'; +import React from 'react'; +import clsx from 'clsx'; + +export type IconProps = { + label?: string; +} & ComponentPropsWithoutRef<'svg'>; + +const Svg = ({ className, ...props }: ComponentProps<'svg'>) => ( + +); + +type FileIconLabelProps = { label?: string }; + +const FileIconLabel = ({ label }: FileIconLabelProps) => ( + + {label} + +); + +export const FilePdfIcon = ({ className, ...props }: Omit) => ( + + + + + + + + + + + + + +); + +export const FileWordIcon = ({ className, label = 'doc', ...props }: IconProps) => ( + + + + + + + + +); + +export const FilePowerPointIcon = ({ className, label = 'ppt', ...props }: IconProps) => ( + + + + + + +); + +export const FileExcelIcon = ({ className = '', label = 'xls', ...props }: IconProps) => ( + + + + + + +); + +export const FileArchiveIcon = ({ className = '', label = '', ...props }: IconProps) => ( + + + + + + +); + +export const FileCodeIcon = ({ className = '', label = 'code', ...props }: IconProps) => ( + + + + + + +); + +export const FileAudioIcon = ({ + className = '', + label = 'audio', + ...props +}: IconProps) => ( + + + + + + +); + +// todo: can we remove this type of icon? missing design +export const FileVideoIcon = ({ className = '', ...props }: IconProps) => ( + + + + + + + +); + +export const FileFallbackIcon = ({ className = '', ...props }: IconProps) => ( + + + + + + + +); diff --git a/src/components/ReactFileUtilities/FileIcon/iconMap.ts b/src/components/FileIcon/iconMap.ts similarity index 72% rename from src/components/ReactFileUtilities/FileIcon/iconMap.ts rename to src/components/FileIcon/iconMap.ts index e11a7cac1f..4764745ed1 100644 --- a/src/components/ReactFileUtilities/FileIcon/iconMap.ts +++ b/src/components/FileIcon/iconMap.ts @@ -18,11 +18,7 @@ type MimeTypeMappedComponent = | 'FileArchiveIcon' | 'FileCodeIcon'; -type GeneralContentTypeComponent = - | 'FileImageIcon' - | 'FileAudioIcon' - | 'FileVideoIcon' - | 'FileAltIcon'; +type GeneralContentTypeComponent = 'FileAudioIcon' | 'FileVideoIcon' | 'FileAltIcon'; type IconComponents = { FileAltIcon: ComponentType; @@ -31,7 +27,6 @@ type IconComponents = { FileCodeIcon: ComponentType; FileExcelIcon: ComponentType; FileFallbackIcon: ComponentType; - FileImageIcon: ComponentType; FilePdfIcon: ComponentType; FilePowerPointIcon: ComponentType; FileVideoIcon: ComponentType; @@ -79,45 +74,23 @@ function generateMimeTypeToIconMap({ function generateGeneralTypeToIconMap({ FileAltIcon, FileAudioIcon, - FileImageIcon, FileVideoIcon, }: Pick, GeneralContentTypeComponent>) { return { 'audio/': FileAudioIcon, - 'image/': FileImageIcon, 'text/': FileAltIcon, 'video/': FileVideoIcon, }; } -export type IconType = 'standard' | 'alt'; - type IconMap = { standard: Record< SupportedMimeType | GeneralType | 'fallback', ComponentType >; - alt?: Record>; }; export const iconMap: IconMap = { - alt: { - ...generateMimeTypeToIconMap({ - FileArchiveIcon: fileIconSet.FileArchiveIconAlt, - FileCodeIcon: fileIconSet.FileCodeIconAlt, - FileExcelIcon: fileIconSet.FileExcelIconAlt, - FilePdfIcon: fileIconSet.FilePdfIcon, - FilePowerPointIcon: fileIconSet.FilePowerPointIconAlt, - FileWordIcon: fileIconSet.FileWordIconAlt, - }), - ...generateGeneralTypeToIconMap({ - FileAltIcon: fileIconSet.FileFallbackIcon, - FileAudioIcon: fileIconSet.FileAudioIconAlt, - FileImageIcon: fileIconSet.FileImageIcon, - FileVideoIcon: fileIconSet.FileVideoIconAlt, - }), - fallback: fileIconSet.FileFallbackIcon, - }, standard: { ...generateMimeTypeToIconMap({ FileArchiveIcon: fileIconSet.FileArchiveIcon, @@ -130,7 +103,6 @@ export const iconMap: IconMap = { ...generateGeneralTypeToIconMap({ FileAltIcon: fileIconSet.FileFallbackIcon, FileAudioIcon: fileIconSet.FileAudioIcon, - FileImageIcon: fileIconSet.FileImageIcon, FileVideoIcon: fileIconSet.FileVideoIcon, }), fallback: fileIconSet.FileFallbackIcon, diff --git a/src/components/ReactFileUtilities/FileIcon/index.ts b/src/components/FileIcon/index.ts similarity index 100% rename from src/components/ReactFileUtilities/FileIcon/index.ts rename to src/components/FileIcon/index.ts diff --git a/src/components/FileIcon/mimeTypes.ts b/src/components/FileIcon/mimeTypes.ts new file mode 100644 index 0000000000..717f401503 --- /dev/null +++ b/src/components/FileIcon/mimeTypes.ts @@ -0,0 +1,340 @@ +export type GeneralType = 'audio/' | 'video/' | 'image/' | 'text/'; + +export type SupportedMimeType = + | (typeof wordMimeTypes)[number] + | (typeof excelMimeTypes)[number] + | (typeof powerpointMimeTypes)[number] + | (typeof archiveFileTypes)[number] + | (typeof codeFileTypes)[number]; + +export const wordMimeTypes = [ + // Microsoft Word + // .doc .dot + 'application/msword', + // .doc .dot + 'application/msword-template', + // .docx + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + // .dotx (no test) + 'application/vnd.openxmlformats-officedocument.wordprocessingml.template', + // .docm + 'application/vnd.ms-word.document.macroEnabled.12', + // .dotm (no test) + 'application/vnd.ms-word.template.macroEnabled.12', + + // LibreOffice/OpenOffice Writer + // .odt + 'application/vnd.oasis.opendocument.text', + // .ott + 'application/vnd.oasis.opendocument.text-template', + // .fodt + 'application/vnd.oasis.opendocument.text-flat-xml', + // .uot + // NOTE: firefox doesn't know mimetype so maybe ignore +]; + +export const excelMimeTypes = [ + // .csv + 'text/csv', + // TODO: maybe more data files + + // Microsoft Excel + // .xls .xlt .xla (no test for .xla) + 'application/vnd.ms-excel', + // .xlsx + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + // .xltx (no test) + 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', + // .xlsm + 'application/vnd.ms-excel.sheet.macroEnabled.12', + // .xltm (no test) + 'application/vnd.ms-excel.template.macroEnabled.12', + // .xlam (no test) + 'application/vnd.ms-excel.addin.macroEnabled.12', + // .xlsb (no test) + 'application/vnd.ms-excel.addin.macroEnabled.12', + + // LibreOffice/OpenOffice Calc + // .ods + 'application/vnd.oasis.opendocument.spreadsheet', + // .ots + 'application/vnd.oasis.opendocument.spreadsheet-template', + // .fods + 'application/vnd.oasis.opendocument.spreadsheet-flat-xml', + // .uos + // NOTE: firefox doesn't know mimetype so maybe ignore +]; + +export const powerpointMimeTypes = [ + // Microsoft Word + // .ppt .pot .pps .ppa (no test for .ppa) + 'application/vnd.ms-powerpoint', + // .pptx + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + // .potx (no test) + 'application/vnd.openxmlformats-officedocument.presentationml.template', + // .ppsx + 'application/vnd.openxmlformats-officedocument.presentationml.slideshow', + // .ppam + 'application/vnd.ms-powerpoint.addin.macroEnabled.12', + // .pptm + 'application/vnd.ms-powerpoint.presentation.macroEnabled.12', + // .potm + 'application/vnd.ms-powerpoint.template.macroEnabled.12', + // .ppsm + 'application/vnd.ms-powerpoint.slideshow.macroEnabled.12', + + // LibreOffice/OpenOffice Writer + // .odp + 'application/vnd.oasis.opendocument.presentation', + // .otp + 'application/vnd.oasis.opendocument.presentation-template', + // .fodp + 'application/vnd.oasis.opendocument.presentation-flat-xml', + // .uop + // NOTE: firefox doesn't know mimetype so maybe ignore +]; + +export const archiveFileTypes = [ + // .zip + 'application/zip', + // .z7 + 'application/x-7z-compressed', + // .ar + 'application/x-archive', + // .tar + 'application/x-tar', + // .tar.gz + 'application/gzip', + // .tar.Z + 'application/x-compress', + // .tar.bz2 + 'application/x-bzip', + // .tar.lz + 'application/x-lzip', + // .tar.lz4 + 'application/x-lz4', + // .tar.lzma + 'application/x-lzma', + // .tar.lzo (no test) + 'application/x-lzop', + // .tar.xz + 'application/x-xz', + // .war + 'application/x-webarchive', + // .rar + 'application/vnd.rar', +]; + +export const codeFileTypes = [ + // .html .htm + 'text/html', + // .css + 'text/css', + // .js + 'application/x-javascript', + 'text/javascript', + // .json + 'application/json', + // .py + 'text/x-python', + // .go + 'text/x-go', + // .c + 'text/x-csrc', + // .cpp + 'text/x-c++src', + // .rb + 'application/x-ruby', + // .rust + 'text/rust', + // .java + 'text/x-java', + // .php + 'application/x-php', + // .cs + 'text/x-csharp', + // .scala + 'text/x-scala', + // .erl + 'text/x-erlang', + // .sh + 'application/x-shellscript', +]; + +export const mimeTypeToExtensionMap: Record = { + // Application types (sorted alphabetically) + 'application/epub+zip': 'epub', + 'application/gzip': 'gz', + 'application/java-archive': 'jar', + 'application/json': 'json', + 'application/ld+json': 'jsonld', + 'application/msword': 'doc', + 'application/msword-template': 'dot', + 'application/octet-stream': 'bin', + 'application/ogg': 'ogx', + 'application/pdf': 'pdf', + 'application/postscript': 'ps', + 'application/rtf': 'rtf', + 'application/vnd.amazon.ebook': 'azw', + 'application/vnd.apple.installer+xml': 'mpkg', + 'application/vnd.mozilla.xul+xml': 'xul', + 'application/vnd.ms-excel': 'xls', + 'application/vnd.ms-excel.addin.macroEnabled.12': 'xlam', + 'application/vnd.ms-excel.sheet.macroEnabled.12': 'xlsm', + 'application/vnd.ms-excel.template.macroEnabled.12': 'xltm', + 'application/vnd.ms-fontobject': 'eot', + 'application/vnd.ms-powerpoint': 'ppt', + 'application/vnd.ms-powerpoint.addin.macroEnabled.12': 'ppam', + 'application/vnd.ms-powerpoint.presentation.macroEnabled.12': 'pptm', + 'application/vnd.ms-powerpoint.slideshow.macroEnabled.12': 'ppsm', + 'application/vnd.ms-powerpoint.template.macroEnabled.12': 'potm', + 'application/vnd.ms-word.document.macroEnabled.12': 'docm', + 'application/vnd.ms-word.template.macroEnabled.12': 'dotm', + 'application/vnd.oasis.opendocument.presentation': 'odp', + 'application/vnd.oasis.opendocument.presentation-flat-xml': 'fodp', + 'application/vnd.oasis.opendocument.presentation-template': 'otp', + 'application/vnd.oasis.opendocument.spreadsheet': 'ods', + 'application/vnd.oasis.opendocument.spreadsheet-flat-xml': 'fods', + 'application/vnd.oasis.opendocument.spreadsheet-template': 'ots', + 'application/vnd.oasis.opendocument.text': 'odt', + 'application/vnd.oasis.opendocument.text-flat-xml': 'fodt', + 'application/vnd.oasis.opendocument.text-template': 'ott', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx', + 'application/vnd.openxmlformats-officedocument.presentationml.slideshow': 'ppsx', + 'application/vnd.openxmlformats-officedocument.presentationml.template': 'potx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.template': 'xltx', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.template': 'dotx', + 'application/vnd.rar': 'rar', + 'application/vnd.visio': 'vsd', + 'application/wasm': 'wasm', + 'application/x-7z-compressed': '7z', + 'application/x-abiword': 'abw', + 'application/x-archive': 'ar', + 'application/x-bzip': 'bz', + 'application/x-bzip2': 'bz2', + 'application/x-cdf': 'cda', + 'application/x-compress': 'Z', + 'application/x-csh': 'csh', + 'application/x-dosexec': 'exe', + 'application/x-freearc': 'arc', + 'application/x-httpd-php': 'php', + 'application/x-iso9660-image': 'iso', + 'application/x-javascript': 'js', + 'application/x-lz4': 'lz4', + 'application/x-lzip': 'lz', + 'application/x-lzma': 'lzma', + 'application/x-lzop': 'lzo', + 'application/x-mobipocket-ebook': 'mobi', + 'application/x-msdownload': 'exe', + 'application/x-perl': 'pl', + 'application/x-php': 'php', + 'application/x-rar-compressed': 'rar', + 'application/x-ruby': 'rb', + 'application/x-sh': 'sh', + 'application/x-shellscript': 'sh', + 'application/x-shockwave-flash': 'swf', + 'application/x-sql': 'sql', + 'application/x-stuffit': 'sit', + 'application/x-tar': 'tar', + 'application/x-webarchive': 'war', + 'application/x-xz': 'xz', + 'application/x-yaml': 'yaml', + 'application/xhtml+xml': 'xhtml', + 'application/xml': 'xml', + 'application/zip': 'zip', + + // Audio types + 'audio/aac': 'aac', + 'audio/flac': 'flac', + 'audio/midi': 'midi', + 'audio/mp4': 'm4a', + 'audio/mpeg': 'mp3', + 'audio/ogg': 'oga', + 'audio/opus': 'opus', + 'audio/wav': 'wav', + 'audio/webm': 'weba', + 'audio/x-aiff': 'aiff', + 'audio/x-m4a': 'm4a', + 'audio/x-midi': 'midi', + 'audio/x-ms-wma': 'wma', + 'audio/x-wav': 'wav', + + // Font types + 'font/otf': 'otf', + 'font/ttf': 'ttf', + 'font/woff': 'woff', + 'font/woff2': 'woff2', + + // Image types + 'image/apng': 'apng', + 'image/avif': 'avif', + 'image/bmp': 'bmp', + 'image/gif': 'gif', + 'image/heic': 'heic', + 'image/heif': 'heif', + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/svg+xml': 'svg', + 'image/tiff': 'tiff', + 'image/vnd.microsoft.icon': 'ico', + 'image/webp': 'webp', + 'image/x-icon': 'ico', + + // Text types + 'text/calendar': 'ics', + 'text/css': 'css', + 'text/csv': 'csv', + 'text/html': 'html', + 'text/javascript': 'js', + 'text/markdown': 'md', + 'text/plain': 'txt', + 'text/rtf': 'rtf', + 'text/rust': 'rs', + 'text/tab-separated-values': 'tsv', + 'text/vcard': 'vcf', + 'text/x-c': 'c', + 'text/x-c++src': 'cpp', + 'text/x-csharp': 'cs', + 'text/x-csrc': 'c', + 'text/x-diff': 'diff', + 'text/x-erlang': 'erl', + 'text/x-go': 'go', + 'text/x-java': 'java', + 'text/x-java-source': 'java', + 'text/x-kotlin': 'kt', + 'text/x-lua': 'lua', + 'text/x-markdown': 'md', + 'text/x-objectivec': 'm', + 'text/x-pascal': 'pas', + 'text/x-perl': 'pl', + 'text/x-python': 'py', + 'text/x-ruby': 'rb', + 'text/x-rust': 'rs', + 'text/x-scala': 'scala', + 'text/x-sh': 'sh', + 'text/x-shellscript': 'sh', + 'text/x-sql': 'sql', + 'text/x-swift': 'swift', + 'text/x-typescript': 'ts', + 'text/x-yaml': 'yaml', + 'text/xml': 'xml', + 'text/yaml': 'yaml', + + // Video types + 'video/3gpp': '3gp', + 'video/3gpp2': '3g2', + 'video/mp2t': 'ts', + 'video/mp4': 'mp4', + 'video/mpeg': 'mpeg', + 'video/ogg': 'ogv', + 'video/quicktime': 'mov', + 'video/webm': 'webm', + 'video/x-flv': 'flv', + 'video/x-m4v': 'm4v', + 'video/x-matroska': 'mkv', + 'video/x-ms-wmv': 'wmv', + 'video/x-msvideo': 'avi', +}; diff --git a/src/components/FileIcon/styling/FileIcon.scss b/src/components/FileIcon/styling/FileIcon.scss new file mode 100644 index 0000000000..cc60379000 --- /dev/null +++ b/src/components/FileIcon/styling/FileIcon.scss @@ -0,0 +1,16 @@ +.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-weight: 800; + letter-spacing: 0.1px; + text-anchor: middle; + } + } +} \ No newline at end of file diff --git a/src/components/Gallery/Image.tsx b/src/components/Gallery/Image.tsx index 097a308256..d8eaf28a9b 100644 --- a/src/components/Gallery/Image.tsx +++ b/src/components/Gallery/Image.tsx @@ -10,8 +10,10 @@ import { useComponentContext } from '../../context'; import type { Attachment } from 'stream-chat'; import type { Dimensions } from '../../types/types'; +import clsx from 'clsx'; export type ImageProps = { + className?: string; dimensions?: Dimensions; innerRef?: MutableRefObject; previewUrl?: string; @@ -33,6 +35,7 @@ export type ImageProps = { */ export const ImageComponent = (props: ImageProps) => { const { + className, dimensions = {}, fallback, image_url, @@ -62,7 +65,7 @@ export const ImageComponent = (props: ImageProps) => { <> ) => ( + +); diff --git a/src/components/Icons/IconArrowRotateClockwise.tsx b/src/components/Icons/IconArrowRotateClockwise.tsx new file mode 100644 index 0000000000..5de62debb0 --- /dev/null +++ b/src/components/Icons/IconArrowRotateClockwise.tsx @@ -0,0 +1,16 @@ +import clsx from 'clsx'; +import type { ComponentProps } from 'react'; +import { BaseIcon } from './BaseIcon'; + +export const IconArrowRotateClockwise = ({ + className, + ...props +}: ComponentProps<'svg'>) => ( + + + +); diff --git a/src/components/Icons/IconCamera.tsx b/src/components/Icons/IconCamera.tsx new file mode 100644 index 0000000000..65e7cc7e23 --- /dev/null +++ b/src/components/Icons/IconCamera.tsx @@ -0,0 +1,14 @@ +import type { ComponentProps } from 'react'; +import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; + +export const IconCamera = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + + +); diff --git a/src/components/Icons/IconChainLink.tsx b/src/components/Icons/IconChainLink.tsx new file mode 100644 index 0000000000..f66b9d20f8 --- /dev/null +++ b/src/components/Icons/IconChainLink.tsx @@ -0,0 +1,20 @@ +import type { ComponentProps } from 'react'; +import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; + +export const IconChainLink = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + + + + + + + + +); diff --git a/src/components/Icons/IconChevronRight.tsx b/src/components/Icons/IconChevronRight.tsx new file mode 100644 index 0000000000..bc4750af19 --- /dev/null +++ b/src/components/Icons/IconChevronRight.tsx @@ -0,0 +1,13 @@ +import clsx from 'clsx'; +import type { ComponentProps } from 'react'; +import { BaseIcon } from './BaseIcon'; + +export const IconChevronRight = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + +); diff --git a/src/components/Icons/IconClose.tsx b/src/components/Icons/IconClose.tsx new file mode 100644 index 0000000000..77e40ad34b --- /dev/null +++ b/src/components/Icons/IconClose.tsx @@ -0,0 +1,13 @@ +import { type ComponentProps } from 'react'; +import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; + +export const IconClose = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + +); diff --git a/src/components/Icons/IconCommand.tsx b/src/components/Icons/IconCommand.tsx new file mode 100644 index 0000000000..05c74bb460 --- /dev/null +++ b/src/components/Icons/IconCommand.tsx @@ -0,0 +1,13 @@ +import type { ComponentProps } from 'react'; +import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; + +export const IconCommand = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + +); diff --git a/src/components/Icons/IconExclamationCircle.tsx b/src/components/Icons/IconExclamationCircle.tsx new file mode 100644 index 0000000000..c39e1bcfd5 --- /dev/null +++ b/src/components/Icons/IconExclamationCircle.tsx @@ -0,0 +1,17 @@ +import type { ComponentProps } from 'react'; +import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; + +export const IconExclamationCircle = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + +); diff --git a/src/components/Icons/IconExclamationTriangle.tsx b/src/components/Icons/IconExclamationTriangle.tsx new file mode 100644 index 0000000000..372d53e7aa --- /dev/null +++ b/src/components/Icons/IconExclamationTriangle.tsx @@ -0,0 +1,20 @@ +import clsx from 'clsx'; +import type { ComponentProps } from 'react'; +import { BaseIcon } from './BaseIcon'; + +export const IconExclamationTriangle = ({ + className, + ...props +}: ComponentProps<'svg'>) => ( + + + +); diff --git a/src/components/Icons/IconFile.tsx b/src/components/Icons/IconFile.tsx new file mode 100644 index 0000000000..39cd5cb985 --- /dev/null +++ b/src/components/Icons/IconFile.tsx @@ -0,0 +1,13 @@ +import type { ComponentProps } from 'react'; +import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; + +export const IconFile = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + +); diff --git a/src/components/Icons/IconLocationPin.tsx b/src/components/Icons/IconLocationPin.tsx new file mode 100644 index 0000000000..ca3232a1a3 --- /dev/null +++ b/src/components/Icons/IconLocationPin.tsx @@ -0,0 +1,14 @@ +import type { ComponentProps } from 'react'; +import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; + +export const IconLocationPin = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + + +); diff --git a/src/components/Icons/IconMicrophone.tsx b/src/components/Icons/IconMicrophone.tsx index bd9dda0d79..8ea5193f1d 100644 --- a/src/components/Icons/IconMicrophone.tsx +++ b/src/components/Icons/IconMicrophone.tsx @@ -1,17 +1,16 @@ import type { ComponentProps } from 'react'; import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; export const IconMicrophone = ({ className, ...props }: ComponentProps<'svg'>) => ( - - + ); diff --git a/src/components/Icons/IconPaperPlane.tsx b/src/components/Icons/IconPaperPlane.tsx index ae5946cf1a..7d48e3114d 100644 --- a/src/components/Icons/IconPaperPlane.tsx +++ b/src/components/Icons/IconPaperPlane.tsx @@ -1,17 +1,16 @@ import type { ComponentProps } from 'react'; import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; export const IconPaperPlane = ({ className, ...props }: ComponentProps<'svg'>) => ( - - + ); diff --git a/src/components/Icons/IconPause.tsx b/src/components/Icons/IconPause.tsx new file mode 100644 index 0000000000..cc98cd96cd --- /dev/null +++ b/src/components/Icons/IconPause.tsx @@ -0,0 +1,14 @@ +import type { ComponentProps } from 'react'; +import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; + +export const IconPause = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + + +); diff --git a/src/components/Icons/IconPlaySolid.tsx b/src/components/Icons/IconPlaySolid.tsx new file mode 100644 index 0000000000..5eebd8f420 --- /dev/null +++ b/src/components/Icons/IconPlaySolid.tsx @@ -0,0 +1,13 @@ +import clsx from 'clsx'; +import type { ComponentProps } from 'react'; +import { BaseIcon } from './BaseIcon'; + +export const IconPlaySolid = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + +); diff --git a/src/components/Icons/IconPlus.tsx b/src/components/Icons/IconPlus.tsx index e1b8c434b8..9d0aefcb98 100644 --- a/src/components/Icons/IconPlus.tsx +++ b/src/components/Icons/IconPlus.tsx @@ -1,14 +1,13 @@ import type { ComponentProps } from 'react'; import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; export const IconPlus = ({ className, ...props }: ComponentProps<'svg'>) => ( - - + ); diff --git a/src/components/Icons/IconPoll.tsx b/src/components/Icons/IconPoll.tsx new file mode 100644 index 0000000000..e630ed3f6f --- /dev/null +++ b/src/components/Icons/IconPoll.tsx @@ -0,0 +1,13 @@ +import clsx from 'clsx'; +import type { ComponentProps } from 'react'; +import { BaseIcon } from './BaseIcon'; + +export const IconPoll = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + +); diff --git a/src/components/Icons/IconVideoCamera.tsx b/src/components/Icons/IconVideoCamera.tsx new file mode 100644 index 0000000000..591238fdae --- /dev/null +++ b/src/components/Icons/IconVideoCamera.tsx @@ -0,0 +1,17 @@ +import type { ComponentProps } from 'react'; +import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; + +export const IconVideoCamera = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + +); diff --git a/src/components/Icons/IconVideoCameraOutline.tsx b/src/components/Icons/IconVideoCameraOutline.tsx new file mode 100644 index 0000000000..c45dd9e389 --- /dev/null +++ b/src/components/Icons/IconVideoCameraOutline.tsx @@ -0,0 +1,17 @@ +import type { ComponentProps } from 'react'; +import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; + +export const IconVideoCameraOutline = ({ + className, + ...props +}: ComponentProps<'svg'>) => ( + + + + +); diff --git a/src/components/Icons/index.ts b/src/components/Icons/index.ts index 8b35066c28..0fa4bfd284 100644 --- a/src/components/Icons/index.ts +++ b/src/components/Icons/index.ts @@ -1,2 +1,18 @@ +export * from './IconArrowRotateClockwise'; +export * from './IconCamera'; +export * from './IconChainLink'; +export * from './IconChevronRight'; +export * from './IconClose'; +export * from './IconCommand'; +export * from './IconExclamationCircle'; +export * from './IconExclamationTriangle'; +export * from './IconFile'; +export * from './IconLocationPin'; export * from './IconMicrophone'; +export * from './IconPaperPlane'; +export * from './IconPause'; +export * from './IconPlaySolid'; export * from './IconPlus'; +export * from './IconPoll'; +export * from './IconVideoCamera'; +export * from './IconVideoCameraOutline'; diff --git a/src/components/Icons/styling/IconArrowRotateClockwise.scss b/src/components/Icons/styling/IconArrowRotateClockwise.scss new file mode 100644 index 0000000000..6ca189a91a --- /dev/null +++ b/src/components/Icons/styling/IconArrowRotateClockwise.scss @@ -0,0 +1,14 @@ +.str-chat { + .str-chat__icon--arrow-rotate-clockwise { + fill: none; + width: 16px; + height: 16px; + + path { + stroke: white; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.5; + } + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconCamera.scss b/src/components/Icons/styling/IconCamera.scss new file mode 100644 index 0000000000..8426bfb27f --- /dev/null +++ b/src/components/Icons/styling/IconCamera.scss @@ -0,0 +1,12 @@ +.str-chat__icon--camera { + fill: none; + height: 12px; + width: 12px; + + path { + stroke: currentColor; + stroke-linecap: square; + stroke-linejoin: round; + stroke-width: 1.2; + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconChainLink.scss b/src/components/Icons/styling/IconChainLink.scss new file mode 100644 index 0000000000..4d9675d840 --- /dev/null +++ b/src/components/Icons/styling/IconChainLink.scss @@ -0,0 +1,17 @@ +.str-chat__icon--chain-link { + fill: none; + height: 12px; + width: 12px; + + path { + stroke: currentColor; + stroke-linecap: round; + stroke-width: 1.2; + } + + #clip-path rect { + fill: var(--base-white); + height: 12px; + width: 12px; + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconChevronRight.scss b/src/components/Icons/styling/IconChevronRight.scss new file mode 100644 index 0000000000..cef40bad3a --- /dev/null +++ b/src/components/Icons/styling/IconChevronRight.scss @@ -0,0 +1,13 @@ +.str-chat__icon--chevron-right { + fill: none; + height: 16px; + width: 16px; + + path { + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.5; + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconClose.scss b/src/components/Icons/styling/IconClose.scss new file mode 100644 index 0000000000..f785f8b537 --- /dev/null +++ b/src/components/Icons/styling/IconClose.scss @@ -0,0 +1,13 @@ +.str-chat { + .str-chat__icon--close { + fill: none; + width: 8px; + height: 8px; + + path { + stroke: var(--base-white); + stroke-linecap: round; + stroke-width: 1.5; + } + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconCommand.scss b/src/components/Icons/styling/IconCommand.scss new file mode 100644 index 0000000000..0a66be4e0d --- /dev/null +++ b/src/components/Icons/styling/IconCommand.scss @@ -0,0 +1,13 @@ +.str-chat__icon--command { + fill: none; + height: 16px; + width: 16px; + + path { + fill: none; + stroke: currentColor; + stroke-linecap: square; + stroke-linejoin: round; + stroke-width: 1.5; + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconExclamationCircle.scss b/src/components/Icons/styling/IconExclamationCircle.scss new file mode 100644 index 0000000000..d7fcb3629c --- /dev/null +++ b/src/components/Icons/styling/IconExclamationCircle.scss @@ -0,0 +1,11 @@ +.str-chat { + .str-chat__icon--exclamation-circle { + fill: none; + width: 14px; + height: 14px; + + path { + fill: #D92F26; + } + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconExclamationTriangle.scss b/src/components/Icons/styling/IconExclamationTriangle.scss new file mode 100644 index 0000000000..7275a89c3d --- /dev/null +++ b/src/components/Icons/styling/IconExclamationTriangle.scss @@ -0,0 +1,11 @@ +.str-chat { + .str-chat__icon--exclamation-triangle { + fill: none; + width: 16px; + height: 16px; + + path { + fill:#D92F26; + } + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconFile.scss b/src/components/Icons/styling/IconFile.scss new file mode 100644 index 0000000000..375cb5606e --- /dev/null +++ b/src/components/Icons/styling/IconFile.scss @@ -0,0 +1,11 @@ +.str-chat__icon--file { + fill: none; + height: 12px; + width: 12px; + + path { + stroke: currentColor; + stroke-linejoin: round; + stroke-width: 1.2; + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconLocationPin.scss b/src/components/Icons/styling/IconLocationPin.scss new file mode 100644 index 0000000000..45a395fcd6 --- /dev/null +++ b/src/components/Icons/styling/IconLocationPin.scss @@ -0,0 +1,11 @@ +.str-chat__icon--location-pin { + fill: none; + height: 12px; + width: 12px; + + path { + stroke: currentColor; + stroke-linejoin: round; + stroke-width: 1.2; + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconMicrophone.scss b/src/components/Icons/styling/IconMicrophone.scss index 9994a1ec7d..81d7149fbb 100644 --- a/src/components/Icons/styling/IconMicrophone.scss +++ b/src/components/Icons/styling/IconMicrophone.scss @@ -1,5 +1,7 @@ .str-chat { .str-chat__icon--microphone { + fill: none; + stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; diff --git a/src/components/Icons/styling/IconPaperPlane.scss b/src/components/Icons/styling/IconPaperPlane.scss index ad6449b712..e846cbafd0 100644 --- a/src/components/Icons/styling/IconPaperPlane.scss +++ b/src/components/Icons/styling/IconPaperPlane.scss @@ -1,6 +1,7 @@ .str-chat { .str-chat__icon--paper-plane { stroke-width: 1.5; + stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; } diff --git a/src/components/Icons/styling/IconPause.scss b/src/components/Icons/styling/IconPause.scss new file mode 100644 index 0000000000..bd1913761d --- /dev/null +++ b/src/components/Icons/styling/IconPause.scss @@ -0,0 +1,11 @@ +.str-chat { + .str-chat__icon--pause { + fill: none; + height: 20px; + width: 20px; + + path { + fill: var(--text-primary); + } + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconPlaySolid.scss b/src/components/Icons/styling/IconPlaySolid.scss new file mode 100644 index 0000000000..1b0d4acbc0 --- /dev/null +++ b/src/components/Icons/styling/IconPlaySolid.scss @@ -0,0 +1,11 @@ +.str-chat { + .str-chat__icon--play-solid { + fill: none; + height: 20px; + width: 20px; + + path { + fill: var(--text-primary); + } + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconPlus.scss b/src/components/Icons/styling/IconPlus.scss index 6e4e4ab629..d33e894b7b 100644 --- a/src/components/Icons/styling/IconPlus.scss +++ b/src/components/Icons/styling/IconPlus.scss @@ -1,6 +1,7 @@ .str-chat { .str-chat__icon--plus { stroke-width: 1.5; + stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; } diff --git a/src/components/Icons/styling/IconPoll.scss b/src/components/Icons/styling/IconPoll.scss new file mode 100644 index 0000000000..98da1d1d0d --- /dev/null +++ b/src/components/Icons/styling/IconPoll.scss @@ -0,0 +1,13 @@ +.str-chat__icon--poll { + fill: none; + height: 10px; + width: 10px; + + path { + fill: none; + stroke: currentColor; + stroke-linecap: square; + stroke-linejoin: round; + stroke-width: 1.2; + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconVideoCameraOutline.scss b/src/components/Icons/styling/IconVideoCameraOutline.scss new file mode 100644 index 0000000000..55166bc30e --- /dev/null +++ b/src/components/Icons/styling/IconVideoCameraOutline.scss @@ -0,0 +1,11 @@ +.str-chat__icon--video-camera-outline { + fill: none; + height: 12px; + width: 12px; + + path { + stroke: currentColor; + stroke-linejoin: round; + stroke-width: 1.2; + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/index.scss b/src/components/Icons/styling/index.scss index 6ef9491b8b..6b73e2b3e3 100644 --- a/src/components/Icons/styling/index.scss +++ b/src/components/Icons/styling/index.scss @@ -1 +1,17 @@ -@use 'IconPlus'; \ No newline at end of file +@use 'IconArrowRotateClockwise'; +@use 'IconCamera'; +@use 'IconChainLink'; +@use 'IconChevronRight'; +@use 'IconClose'; +@use 'IconCommand'; +@use 'IconExclamationCircle'; +@use 'IconExclamationTriangle'; +@use 'IconFile'; +@use 'IconLocationPin'; +@use 'IconMicrophone'; +@use 'IconPaperPlane'; +@use 'IconPause'; +@use 'IconPlaySolid'; +@use 'IconPlus'; +@use 'IconPoll'; +@use 'IconVideoCameraOutline'; \ No newline at end of file diff --git a/src/components/MediaRecorder/AudioRecorder/AudioRecorder.tsx b/src/components/MediaRecorder/AudioRecorder/AudioRecorder.tsx index 545468dd28..1174619738 100644 --- a/src/components/MediaRecorder/AudioRecorder/AudioRecorder.tsx +++ b/src/components/MediaRecorder/AudioRecorder/AudioRecorder.tsx @@ -6,11 +6,10 @@ import { BinIcon, CheckSignIcon, LoadingIndicatorIcon, - MicIcon, PauseIcon, - SendIcon, } from '../../MessageInput'; import { useMessageInputContext } from '../../../context/MessageInputContext'; +import { IconMicrophone, IconPaperPlane } from '../../Icons'; export const AudioRecorder = () => { const messageInputContext = useMessageInputContext(); @@ -59,7 +58,7 @@ export const AudioRecorder = () => { className='str-chat__audio_recorder__resume-recording-button' onClick={recorder.resume} > - + )} {state.recording && ( @@ -78,7 +77,7 @@ export const AudioRecorder = () => { disabled={isUploadingFile} onClick={completeRecording} > - {isUploadingFile ? : } + {isUploadingFile ? : } ) : ( -); +export const StartRecordingAudioButton = forwardRef< + HTMLButtonElement, + StartRecordingAudioButtonProps +>(function StartRecordingAudioButton(props, ref) { + return ( + + ); +}); diff --git a/src/components/MediaRecorder/RecordingPermissionDeniedNotification.tsx b/src/components/MediaRecorder/RecordingPermissionDeniedNotification.tsx index fde46039b8..8b50791620 100644 --- a/src/components/MediaRecorder/RecordingPermissionDeniedNotification.tsx +++ b/src/components/MediaRecorder/RecordingPermissionDeniedNotification.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { useTranslationContext } from '../../context'; - import type { RecordingPermission } from './classes/BrowserPermission'; +import { Button } from '../Button'; +import clsx from 'clsx'; export type RecordingPermissionDeniedNotificationProps = { onClose: () => void; @@ -33,12 +34,17 @@ export const RecordingPermissionDeniedNotification = ({ {permissionTranslations.body[permissionName]}

- +
); diff --git a/src/components/MessageActions/RemindMeSubmenu.tsx b/src/components/MessageActions/RemindMeSubmenu.tsx index 21d978f32e..f205c1be7d 100644 --- a/src/components/MessageActions/RemindMeSubmenu.tsx +++ b/src/components/MessageActions/RemindMeSubmenu.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useChatContext, useMessageContext, useTranslationContext } from '../../context'; -import { ButtonWithSubmenu } from '../Dialog'; +import { ContextMenuButton } from '../Dialog'; import type { ComponentProps } from 'react'; export const RemindMeActionButton = ({ @@ -10,14 +10,14 @@ export const RemindMeActionButton = ({ const { t } = useTranslationContext(); return ( - {t('Remind Me')} - + ); }; diff --git a/src/components/MessageInput/AttachmentPreviewList/AttachmentPreviewList.tsx b/src/components/MessageInput/AttachmentPreviewList/AttachmentPreviewList.tsx index d6cebcf315..c45050c99f 100644 --- a/src/components/MessageInput/AttachmentPreviewList/AttachmentPreviewList.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/AttachmentPreviewList.tsx @@ -9,38 +9,48 @@ import { isLocalVoiceRecordingAttachment, isScrapedContent, } from 'stream-chat'; -import type { UnsupportedAttachmentPreviewProps } from './UnsupportedAttachmentPreview'; -import { UnsupportedAttachmentPreview as DefaultUnknownAttachmentPreview } from './UnsupportedAttachmentPreview'; -import type { VoiceRecordingPreviewProps } from './VoiceRecordingPreview'; -import { VoiceRecordingPreview as DefaultVoiceRecordingPreview } from './VoiceRecordingPreview'; -import type { FileAttachmentPreviewProps } from './FileAttachmentPreview'; -import DefaultFilePreview from './FileAttachmentPreview'; -import type { ImageAttachmentPreviewProps } from './ImageAttachmentPreview'; -import { ImageAttachmentPreview as DefaultImagePreview } from './ImageAttachmentPreview'; +import { + UnsupportedAttachmentPreview as DefaultUnknownAttachmentPreview, + type UnsupportedAttachmentPreviewProps, +} from './UnsupportedAttachmentPreview'; +import { type VoiceRecordingPreviewProps } from './VoiceRecordingPreview'; +import { + FileAttachmentPreview as DefaultFileAttachmentPreview, + type FileAttachmentPreviewProps, +} from './FileAttachmentPreview'; +import { + type AudioAttachmentPreviewProps, + AudioAttachmentPreview as DefaultAudioAttachmentPreview, +} from './AudioAttachmentPreview'; +import { type ImageAttachmentPreviewProps } from './ImageAttachmentPreview'; import { useAttachmentsForPreview, useMessageComposer } from '../hooks'; import { GeolocationPreview as DefaultGeolocationPreview, type GeolocationPreviewProps, } from './GeolocationPreview'; +import { + MediaAttachmentPreview, + type MediaAttachmentPreviewProps, +} from './MediaAttachmentPreview'; export type AttachmentPreviewListProps = { - AudioAttachmentPreview?: ComponentType; + AudioAttachmentPreview?: ComponentType; FileAttachmentPreview?: ComponentType; GeolocationPreview?: ComponentType; ImageAttachmentPreview?: ComponentType; UnsupportedAttachmentPreview?: ComponentType; - VideoAttachmentPreview?: ComponentType; + VideoAttachmentPreview?: ComponentType; VoiceRecordingPreview?: ComponentType; }; export const AttachmentPreviewList = ({ - AudioAttachmentPreview = DefaultFilePreview, - FileAttachmentPreview = DefaultFilePreview, + AudioAttachmentPreview = DefaultAudioAttachmentPreview, + FileAttachmentPreview = DefaultFileAttachmentPreview, GeolocationPreview = DefaultGeolocationPreview, - ImageAttachmentPreview = DefaultImagePreview, + ImageAttachmentPreview = MediaAttachmentPreview, UnsupportedAttachmentPreview = DefaultUnknownAttachmentPreview, - VideoAttachmentPreview = DefaultFilePreview, - VoiceRecordingPreview = DefaultVoiceRecordingPreview, + VideoAttachmentPreview = MediaAttachmentPreview, + VoiceRecordingPreview = DefaultAudioAttachmentPreview, }: AttachmentPreviewListProps) => { const messageComposer = useMessageComposer(); @@ -51,82 +61,82 @@ export const AttachmentPreviewList = ({ return (
-
- {location && ( - - )} - {attachments.map((attachment) => { - if (isScrapedContent(attachment)) return null; - if (isLocalVoiceRecordingAttachment(attachment)) { - return ( - - ); - } else if (isLocalAudioAttachment(attachment)) { - return ( - - ); - } else if (isLocalVideoAttachment(attachment)) { - return ( - - ); - } else if (isLocalImageAttachment(attachment)) { - return ( - - ); - } else if (isLocalFileAttachment(attachment)) { - return ( - - ); - } else if (isLocalAttachment(attachment)) { - return ( - - ); + {/**/} + {location && ( + + /> + )} + {attachments.map((attachment) => { + if (isScrapedContent(attachment)) return null; + if (isLocalVoiceRecordingAttachment(attachment)) { + return ( + + ); + } else if (isLocalAudioAttachment(attachment)) { + return ( + + ); + } else if (isLocalVideoAttachment(attachment)) { + return ( + + ); + } else if (isLocalImageAttachment(attachment)) { + return ( + + ); + } else if (isLocalFileAttachment(attachment)) { + return ( + + ); + } else if (isLocalAttachment(attachment)) { + return ( + + ); + } + return null; + })} + {/*
*/}
); }; diff --git a/src/components/MessageInput/AttachmentPreviewList/AudioAttachmentPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/AudioAttachmentPreview.tsx new file mode 100644 index 0000000000..e064a6eaaf --- /dev/null +++ b/src/components/MessageInput/AttachmentPreviewList/AudioAttachmentPreview.tsx @@ -0,0 +1,163 @@ +import type { UploadAttachmentPreviewProps } from './types'; +import type { LocalAudioAttachment, LocalVoiceRecordingAttachment } from 'stream-chat'; +import { useTranslationContext } from '../../../context'; +import React, { useEffect } from 'react'; +import clsx from 'clsx'; +import { LoadingIndicatorIcon } from '../icons'; +import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton'; +import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot'; +import { FileSizeIndicator, WaveProgressBar } from '../../Attachment'; +import { IconExclamationCircle, IconExclamationTriangle } from '../../Icons'; +import { PlayButton } from '../../Button'; +import { + type AudioPlayerState, + DurationDisplay, + useAudioPlayer, +} from '../../AudioPlayback'; +import { useStateStore } from '../../../store'; + +export type AudioAttachmentPreviewProps> = + UploadAttachmentPreviewProps< + | LocalAudioAttachment + | LocalVoiceRecordingAttachment + >; + +const audioPlayerStateSelector = (state: AudioPlayerState) => ({ + isPlaying: state.isPlaying, + progressPercent: state.progressPercent, + secondsElapsed: state.secondsElapsed, +}); + +export const AudioAttachmentPreview = ({ + attachment, + handleRetry, + removeAttachments, +}: AudioAttachmentPreviewProps) => { + const { t } = useTranslationContext(); + const { id, previewUri, uploadPermissionCheck, uploadState } = + attachment.localMetadata ?? {}; + const url = attachment.asset_url || previewUri; + + const audioPlayer = useAudioPlayer({ + fileSize: attachment.localMetadata.file.size, + mimeType: attachment.localMetadata.file.type, + requester: attachment.localMetadata.id, + src: url, + title: attachment.title, + waveformData: attachment.waveform_data, + }); + + useEffect(() => { + audioPlayer?.cancelScheduledRemoval(); + return () => { + audioPlayer?.scheduleRemoval(); + }; + }, [audioPlayer]); + + const { isPlaying, progressPercent, secondsElapsed } = + useStateStore(audioPlayer?.state, audioPlayerStateSelector) ?? {}; + + const hasWaveform = !!audioPlayer?.waveformData?.length; + const hasSizeLimitError = uploadPermissionCheck?.reason === 'size_limit'; + const hasFatalError = uploadState === 'blocked' || hasSizeLimitError; + const hasRetriableError = uploadState === 'failed' && !!handleRetry; + const hasError = hasRetriableError || hasFatalError; + + const showProgressControls = !hasError || (hasError && isPlaying); + + return ( + + { + audioPlayer?.togglePlay(); + }} + /> + +
+
+ {attachment.title} +
+
+ {uploadState === 'uploading' && } + {showProgressControls ? ( + <> + {!attachment.duration && !progressPercent && !isPlaying && ( + + )} + {hasWaveform ? ( + <> + + + + ) : ( + + )} + + ) : hasFatalError ? ( +
+ + + {hasSizeLimitError + ? t('File too large') + : uploadState === 'blocked' + ? t('Upload blocked') + : t('Upload failed')} + +
+ ) : ( +
+ + {t('Upload error')} + +
+ )} +
+
+ { + if (id) removeAttachments([id]); + }} + uploadState={uploadState} + /> +
+ ); +}; diff --git a/src/components/MessageInput/AttachmentPreviewList/FileAttachmentPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/FileAttachmentPreview.tsx index 4e03fe9d27..9f60f2c7b8 100644 --- a/src/components/MessageInput/AttachmentPreviewList/FileAttachmentPreview.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/FileAttachmentPreview.tsx @@ -1,88 +1,87 @@ import React from 'react'; import { useTranslationContext } from '../../../context'; -import { FileIcon } from '../../ReactFileUtilities'; -import { CloseIcon, DownloadIcon, LoadingIndicatorIcon, RetryIcon } from '../icons'; +import { FileIcon } from '../../FileIcon'; +import { LoadingIndicatorIcon } from '../icons'; -import type { - LocalAudioAttachment, - LocalFileAttachment, - LocalVideoAttachment, -} from 'stream-chat'; +import type { LocalFileAttachment } from 'stream-chat'; import type { UploadAttachmentPreviewProps } from './types'; +import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton'; +import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot'; +import { FileSizeIndicator } from '../../Attachment'; +import { IconExclamationCircle, IconExclamationTriangle } from '../../Icons'; export type FileAttachmentPreviewProps = - UploadAttachmentPreviewProps< - | LocalFileAttachment - | LocalAudioAttachment - | LocalVideoAttachment - >; + UploadAttachmentPreviewProps>; -const FileAttachmentPreview = ({ +export const FileAttachmentPreview = ({ attachment, handleRetry, removeAttachments, }: FileAttachmentPreviewProps) => { const { t } = useTranslationContext('FilePreview'); - const uploadState = attachment.localMetadata?.uploadState; + const { id, uploadPermissionCheck, uploadState } = attachment.localMetadata ?? {}; + + const hasSizeLimitError = uploadPermissionCheck?.reason === 'size_limit'; + const hasFatalError = uploadState === 'blocked' || hasSizeLimitError; + const hasRetriableError = uploadState === 'failed' && !!handleRetry; + const hasError = hasRetriableError || hasFatalError; return ( -
-
- +
+
- - - {['blocked', 'failed'].includes(uploadState) && !!handleRetry && ( - - )} - -
+
{attachment.title}
- {/* undefined if loaded from a draft */} - {(typeof uploadState === 'undefined' || uploadState === 'finished') && - !!attachment.asset_url && ( - - - +
+ {uploadState === 'uploading' && } + {!hasError && } + {hasFatalError && ( +
+ + + {hasSizeLimitError + ? t('File too large') + : uploadState === 'blocked' + ? t('Upload blocked') + : t('Upload failed')} + +
)} - {uploadState === 'uploading' && } + {hasRetriableError && ( +
+ + {t('Upload error')} + +
+ )} +
-
+ + { + if (id) removeAttachments([id]); + }} + uploadState={uploadState} + /> + ); }; -export default FileAttachmentPreview; diff --git a/src/components/MessageInput/AttachmentPreviewList/GeolocationPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/GeolocationPreview.tsx index 8faa174ef8..b91b839aa9 100644 --- a/src/components/MessageInput/AttachmentPreviewList/GeolocationPreview.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/GeolocationPreview.tsx @@ -1,9 +1,9 @@ import type { LiveLocationPreview, StaticLocationPreview } from 'stream-chat'; -import { CloseIcon } from '../icons'; import type { ComponentType } from 'react'; import React from 'react'; import { useTranslationContext } from '../../../context'; import { GeolocationIcon } from '../../Attachment/icons'; +import { IconClose } from '../../Icons'; type GeolocationPreviewImageProps = { location: StaticLocationPreview | LiveLocationPreview; @@ -33,12 +33,12 @@ export const GeolocationPreview = ({ {remove && ( )} diff --git a/src/components/MessageInput/AttachmentPreviewList/ImageAttachmentPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/ImageAttachmentPreview.tsx index 91008df193..7481338f62 100644 --- a/src/components/MessageInput/AttachmentPreviewList/ImageAttachmentPreview.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/ImageAttachmentPreview.tsx @@ -1,72 +1,5 @@ -import clsx from 'clsx'; -import React, { useCallback, useState } from 'react'; -import { CloseIcon, LoadingIndicatorIcon, RetryIcon } from '../icons'; -import { BaseImage as DefaultBaseImage } from '../../Gallery'; -import { useComponentContext, useTranslationContext } from '../../../context'; import type { LocalImageAttachment } from 'stream-chat'; import type { UploadAttachmentPreviewProps } from './types'; export type ImageAttachmentPreviewProps> = UploadAttachmentPreviewProps>; - -export const ImageAttachmentPreview = ({ - attachment, - handleRetry, - removeAttachments, -}: ImageAttachmentPreviewProps) => { - const { t } = useTranslationContext('ImagePreviewItem'); - const { BaseImage = DefaultBaseImage } = useComponentContext('ImagePreview'); - const [previewError, setPreviewError] = useState(false); - - const { id, uploadState } = attachment.localMetadata ?? {}; - - const handleLoadError = useCallback(() => setPreviewError(true), []); - const assetUrl = attachment.image_url || attachment.localMetadata.previewUri; - - return ( -
- - - {['blocked', 'failed'].includes(uploadState) && ( - - )} - - {uploadState === 'uploading' && ( -
- -
- )} - - {assetUrl && ( - - )} -
- ); -}; diff --git a/src/components/MessageInput/AttachmentPreviewList/MediaAttachmentPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/MediaAttachmentPreview.tsx new file mode 100644 index 0000000000..ac102d29f1 --- /dev/null +++ b/src/components/MessageInput/AttachmentPreviewList/MediaAttachmentPreview.tsx @@ -0,0 +1,138 @@ +import type { UploadAttachmentPreviewProps } from './types'; +import { + isVideoAttachment, + type LocalImageAttachment, + type LocalVideoAttachment, +} from 'stream-chat'; +import { useComponentContext, useTranslationContext } from '../../../context'; +import { BaseImage as DefaultBaseImage } from '../../Gallery'; +import React, { + type KeyboardEvent, + type MouseEvent, + useCallback, + useMemo, + useState, +} from 'react'; +import clsx from 'clsx'; +import { LoadingIndicatorIcon } from '../icons'; +import { + IconArrowRotateClockwise, + IconExclamationCircle, + IconVideoCamera, +} from '../../Icons'; +import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton'; +import { Button } from '../../Button'; +import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot'; + +export type MediaAttachmentPreviewProps> = + UploadAttachmentPreviewProps< + LocalVideoAttachment | LocalImageAttachment + >; + +export const MediaAttachmentPreview = ({ + attachment, + handleRetry, + removeAttachments, +}: MediaAttachmentPreviewProps) => { + const { t } = useTranslationContext(); + const { BaseImage = DefaultBaseImage, LoadingIndicator = LoadingIndicatorIcon } = + useComponentContext(); + const [thumbnailPreviewError, setThumbnailPreviewError] = useState(false); + + const { id, uploadPermissionCheck, uploadState } = attachment.localMetadata ?? {}; + + const isUploading = uploadState === 'uploading'; + const handleThumbnailLoadError = useCallback(() => setThumbnailPreviewError(true), []); + const hasSizeLimitError = uploadPermissionCheck?.reason === 'size_limit'; + const hasFatalError = uploadState === 'blocked' || hasSizeLimitError; + const hasRetriableError = uploadState === 'failed' && !!handleRetry; + const hasUploadError = hasRetriableError || hasFatalError; + + const retry = (e: MouseEvent | KeyboardEvent) => { + e.stopPropagation(); + handleRetry(attachment); + return false; + }; + + const thumbnail = useMemo( + () => + isVideoAttachment(attachment) + ? { + alt: attachment.title, + title: attachment.title, + url: attachment.thumb_url, + } + : { + alt: attachment.fallback, + title: attachment.fallback, + url: attachment.image_url || attachment.localMetadata.previewUri, + }, + [attachment], + ); + + return ( + +
+ {thumbnail.url && ( + + )} + +
+ {isUploading && } + + {isVideoAttachment(attachment) && + !hasUploadError && + uploadState !== 'uploading' && ( +
+ + {attachment.duration &&
{attachment.duration}
} +
+ )} + + {hasFatalError && } + + {hasRetriableError && ( + + )} +
+
+ + { + if (id) removeAttachments([id]); + }} + uploadState={uploadState} + /> +
+ ); +}; diff --git a/src/components/MessageInput/AttachmentPreviewList/UnsupportedAttachmentPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/UnsupportedAttachmentPreview.tsx index e706bfe988..ab7563d925 100644 --- a/src/components/MessageInput/AttachmentPreviewList/UnsupportedAttachmentPreview.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/UnsupportedAttachmentPreview.tsx @@ -1,9 +1,7 @@ import React from 'react'; -import { isLocalUploadAttachment } from 'stream-chat'; -import { CloseIcon, DownloadIcon, LoadingIndicatorIcon, RetryIcon } from '../icons'; -import { FileIcon } from '../../ReactFileUtilities'; -import { useTranslationContext } from '../../../context'; import type { AnyLocalAttachment, LocalUploadAttachment } from 'stream-chat'; +import { type LocalFileAttachment } from 'stream-chat'; +import { FileAttachmentPreview } from './FileAttachmentPreview'; export type UnsupportedAttachmentPreviewProps< CustomLocalMetadata = Record, @@ -19,64 +17,10 @@ export const UnsupportedAttachmentPreview = ({ attachment, handleRetry, removeAttachments, -}: UnsupportedAttachmentPreviewProps) => { - const { t } = useTranslationContext('UnsupportedAttachmentPreview'); - const title = attachment.title ?? t('Unsupported attachment'); - return ( -
-
- -
- - - - {isLocalUploadAttachment(attachment) && - ['blocked', 'failed'].includes(attachment.localMetadata?.uploadState) && - !!handleRetry && ( - - )} - -
-
- {title} -
- {attachment.localMetadata?.uploadState === 'finished' && - !!attachment.asset_url && ( - - - - )} - {attachment.localMetadata?.uploadState === 'uploading' && ( - - )} -
-
- ); -}; +}: UnsupportedAttachmentPreviewProps) => ( + +); diff --git a/src/components/MessageInput/AttachmentPreviewList/VoiceRecordingPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/VoiceRecordingPreview.tsx index 19b302f944..87a94a1f71 100644 --- a/src/components/MessageInput/AttachmentPreviewList/VoiceRecordingPreview.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/VoiceRecordingPreview.tsx @@ -1,92 +1,5 @@ -import React, { useEffect } from 'react'; -import { PlayButton } from '../../Attachment'; -import { RecordingTimer } from '../../MediaRecorder'; -import { CloseIcon, LoadingIndicatorIcon, RetryIcon } from '../icons'; -import { FileIcon } from '../../ReactFileUtilities'; import type { LocalVoiceRecordingAttachment } from 'stream-chat'; import type { UploadAttachmentPreviewProps } from './types'; -import { useTranslationContext } from '../../../context'; -import { type AudioPlayerState, useAudioPlayer } from '../../AudioPlayback'; -import { useStateStore } from '../../../store'; - -const audioPlayerStateSelector = (state: AudioPlayerState) => ({ - isPlaying: state.isPlaying, - secondsElapsed: state.secondsElapsed, -}); export type VoiceRecordingPreviewProps> = UploadAttachmentPreviewProps>; - -export const VoiceRecordingPreview = ({ - attachment, - handleRetry, - removeAttachments, -}: VoiceRecordingPreviewProps) => { - const { t } = useTranslationContext(); - - const audioPlayer = useAudioPlayer({ - mimeType: attachment.mime_type, - src: attachment.asset_url, - }); - - const { isPlaying, secondsElapsed } = - useStateStore(audioPlayer?.state, audioPlayerStateSelector) ?? {}; - - useEffect(() => { - audioPlayer?.cancelScheduledRemoval(); - return () => { - audioPlayer?.scheduleRemoval(); - }; - }, [audioPlayer]); - - if (!audioPlayer) return null; - - return ( -
- - - - - {['blocked', 'failed'].includes(attachment.localMetadata?.uploadState) && - !!handleRetry && ( - - )} - -
-
- {attachment.title} -
- {typeof attachment.duration !== 'undefined' && ( - - )} - {attachment.localMetadata?.uploadState === 'uploading' && ( - - )} -
-
- -
-
- ); -}; diff --git a/src/components/MessageInput/AttachmentPreviewList/index.ts b/src/components/MessageInput/AttachmentPreviewList/index.ts index f61151a217..3d7db62a3f 100644 --- a/src/components/MessageInput/AttachmentPreviewList/index.ts +++ b/src/components/MessageInput/AttachmentPreviewList/index.ts @@ -1,7 +1,9 @@ export * from './AttachmentPreviewList'; +export type { AudioAttachmentPreviewProps } from './AudioAttachmentPreview'; export type { FileAttachmentPreviewProps } from './FileAttachmentPreview'; export type { GeolocationPreviewProps } from './GeolocationPreview'; export type { ImageAttachmentPreviewProps } from './ImageAttachmentPreview'; export type { UploadAttachmentPreviewProps as AttachmentPreviewProps } from './types'; export type { UnsupportedAttachmentPreviewProps } from './UnsupportedAttachmentPreview'; +export type { MediaAttachmentPreviewProps } from './MediaAttachmentPreview'; export type { VoiceRecordingPreviewProps } from './VoiceRecordingPreview'; diff --git a/src/components/MessageInput/AttachmentPreviewList/utils/AttachmentPreviewRoot.tsx b/src/components/MessageInput/AttachmentPreviewList/utils/AttachmentPreviewRoot.tsx new file mode 100644 index 0000000000..22b22556a4 --- /dev/null +++ b/src/components/MessageInput/AttachmentPreviewList/utils/AttachmentPreviewRoot.tsx @@ -0,0 +1,113 @@ +import React, { + type ComponentProps, + type KeyboardEvent, + type MouseEvent, + useState, +} from 'react'; +import { useComponentContext, useTranslationContext } from '../../../../context'; +import { + isImageAttachment, + isVideoAttachment, + type LocalUploadAttachment, +} from 'stream-chat'; +import { GlobalModal } from '../../../Modal'; +import { ModalGallery } from '../../../Gallery'; +import { VideoPlayer } from '../../../VideoPlayer'; + +type AttachmentPreviewRootProps = Omit, 'onClick' | 'onKeyDown'> & { + attachment: LocalUploadAttachment; + /** + * Returns boolean value to signal whether the event handling should be terminated immediately (return false) + * or default logic can be executed next (return true) + */ + onPressed?: (e: MouseEvent | KeyboardEvent) => boolean; +}; + +const INTERACTIVE_SELECTOR = + 'button, a, input, textarea, select, [role="button"], [role="link"], [data-interactive="true"]'; + +function hasInteractiveAncestorBeforeRoot( + target: EventTarget | null, + root: HTMLElement | null, +): boolean { + if (!(target instanceof Element) || !root) return false; + + let el: Element | null = target; + while (el && el !== root) { + if (el.matches(INTERACTIVE_SELECTOR)) return true; + el = el.parentElement; + } + return false; +} + +// todo: use this component for all the attachment previews +export const AttachmentPreviewRoot = ({ + attachment, + onPressed, + tabIndex = 0, + ...props +}: AttachmentPreviewRootProps) => { + const { t } = useTranslationContext('FilePreview'); + const { Modal = GlobalModal } = useComponentContext(); + const [showPreview, setShowPreview] = useState(false); + const [root, setRoot] = useState(null); + const url = + attachment.asset_url || attachment.image_url || attachment.localMetadata.previewUri; + + const canDownloadAttachment = !!url; + + const canPreviewAttachment = + (!!url && isImageAttachment(attachment)) || isVideoAttachment(attachment); + + const handlePressed = (e: MouseEvent | KeyboardEvent) => { + if (e.defaultPrevented) return; + + if (hasInteractiveAncestorBeforeRoot(e.target as Element, root)) return; + + if (onPressed) { + const shouldContinue = onPressed(e); + if (!shouldContinue) return; + } + + if (canPreviewAttachment) { + setShowPreview(true); + return; + } + + if (canDownloadAttachment) { + window.open(url, '_blank', 'noopener,noreferrer'); + } + }; + + return ( +
{ + if (e.key !== 'Enter' && e.key !== ' ') return; + e.preventDefault(); + handlePressed(e); + }} + ref={setRoot} + role={showPreview ? 'button' : canDownloadAttachment ? 'link' : props.role} + tabIndex={showPreview || canDownloadAttachment ? tabIndex : -1} + > + {props.children} + { + e.stopPropagation(); + setShowPreview(false); + }} + open={showPreview && canPreviewAttachment} + > + {isImageAttachment(attachment) ? ( + + ) : isVideoAttachment(attachment) && url ? ( + + ) : null} + +
+ ); +}; diff --git a/src/components/MessageInput/AttachmentSelector.tsx b/src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx similarity index 70% rename from src/components/MessageInput/AttachmentSelector.tsx rename to src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx index b125856808..a4a999b732 100644 --- a/src/components/MessageInput/AttachmentSelector.tsx +++ b/src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx @@ -1,83 +1,99 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { UploadIcon as DefaultUploadIcon } from './icons'; -import { useAttachmentManagerState } from './hooks/useAttachmentManagerState'; -import { CHANNEL_CONTAINER_ID } from '../Channel/constants'; -import { DialogAnchor, useDialogIsOpen, useDialogOnNearestManager } from '../Dialog'; -import { DialogMenuButton } from '../Dialog/DialogMenu'; -import { Modal as DefaultModal } from '../Modal'; -import { ShareLocationDialog as DefaultLocationDialog } from '../Location'; -import { PollCreationDialog as DefaultPollCreationDialog } from '../Poll'; -import { Portal } from '../Portal/Portal'; -import { UploadFileInput } from '../ReactFileUtilities'; +import React, { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; +import { useAttachmentManagerState, useMessageComposer } from '../hooks'; +import { CHANNEL_CONTAINER_ID } from '../../Channel/constants'; +import { + ContextMenu, + ContextMenuButton, + DialogAnchor, + useDialogIsOpen, + useDialogOnNearestManager, +} from '../../Dialog'; +import { Modal as DefaultModal } from '../../Modal'; +import { ShareLocationDialog as DefaultLocationDialog } from '../../Location'; +import { PollCreationDialog as DefaultPollCreationDialog } from '../../Poll'; +import { Portal } from '../../Portal/Portal'; +import { UploadFileInput } from '../../ReactFileUtilities'; import { useChannelStateContext, useComponentContext, useTranslationContext, -} from '../../context'; +} from '../../../context'; import { AttachmentSelectorContextProvider, useAttachmentSelectorContext, -} from '../../context/AttachmentSelectorContext'; -import { useStableId } from '../UtilityComponents/useStableId'; +} from '../../../context/AttachmentSelectorContext'; +import { useStableId } from '../../UtilityComponents/useStableId'; import clsx from 'clsx'; -import { useMessageComposer } from './hooks'; +import { Button, type ButtonProps } from '../../Button'; +import { IconCommand, IconFile, IconLocationPin, IconPlus, IconPoll } from '../../Icons'; +import { useIsCooldownActive } from '../hooks/useIsCooldownActive'; + +const AttachmentSelectorMenuInitButtonIcon = () => { + const { AttachmentSelectorInitiationButtonContents } = useComponentContext(); + + if (AttachmentSelectorInitiationButtonContents) { + return ; + } + + return ; +}; + +export const AttachmentSelectorButton = forwardRef( + function AttachmentSelectorButton({ className, ...props }, ref) { + return ( + + ); + }, +); export const SimpleAttachmentSelector = () => { - const { - AttachmentSelectorInitiationButtonContents, - FileUploadIcon = DefaultUploadIcon, - } = useComponentContext(); const { channelCapabilities } = useChannelStateContext(); const inputRef = useRef(null); - const [labelElement, setLabelElement] = useState(null); + const [buttonElement, setButtonElement] = useState(null); const id = useStableId(); + const isCooldownActive = useIsCooldownActive(); useEffect(() => { - if (!labelElement) return; + if (!buttonElement) return; const handleKeyUp = (event: KeyboardEvent) => { if (![' ', 'Enter'].includes(event.key) || !inputRef.current) return; event.preventDefault(); inputRef.current.click(); }; - labelElement.addEventListener('keyup', handleKeyUp); + buttonElement.addEventListener('keyup', handleKeyUp); return () => { - labelElement.removeEventListener('keyup', handleKeyUp); + buttonElement.removeEventListener('keyup', handleKeyUp); }; - }, [labelElement]); + }, [buttonElement]); if (!channelCapabilities['upload-file']) return null; return ( -
+
+ inputRef.current?.click()} + ref={setButtonElement} + /> -
); }; -const AttachmentSelectorMenuInitButtonIcon = () => { - const { AttachmentSelectorInitiationButtonContents, FileUploadIcon } = - useComponentContext('SimpleAttachmentSelector'); - if (AttachmentSelectorInitiationButtonContents) { - return ; - } - if (FileUploadIcon) { - return ; - } - return
; -}; - export type AttachmentSelectorModalContentProps = { close: () => void; }; @@ -89,59 +105,78 @@ export type AttachmentSelectorActionProps = { export type AttachmentSelectorAction = { ActionButton: React.ComponentType; - type: 'uploadFile' | 'createPoll' | 'addLocation' | (string & {}); + type: 'uploadFile' | 'createPoll' | 'addLocation' | 'selectCommand' | (string & {}); ModalContent?: React.ComponentType; }; export const DefaultAttachmentSelectorComponents = { + // todo: we do not know how the submenu should look like + Command() { + const { t } = useTranslationContext(); + return ( + null} + > + {t('Commands')} + + ); + }, File({ closeMenu }: AttachmentSelectorActionProps) { const { t } = useTranslationContext(); const { fileInput } = useAttachmentSelectorContext(); const { isUploadEnabled } = useAttachmentManagerState(); return ( - { if (fileInput) fileInput.click(); closeMenu(); }} > {t('File')} - + ); }, Location({ closeMenu, openModalForAction }: AttachmentSelectorActionProps) { const { t } = useTranslationContext(); return ( - { openModalForAction('addLocation'); closeMenu(); }} > {t('Location')} - + ); }, Poll({ closeMenu, openModalForAction }: AttachmentSelectorActionProps) { const { t } = useTranslationContext(); return ( - { openModalForAction('createPoll'); closeMenu(); }} > {t('Poll')} - + ); }, }; +/** + * Order of AttachmentSelectorAction objects defines the order in the context menu width index 0 being at the top. + */ export const defaultAttachmentSelectorActionSet: AttachmentSelectorAction[] = [ { ActionButton: DefaultAttachmentSelectorComponents.File, type: 'uploadFile' }, { @@ -152,6 +187,7 @@ export const defaultAttachmentSelectorActionSet: AttachmentSelectorAction[] = [ ActionButton: DefaultAttachmentSelectorComponents.Location, type: 'addLocation', }, + { ActionButton: DefaultAttachmentSelectorComponents.Command, type: 'selectCommand' }, ]; export type AttachmentSelectorProps = { @@ -204,7 +240,7 @@ export const AttachmentSelector = ({ const { Modal = DefaultModal } = useComponentContext(); const { channelCapabilities } = useChannelStateContext(); const messageComposer = useMessageComposer(); - + const isCooldownActive = useIsCooldownActive(); const actions = useAttachmentSelectorActionsFiltered(attachmentSelectorActionSet); const menuDialogId = `attachment-actions-menu${messageComposer.threadId ? '-thread' : ''}`; @@ -245,17 +281,17 @@ export const AttachmentSelector = ({
{channelCapabilities['upload-file'] && } - + /> -
@@ -275,7 +311,7 @@ export const AttachmentSelector = ({ openModalForAction={openModal} /> ))} -
+
>; -}; -export const CooldownTimer = ({ cooldownInterval }: CooldownTimerProps) => { - const secondsLeft = useTimer({ startFrom: cooldownInterval }); +export const CooldownTimer = () => { + const secondsLeft = useCooldownRemaining(); return (
diff --git a/src/components/MessageInput/LinkPreviewList.tsx b/src/components/MessageInput/LinkPreviewList.tsx index 00b7c75d69..c43cc25359 100644 --- a/src/components/MessageInput/LinkPreviewList.tsx +++ b/src/components/MessageInput/LinkPreviewList.tsx @@ -1,16 +1,18 @@ import clsx from 'clsx'; import React, { useState } from 'react'; -import type { - LinkPreview, - LinkPreviewsManagerState, - MessageComposerState, -} from 'stream-chat'; +import type { LinkPreview, LinkPreviewsManagerState } from 'stream-chat'; import { LinkPreviewsManager } from 'stream-chat'; import { useStateStore } from '../../store'; import { PopperTooltip } from '../Tooltip'; import { useEnterLeaveHandlers } from '../Tooltip/hooks'; import { useMessageComposer } from './hooks'; -import { CloseIcon, LinkIcon } from './icons'; +import { ImageComponent } from '../Gallery'; +import { RemoveAttachmentPreviewButton } from './RemoveAttachmentPreviewButton'; +import { IconChainLink } from '../Icons'; + +export type LinkPreviewListProps = { + displayLinkCount?: number; +}; const linkPreviewsManagerStateSelector = (state: LinkPreviewsManagerState) => ({ linkPreviews: Array.from(state.previews.values()).filter( @@ -20,29 +22,19 @@ const linkPreviewsManagerStateSelector = (state: LinkPreviewsManagerState) => ({ ), }); -const messageComposerStateSelector = (state: MessageComposerState) => ({ - quotedMessage: state.quotedMessage, -}); - -export const LinkPreviewList = () => { +export const LinkPreviewList = ({ displayLinkCount = 1 }: LinkPreviewListProps) => { const messageComposer = useMessageComposer(); const { linkPreviewsManager } = messageComposer; - const { quotedMessage } = useStateStore( - messageComposer.state, - messageComposerStateSelector, - ); const { linkPreviews } = useStateStore( linkPreviewsManager.state, linkPreviewsManagerStateSelector, ); - const showLinkPreviews = linkPreviews.length > 0 && !quotedMessage; - - if (!showLinkPreviews) return null; + if (linkPreviews.length === 0) return null; return (
- {linkPreviews.map((linkPreview) => ( + {linkPreviews.slice(0, displayLinkCount).map((linkPreview) => ( ))}
@@ -58,6 +50,7 @@ export const LinkPreviewCard = ({ linkPreview }: LinkPreviewProps) => { const { handleEnter, handleLeave, tooltipVisible } = useEnterLeaveHandlers(); const [referenceElement, setReferenceElement] = useState(null); + const { image_url, thumb_url, title } = linkPreview; if ( !LinkPreviewsManager.previewIsLoaded(linkPreview) && @@ -72,6 +65,9 @@ export const LinkPreviewCard = ({ linkPreview }: LinkPreviewProps) => { LinkPreviewsManager.previewIsLoading(linkPreview), })} data-testid='link-preview-card' + onMouseEnter={handleEnter} + onMouseLeave={handleLeave} + ref={setReferenceElement} > { > {linkPreview.og_scrape_url} -
- -
+ + {(image_url || thumb_url) && ( + + )}
{linkPreview.title} @@ -95,15 +92,17 @@ export const LinkPreviewCard = ({ linkPreview }: LinkPreviewProps) => {
{linkPreview.text}
+
+ + {linkPreview.og_scrape_url} +
- + />
); }; diff --git a/src/components/MessageInput/MessageComposerActions.tsx b/src/components/MessageInput/MessageComposerActions.tsx new file mode 100644 index 0000000000..57b16169c8 --- /dev/null +++ b/src/components/MessageInput/MessageComposerActions.tsx @@ -0,0 +1,72 @@ +import React, { useCallback } from 'react'; +import { StopAIGenerationButton as DefaultStopAIGenerationButton } from './StopAIGenerationButton'; +import { CooldownTimer as DefaultCooldownTimer } from './CooldownTimer'; +import { SendButton as DefaultSendButton } from './SendButton'; +import { + useChannelStateContext, + useComponentContext, + useMessageInputContext, +} from '../../context'; +import { AIStates, useAIState } from '../AIStateIndicator'; +import { useMessageCompositionIsEmpty } from './hooks'; +import { AudioRecordingButtonWithNotification } from '../MediaRecorder/AudioRecorder/AudioRecordingButtonWithNotification'; +import { useIsCooldownActive } from './hooks/useIsCooldownActive'; + +export const MessageComposerActions = () => { + const { channel } = useChannelStateContext(); + const { hideSendButton } = useMessageInputContext(); + + const { + CooldownTimer = DefaultCooldownTimer, + SendButton = DefaultSendButton, + StopAIGenerationButton: StopAIGenerationButtonOverride, + } = useComponentContext(); + + const compositionIsEmpty = useMessageCompositionIsEmpty(); + /** + * This bit here is needed to make sure that we can get rid of the default behaviour + * if need be. Essentially, this allows us to pass StopAIGenerationButton={null} and + * completely circumvent the default logic if it's not what we want. We need it as a + * prop because there is no other trivial way to override the SendMessage button otherwise. + */ + const StopAIGenerationButton = + StopAIGenerationButtonOverride === undefined + ? DefaultStopAIGenerationButton + : StopAIGenerationButtonOverride; + + const { handleSubmit, recordingController } = useMessageInputContext(); + const isCooldownActive = useIsCooldownActive(); + + const { aiState } = useAIState(channel); + const stopGenerating = useCallback(() => channel?.stopAIResponse(), [channel]); + const shouldDisplayStopAIGeneration = + [AIStates.Thinking, AIStates.Generating].includes(aiState) && + !!StopAIGenerationButton; + + const recordingEnabled = !!(recordingController.recorder && navigator.mediaDevices); // account for requirement on iOS as per this bug report: https://bugs.webkit.org/show_bug.cgi?id=252303 + + let content = ; + + if (shouldDisplayStopAIGeneration) { + content = ; + } else if (hideSendButton) return null; + + if (isCooldownActive) { + content = ; + } else if (compositionIsEmpty && recordingEnabled) { + content = ; + } + + return
{content}
; +}; + +export const AdditionalMessageComposerActions = () => { + const { EmojiPicker } = useComponentContext(); + const isCooldownActive = useIsCooldownActive(); + + return ( +
+ {!isCooldownActive && EmojiPicker ? : null} +
+ ); +}; diff --git a/src/components/MessageInput/MessageInput.tsx b/src/components/MessageInput/MessageInput.tsx index aa3b89b71f..51df19f63f 100644 --- a/src/components/MessageInput/MessageInput.tsx +++ b/src/components/MessageInput/MessageInput.tsx @@ -3,7 +3,6 @@ import React, { useEffect } from 'react'; import { MessageInputFlat } from './MessageInputFlat'; import { useMessageComposer } from './hooks'; -import { useCooldownTimer } from './hooks/useCooldownTimer'; import { useCreateMessageInputContext } from './hooks/useCreateMessageInputContext'; import { useMessageInputControls } from './hooks/useMessageInputControls'; import type { ComponentContextValue } from '../../context/ComponentContext'; @@ -56,7 +55,8 @@ export type MessageInputProps = { emojiSearchIndex?: ComponentContextValue['emojiSearchIndex']; /** If true, focuses the text input on component mount */ focus?: boolean; - /** Allows to hide MessageInput's send button. */ + // todo: what sense does hideSendButton prop make, when we have message composer actions (recording, send msg). Can we remove it? + // /** Allows to hide MessageInput's send button. */ hideSendButton?: boolean; /** Custom UI component handling how the message input is rendered, defaults to and accepts the same props as [MessageInputFlat](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/MessageInputFlat.tsx) */ Input?: React.ComponentType; @@ -77,7 +77,6 @@ export type MessageInputProps = { }) => Promise | void; /** When replying in a thread, the parent message object */ parent?: LocalMessage; - /** If true, will use an optional dependency to support transliteration in the input for mentions, default is false. See: https://github.com/getstream/transliterate */ /** * Currently, `Enter` is the default submission key and `Shift`+`Enter` is the default combination for the new line. * If specified, this function overrides the default behavior specified previously. @@ -91,12 +90,10 @@ export type MessageInputProps = { }; const MessageInputProvider = (props: PropsWithChildren) => { - const cooldownTimerState = useCooldownTimer(); const messageInputUiApi = useMessageInputControls(props); const { emojiSearchIndex } = useComponentContext('MessageInput'); const messageInputContextValue = useCreateMessageInputContext({ - ...cooldownTimerState, ...messageInputUiApi, ...props, emojiSearchIndex: props.emojiSearchIndex ?? emojiSearchIndex, diff --git a/src/components/MessageInput/MessageInputFlat.tsx b/src/components/MessageInput/MessageInputFlat.tsx index 8f1209a0f1..725846bb38 100644 --- a/src/components/MessageInput/MessageInputFlat.tsx +++ b/src/components/MessageInput/MessageInputFlat.tsx @@ -1,152 +1,109 @@ -import React, { useCallback, useState } from 'react'; +import React from 'react'; import { AttachmentSelector as DefaultAttachmentSelector, SimpleAttachmentSelector, -} from './AttachmentSelector'; +} from './AttachmentSelector/AttachmentSelector'; import { AttachmentPreviewList as DefaultAttachmentPreviewList } from './AttachmentPreviewList'; -import { CooldownTimer as DefaultCooldownTimer } from './CooldownTimer'; -import { SendButton as DefaultSendButton } from './SendButton'; -import { StopAIGenerationButton as DefaultStopAIGenerationButton } from './StopAIGenerationButton'; -import { - AudioRecorder as DefaultAudioRecorder, - RecordingPermissionDeniedNotification as DefaultRecordingPermissionDeniedNotification, - StartRecordingAudioButton as DefaultStartRecordingAudioButton, - RecordingPermission, -} from '../MediaRecorder'; -import { - QuotedMessagePreview as DefaultQuotedMessagePreview, - QuotedMessagePreviewHeader, -} from './QuotedMessagePreview'; +import { AudioRecorder as DefaultAudioRecorder } from '../MediaRecorder'; +import { QuotedMessagePreview as DefaultQuotedMessagePreview } from './QuotedMessagePreview'; import { LinkPreviewList as DefaultLinkPreviewList } from './LinkPreviewList'; import { SendToChannelCheckbox as DefaultSendToChannelCheckbox } from './SendToChannelCheckbox'; import { TextareaComposer as DefaultTextareaComposer } from '../TextareaComposer'; -import { AIStates, useAIState } from '../AIStateIndicator'; -import { RecordingAttachmentType } from '../MediaRecorder/classes'; - -import { useChatContext } from '../../context/ChatContext'; import { useMessageInputContext } from '../../context/MessageInputContext'; import { useComponentContext } from '../../context/ComponentContext'; -import { useAttachmentManagerState } from './hooks/useAttachmentManagerState'; import { useMessageContext } from '../../context'; import { WithDragAndDropUpload } from './WithDragAndDropUpload'; +import { + AdditionalMessageComposerActions as DefaultAdditionalMessageComposerActions, + MessageComposerActions, +} from './MessageComposerActions'; +import { useMessageComposer } from './hooks'; +import { useStateStore } from '../../store'; +import { + type AttachmentManagerState, + LinkPreviewsManager, + type LinkPreviewsManagerState, + type MessageComposerState, +} from 'stream-chat'; -export const MessageInputFlat = () => { - const { message } = useMessageContext(); - const { - asyncMessagesMultiSendEnabled, - cooldownRemaining, - handleSubmit, - hideSendButton, - recordingController, - setCooldownRemaining, - } = useMessageInputContext('MessageInputFlat'); +const messageComposerStateSelector = ({ quotedMessage }: MessageComposerState) => ({ + quotedMessage, +}); + +const attachmentManagerStateSelector = (state: AttachmentManagerState) => ({ + attachments: state.attachments, +}); +const linkPreviewsManagerStateSelector = (state: LinkPreviewsManagerState) => ({ + linkPreviews: Array.from(state.previews.values()).filter( + (preview) => + LinkPreviewsManager.previewIsLoaded(preview) || + LinkPreviewsManager.previewIsLoading(preview), + ), +}); + +const MessageComposerPreviews = () => { const { AttachmentPreviewList = DefaultAttachmentPreviewList, - AttachmentSelector = message ? SimpleAttachmentSelector : DefaultAttachmentSelector, - AudioRecorder = DefaultAudioRecorder, - CooldownTimer = DefaultCooldownTimer, - EmojiPicker, LinkPreviewList = DefaultLinkPreviewList, QuotedMessagePreview = DefaultQuotedMessagePreview, - RecordingPermissionDeniedNotification = DefaultRecordingPermissionDeniedNotification, - SendButton = DefaultSendButton, - SendToChannelCheckbox = DefaultSendToChannelCheckbox, - StartRecordingAudioButton = DefaultStartRecordingAudioButton, - StopAIGenerationButton: StopAIGenerationButtonOverride, - TextareaComposer = DefaultTextareaComposer, } = useComponentContext(); - const { channel } = useChatContext('MessageInputFlat'); - const { aiState } = useAIState(channel); - const stopGenerating = useCallback(() => channel?.stopAIResponse(), [channel]); - - const [ - showRecordingPermissionDeniedNotification, - setShowRecordingPermissionDeniedNotification, - ] = useState(false); - const closePermissionDeniedNotification = useCallback(() => { - setShowRecordingPermissionDeniedNotification(false); - }, []); - - const { attachments } = useAttachmentManagerState(); + const messageComposer = useMessageComposer(); + const { quotedMessage } = useStateStore( + messageComposer.state, + messageComposerStateSelector, + ); - if (recordingController.recordingState) return ; + const { attachments } = useStateStore( + messageComposer.attachmentManager.state, + attachmentManagerStateSelector, + ); - const recordingEnabled = !!(recordingController.recorder && navigator.mediaDevices); // account for requirement on iOS as per this bug report: https://bugs.webkit.org/show_bug.cgi?id=252303 - const isRecording = !!recordingController.recordingState; + const { linkPreviewsManager } = messageComposer; + const { linkPreviews } = useStateStore( + linkPreviewsManager.state, + linkPreviewsManagerStateSelector, + ); - /** - * This bit here is needed to make sure that we can get rid of the default behaviour - * if need be. Essentially, this allows us to pass StopAIGenerationButton={null} and - * completely circumvent the default logic if it's not what we want. We need it as a - * prop because there is no other trivial way to override the SendMessage button otherwise. - */ - const StopAIGenerationButton = - StopAIGenerationButtonOverride === undefined - ? DefaultStopAIGenerationButton - : StopAIGenerationButtonOverride; - const shouldDisplayStopAIGeneration = - [AIStates.Thinking, AIStates.Generating].includes(aiState) && - !!StopAIGenerationButton; + if (!quotedMessage && attachments.length === 0 && linkPreviews.length === 0) + return null; + // todo: pass the entity arrays from here so that the preview lists do not have to subscribe to the composer state changes too? return ( - - {recordingEnabled && - recordingController.permissionState === 'denied' && - showRecordingPermissionDeniedNotification && ( - - )} +
+ + - +
+ ); +}; -
- -
- - -
- - {EmojiPicker && } -
+export const MessageInputFlat = () => { + const { message } = useMessageContext(); + const { recordingController } = useMessageInputContext(); + + const { + AdditionalMessageComposerActions = DefaultAdditionalMessageComposerActions, + AttachmentSelector = message ? SimpleAttachmentSelector : DefaultAttachmentSelector, + AudioRecorder = DefaultAudioRecorder, + SendToChannelCheckbox = DefaultSendToChannelCheckbox, + TextareaComposer = DefaultTextareaComposer, + } = useComponentContext(); + + if (recordingController.recordingState) return ; + + return ( + + +
+ +
+ + +
- {shouldDisplayStopAIGeneration ? ( - - ) : ( - !hideSendButton && ( - <> - {cooldownRemaining ? ( - - ) : ( - <> - - {recordingEnabled && ( - a.type === RecordingAttachmentType.VOICE_RECORDING, - )) - } - onClick={() => { - recordingController.recorder?.start(); - setShowRecordingPermissionDeniedNotification(true); - }} - /> - )} - - )} - - ) - )}
diff --git a/src/components/MessageInput/QuotedMessageIndicator.tsx b/src/components/MessageInput/QuotedMessageIndicator.tsx new file mode 100644 index 0000000000..54f1e38925 --- /dev/null +++ b/src/components/MessageInput/QuotedMessageIndicator.tsx @@ -0,0 +1,9 @@ +import clsx from 'clsx'; + +export const QuotedMessageIndicator = ({ isOwnMessage }: { isOwnMessage?: boolean }) => ( +
+); diff --git a/src/components/MessageInput/QuotedMessagePreview.tsx b/src/components/MessageInput/QuotedMessagePreview.tsx index 19aa99213b..e546516515 100644 --- a/src/components/MessageInput/QuotedMessagePreview.tsx +++ b/src/components/MessageInput/QuotedMessagePreview.tsx @@ -1,61 +1,250 @@ -import React, { useMemo } from 'react'; +import React, { + type ComponentType, + type ReactElement, + type ReactNode, + useMemo, +} from 'react'; -import { CloseIcon } from './icons'; -import { Attachment as DefaultAttachment } from '../Attachment'; -import { Avatar as DefaultAvatar } from '../Avatar'; -import { Poll } from '../Poll'; +import { displayDuration, SUPPORTED_VIDEO_FORMATS } from '../Attachment'; import { useChatContext } from '../../context/ChatContext'; -import { useComponentContext } from '../../context/ComponentContext'; import { useTranslationContext } from '../../context/TranslationContext'; import { useStateStore } from '../../store'; import { useMessageComposer } from './hooks'; -import { renderText as defaultRenderText } from '../Message/renderText'; -import type { MessageComposerState, TranslationLanguages } from 'stream-chat'; +import { + isAudioAttachment, + isFileAttachment, + isScrapedContent, + isVideoAttachment, + isVoiceRecordingAttachment, + type PollResponse, +} from 'stream-chat'; +import { + type Attachment, + isImageAttachment, + type LocalMessage, + type MessageComposerState, + type SharedLocationResponse, + type TranslationLanguages, +} from 'stream-chat'; import type { MessageContextValue } from '../../context'; +import { RemoveAttachmentPreviewButton } from './RemoveAttachmentPreviewButton'; +import { + IconCamera, + IconChainLink, + IconFile, + IconLocationPin, + IconMicrophone, + IconPlaySolid, + IconPoll, + IconVideoCameraOutline, +} from '../Icons'; +import clsx from 'clsx'; +import { ImageComponent } from '../Gallery'; +import { FileIcon } from '../FileIcon'; +import { QuotedMessageIndicator } from './QuotedMessageIndicator'; const messageComposerStateStoreSelector = (state: MessageComposerState) => ({ quotedMessage: state.quotedMessage, }); -export const QuotedMessagePreviewHeader = () => { - const { t } = useTranslationContext('QuotedMessagePreview'); - const messageComposer = useMessageComposer(); - const { quotedMessage } = useStateStore( - messageComposer.state, - messageComposerStateStoreSelector, - ); +export type QuotedMessagePreviewProps = { + getQuotedMessageAuthor?: (message: LocalMessage) => string; + renderText?: MessageContextValue['renderText']; +}; - if (!quotedMessage) return null; +const NullAttachmentIcon = () => null; - return ( -
-
- {t('Reply to Message')} -
- -
+type AttachmentType = 'documents' | 'images' | 'links' | 'videos' | 'voiceRecordings'; + +const getAttachmentType = (attachment: Attachment) => { + if (isScrapedContent(attachment)) { + return 'link'; + } else if (isVideoAttachment(attachment, SUPPORTED_VIDEO_FORMATS)) { + return 'video'; + } else if (isImageAttachment(attachment)) { + return 'image'; + } else if (isAudioAttachment(attachment)) { + return 'audio'; + } else if (isVoiceRecordingAttachment(attachment)) { + return 'voiceRecording'; + } else if (isFileAttachment(attachment, SUPPORTED_VIDEO_FORMATS)) { + return 'file'; + } + + return 'unsupported'; +}; + +type GroupedAttachments = Record & { + locations: SharedLocationResponse[]; + polls: PollResponse[]; + total: number; +}; + +const getGroupedAttachments = (quotedMessage: LocalMessage | null) => { + const groupedAttachments = { + documents: [], + images: [], + links: [], + locations: [], + polls: [], + total: 0, + videos: [], + voiceRecordings: [], + }; + + if (!quotedMessage || !quotedMessage.attachments) return groupedAttachments; + + const result = quotedMessage.attachments.reduce( + (count, attachment) => { + switch (getAttachmentType(attachment)) { + case 'link': + count.links.push(attachment); + count.total += 1; + break; + case 'video': + count.videos.push(attachment); + count.total += 1; + break; + case 'voiceRecording': + count.voiceRecordings.push(attachment); + count.total += 1; + break; + case 'audio': + case 'file': + count.documents.push(attachment); + count.total += 1; + break; + default: + if (isImageAttachment(attachment)) { + count.images.push(attachment); + count.total += 1; + } + } + + return count; + }, + groupedAttachments, ); + if (quotedMessage.shared_location) { + result.locations.push(quotedMessage.shared_location); + result.total += 1; + } else if (quotedMessage.poll) { + result.polls.push(quotedMessage.poll); + result.total += 1; + } + + return result; }; -export type QuotedMessagePreviewProps = { - renderText?: MessageContextValue['renderText']; +type PreviewType = + | 'voice' + | 'file' + | 'image' + | 'link' + | 'location' + | 'poll' + | 'video' + | 'mixed'; + +const getAttachmentIconWithType = ( + quotedMessage: LocalMessage | null, +): { + groupedAttachments: GroupedAttachments; + Icon: ComponentType; + PreviewImage: ReactElement | null; + previewType: PreviewType | null; +} => { + const groupedAttachments = getGroupedAttachments(quotedMessage); + const result = { + groupedAttachments, + Icon: NullAttachmentIcon, + PreviewImage: null, + previewType: null, + }; + if (!groupedAttachments.total) return result; + if (groupedAttachments.polls.length > 0) + return { ...result, Icon: IconPoll, previewType: 'poll' }; + if (groupedAttachments.locations.length > 0) + // todo: we do not generate the location preview image + return { ...result, Icon: IconLocationPin, previewType: 'location' }; + if ( + groupedAttachments.documents.length === groupedAttachments.total && + groupedAttachments.documents.length === 1 + ) { + const fileAttachment = groupedAttachments.documents[0] as Attachment; + return { + ...result, + Icon: IconFile, + PreviewImage: ( + + ), + previewType: 'file', + }; + } + if (groupedAttachments.links.length === groupedAttachments.total) { + const linkAttachment = groupedAttachments.links[0]; + return { + ...result, + Icon: IconChainLink, + PreviewImage: ( + + ), + previewType: 'link', + }; + } + if (groupedAttachments.videos.length === groupedAttachments.total) { + const videoAttachment = groupedAttachments.videos[0]; + return { + ...result, + Icon: IconVideoCameraOutline, + PreviewImage: ( + <> + +
+ +
+ + ), + previewType: 'video', + }; + } + if (groupedAttachments.images.length === groupedAttachments.total) { + const imageAttachment = groupedAttachments.images[0]; + return { + ...result, + Icon: IconCamera, + PreviewImage: ( + + ), + previewType: 'image', + }; + } + if (groupedAttachments.voiceRecordings.length === groupedAttachments.total) + return { ...result, Icon: IconMicrophone, previewType: 'voice' }; + + return { ...result, Icon: IconFile, previewType: 'mixed' }; }; export const QuotedMessagePreview = ({ - renderText = defaultRenderText, + getQuotedMessageAuthor, + renderText, }: QuotedMessagePreviewProps) => { const { client } = useChatContext(); - const { Attachment = DefaultAttachment, Avatar = DefaultAvatar } = - useComponentContext('QuotedMessagePreview'); - const { userLanguage } = useTranslationContext('QuotedMessagePreview'); + const { t, userLanguage } = useTranslationContext(); const messageComposer = useMessageComposer(); const { quotedMessage } = useStateStore( messageComposer.state, @@ -69,51 +258,105 @@ export const QuotedMessagePreview = ({ [quotedMessage?.i18n, quotedMessage?.text, userLanguage], ); - const renderedText = useMemo( - () => renderText(quotedMessageText, quotedMessage?.mentioned_users), - [quotedMessage, quotedMessageText, renderText], - ); + const { AttachmentIcon, PreviewImage, renderedText } = useMemo(() => { + if (!quotedMessage) return { AttachmentIcon: NullAttachmentIcon, renderedText: null }; - const quotedMessageAttachments = useMemo( - () => - quotedMessage?.attachments?.length ? quotedMessage.attachments.slice(0, 1) : [], - [quotedMessage], - ); + const { + groupedAttachments, + Icon: AttachmentIcon, + PreviewImage, + previewType, + } = getAttachmentIconWithType(quotedMessage); - const poll = quotedMessage?.poll_id && client.polls.fromState(quotedMessage.poll_id); + let renderedText: ReactNode | undefined; - if (!quotedMessageText && !quotedMessageAttachments.length && !poll) return null; + if (!quotedMessageText) { + if (previewType === 'poll') { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + renderedText = quotedMessage.poll!.name; + } else if (previewType === 'location') { + renderedText = t('Live location'); + } else if (previewType === 'voice') { + { + const voiceRecording = groupedAttachments.voiceRecordings[0]; + renderedText = t('Voice message {{ duration }}', { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + duration: displayDuration(voiceRecording!.duration), + }); + } + } else if (previewType === 'link') { + renderedText = groupedAttachments.links[0].title; + } else if (previewType === 'mixed') { + renderedText = t('{{ count }} files', { count: groupedAttachments.total }); + } else if (previewType === 'video') { + renderedText = + groupedAttachments.videos.length === 1 + ? t('Video') + : t('{{ count }} videos', { + count: groupedAttachments.videos.length, + }); + } else if (previewType === 'file') { + renderedText = groupedAttachments.documents[0].title; + } else if (previewType === 'image') { + renderedText = + groupedAttachments.images.length === 1 + ? t('Photo') + : t('{{ count }} photos', { + count: groupedAttachments.images.length, + }); + } + } else if (renderText) { + renderedText = renderText(quotedMessageText, quotedMessage?.mentioned_users); + } else { + renderedText = quotedMessageText; + } + return { + AttachmentIcon, + PreviewImage, + renderedText, + }; + }, [quotedMessage, quotedMessageText, renderText, t]); + + const isOwnMessage = client.user?.id === quotedMessage?.user?.id; + + if (!quotedMessage || (!renderedText && !AttachmentIcon && !PreviewImage)) return null; + + const authorName = getQuotedMessageAuthor?.(quotedMessage) ?? quotedMessage.user?.name; return (
- {quotedMessage?.user && ( - - )} -
- {poll ? ( - - ) : ( - <> - {!!quotedMessageAttachments.length && ( - - )} -
- {renderedText} -
- - )} + +
+
+ {isOwnMessage + ? t('You') + : authorName + ? t('Reply to {{ authorName }}', { authorName }) + : t('Reply')} +
+ +
+ + {renderedText} +
+ {PreviewImage && ( +
{PreviewImage}
+ )} + + messageComposer.setQuotedMessage(null)} + />
); }; diff --git a/src/components/MessageInput/RemoveAttachmentPreviewButton.tsx b/src/components/MessageInput/RemoveAttachmentPreviewButton.tsx new file mode 100644 index 0000000000..f6dfa409db --- /dev/null +++ b/src/components/MessageInput/RemoveAttachmentPreviewButton.tsx @@ -0,0 +1,34 @@ +import clsx from 'clsx'; +import { IconClose } from '../Icons'; +import { Button } from '../Button'; +import React, { type ComponentProps } from 'react'; +import { useTranslationContext } from '../../context'; +import type { AttachmentLoadingState } from 'stream-chat'; + +export const RemoveAttachmentPreviewButton = ({ + className, + uploadState, + ...props +}: ComponentProps<'button'> & { + uploadState?: AttachmentLoadingState; +}) => { + const { t } = useTranslationContext(); + return ( + + ); +}; diff --git a/src/components/MessageInput/SendButton.tsx b/src/components/MessageInput/SendButton.tsx index 21db71cffe..76637c2136 100644 --- a/src/components/MessageInput/SendButton.tsx +++ b/src/components/MessageInput/SendButton.tsx @@ -1,8 +1,10 @@ import React from 'react'; -import { SendIcon } from './icons'; import { useMessageComposerHasSendableData } from './hooks'; import type { UpdatedMessage } from 'stream-chat'; import { useTranslationContext } from '../../context'; +import { IconPaperPlane } from '../Icons/IconPaperPlane'; +import { Button } from '../Button'; +import clsx from 'clsx'; export type SendButtonProps = { sendMessage: ( @@ -10,20 +12,27 @@ export type SendButtonProps = { customMessageData?: Omit, ) => void; } & React.ComponentProps<'button'>; + export const SendButton = ({ sendMessage, ...rest }: SendButtonProps) => { const { t } = useTranslationContext(); const hasSendableData = useMessageComposerHasSendableData(); return ( - + + ); }; diff --git a/src/components/MessageInput/WithDragAndDropUpload.tsx b/src/components/MessageInput/WithDragAndDropUpload.tsx index 00b2b6a343..d9bf4f54ec 100644 --- a/src/components/MessageInput/WithDragAndDropUpload.tsx +++ b/src/components/MessageInput/WithDragAndDropUpload.tsx @@ -7,6 +7,7 @@ import type { MessageComposerConfig } from 'stream-chat'; import { useMessageInputContext, useTranslationContext } from '../../context'; import { useAttachmentManagerState, useMessageComposer } from './hooks'; import { useStateStore } from '../../store'; +import { useIsCooldownActive } from './hooks/useIsCooldownActive'; const DragAndDropUploadContext = React.createContext<{ subscribeToDrop: ((fn: (files: File[]) => void) => () => void) | null; @@ -81,6 +82,8 @@ export const WithDragAndDropUpload = ({ messageComposer.configState, attachmentManagerConfigStateSelector, ); + + const isCooldownActive = useIsCooldownActive(); // if message input context is available, there's no need to use the queue const isWithinMessageInputContext = Object.keys(messageInputContext).length > 0; @@ -109,9 +112,7 @@ export const WithDragAndDropUpload = ({ accept, // apply `disabled` rules if available, otherwise allow anything and // let the `uploadNewFiles` handle the limitations internally - disabled: isWithinMessageInputContext - ? !isUploadEnabled || (messageInputContext.cooldownRemaining ?? 0) > 0 - : false, + disabled: isWithinMessageInputContext ? !isUploadEnabled || isCooldownActive : false, multiple: multipleUploads, noClick: true, onDrop: isWithinMessageInputContext diff --git a/src/components/MessageInput/__tests__/AttachmentPreviewList.test.js b/src/components/MessageInput/__tests__/AttachmentPreviewList.test.js index 27ed1f76a4..1ec031a244 100644 --- a/src/components/MessageInput/__tests__/AttachmentPreviewList.test.js +++ b/src/components/MessageInput/__tests__/AttachmentPreviewList.test.js @@ -263,7 +263,7 @@ describe('AttachmentPreviewList', () => { file: 'FileAttachmentPreview', image: 'ImageAttachmentPreview', unsupported: 'UnsupportedAttachmentPreview', - video: 'VideoAttachmentPreview', + video: 'MediaAttachmentPreview', voiceRecording: 'VoiceRecordingPreview', }; const title = `${type}-attachment`; diff --git a/src/components/MessageInput/hooks/__tests__/useCooldownTimer.test.js b/src/components/MessageInput/hooks/__tests__/useCooldownTimer.test.js index 90526a4e9f..c5ddb59e6c 100644 --- a/src/components/MessageInput/hooks/__tests__/useCooldownTimer.test.js +++ b/src/components/MessageInput/hooks/__tests__/useCooldownTimer.test.js @@ -1,7 +1,7 @@ import React from 'react'; import { renderHook } from '@testing-library/react'; -import { useCooldownTimer } from '../useCooldownTimer'; +import { useCooldownRemaining } from '../useCooldownRemaining'; import { ChannelStateProvider, ChatProvider } from '../../../../context'; import { getTestClient } from '../../../../mock-builders'; @@ -17,12 +17,12 @@ async function renderUseCooldownTimerHook({ channel, chatContext }) { {children} ); - return renderHook(useCooldownTimer, { wrapper }); + return renderHook(useCooldownRemaining, { wrapper }); } const cid = 'cid'; const cooldown = 30; -describe('useCooldownTimer', () => { +describe('useCooldownRemaining', () => { it('should set remaining cooldown time to 0 if no channel.cooldown', async () => { const channel = { cid }; const chatContext = { latestMessageDatesByChannels: {} }; diff --git a/src/components/MessageInput/hooks/index.ts b/src/components/MessageInput/hooks/index.ts index 0c8fd01bd1..ef36b94c02 100644 --- a/src/components/MessageInput/hooks/index.ts +++ b/src/components/MessageInput/hooks/index.ts @@ -1,7 +1,8 @@ export * from './useAttachmentManagerState'; export * from './useAttachmentsForPreview'; export * from './useCanCreatePoll'; -export * from './useCooldownTimer'; +export * from './useCooldownRemaining'; export * from './useMessageInputControls'; export * from './useMessageComposer'; export * from './useMessageComposerHasSendableData'; +export * from './useMessageCompositionIsEmpty'; diff --git a/src/components/MessageInput/hooks/useCooldownRemaining.tsx b/src/components/MessageInput/hooks/useCooldownRemaining.tsx new file mode 100644 index 0000000000..5de5eb8e54 --- /dev/null +++ b/src/components/MessageInput/hooks/useCooldownRemaining.tsx @@ -0,0 +1,22 @@ +import { type CooldownTimerState } from 'stream-chat'; + +import { useChannelStateContext } from '../../../context'; +import { useStateStore } from '../../../store'; + +const cooldownTimerStateSelector = (state: CooldownTimerState) => ({ + cooldownRemaining: state.cooldownRemaining, +}); + +/** + * Provides and initial value of cooldown, from which the countdown should start, e.g.: + * + * The value of channel.data.cooldown is 100s but 30s has already elapsed, user reloads the page, + * the initial value is now 70s from which the countdown will continue using useTimer() hook. + */ +export const useCooldownRemaining = (): number => { + const { channel } = useChannelStateContext(); + return ( + useStateStore(channel.cooldownTimer.state, cooldownTimerStateSelector) + .cooldownRemaining ?? 0 + ); +}; diff --git a/src/components/MessageInput/hooks/useCooldownTimer.tsx b/src/components/MessageInput/hooks/useCooldownTimer.tsx deleted file mode 100644 index 5a36d86bef..0000000000 --- a/src/components/MessageInput/hooks/useCooldownTimer.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import type React from 'react'; -import { useEffect, useMemo, useState } from 'react'; -import type { ChannelResponse } from 'stream-chat'; - -import { useChannelStateContext, useChatContext } from '../../../context'; - -export type CooldownTimerState = { - cooldownInterval: number; - setCooldownRemaining: React.Dispatch>; - cooldownRemaining?: number; -}; - -export const useCooldownTimer = (): CooldownTimerState => { - const { client, latestMessageDatesByChannels } = useChatContext('useCooldownTimer'); - const { channel, messages = [] } = useChannelStateContext('useCooldownTimer'); - const [cooldownRemaining, setCooldownRemaining] = useState(); - - const { cooldown: cooldownInterval = 0, own_capabilities } = (channel.data || - {}) as ChannelResponse; - - const skipCooldown = own_capabilities?.includes('skip-slow-mode'); - - const ownLatestMessageDate = useMemo( - () => - latestMessageDatesByChannels[channel.cid] ?? - [...messages] - .sort( - (a, b) => (b.created_at as Date)?.getTime() - (a.created_at as Date)?.getTime(), - ) - .find((v) => v.user?.id === client.user?.id)?.created_at, - [messages, client.user?.id, latestMessageDatesByChannels, channel.cid], - ) as Date; - - useEffect(() => { - const timeSinceOwnLastMessage = ownLatestMessageDate - ? // prevent negative values - Math.max(0, (new Date().getTime() - ownLatestMessageDate.getTime()) / 1000) - : undefined; - - const remaining = - !skipCooldown && - typeof timeSinceOwnLastMessage !== 'undefined' && - cooldownInterval > timeSinceOwnLastMessage - ? Math.round(cooldownInterval - timeSinceOwnLastMessage) - : 0; - - setCooldownRemaining(remaining); - - if (!remaining) return; - - const timeout = setTimeout(() => { - setCooldownRemaining(0); - }, remaining * 1000); - - return () => { - clearTimeout(timeout); - }; - }, [cooldownInterval, ownLatestMessageDate, skipCooldown]); - - return { - cooldownInterval, - cooldownRemaining, - setCooldownRemaining, - }; -}; diff --git a/src/components/MessageInput/hooks/useCreateMessageInputContext.ts b/src/components/MessageInput/hooks/useCreateMessageInputContext.ts index e5f4e25135..03d8617d24 100644 --- a/src/components/MessageInput/hooks/useCreateMessageInputContext.ts +++ b/src/components/MessageInput/hooks/useCreateMessageInputContext.ts @@ -8,8 +8,6 @@ export const useCreateMessageInputContext = (value: MessageInputContextValue) => asyncMessagesMultiSendEnabled, audioRecordingEnabled, clearEditingState, - cooldownInterval, - cooldownRemaining, emojiSearchIndex, focus, handleSubmit, @@ -20,7 +18,6 @@ export const useCreateMessageInputContext = (value: MessageInputContextValue) => onPaste, parent, recordingController, - setCooldownRemaining, shouldSubmit, textareaRef, } = value; @@ -33,8 +30,6 @@ export const useCreateMessageInputContext = (value: MessageInputContextValue) => asyncMessagesMultiSendEnabled, audioRecordingEnabled, clearEditingState, - cooldownInterval, - cooldownRemaining, emojiSearchIndex, focus, handleSubmit, @@ -45,7 +40,6 @@ export const useCreateMessageInputContext = (value: MessageInputContextValue) => onPaste, parent, recordingController, - setCooldownRemaining, shouldSubmit, textareaRef, }), @@ -53,8 +47,6 @@ export const useCreateMessageInputContext = (value: MessageInputContextValue) => [ asyncMessagesMultiSendEnabled, audioRecordingEnabled, - cooldownInterval, - cooldownRemaining, emojiSearchIndex, handleSubmit, hideSendButton, diff --git a/src/components/MessageInput/hooks/useIsCooldownActive.ts b/src/components/MessageInput/hooks/useIsCooldownActive.ts new file mode 100644 index 0000000000..7683088b53 --- /dev/null +++ b/src/components/MessageInput/hooks/useIsCooldownActive.ts @@ -0,0 +1,13 @@ +import { useChannelStateContext } from '../../../context'; +import { useStateStore } from '../../../store'; +import type { CooldownTimerState } from 'stream-chat'; + +const cooldownTimerStateSelector = (state: CooldownTimerState) => ({ + isCooldownActive: !!state.cooldownRemaining, +}); + +export const useIsCooldownActive = () => { + const { channel } = useChannelStateContext(); + return useStateStore(channel.cooldownTimer.state, cooldownTimerStateSelector) + .isCooldownActive; +}; diff --git a/src/components/MessageInput/hooks/useMessageCompositionIsEmpty.ts b/src/components/MessageInput/hooks/useMessageCompositionIsEmpty.ts new file mode 100644 index 0000000000..2f2562b6d0 --- /dev/null +++ b/src/components/MessageInput/hooks/useMessageCompositionIsEmpty.ts @@ -0,0 +1,11 @@ +import type { EditingAuditState } from 'stream-chat'; +import { useMessageComposer } from './useMessageComposer'; +import { useStateStore } from '../../../store'; + +const editingAuditStateStateSelector = (state: EditingAuditState) => state; + +export const useMessageCompositionIsEmpty = () => { + const messageComposer = useMessageComposer(); + useStateStore(messageComposer.editingAuditState, editingAuditStateStateSelector); + return messageComposer.compositionIsEmpty; +}; diff --git a/src/components/MessageInput/hooks/useTimer.ts b/src/components/MessageInput/hooks/useTimer.ts deleted file mode 100644 index 4ea7f57cc8..0000000000 --- a/src/components/MessageInput/hooks/useTimer.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useEffect, useState } from 'react'; - -export const useTimer = ({ startFrom }: { startFrom: number }) => { - const [secondsLeft, setSecondsLeft] = useState(); - - useEffect(() => { - let countdownTimeout: ReturnType; - if (typeof secondsLeft === 'number' && secondsLeft > 0) { - countdownTimeout = setTimeout(() => { - setSecondsLeft(secondsLeft - 1); - }, 1000); - } - return () => { - clearTimeout(countdownTimeout); - }; - }, [secondsLeft]); - - useEffect(() => { - setSecondsLeft(startFrom ?? 0); - }, [startFrom]); - - return secondsLeft; -}; diff --git a/src/components/MessageInput/icons.tsx b/src/components/MessageInput/icons.tsx index 20829b4a6f..94b8de7037 100644 --- a/src/components/MessageInput/icons.tsx +++ b/src/components/MessageInput/icons.tsx @@ -3,16 +3,14 @@ import { nanoid } from 'nanoid'; import { useTranslationContext } from '../../context/TranslationContext'; -export const LoadingIndicatorIcon = ({ size = 20 }: { size?: number }) => { +export const LoadingIndicatorIcon = () => { const id = useMemo(() => nanoid(), []); return (
@@ -64,95 +62,6 @@ export const UploadIcon = () => { ); }; -export const CloseIcon = () => ( - - - -); - -export const RetryIcon = () => ( - - - -); - -export const DownloadIcon = () => ( - - - -); - -export const LinkIcon = () => ( - - - -); - -export const SendIcon = () => { - const { t } = useTranslationContext('SendButton'); - return ( - - {t('Send')} - - - ); -}; - -export const MicIcon = () => ( - - - - -); - export const BinIcon = () => ( diff --git a/src/components/MessageInput/styling/AttachmentPreview.scss b/src/components/MessageInput/styling/AttachmentPreview.scss new file mode 100644 index 0000000000..eca51ced1d --- /dev/null +++ b/src/components/MessageInput/styling/AttachmentPreview.scss @@ -0,0 +1,209 @@ +@use '../../../styling/utils'; + +.str-chat { + .str-chat__attachment-preview-list { + padding: var(--spacing-xxs); + display: flex; + align-items: center; + //justify-content: center; + justify-content: flex-start; + width: 100%; + + min-width: 0; + max-width: 100%; + overflow-x: auto; + flex: 1 1 auto; + overflow-y: hidden; + gap: var(--spacing-md); + + //.str-chat__attachment-list-scroll-container { + // display: flex; + // gap: var(--spacing-md); + // align-items: center; + // justify-content: flex-start; + // width: 100%; + // min-width: 0; + // + // overflow-x: auto; + // overflow-y: hidden; + // padding-block: var(--spacing-md); + //} + } + .str-chat__attachment-preview-audio, + .str-chat__attachment-preview-file, + .str-chat__attachment-preview-voice-recording, + .str-chat__attachment-preview-unsupported { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-md); + padding-right: var(--spacing-sm); + min-width: 224px; + max-width: 280px; + border-radius: var(--radius-lg); + border: 1px solid var(--border-core-default); + } + + .str-chat__attachment-preview-audio, + .str-chat__attachment-preview-file, + .str-chat__attachment-preview-voice-recording, + .str-chat__attachment-preview-unsupported, + .str-chat__attachment-preview-media { + position: relative; + &:focus-visible { + @include utils.focusable; + } + } + + .str-chat__attachment-preview-media { + width: 72px; + height: 72px; + cursor: pointer; + border: 1px solid var(--border-core-default); + border-radius: var(--message-bubble-radius-attachment); + + .str-chat__attachment-preview-media__thumbnail-wrapper { + border-radius: var(--message-bubble-radius-attachment); + overflow: hidden; + height: 100%; + width: 100%; + + img { + width: 72px; + height: 72px; + object-fit: cover; + } + } + + .str-chat__attachment-preview-media__video-indicator { + display: flex; + align-items: center; + gap: var(--spacing-xxs); + position: absolute; + bottom: var(--spacing-xxs); + left: var(--spacing-xxs); + padding-inline: var(--button-padding-x-icon-only-sm); + padding-block: var(--button-padding-y-sm); + border-radius: var(--radius-max); + // todo: change to --badge-bg when the variable is available + background-color: var(--chat-bg-typing-indicator); + color: var(--badge-text); + font-size: var(--typography-font-size-xxs); + font-weight: var(--typography-font-weight-bold); + line-height: var(--line-height-line-height-10); + + .str-chat__icon--video-camera { + width: 10px; + height: 8px; + fill: currentColor; + } + } + } + + .str-chat__attachment-preview-media__overlay { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + inset: 0; + border-radius: var(--message-bubble-radius-attachment); + + &:hover { + @include utils.overlay-after(var(--background-core-hover)); + background-color: var(--background-core-hover); + } + + &:active { + @include utils.overlay-after(var(--background-core-pressed)); + background-color: var(--background-core-pressed); + } + + .str-chat__loading-indicator, + .str-chat__icon--exclamation-circle { + width: 14px; + height: 14px; + position: absolute; + left: var(--spacing-xxs); + bottom: var(--spacing-xxs); + border: 2px solid var(--control-remove-control-border); + border-radius: var(--radius-max); + } + } + + .str-chat__attachment-preview-media--upload-error { + .str-chat__attachment-preview-media__overlay { + background-color: var(--background-core-overlay); + } + } + + .str-chat__attachment-preview-media--uploading { + .str-chat__attachment-preview-media__overlay { + background: linear-gradient(180deg, var(--base-white) 0%, var(--slate-100) 100%); + } + } + + .str-chat__attachment-preview-file__icon { + display: flex; + align-items: center; + } + + .str-chat__attachment-preview-file__info { + @include utils.ellipsis-text-parent; + flex: 1; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: var(--spacing-xxs); + + .str-chat__attachment-preview-file-name { + @include utils.ellipsis-text; + max-width: 100%; + font-weight: var(--typography-font-weight-semi-bold); + font-size: var(--typography-font-size-sm); + line-height: var(--typography-line-height-tight); + } + + .str-chat__attachment-preview-file__data { + display: flex; + align-items: center; + gap: var(--spacing-xxs); + color: var(--text-secondary); + font-weight: var(--typography-font-weight-regular); + font-size: var(--typography-font-size-xs); + line-height: var(--typography-line-height-tight); + + .str-chat__loading-indicator { + width: var(--size-12); + height: var(--size-12); + } + + .str-chat__attachment-preview-file__fatal-error { + display: flex; + align-items: center; + gap: var(--spacing-xxs); + color: var(--color-accent-error); + } + + .str-chat__attachment-preview-file__retriable-error { + display: flex; + align-items: center; + gap: var(--spacing-xxs); + white-space: nowrap; + + .str-chat__attachment-preview-file__retry-upload-button { + @include utils.button-reset; + color: var(--color-accent-primary); + cursor: pointer; + } + } + } + } + + .str-chat__button-play { + height: var(--button-visual-height-md); + width: var(--button-visual-height-md); + border: 1px solid var(--control-play-control-border); + background-color: var(--control-play-control-bg); + } +} diff --git a/src/components/MessageInput/styling/AttachmentPreviewThumbnail.scss b/src/components/MessageInput/styling/AttachmentPreviewThumbnail.scss new file mode 100644 index 0000000000..cb0b4a089f --- /dev/null +++ b/src/components/MessageInput/styling/AttachmentPreviewThumbnail.scss @@ -0,0 +1,8 @@ +// todo: should we have img dimensions determined by semantic variables? +.str-chat__attachment-preview__thumbnail { + border-radius: var(--radius-md); + overflow: hidden; + width: 40px; + height: 40px; + object-fit: cover; +} \ No newline at end of file diff --git a/src/components/MessageInput/styling/AttachmentSelector.scss b/src/components/MessageInput/styling/AttachmentSelector.scss new file mode 100644 index 0000000000..20aa25998b --- /dev/null +++ b/src/components/MessageInput/styling/AttachmentSelector.scss @@ -0,0 +1,26 @@ +.str-chat { + .str-chat__attachment-selector { + .str-chat__attachment-selector__menu-button { + .str-chat__attachment-selector__menu-button__icon { + width: var(--icon-size-md); + color: var(--button-style-outline-text); + } + } + } + + .str-chat__file-input { + display: none; + } + + .str-chat__attachment-selector-actions-menu { + min-width: 200px; + } + + .str-chat__message-composer--floating { + .str-chat__attachment-selector__menu-button { + background-color: var(--background-elevation-elevation-1); + // todo: variable exists only in Figma, not added to tokens repo + box-shadow: var(--shadow-web-light-elevation-2); + } + } +} \ No newline at end of file diff --git a/src/components/MessageInput/styling/LinkPreviewList.scss b/src/components/MessageInput/styling/LinkPreviewList.scss new file mode 100644 index 0000000000..307f34e471 --- /dev/null +++ b/src/components/MessageInput/styling/LinkPreviewList.scss @@ -0,0 +1,82 @@ +@use '../../../styling/utils'; + +.str-chat__link-preview-list { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + width: 100%; + padding: var(--spacing-xxs); +} + +.str-chat__link-preview-card { + position: relative; + width: 100%; + min-width: 0; + display: flex; + align-items: center; + justify-content: space-around; + gap: var(--spacing-xs); + padding-inline: var(--spacing-xs) var(--spacing-sm); + padding-block: var(--spacing-xs); + background-color: var(--chat-bg-outgoing); + border-radius: var(--message-bubble-radius-attachment); + + .str-chat__tooltip { + @include utils.ellipsis-text(); + display: block; + max-width: calc(var(--str-chat__spacing-px) * 250); + padding-inline: 0.5rem; + } + + .str-chat__link-preview-card__icon-container { + display: flex; + align-items: center; + } + + .str-chat__link-preview-card__content { + min-width: 0; + flex: 1; + font-size: var(--typography-font-size-xs); + line-height: var(--typography-line-height-tight); + + .str-chat__link-preview-card__content-title, + .str-chat__link-preview-card__content-description, + .str-chat__link-preview-card__content__url { + @include utils.ellipsis-text(); + } + + .str-chat__link-preview-card__content-title { + font-weight: var(--typography-font-weight-semi-bold); + } + + .str-chat__link-preview-card__content__url { + display: flex; + align-items: center; + gap: var(--spacing-xxs); + } + } + + .str-chat__link-preview-card__dismiss-button { + @include utils.button-reset; + cursor: pointer; + } +} + +//.str-chat__link-preview-card--loading { +// .str-chat__link-preview-card__content { +// display: flex; +// flex-direction: column; +// gap: 0.25rem; +// +// .str-chat__link-preview-card__content-title { +// height: calc(var(--str-chat__spacing-px) * 16); +// width: 100% +// } +// +// .str-chat__link-preview-card__content-description { +// height: calc(var(--str-chat__spacing-px) * 12); +// width: 100%; +// } +// } +//} diff --git a/src/components/MessageInput/styling/MessageComposer.scss b/src/components/MessageInput/styling/MessageComposer.scss new file mode 100644 index 0000000000..e8d5174a09 --- /dev/null +++ b/src/components/MessageInput/styling/MessageComposer.scss @@ -0,0 +1,404 @@ +@use '../../../styling/utils'; + +.str-chat { + /* + Styles for floating like composer + */ + .str-chat__message-composer--floating { + position: fixed; + bottom: 0; + background-color: var(--base-transparent-0); + // todo: variable exists only in Figma, not added to tokens repo + box-shadow: var(--shadow-web-light-elevation-2); + } + + .str-chat__message-composer { + display: flex; + align-items: end; + width: 100%; + max-width: 768px; + padding: var(--spacing-xs); + gap: var(--spacing-xs); + min-width: 0; + } + + .str-chat__message-composer-compose-area { + display: flex; + flex-direction: column; + width: 100%; + padding-inline: var(--spacing-xs); + padding-block: var(--spacing-sm); + border: 1px solid var(--border-core-default); + border-radius: var(--radius-3xl); + color: var(--input-text-default); + background-color: var(--composer-bg); + min-width: 0; + } + + .str-chat__message-composer-previews { + display: flex; + flex-direction: column; + width: 100%; + padding-bottom: var(--spacing-xs); + gap: var(--spacing-xxs); + + min-width: 0; + } + + .str-chat__message-composer-controls { + display: flex; + align-items: end; + width: 100%; + gap: var(--spacing-xs); + + $controls-containers-min-height: 26px; + + .str-chat__message-composer__actions, + .str-chat__message-composer__additional-actions { + height: $controls-containers-min-height; + display: flex; + align-items: center; + } + + .str-chat__textarea { + flex: 1; + position: relative; + display: flex; + align-items: center; + margin-inline: var(--spacing-xxs) var(--spacing-xs); + min-height: $controls-containers-min-height; // align with the attachment button + + textarea { + resize: none; + border: none; + box-shadow: none; + outline: none; + background-color: transparent; + width: 100%; + color: var(--input-text-default); + font-size: var(--typography-font-size-md); + } + } + + .str-chat__emoji-picker-button { + display: flex; + cursor: pointer; + + .str-chat__icon--emoji { + width: var(--icon-size-md); + } + } + + .str-chat__start-recording-audio-button { + .str-chat__icon--microphone { + width: var(--icon-size-md); + } + } + + .str-chat__send-button { + .str-chat__icon--paper-plane { + width: var(--icon-size-md); + fill: none; + } + } + + // todo: we need designs for this - I am hard-coding the dimensions + .str-chat__stop-ai-generation-button { + width: 30px; + height: 28px; + cursor: pointer; + background-image: var(--str-chat__circle-stop-icon); + background-color: transparent; + border-width: 0; + } + + .str-chat__message-input-cooldown { + display: flex; + align-items: center; + justify-content: center; + height: var(--button-visual-height-md); + width: var(--button-visual-height-md); + border-radius: var(--button-radius-full); + background-color: var(--background-core-disabled); + color: var(--text-disabled); + } + + [dir='rtl'] .str-chat__send-button, + [dir='rtl'] .str-chat__start-recording-audio-button { + svg { + transform: scale(-1, 1); + } + } + + + } + + // todo: need designs? what kind of action buttons to use on modals? + .str-chat__recording-permission-denied-notification { + max-width: 100%; + padding: 1rem; + margin-inline: 0.5rem; + border-radius: var(--radius-2xl); + + .str-chat__recording-permission-denied-notification__dismiss-button-container { + display: flex; + justify-content: flex-end; + } + + .str-chat__recording-permission-denied-notification__heading, + .str-chat__recording-permission-denied-notification__dismiss-button { + font: var(--str-chat__subtitle2-medium-text); + } + + .str-chat__recording-permission-denied-notification__message { + font: var(--str-chat__subtitle-text); + } + + //.str-chat__recording-permission-denied-notification__dismiss-button { + // text-transform: uppercase; + //} + } + + // todo: need designs? + .str-chat__send-to-channel-checkbox__container { + width: 100%; + display: flex; + padding: 0.5rem 0.75rem; + + .str-chat__send-to-channel-checkbox__field { + display: flex; + align-items: center; + + * { + cursor: pointer; + } + + label { + padding-inline: 0.5rem; + color: var(--str-chat__text-low-emphasis-color); + font: var(--str-chat__body-text); + } + + input { + margin: 0; + } + } + } +} + +// +// +//// theme +// +//.str-chat { +// /* The border radius of the component */ +// --str-chat__message-input-border-radius: 0; +// +// /* The text/icon color of the component */ +// --str-chat__message-input-color: var(--str-chat__text-color); +// +// /* The background color of the component */ +// --str-chat__message-input-background-color: var(--str-chat__secondary-background-color); +// +// /* Top border of the component */ +// --str-chat__message-input-border-block-start: none; +// +// /* Bottom border of the component */ +// --str-chat__message-input-border-block-end: none; +// +// /* Left (right in RTL layout) border of the component */ +// --str-chat__message-input-border-inline-start: none; +// +// /* Right (left in RTL layout) border of the component */ +// --str-chat__message-input-border-inline-end: none; +// +// /* Box shadow applied to the component */ +// --str-chat__message-input-box-shadow: none; +// +// /* The border radius used for the borders of the textarea */ +// --str-chat__message-textarea-border-radius: var(--str-chat__border-radius-md); +// +// /* The text/icon color of the textarea */ +// --str-chat__message-textarea-color: var(--str-chat__text-color); +// +// /* The background color of the textarea */ +// --str-chat__message-textarea-background-color: transparent; +// +// /* Top border of the textarea */ +// --str-chat__message-textarea-border-block-start: 1px solid var(--str-chat__surface-color); +// +// /* Bottom border of the textarea */ +// --str-chat__message-textarea-border-block-end: 1px solid var(--str-chat__surface-color); +// +// /* Left (right in RTL layout) border of the textarea */ +// --str-chat__message-textarea-border-inline-start: 1px solid var(--str-chat__surface-color); +// +// /* Right (left in RTL layout) border of the textarea */ +// --str-chat__message-textarea-border-inline-end: 1px solid var(--str-chat__surface-color); +// +// /* Box shadow applied to the textarea */ +// --str-chat__message-textarea-box-shadow: none; +// +// /* The border radius used for the borders of the send button */ +// --str-chat__message-send-border-radius: var(--str-chat__border-radius-circle); +// +// /* The text/icon color of the send button */ +// --str-chat__message-send-color: var(--str-chat__primary-color); +// +// /* The background color of the send button */ +// --str-chat__message-send-background-color: transparent; +// +// /* Top border of the send button */ +// --str-chat__message-send-border-block-start: 0; +// +// /* Bottom border of the send button */ +// --str-chat__message-send-border-block-end: 0; +// +// /* Left (right in RTL layout) border of the send button */ +// --str-chat__message-send-border-inline-start: 0; +// +// /* Right (left in RTL layout) border of the send button */ +// --str-chat__message-send-border-inline-end: 0; +// +// /* Box shadow applied to the send button */ +// --str-chat__message-send-box-shadow: none; +// +// /* The color of the send button in disabled state */ +// --str-chat__message-send-disabled-color: var(--str-chat__disabled-color); +// +// /* The background color of the send button in disabled state */ +// --str-chat__message-send-disabled-background-color: var(--str-chat__disabled-color); +// +// /* The border radius used for the borders of the audio recording button */ +// --str-chat__start-recording-audio-button-border-radius: var(--str-chat__border-radius-circle); +// +// /* The text/icon color of the audio recording button */ +// --str-chat__start-recording-audio-button-color: var(--str-chat__text-low-emphasis-color); +// +// /* The background color of the audio recording button */ +// --str-chat__start-recording-audio-button-background-color: transparent; +// +// /* Top border of the audio recording button */ +// --str-chat__start-recording-audio-button-border-block-start: 0; +// +// /* Bottom border of the audio recording button */ +// --str-chat__start-recording-audio-button-border-block-end: 0; +// +// /* Left (right in RTL layout) border of the audio recording button */ +// --str-chat__start-recording-audio-button-border-inline-start: 0; +// +// /* Right (left in RTL layout) border of the audio recording button */ +// --str-chat__start-recording-audio-button-border-inline-end: 0; +// +// /* Box shadow applied to the audio recording button */ +// --str-chat__start-recording-audio-button-box-shadow: none; +// +// /* The color of the audio recording button in disabled state */ +// --str-chat__start-recording-audio-button-disabled-color: var(--str-chat__disabled-color); +// +// /* The background color of the audio recording button in disabled state */ +// --str-chat__start-recording-audio-button-disabled-background-color: transparent; +// +// /* The border radius used for the borders of the tool buttons of the message input (such as attachment upload button) */ +// --str-chat__message-input-tools-border-radius: var(--str-chat__border-radius-circle); +// +// /* The text/icon color of the tool buttons of the message input (such as attachment upload button) */ +// --str-chat__message-input-tools-color: var(--str-chat__text-low-emphasis-color); +// +// /* The background color of the tool buttons of the message input (such as attachment upload button) */ +// --str-chat__message-input-tools-background-color: transparent; +// +// /* Top border of the tool buttons of the message input (such as attachment upload button) */ +// --str-chat__message-input-tools-border-block-start: 0; +// +// /* Bottom border of the tool buttons of the message input (such as attachment upload button) */ +// --str-chat__message-input-tools-border-block-end: 0; +// +// /* Left (right in RTL layout) border of the tool buttons of the message input (such as attachment upload button) */ +// --str-chat__message-input-tools-border-inline-start: 0; +// +// /* Right (left in RTL layout) border of the tool buttons of the message input (such as attachment upload button) */ +// --str-chat__message-input-tools-border-inline-end: 0; +// +// /* Box shadow applied to the tool buttons of the message input (such as attachment upload button) */ +// --str-chat__message-input-tools-box-shadow: none; +// + +// +// /* Color applied to an icon in a button that opens attachment selector */ +// --str-chat__attachment-selector-button-icon-color: var(--str-chat__text-low-emphasis-color); +// +// /* Color applied to an icon in a button that opens attachment selector when hovered over */ +// --str-chat__attachment-selector-button-icon-color-hover: var(--str-chat__primary-color); +// +// /* Color applied to an attachment selector menu item icon when hovered over */ +// --str-chat__attachment-selector-actions-menu-button-icon-color: var(--str-chat__primary-color); +// +// /* Color applied to an attachment selector menu item icon when hovered over or focused */ +// --str-chat__attachment-selector-actions-menu-button-icon-color-active: var( +// --str-chat__primary-color +// ); +//} +// +//.str-chat__message-input { +// @include utils.component-layer-overrides('message-input'); +// +// .str-chat__file-input-container { +// --str-chat-icon-color: var(--str-chat__message-input-tools-color); +// @include utils.component-layer-overrides('message-input-tools'); +// +// svg path { +// fill: var(--str-chat__message-input-tools-color); +// } +// } +// +// .str-chat__attachment-preview-image-error { +// svg path { +// fill: var(--str-chat__primary-color); +// } +// } +// +// +// +//.str-chat__attachment-selector-actions-menu { +// .str-chat__attachment-selector-actions-menu__button { +// color: var(--str-chat__text-low-emphasis-color); +// +// .str-chat__context-menu__button-icon { +// background-color: var(--str-chat__attachment-selector-actions-menu-button-icon-color); +// } +// +// &:hover, +// &:focus { +// color: var(--str-chat__text-color); +// +// .str-chat__context-menu__button-icon { +// background-color: var( +// --str-chat__attachment-selector-actions-menu-button-icon-color-active +// ); +// } +// } +// } +// +// .str-chat__attachment-selector-actions-menu__upload-file-button { +// .str-chat__context-menu__button-icon { +// -webkit-mask: var(--str-chat__folder-icon) no-repeat center / contain; +// mask: var(--str-chat__folder-icon) no-repeat center / contain; +// } +// } +// +// .str-chat__attachment-selector-actions-menu__create-poll-button { +// .str-chat__context-menu__button-icon { +// -webkit-mask: var(--str-chat__poll-icon) no-repeat center / contain; +// mask: var(--str-chat__poll-icon) no-repeat center / contain; +// } +// } +// +// .str-chat__attachment-selector-actions-menu__add-location-button { +// .str-chat__context-menu__button-icon { +// -webkit-mask: var(--str-chat__location-icon) no-repeat center / contain; +// mask: var(--str-chat__location-icon) no-repeat center / contain; +// } +// } +//} +// \ No newline at end of file diff --git a/src/components/MessageInput/styling/QuotedMessageIndicator.scss b/src/components/MessageInput/styling/QuotedMessageIndicator.scss new file mode 100644 index 0000000000..2ed2727226 --- /dev/null +++ b/src/components/MessageInput/styling/QuotedMessageIndicator.scss @@ -0,0 +1,10 @@ +.str-chat__quoted-message-indicator { + background-color: var(--chat-reply-indicator-incoming); + border-radius: var(--radius-max); + height: 100%; + width: 2px; +} + +.str-chat__quoted-message-indicator--own-message { + background-color: var(--chat-reply-indicator-outgoing); +} \ No newline at end of file diff --git a/src/components/MessageInput/styling/QuotedMessagePreview.scss b/src/components/MessageInput/styling/QuotedMessagePreview.scss new file mode 100644 index 0000000000..79a98f5c51 --- /dev/null +++ b/src/components/MessageInput/styling/QuotedMessagePreview.scss @@ -0,0 +1,87 @@ +@use '../../../styling/utils'; + +.str-chat { + + .str-chat__quoted-message-preview { + display: flex; + align-items: center; + position: relative; + background-color: var(--chat-bg-incoming); + padding: var(--spacing-xs); + border-radius: var(--message-bubble-radius-attachment); + + .str-chat__quoted-message-indicator { + height: 36px; + } + + .str-chat__quoted-message-preview__content { + display: flex; + flex-direction: column; + justify-content: center; + flex: 1; + min-width: 0; + font-size: var(--typography-font-size-xs); + line-height: var(--typography-line-height-tight); + height: 40px; // to keep the same height even though the image preview is missing (it has 40px height) + + .str-chat__quoted-message-preview__author { + @include utils.ellipsis-text(); + overflow-x: hidden; // force ellipsis to show + font-weight: var(--typography-font-weight-semi-bold); + } + + .str-chat__quoted-message-preview__message { + //@include utils.ellipsis-text-parent; + display: flex; + align-items: center; + gap: var(--spacing-xxs); + + svg { + height: var(--typography-font-size-xs); + width: var(--typography-font-size-xs); + } + + .str-chat__icon--microphone path { + stroke-width: 2; + } + + span { + @include utils.ellipsis-text(); + min-width: 0; + flex: 1 1; + } + } + } + + .str-chat__quoted-message-preview__image { + display: flex; + position: relative; + + .str-chat__attachment-preview__thumbnail__play-indicator { + display: flex; + align-items: center; + justify-content: center; + height: 20px; + width: 20px; + position: absolute; + left: 10px; + top: 10px; + border-radius: var(--radius-max); + background-color: var(--control-play-control-bg-inverse); + + .str-chat__icon--play-solid { + height: 12px; + width: 12px; + + path { + fill: var(--control-play-control-icon-inverse); + } + } + } + } + } + + .str-chat__quoted-message-preview--own { + background-color: var(--chat-bg-outgoing); + } +} \ No newline at end of file diff --git a/src/components/MessageInput/styling/RemoveAttachmentPreviewButton.scss b/src/components/MessageInput/styling/RemoveAttachmentPreviewButton.scss new file mode 100644 index 0000000000..1fccae20e5 --- /dev/null +++ b/src/components/MessageInput/styling/RemoveAttachmentPreviewButton.scss @@ -0,0 +1,11 @@ +.str-chat__button.str-chat__attachment-preview__remove-button { + position: absolute; + z-index: 1; + // todo: do we need semantic variable here? + top: -6px; + right: -6px; + // todo: replace --base-black with semantic variable + background-color: var(--base-black); + border: 3px solid var(--control-remove-control-border); + border-radius: var(--radius-max); +} \ No newline at end of file diff --git a/src/components/MessageInput/styling/index.scss b/src/components/MessageInput/styling/index.scss new file mode 100644 index 0000000000..eeed997fb7 --- /dev/null +++ b/src/components/MessageInput/styling/index.scss @@ -0,0 +1,8 @@ +@use "AttachmentPreview"; +@use "AttachmentPreviewThumbnail"; +@use "AttachmentSelector"; +@use "LinkPreviewList"; +@use "MessageComposer"; +@use "QuotedMessageIndicator"; +@use "QuotedMessagePreview"; +@use "RemoveAttachmentPreviewButton"; \ No newline at end of file diff --git a/src/components/Poll/Poll.tsx b/src/components/Poll/Poll.tsx index ce140280d6..702f29539b 100644 --- a/src/components/Poll/Poll.tsx +++ b/src/components/Poll/Poll.tsx @@ -4,6 +4,7 @@ import { QuotedPoll as DefaultQuotedPoll } from './QuotedPoll'; import { PollProvider, useComponentContext } from '../../context'; import type { Poll as PollClass } from 'stream-chat'; +// todo: remove QuotedPoll component references export const Poll = ({ isQuoted, poll }: { poll: PollClass; isQuoted?: boolean }) => { const { PollContent = DefaultPollContent, QuotedPoll = DefaultQuotedPoll } = useComponentContext(); diff --git a/src/components/Poll/QuotedPoll.tsx b/src/components/Poll/QuotedPoll.tsx index 62979197f2..ec70e08add 100644 --- a/src/components/Poll/QuotedPoll.tsx +++ b/src/components/Poll/QuotedPoll.tsx @@ -15,6 +15,7 @@ const pollStateSelectorQuotedPoll = ( name: nextValue.name, }); +// todo: export const QuotedPoll = () => { const { poll } = usePollContext(); const { is_closed, name } = useStateStore(poll.state, pollStateSelectorQuotedPoll); diff --git a/src/components/ReactFileUtilities/FileIcon/FileIcon.tsx b/src/components/ReactFileUtilities/FileIcon/FileIcon.tsx deleted file mode 100644 index e8be8a2f5e..0000000000 --- a/src/components/ReactFileUtilities/FileIcon/FileIcon.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; - -import type { IconType } from './iconMap'; -import { iconMap } from './iconMap'; - -export type FileIconProps = { - big?: boolean; - className?: string; - filename?: string; - mimeType?: string; - size?: number; // big icon on sent attachment - sizeSmall?: number; // small icon on file upload preview - type?: IconType; -}; - -export function mimeTypeToIcon(type: IconType = 'standard', mimeType?: string) { - const theMap = iconMap[type] || iconMap['standard']; - - if (!mimeType) return theMap.fallback; - - const icon = theMap[mimeType]; - if (icon) return icon; - - if (mimeType.startsWith('audio/')) return theMap['audio/']; - if (mimeType.startsWith('video/')) return theMap['video/']; - if (mimeType.startsWith('image/')) return theMap['image/']; - if (mimeType.startsWith('text/')) return theMap['text/']; - - return theMap.fallback; -} - -export const FileIcon = (props: FileIconProps) => { - const { - big = false, - mimeType, - size = 50, - sizeSmall = 20, - type = 'standard', - ...rest - } = props; - - const Icon = mimeTypeToIcon(type, mimeType); - - return ; -}; diff --git a/src/components/ReactFileUtilities/FileIcon/FileIconSet.tsx b/src/components/ReactFileUtilities/FileIcon/FileIconSet.tsx deleted file mode 100644 index 44c245117d..0000000000 --- a/src/components/ReactFileUtilities/FileIcon/FileIconSet.tsx +++ /dev/null @@ -1,618 +0,0 @@ -import type { ComponentPropsWithoutRef } from 'react'; -import React from 'react'; -import clsx from 'clsx'; - -export type IconTypeV2 = 'standard' | 'alt'; - -export type IconProps = { - mimeType?: string; - size?: number; - type?: IconTypeV2; -} & ComponentPropsWithoutRef<'svg'>; - -const DEFAULT_SIZE = 40; - -export const FilePdfIcon = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - -); - -export const FileWordIcon = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - - -); - -export const FileWordIconAlt = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - - - - - - - -); - -export const FilePowerPointIcon = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - -); - -export const FilePowerPointIconAlt = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - - - - - -); - -export const FileExcelIcon = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - -); - -export const FileExcelIconAlt = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - - - - - -); - -export const FileArchiveIcon = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - -); - -export const FileArchiveIconAlt = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - - - - - -); - -export const FileCodeIcon = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - -); - -export const FileCodeIconAlt = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - - - - - -); - -export const FileAudioIcon = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - -); - -export const FileAudioIconAlt = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - - - - - -); - -export const FileVideoIcon = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - -); - -export const FileVideoIconAlt = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - - - - - -); - -export const FileFallbackIcon = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - - - - - - - -); - -// v1 icon without possibility to specify size via props -export const FileImageIcon = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - -); diff --git a/src/components/ReactFileUtilities/FileIcon/mimeTypes.ts b/src/components/ReactFileUtilities/FileIcon/mimeTypes.ts deleted file mode 100644 index 2a9623ee5b..0000000000 --- a/src/components/ReactFileUtilities/FileIcon/mimeTypes.ts +++ /dev/null @@ -1,163 +0,0 @@ -export type GeneralType = 'audio/' | 'video/' | 'image/' | 'text/'; - -export type SupportedMimeType = - | (typeof wordMimeTypes)[number] - | (typeof excelMimeTypes)[number] - | (typeof powerpointMimeTypes)[number] - | (typeof archiveFileTypes)[number] - | (typeof codeFileTypes)[number]; - -export const wordMimeTypes = [ - // Microsoft Word - // .doc .dot - 'application/msword', - // .doc .dot - 'application/msword-template', - // .docx - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - // .dotx (no test) - 'application/vnd.openxmlformats-officedocument.wordprocessingml.template', - // .docm - 'application/vnd.ms-word.document.macroEnabled.12', - // .dotm (no test) - 'application/vnd.ms-word.template.macroEnabled.12', - - // LibreOffice/OpenOffice Writer - // .odt - 'application/vnd.oasis.opendocument.text', - // .ott - 'application/vnd.oasis.opendocument.text-template', - // .fodt - 'application/vnd.oasis.opendocument.text-flat-xml', - // .uot - // NOTE: firefox doesn't know mimetype so maybe ignore -]; - -export const excelMimeTypes = [ - // .csv - 'text/csv', - // TODO: maybe more data files - - // Microsoft Excel - // .xls .xlt .xla (no test for .xla) - 'application/vnd.ms-excel', - // .xlsx - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - // .xltx (no test) - 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', - // .xlsm - 'application/vnd.ms-excel.sheet.macroEnabled.12', - // .xltm (no test) - 'application/vnd.ms-excel.template.macroEnabled.12', - // .xlam (no test) - 'application/vnd.ms-excel.addin.macroEnabled.12', - // .xlsb (no test) - 'application/vnd.ms-excel.addin.macroEnabled.12', - - // LibreOffice/OpenOffice Calc - // .ods - 'application/vnd.oasis.opendocument.spreadsheet', - // .ots - 'application/vnd.oasis.opendocument.spreadsheet-template', - // .fods - 'application/vnd.oasis.opendocument.spreadsheet-flat-xml', - // .uos - // NOTE: firefox doesn't know mimetype so maybe ignore -]; - -export const powerpointMimeTypes = [ - // Microsoft Word - // .ppt .pot .pps .ppa (no test for .ppa) - 'application/vnd.ms-powerpoint', - // .pptx - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - // .potx (no test) - 'application/vnd.openxmlformats-officedocument.presentationml.template', - // .ppsx - 'application/vnd.openxmlformats-officedocument.presentationml.slideshow', - // .ppam - 'application/vnd.ms-powerpoint.addin.macroEnabled.12', - // .pptm - 'application/vnd.ms-powerpoint.presentation.macroEnabled.12', - // .potm - 'application/vnd.ms-powerpoint.template.macroEnabled.12', - // .ppsm - 'application/vnd.ms-powerpoint.slideshow.macroEnabled.12', - - // LibreOffice/OpenOffice Writer - // .odp - 'application/vnd.oasis.opendocument.presentation', - // .otp - 'application/vnd.oasis.opendocument.presentation-template', - // .fodp - 'application/vnd.oasis.opendocument.presentation-flat-xml', - // .uop - // NOTE: firefox doesn't know mimetype so maybe ignore -]; - -export const archiveFileTypes = [ - // .zip - 'application/zip', - // .z7 - 'application/x-7z-compressed', - // .ar - 'application/x-archive', - // .tar - 'application/x-tar', - // .tar.gz - 'application/gzip', - // .tar.Z - 'application/x-compress', - // .tar.bz2 - 'application/x-bzip', - // .tar.lz - 'application/x-lzip', - // .tar.lz4 - 'application/x-lz4', - // .tar.lzma - 'application/x-lzma', - // .tar.lzo (no test) - 'application/x-lzop', - // .tar.xz - 'application/x-xz', - // .war - 'application/x-webarchive', - // .rar - 'application/vnd.rar', -]; - -export const codeFileTypes = [ - // .html .htm - 'text/html', - // .css - 'text/css', - // .js - 'application/x-javascript', - 'text/javascript', - // .json - 'application/json', - // .py - 'text/x-python', - // .go - 'text/x-go', - // .c - 'text/x-csrc', - // .cpp - 'text/x-c++src', - // .rb - 'application/x-ruby', - // .rust - 'text/rust', - // .java - 'text/x-java', - // .php - 'application/x-php', - // .cs - 'text/x-csharp', - // .scala - 'text/x-scala', - // .erl - 'text/x-erlang', - // .sh - 'application/x-shellscript', -]; diff --git a/src/components/ReactFileUtilities/UploadButton.tsx b/src/components/ReactFileUtilities/UploadButton.tsx index a4e4f78b07..081668871d 100644 --- a/src/components/ReactFileUtilities/UploadButton.tsx +++ b/src/components/ReactFileUtilities/UploadButton.tsx @@ -7,6 +7,7 @@ import { useHandleFileChangeWrapper } from './utils'; import { useMessageInputContext, useTranslationContext } from '../../context'; import { useMessageComposer } from '../MessageInput'; import { useAttachmentManagerState } from '../MessageInput/hooks/useAttachmentManagerState'; +import { useIsCooldownActive } from '../MessageInput/hooks/useIsCooldownActive'; import { useStateStore } from '../../store'; import type { MessageComposerConfig } from 'stream-chat'; import type { PartialSelected } from '../../types/types'; @@ -50,7 +51,7 @@ export const UploadFileInput = forwardRef(function UploadFileInput( ref: React.ForwardedRef, ) { const { t } = useTranslationContext('UploadFileInput'); - const { cooldownRemaining, textareaRef } = useMessageInputContext(); + const { textareaRef } = useMessageInputContext(); const messageComposer = useMessageComposer(); const { attachmentManager } = messageComposer; const { isUploadEnabled } = useAttachmentManagerState(); @@ -58,6 +59,7 @@ export const UploadFileInput = forwardRef(function UploadFileInput( messageComposer.configState, attachmentManagerConfigStateSelector, ); + const isCooldownActive = useIsCooldownActive(); const id = useMemo(() => nanoid(), []); const onFileChange = useCallback( @@ -74,7 +76,7 @@ export const UploadFileInput = forwardRef(function UploadFileInput( accept={acceptedFiles?.join(',')} aria-label={t('aria/File upload')} data-testid='file-input' - disabled={!isUploadEnabled || !!cooldownRemaining} + disabled={!isUploadEnabled || isCooldownActive} id={id} multiple={maxNumberOfFilesPerMessage > 1} {...props} diff --git a/src/components/ReactFileUtilities/index.ts b/src/components/ReactFileUtilities/index.ts index 667a490567..a3a8c7251a 100644 --- a/src/components/ReactFileUtilities/index.ts +++ b/src/components/ReactFileUtilities/index.ts @@ -1,4 +1,3 @@ -export * from './FileIcon'; export * from './LoadingIndicator'; export * from './UploadButton'; export * from './types'; diff --git a/src/components/TextareaComposer/TextareaComposer.tsx b/src/components/TextareaComposer/TextareaComposer.tsx index 7b19c2c956..3b68c67a66 100644 --- a/src/components/TextareaComposer/TextareaComposer.tsx +++ b/src/components/TextareaComposer/TextareaComposer.tsx @@ -7,7 +7,7 @@ import type { } from 'react'; import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import Textarea from 'react-textarea-autosize'; -import { useMessageComposer } from '../MessageInput'; +import { useCooldownRemaining, useMessageComposer } from '../MessageInput'; import type { AttachmentManagerState, MessageComposerConfig, @@ -87,7 +87,6 @@ export const TextareaComposer = ({ const { AutocompleteSuggestionList = DefaultSuggestionList } = useComponentContext(); const { additionalTextareaProps, - cooldownRemaining, focus, handleSubmit, maxRows: maxRowsContext, @@ -96,9 +95,15 @@ export const TextareaComposer = ({ shouldSubmit: shouldSubmitContext, textareaRef, } = useMessageInputContext(); + const cooldownRemaining = useCooldownRemaining(); + + const placeholder = cooldownRemaining + ? t('Slow mode, wait {{ seconds }}s...', { seconds: cooldownRemaining }) + : (placeholderProp ?? additionalTextareaProps?.placeholder ?? t('Type your message')); + const maxRows = maxRowsProp ?? maxRowsContext ?? 1; const minRows = minRowsProp ?? minRowsContext; - const placeholder = placeholderProp ?? additionalTextareaProps?.placeholder; + const shouldSubmit = shouldSubmitProp ?? shouldSubmitContext ?? defaultShouldSubmit; const messageComposer = useMessageComposer(); @@ -282,19 +287,14 @@ export const TextareaComposer = ({ return (