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/PlayButton.tsx b/src/components/Button/PlayButton.tsx
new file mode 100644
index 0000000000..459f79014d
--- /dev/null
+++ b/src/components/Button/PlayButton.tsx
@@ -0,0 +1,25 @@
+import { Button } from './Button';
+import type { ComponentProps } from 'react';
+import clsx from 'clsx';
+import { IconPause, IconPlaySolid } from '../Icons';
+
+export type PlayButtonProps = ComponentProps<'button'> & {
+ isPlaying: boolean;
+};
+
+export const PlayButton = ({ className, isPlaying, ...props }: PlayButtonProps) => (
+
+);
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 ? : }
) : (
);
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')}
+ {
+ handleRetry(attachment);
+ }}
+ type='button'
+ >
+ {t('Retry upload')}
+
+
+ )}
+
+
+ {
+ 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 (
-
-
-
+
+
-
- attachment.localMetadata?.id &&
- removeAttachments([attachment.localMetadata?.id])
- }
- type='button'
- >
-
-
-
- {['blocked', 'failed'].includes(uploadState) && !!handleRetry && (
-
{
- handleRetry(attachment);
- }}
- >
-
-
- )}
-
-
+
{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')}
+ {
+ handleRetry(attachment);
+ }}
+ type='button'
+ >
+ {t('Retry upload')}
+
+
+ )}
+
-
+
+
{
+ 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 (
-
-
id && removeAttachments([id])}
- type='button'
- >
-
-
-
- {['blocked', 'failed'].includes(uploadState) && (
-
handleRetry(attachment)}
- >
-
-
- )}
-
- {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 (
-
-
-
-
-
-
- attachment.localMetadata?.id &&
- removeAttachments([attachment.localMetadata?.id])
- }
- type='button'
- >
-
-
-
- {isLocalUploadAttachment(attachment) &&
- ['blocked', 'failed'].includes(attachment.localMetadata?.uploadState) &&
- !!handleRetry && (
-
handleRetry(attachment)}
- >
-
-
- )}
-
-
-
- {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 (
-
-
-
-
- attachment.localMetadata?.id && removeAttachments([attachment.localMetadata.id])
- }
- type='button'
- >
-
-
-
- {['blocked', 'failed'].includes(attachment.localMetadata?.uploadState) &&
- !!handleRetry && (
-
handleRetry(attachment)}
- >
-
-
- )}
-
-
-
- {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'] &&
}
-
menuDialog?.toggle()}
ref={menuButtonRef}
- >
-
-
+ />
>;
-};
-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}
+
-
linkPreviewsManager.dismissPreview(linkPreview.og_scrape_url)}
- type='button'
- >
-
-
+ />
);
};
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')}
-
-
messageComposer.setQuotedMessage(null)}
- >
-
-
-
+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')}
+
+
+
+ {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 (
);
};
diff --git a/src/plugins/Emojis/icons.tsx b/src/plugins/Emojis/icons.tsx
index a71b506c8f..e30d12bef4 100644
--- a/src/plugins/Emojis/icons.tsx
+++ b/src/plugins/Emojis/icons.tsx
@@ -1,14 +1,29 @@
-import React from 'react';
+import React, { type ComponentProps } from 'react';
+import clsx from 'clsx';
-export const EmojiPickerIcon = () => (
+export const EmojiPickerIcon = ({ className, ...props }: ComponentProps<'svg'>) => (
-
-
-
+
+
+
+
);
diff --git a/src/plugins/Emojis/styling/icons.scss b/src/plugins/Emojis/styling/icons.scss
new file mode 100644
index 0000000000..1ae3afd371
--- /dev/null
+++ b/src/plugins/Emojis/styling/icons.scss
@@ -0,0 +1,7 @@
+.str-chat {
+ .str-chat__icon--emoji {
+ path {
+ stroke-width: 1.2;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/plugins/Emojis/styling/index.scss b/src/plugins/Emojis/styling/index.scss
new file mode 100644
index 0000000000..a7a9ede6c7
--- /dev/null
+++ b/src/plugins/Emojis/styling/index.scss
@@ -0,0 +1 @@
+@use "icons";
\ No newline at end of file
diff --git a/src/styling/_utils.scss b/src/styling/_utils.scss
new file mode 100644
index 0000000000..6547bd2dfd
--- /dev/null
+++ b/src/styling/_utils.scss
@@ -0,0 +1,61 @@
+@mixin button-reset {
+ background: none;
+ border: none;
+ padding-inline: 0;
+}
+
+@mixin overlay-after($color: black) {
+ &::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: $color;
+ pointer-events: none;
+ }
+}
+
+@mixin flex-row-center {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+@mixin flex-col-center {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+@mixin ellipsis-text-parent {
+ overflow-y: visible;
+ // Not every browser supports clip, so hidden is defined as a fallback
+ overflow-x: hidden;
+ overflow-x: clip;
+ min-width: 0;
+}
+
+@mixin ellipsis-text {
+ white-space: nowrap;
+ overflow-y: visible;
+ // Not every browser supports clip, so hidden is defined as a fallback
+ overflow-x: hidden;
+ overflow-x: clip;
+ text-overflow: ellipsis;
+}
+
+@mixin unset-button($outline-radius: var(--radius-max)) {
+ padding: unset;
+ background: unset;
+ border: unset;
+ border-radius: $outline-radius;
+}
+
+@mixin loading-item-background($color) {
+ background-image: linear-gradient(-90deg, #{$color} 0%, #{$color} 100%);
+}
+
+@mixin focusable {
+ outline: 2px solid var(--border-utility-focus);
+ outline-offset: 2px;
+}
\ No newline at end of file
diff --git a/src/styling/_variables.scss b/src/styling/_variables.scss
new file mode 100644
index 0000000000..1f7e616f97
--- /dev/null
+++ b/src/styling/_variables.scss
@@ -0,0 +1,2 @@
+/* declare asset path, useful if you want to create your own style bundle */
+$assetsPath: '../assets' !default;
\ No newline at end of file
diff --git a/src/styling/accessibility.scss b/src/styling/accessibility.scss
new file mode 100644
index 0000000000..d74c8f01ab
--- /dev/null
+++ b/src/styling/accessibility.scss
@@ -0,0 +1,6 @@
+@media (prefers-reduced-motion: reduce) {
+ // todo: come up with a general class that will apply the transform
+ .str-chat__attachment-selector__menu-button__icon {
+ transform: none;
+ }
+}
\ No newline at end of file
diff --git a/src/styling/animations.scss b/src/styling/animations.scss
new file mode 100644
index 0000000000..53407136d8
--- /dev/null
+++ b/src/styling/animations.scss
@@ -0,0 +1,31 @@
+.str-chat__prepare-rotate45 {
+ transition: transform 200ms ease;
+ transform: rotate(0);
+ transform-origin: center center;
+}
+
+.str-chat__rotate45 {
+ transform: rotate(45deg);
+}
+
+@mixin loading-animation {
+ animation: pulsate 1s linear 0s infinite alternate;
+
+ &:nth-of-type(2) {
+ animation: pulsate 1s linear 0.3334s infinite alternate;
+ }
+
+ &:last-of-type {
+ animation: pulsate 1s linear 0.6667s infinite alternate;
+ }
+
+ @keyframes pulsate {
+ from {
+ opacity: 0.5;
+ }
+
+ to {
+ opacity: 1;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/styling/assets/EmojiOneColor.woff2 b/src/styling/assets/EmojiOneColor.woff2
new file mode 100644
index 0000000000..823042cf74
Binary files /dev/null and b/src/styling/assets/EmojiOneColor.woff2 differ
diff --git a/src/styling/assets/NotoColorEmoji-flags.woff2 b/src/styling/assets/NotoColorEmoji-flags.woff2
new file mode 100644
index 0000000000..adaeb48e0f
Binary files /dev/null and b/src/styling/assets/NotoColorEmoji-flags.woff2 differ
diff --git a/src/styling/assets/icons/stream-chat-icons.eot b/src/styling/assets/icons/stream-chat-icons.eot
new file mode 100644
index 0000000000..441758224b
Binary files /dev/null and b/src/styling/assets/icons/stream-chat-icons.eot differ
diff --git a/src/styling/assets/icons/stream-chat-icons.svg b/src/styling/assets/icons/stream-chat-icons.svg
new file mode 100644
index 0000000000..3bee19686a
--- /dev/null
+++ b/src/styling/assets/icons/stream-chat-icons.svg
@@ -0,0 +1,50 @@
+
+
+
+Copyright (C) 2024 by original authors @ fontello.com
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/styling/assets/icons/stream-chat-icons.ttf b/src/styling/assets/icons/stream-chat-icons.ttf
new file mode 100644
index 0000000000..cd24d00b89
Binary files /dev/null and b/src/styling/assets/icons/stream-chat-icons.ttf differ
diff --git a/src/styling/assets/icons/stream-chat-icons.woff b/src/styling/assets/icons/stream-chat-icons.woff
new file mode 100644
index 0000000000..0538a46a86
Binary files /dev/null and b/src/styling/assets/icons/stream-chat-icons.woff differ
diff --git a/src/styling/assets/icons/stream-chat-icons.woff2 b/src/styling/assets/icons/stream-chat-icons.woff2
new file mode 100644
index 0000000000..a9fab6d3b1
Binary files /dev/null and b/src/styling/assets/icons/stream-chat-icons.woff2 differ
diff --git a/src/styling/base.scss b/src/styling/base.scss
index bfa3b05829..bad56e1a6d 100644
--- a/src/styling/base.scss
+++ b/src/styling/base.scss
@@ -1,4 +1,10 @@
.str-chat {
font-family: var(--typography-font-family-sans), system-ui, -apple-system, BlinkMacSystemFont, “Segoe UI”, Roboto, sans-serif;
font-size: var(--typography-font-size-md);
+ font-weight: var(--typography-font-weight-regular);
+
+ *:not(:disabled):focus-visible {
+ outline: 2px solid var(--border-utility-focus);
+ outline-offset: 2px;
+ }
}
\ No newline at end of file
diff --git a/src/styling/icons.scss b/src/styling/icons.scss
new file mode 100644
index 0000000000..e2dbcb2573
--- /dev/null
+++ b/src/styling/icons.scss
@@ -0,0 +1,21 @@
+@use 'variables';
+
+@font-face {
+ font-family: 'stream-chat-icons';
+ src: url('#{variables.$assetsPath}/icons/stream-chat-icons.eot');
+ src: url('#{variables.$assetsPath}/icons/stream-chat-icons.eot#iefix') format('embedded-opentype'),
+ url('#{variables.$assetsPath}/icons/stream-chat-icons.woff') format('woff2'),
+ url('#{variables.$assetsPath}/icons/stream-chat-icons.woff') format('woff'),
+ url('#{variables.$assetsPath}/icons/stream-chat-icons.ttf') format('truetype'),
+ url('#{variables.$assetsPath}/icons/stream-chat-icons.svg#stream-chat-icons') format('svg');
+ font-weight: normal;
+ font-style: normal;
+}
+/* Chrome hack: SVG is rendered more smooth in Windozze. 100% magic, uncomment if you need it. */
+/* Note, that will break hinting! In other OS-es font will be not as sharp as it could be */
+@media screen and (-webkit-min-device-pixel-ratio: 0) {
+ @font-face {
+ font-family: 'stream-chat-icons';
+ src: url('#{variables.$assetsPath}/icons/stream-chat-icons.svg#stream-chat-icons') format('svg');
+ }
+}
\ No newline at end of file
diff --git a/src/styling/index.scss b/src/styling/index.scss
index c40c370069..59a6393b63 100644
--- a/src/styling/index.scss
+++ b/src/styling/index.scss
@@ -1,10 +1,21 @@
// Base styles
+@use "./accessibility";
+@use "./animations";
@use "./base";
+@use "./icons";
@use "./variables.css";
-@use "../components/Icons/styling";
-// Base components with base styles
+// alias is necessary to allow sass create namespaces with different names (and not same name styling)
+@use "../components/Icons/styling" as Icons;
+
+// Base components
@use "../components/Button/styling/Button";
+@use "../components/Dialog/styling" as Dialog;
+
+// Specific components
+@use "../components/AudioPlayback/styling" as AudioPlayback;
+@use "../components/FileIcon/styling/FileIcon";
+@use "../components/MessageInput/styling" as MessageComposer;
// Layers have to be kept the last
@import 'modern-normalize' layer(css-reset);
diff --git a/src/styling/utils.scss b/src/styling/utils.scss
deleted file mode 100644
index 8a511919cc..0000000000
--- a/src/styling/utils.scss
+++ /dev/null
@@ -1,14 +0,0 @@
-@mixin button-reset {
- background: none;
- border: none;
-}
-
-@mixin overlay-after($opacity, $color: black) {
- &::after {
- content: "";
- position: absolute;
- inset: 0;
- background: rgba($color, $opacity);
- pointer-events: none;
- }
-}
\ No newline at end of file
diff --git a/src/styling/variables.css b/src/styling/variables.css
index d98c7e512b..4d357e4ae8 100644
--- a/src/styling/variables.css
+++ b/src/styling/variables.css
@@ -1,100 +1,127 @@
/**
- Populate with px unit all the variables in @stream-chat-react/src/styling/variables.css
- where it makes sense according to the variable name. This file has be copied in from another repository.
- If a value is 0, do not append px. Line height is also unit-less.
+ * Do not edit directly, this file was auto-generated.
*/
+
.str-chat {
- --base-transparent: rgba(255, 255, 255, 0);
+ --base-transparent-0: rgba(255, 255, 255, 0);
+ --base-transparent-black-5: rgba(0, 0, 0, 0.05); /** Used for bg in closeButton */
+ --base-transparent-black-10: rgba(0, 0, 0, 0.1); /** Used for bg in closeButton */
+ --base-transparent-white-70: rgba(255, 255, 255, 0.7);
+ --base-transparent-white-10: rgba(255, 255, 255, 0.1);
+ --base-transparent-white-20: rgba(255, 255, 255, 0.2);
+ --base-transparent-black-70: rgba(0, 0, 0, 0.7); /** Used for bg in closeButton */
--base-black: #000000;
--base-white: #ffffff;
- --slate-50: #fafbfc;
- --slate-100: #f2f4f6;
- --slate-200: #e2e6ea;
- --slate-300: #d0d5da;
- --slate-400: #b8bec4;
- --slate-500: #9ea4aa;
- --slate-600: #838990;
- --slate-700: #6a7077;
- --slate-800: #50565d;
- --slate-900: #384047;
- --slate-950: #1e252b;
- --neutral-50: #f7f7f7;
- --neutral-100: #ededed;
- --neutral-200: #d9d9d9;
- --neutral-300: #c1c1c1;
- --neutral-400: #a3a3a3;
- --neutral-500: #7f7f7f;
- --neutral-600: #636363;
- --neutral-700: #4a4a4a;
- --neutral-800: #383838;
- --neutral-900: #262626;
- --neutral-950: #151515;
- --blue-50: #ebf3ff;
- --blue-100: #d2e3ff;
- --blue-200: #a6c4ff;
- --blue-300: #7aa7ff;
- --blue-400: #4e8bff;
+ --slate-50: #f6f8fa;
+ --slate-100: #ebeef1;
+ --slate-150: #d5dbe1;
+ --slate-200: #c0c8d2;
+ --slate-300: #a3acba;
+ --slate-400: #87909f;
+ --slate-500: #687385;
+ --slate-600: #545969;
+ --slate-700: #414552;
+ --slate-800: #30313d;
+ --slate-900: #1a1b25;
+ --neutral-50: #f8f8f8;
+ --neutral-100: #efefef;
+ --neutral-150: #d8d8d8;
+ --neutral-200: #c4c4c4;
+ --neutral-300: #ababab;
+ --neutral-400: #8f8f8f;
+ --neutral-500: #6a6a6a;
+ --neutral-600: #565656;
+ --neutral-700: #464646;
+ --neutral-800: #323232;
+ --neutral-900: #1c1c1c;
+ --blue-50: #f3f7ff;
+ --blue-100: #e3edff;
+ --blue-150: #c3d9ff;
+ --blue-200: #a5c5ff;
+ --blue-300: #78a8ff;
+ --blue-400: #4586ff;
--blue-500: #005fff;
- --blue-600: #0052ce;
- --blue-700: #0042a3;
- --blue-800: #003179;
- --blue-900: #001f4f;
- --blue-950: #001025;
- --cyan-50: #f0fcfe;
- --cyan-100: #d7f7fb;
- --cyan-200: #bdf1f8;
- --cyan-300: #a3ecf4;
- --cyan-400: #89e6f1;
- --cyan-500: #69e5f6;
- --cyan-600: #3ec9d9;
- --cyan-700: #28a8b5;
- --cyan-800: #1c8791;
- --cyan-900: #125f66;
- --cyan-950: #0b3d44;
- --green-50: #e8fff5;
- --green-100: #c9fce7;
- --green-200: #a9f8d9;
- --green-300: #88f2ca;
- --green-400: #59e9b5;
- --green-500: #00e2a1;
- --green-600: #00b681;
- --green-700: #008d64;
- --green-800: #006548;
- --green-900: #003d2b;
- --green-950: #002319;
- --purple-50: #f5effe;
- --purple-100: #ebdefd;
- --purple-200: #d8bffc;
- --purple-300: #c79ffc;
- --purple-400: #b98af9;
- --purple-500: #b38af8;
- --purple-600: #996ce3;
- --purple-700: #7f55c7;
- --purple-800: #6640ab;
- --purple-900: #4d2c8f;
- --purple-950: #351c6b;
- --yellow-50: #fff9e5;
- --yellow-100: #fff1c2;
- --yellow-200: #ffe8a0;
- --yellow-300: #ffde7d;
- --yellow-400: #ffd65a;
- --yellow-500: #ffd233;
- --yellow-600: #e6b400;
- --yellow-700: #c59600;
- --yellow-800: #9f7700;
- --yellow-900: #7a5a00;
- --yellow-950: #4f3900;
- --red-50: #fcebea;
- --red-100: #f8cfcd;
- --red-200: #f3b3b0;
- --red-300: #ed958f;
- --red-400: #e6756c;
- --red-500: #d92f26;
- --red-600: #b9261f;
- --red-700: #98201a;
- --red-800: #761915;
- --red-900: #54120f;
- --red-950: #360b09;
+ --blue-600: #1b53bd;
+ --blue-700: #19418d;
+ --blue-800: #142f63;
+ --blue-900: #091a3b;
+ --cyan-50: #f1fbfc;
+ --cyan-100: #d1f3f6;
+ --cyan-150: #a9e4ea;
+ --cyan-200: #72d7e0;
+ --cyan-300: #45bcc7;
+ --cyan-400: #1e9ea9;
+ --cyan-500: #248088;
+ --cyan-600: #006970;
+ --cyan-700: #065056;
+ --cyan-800: #003a3f;
+ --cyan-900: #002124;
+ --green-50: #e1ffee;
+ --green-100: #bdfcdb;
+ --green-150: #8febbd;
+ --green-200: #59dea3;
+ --green-300: #00c384;
+ --green-400: #00a46e;
+ --green-500: #277e59;
+ --green-600: #006643;
+ --green-700: #004f33;
+ --green-800: #003a25;
+ --green-900: #002213;
+ --purple-50: #f7f8ff;
+ --purple-100: #ecedff;
+ --purple-150: #d4d7ff;
+ --purple-200: #c1c5ff;
+ --purple-300: #a1a3ff;
+ --purple-400: #8482fc;
+ --purple-500: #644af9;
+ --purple-600: #553bd8;
+ --purple-700: #4032a1;
+ --purple-800: #2e2576;
+ --purple-900: #1a114d;
+ --yellow-50: #fef9da;
+ --yellow-100: #fcedb9;
+ --yellow-150: #fcd579;
+ --yellow-200: #f6bf57;
+ --yellow-300: #fa922b;
+ --yellow-400: #f26d10;
+ --yellow-500: #c84801;
+ --yellow-600: #a82c00;
+ --yellow-700: #842106;
+ --yellow-800: #5f1a05;
+ --yellow-900: #331302;
+ --red-50: #fff5fa;
+ --red-100: #ffe7f2;
+ --red-150: #ffccdf;
+ --red-200: #ffb1cd;
+ --red-300: #fe87a1;
+ --red-400: #fc526a;
+ --red-500: #d90d10;
+ --red-600: #b3093c;
+ --red-700: #890d37;
+ --red-800: #68052b;
+ --red-900: #3e021a;
+ --violet-50: #fef4ff;
+ --violet-100: #fbe8fe;
+ --violet-150: #f7cffc;
+ --violet-200: #eeb5f4;
+ --violet-300: #e68bec;
+ --violet-400: #d75fe7;
+ --violet-500: #b716ca;
+ --violet-600: #9d00ae;
+ --violet-700: #7c0089;
+ --violet-800: #5c0066;
+ --violet-900: #36003d;
+ --lime-50: #f1fde8;
+ --lime-100: #d4ffb0;
+ --lime-150: #b1ee79;
+ --lime-200: #9cda5d;
+ --lime-300: #78c100;
+ --lime-400: #639e11;
+ --lime-500: #4b7a0a;
+ --lime-600: #3e6213;
+ --lime-700: #355315;
+ --lime-800: #203a00;
+ --lime-900: #112100;
--size-2: 2px;
--size-4: 4px;
--size-6: 6px;
@@ -143,7 +170,7 @@
--w150: 1.5;
--w200: 2;
--w300: 3;
- --w400: 400;
+ --w400: 4;
--w120: 1.2;
--font-family-geist: "Geist"; /** Primary sans-serif font for web typography. Use Geist as the main typeface. Recommended fallbacks: system-ui, -apple-system, BlinkMacSystemFont, “Segoe UI”, Roboto, sans-serif. */
--font-family-geist-mono: "Geist Mono"; /** Primary monospace font for web typography. Use Geist Mono for code, timestamps, and technical text. Recommended fallbacks: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace. */
@@ -170,42 +197,52 @@
--font-size-size-48: 48px;
--font-size-size-13: 13px;
--font-size-size-8: 8px;
- --line-height-line-height-10: 10;
- --line-height-line-height-12: 12;
- --line-height-line-height-14: 14;
- --line-height-line-height-15: 15;
- --line-height-line-height-16: 16;
- --line-height-line-height-17: 17;
- --line-height-line-height-18: 18;
- --line-height-line-height-20: 20;
- --line-height-line-height-24: 24;
- --line-height-line-height-28: 28;
- --line-height-line-height-32: 32;
- --line-height-line-height-40: 40;
- --line-height-line-height-48: 48;
+ --line-height-line-height-8: 8px;
+ --line-height-line-height-10: 10px;
+ --line-height-line-height-12: 12px;
+ --line-height-line-height-14: 14px;
+ --line-height-line-height-15: 15px;
+ --line-height-line-height-16: 16px;
+ --line-height-line-height-17: 17px;
+ --line-height-line-height-18: 18px;
+ --line-height-line-height-20: 20px;
+ --line-height-line-height-24: 24px;
+ --line-height-line-height-28: 28px;
+ --line-height-line-height-32: 32px;
+ --line-height-line-height-40: 40px;
+ --line-height-line-height-48: 48px;
+ --typography-font-weight-regular: 400;
+ --typography-font-weight-medium: 500;
+ --typography-font-weight-semi-bold: 600;
+ --typography-font-weight-bold: 700;
--light-elevation-0: 0 0 0 0 rgba(0,0,0,0); /** Base elevation level. */
- --light-elevation-1: 0 1px 2px 0 rgba(0,0,0,0.1), 0 4px 8px 0 rgba(0,0,0,0.06); /** Low elevation level for subtle separation. */
- --light-elevation-2: 0 2px 4px 0 rgba(0,0,0,0.12), 0 6px 16px 0 rgba(0,0,0,0.06);
- --light-elevation-3: 0 4px 8px 0 rgba(0,0,0,0.14), 0 12px 24px 0 rgba(0,0,0,0.1);
- --light-elevation-4: 0 6px 12px 0 rgba(0,0,0,0.16), 0 20px 32px 0 rgba(0,0,0,0.12);
+ --light-elevation-1: 0 0 0 1px rgba(0,0,0,0.05), 0 1px 2px 0 rgba(0,0,0,0.1), 0 4px 8px 0 rgba(0,0,0,0.06); /** Low elevation level for subtle separation. */
+ --light-elevation-2: 0 0 0 1px rgba(0,0,0,0.05), 0 2px 4px 0 rgba(0,0,0,0.12), 0 6px 16px 0 rgba(0,0,0,0.06);
+ --light-elevation-3: 0 0 0 1px rgba(0,0,0,0.05), 0 4px 8px 0 rgba(0,0,0,0.14), 0 12px 24px 0 rgba(0,0,0,0.1);
+ --light-elevation-4: 0 0 0 1px rgba(0,0,0,0.05), 0 6px 12px 0 rgba(0,0,0,0.16), 0 20px 32px 0 rgba(0,0,0,0.12);
--dark-elevation-0: 0 0 0 0 rgba(0,0,0,0);
- --dark-elevation-1: 0 1px 2px 0 rgba(0,0,0,0.2), 0 4px 8px 0 rgba(0,0,0,0.1);
- --dark-elevation-2: 0 2px 4px 0 rgba(0,0,0,0.22), 0 6px 16px 0 rgba(0,0,0,0.12);
- --dark-elevation-3: 0 4px 8px 0 rgba(0,0,0,0.24), 0 12px 24px 0 rgba(0,0,0,0.14);
- --dark-elevation-4: 0 6px 12px 0 rgba(0,0,0,0.28), inset 0 20px 32px 0 rgba(0,0,0,0.16);
- --w500: 500;
- --w600: 600;
- --w700: 700;
- --device-radius: 0.5rem;
- --state-hover: rgba(0, 0, 0, 0.05); /** Hover feedback overlay. */
- --state-pressed: rgba(0, 0, 0, 0.1); /** Pressed feedback overlay. */
- --state-selected: rgba(0, 0, 0, 0.1); /** Selected overlay. */
- --state-bg-overlay: rgba(0, 0, 0, 0.5);
- --border-core-image: rgba(0, 0, 0, 0.1); /** Image frame border treatment. */
- --border-core-opacity-25: rgba(0, 0, 0, 0.25); /** Utility border for overlays. */
+ --dark-elevation-1: 0 0 0 1px rgba(255,255,255,0.15), 0 1px 2px 0 rgba(0,0,0,0.2), 0 4px 8px 0 rgba(0,0,0,0.1);
+ --dark-elevation-2: 0 0 0 1px rgba(255,255,255,0.15), 0 2px 4px 0 rgba(0,0,0,0.22), 0 6px 16px 0 rgba(0,0,0,0.12);
+ --dark-elevation-3: 0 0 0 1px rgba(255,255,255,0.15), 0 4px 8px 0 rgba(0,0,0,0.24), 0 12px 24px 0 rgba(0,0,0,0.14);
+ --dark-elevation-4: 0 0 0 1px rgba(255,255,255,0.15), 0 6px 12px 0 rgba(0,0,0,0.28), 0 20px 32px 0 rgba(0,0,0,0.16);
+ --button-padding-y-lg: 14px;
+ --button-padding-y-md: 10px;
+ --button-padding-y-sm: 6px;
+ --button-padding-x-icon-only-lg: 14px;
+ --button-padding-x-icon-only-md: 10px;
+ --button-padding-x-icon-only-sm: 6px;
+ --button-padding-x-with-label-lg: 16px;
+ --button-padding-x-with-label-md: 16px;
+ --button-padding-x-with-label-sm: 16px;
+ --background-core-hover: rgba(30, 37, 43, 0.05); /** Hover feedback overlay. */
+ --background-core-pressed: rgba(30, 37, 43, 0.1); /** Pressed feedback overlay. */
+ --background-core-selected: rgba(30, 37, 43, 0.15); /** Selected overlay. */
+ --background-core-scrim: rgba(0, 0, 0, 0.25); /** Dimmed overlay for modals. */
+ --background-core-overlay: rgba(255, 255, 255, 0.75); /** Selected overlay. */
+ --border-core-opacity-10: rgba(0, 0, 0, 0.1); /** Image frame border treatment. */
+ --border-core-opacity-25: rgba(0, 0, 0, 0.25); /** Image frame border treatment. */
--system-bg-blur: rgba(255, 255, 255, 0.01);
--system-scrollbar: rgba(0, 0, 0, 0.5);
- --background-core-overlay: rgba(0, 0, 0, 0.1); /** Dimmed overlay for modals. */
--typography-font-family-sans: var(--font-family-geist);
--typography-font-family-mono: var(--font-family-geist-mono);
--typography-font-size-xxs: var(--font-size-size-10); /** Micro text such as timestamps or subtle metadata. */
@@ -239,6 +276,7 @@
--spacing-2xl: var(--space-32); /** Larger spacing for panels, modals, and gutters. */
--spacing-3xl: var(--space-40); /** Used for wide layout spacing and breathing room. */
--spacing-lg: var(--space-20); /** Medium spacing for grouping elements and section breaks. */
+ --spacing-xxxs: var(--space-2);
--device-safe-area-bottom: var(--space-0);
--device-safe-area-top: var(--space-0);
--button-radius-lg: var(--radius-full);
@@ -248,19 +286,25 @@
--button-visual-height-sm: var(--size-32);
--button-visual-height-md: var(--size-40);
--button-visual-height-lg: var(--size-48);
- --button-hit-target-min-height: var(--size-48); /* manually adjusted */
- --button-hit-target-min-width: var(--size-48); /* manually adjusted */
- --button-type-secondary-bg: var(--base-transparent);
- --button-type-destructive-text: var(--base-white);
- --button-style-ghost-bg: var(--base-transparent);
- --button-style-ghost-border: var(--base-transparent);
- --button-style-outline-bg: var(--base-transparent);
- --button-style-outline-border-on-chat-outgoing: var(--blue-300);
- --button-style-liquid-glass-text-primary: var(--base-white);
- --button-style-liquid-glass-text-destructive: var(--base-white);
- --button-style-liquid-glass-bg-secondary: var(--base-white);
- --button-style-liquid-glass-bg-primary: var(--base-transparent);
- --button-style-liquid-glass-bg-destructive: var(--base-transparent);
+ /**
+ * Minimum interactive hit target size.
+ *
+ * iOS / Android: enforce minimum touch target.
+ * Web: do not apply a min-width or min-height; size to content.
+ *
+ * Note: Web uses a placeholder value in Figma due to variable mode constraints.
+ */
+ --button-hit-target-min-height: var(--size-48);
+ /**
+ * Minimum interactive hit target size.
+ *
+ * iOS / Android: enforce minimum touch target.
+ * Web: do not apply a min-width or min-height; size to content.
+ *
+ * Note: Web uses a placeholder value in Figma due to variable mode constraints.
+ */
+ --button-hit-target-min-width: var(--size-48);
+ --button-primary-bg-liquid-glass: var(--base-transparent-0);
--icon-size-xs: var(--size-12);
--icon-size-sm: var(--size-16);
--icon-size-md: var(--size-20);
@@ -268,83 +312,68 @@
--icon-stroke-subtle: var(--w120);
--icon-stroke-default: var(--w150);
--icon-stroke-emphasis: var(--w200);
- --accent-primary: var(--blue-500); /** Main brand accent for interactive elements. */
- --accent-success: var(--green-500); /** For success states and positive actions. */
- --accent-warning: var(--yellow-500); /** Warning or caution messages. */
- --accent-error: var(--red-500); /** Destructive actions and error states. */
- --accent-neutral: var(--slate-500); /** Neutral accent for low-priority badges. */
- --state-text-disabled: var(--slate-400); /** Disabled text and icon color. Matches foundation disabled colors. */
- --state-bg-disabled: var(--slate-200); /** Optional disabled background for inputs, buttons, or chips. */
- --border-utility-focus: var(--blue-300); /** Focus ring or focus border. */
- --border-utility-border: var(--slate-100); /** Disabled state border. */
- --border-utility-error: var(--red-500); /** Error state. */
- --border-utility-warning: var(--yellow-500); /** Warning borders. */
- --border-utility-success: var(--green-500); /** Success borders. */
- --border-core-surface: var(--slate-400); /** Standard surface border. */
- --border-core-surface-subtle: var(--slate-200); /** Very light separators. */
- --border-core-surface-strong: var(--slate-600); /** Stronger surface border. */
+ --color-accent-success: var(--green-300); /** For success states and positive actions. */
+ --color-accent-warning: var(--yellow-400); /** Warning or caution messages. */
+ --color-accent-error: var(--red-500); /** Destructive actions and error states. */
+ --color-accent-neutral: var(--slate-500); /** Neutral accent for low-priority badges. */
+ --color-accent-black: var(--base-black); /** Neutral accent for low-priority badges. */
+ --color-brand-50: var(--blue-50);
+ --color-brand-100: var(--blue-100);
+ --color-brand-150: var(--blue-150);
+ --color-brand-200: var(--blue-200);
+ --color-brand-300: var(--blue-300);
+ --color-brand-400: var(--blue-400);
+ --color-brand-500: var(--blue-500);
+ --color-brand-600: var(--blue-600);
+ --color-brand-700: var(--blue-700);
+ --color-brand-800: var(--blue-800);
+ --color-brand-900: var(--blue-900);
+ --background-core-disabled: var(--slate-100); /** Optional disabled background for inputs, buttons, or chips. */
+ --background-core-surface: var(--slate-200); /** Standard section background. */
+ --background-core-surface-subtle: var(--slate-100); /** Very light section background. */
+ --background-core-surface-strong: var(--slate-300); /** Stronger section background. */
+ --background-elevation-elevation-0: var(--base-white); /** Flat surfaces. */
+ --background-elevation-elevation-1: var(--base-white); /** Slightly elevated surfaces. */
+ --background-elevation-elevation-2: var(--base-white); /** Card-like elements. */
+ --background-elevation-elevation-3: var(--base-white); /** Popovers. */
+ --background-elevation-elevation-4: var(--base-white); /** Dialogs, modals. */
+ --border-utility-disabled: var(--slate-200); /** Optional disabled background for inputs, buttons, or chips. */
+ --border-core-default: var(--slate-150); /** Standard surface border. */
+ --border-core-subtle: var(--slate-100); /** Very light separators. */
+ --border-core-strong: var(--slate-200); /** Stronger surface border. */
--border-core-on-dark: var(--base-white); /** Used on dark backgrounds. */
--border-core-on-accent: var(--base-white); /** Borders on accent backgrounds. */
- --border-core-primary: var(--blue-600); /** Selected or active state border. */
- --border-core-subtle: var(--slate-100); /** Light outlines. */
--chat-bg-incoming: var(--slate-100); /** Incoming bubble background. */
- --chat-bg-outgoing: var(--blue-100); /** Outgoing bubble background. */
- --chat-bg-attachment-incoming: var(--slate-200); /** Attachment card in incoming bubble. */
- --chat-bg-attachment-outgoing: var(--blue-200); /** Attachment card in outgoing bubble. */
- --chat-bg-typing-indicator: var(--base-black); /** Typing indicator chip. */
- --chat-border-outgoing: var(--base-transparent);
- --chat-border-incoming: var(--base-transparent);
+ --chat-bg-attachment-incoming: var(--slate-150); /** Attachment card in incoming bubble. */
+ --chat-border-outgoing: var(--base-transparent-0);
+ --chat-border-incoming: var(--base-transparent-0);
+ --chat-border-on-chat-incoming: var(--slate-400);
--chat-reply-indicator-incoming: var(--slate-400); /** Reply indicator shading for incoming. */
- --chat-reply-indicator-outgoing: var(--blue-400); /** Reply indicator shading for outgoing. */
--chat-waveform-bar: var(--border-core-opacity-25);
--chat-poll-progress-track-incoming: var(--slate-600);
--chat-poll-progress-fill-incoming: var(--slate-300);
- --chat-poll-progress-fill-outgoing: var(--blue-200);
--chat-thread-connector-incoming: var(--slate-200);
- --input-bg-default: var(--base-transparent); /** Background of the chat input field. Slightly elevated over the app background in light and dark, solid white in high-contrast. */
- --input-bg-hover: var(--state-hover); /** Hover state for the input surface. Implemented as a hover overlay on top of chat-input-bg, not as a separate base color. No overlay in high-contrast. */
- --input-text-default: var(--slate-900); /** Main text inside the chat input. */
- --input-text-placeholder: var(--slate-600); /** Placeholder text for the input. Lower emphasis than main text. */
- --input-text-icon: var(--slate-700); /** Icons inside the input area (attach, emoji, camera, send when idle). Matches secondary text strength. */
- --input-text-disabled: var(--slate-400); /** Placeholder text for the input. Lower emphasis than main text. */
- --presence-border: var(--base-white); /** The thin outline around the presence dot. Matches the local surface behind the avatar. In high-contrast it uses the base surface. */
--system-text: var(--base-black);
- --badge-border: var(--base-white);
- --badge-text: var(--base-white);
- --badge-text-inverse: var(--slate-900);
- --badge-bg-inverse: var(--base-white);
- --control-radiocheck-bg: var(--base-transparent);
- --control-radiocheck-bg-disabled: var(--base-transparent);
- --control-remove-bg: var(--slate-900);
- --control-remove-icon: var(--base-white);
+ --control-radiocheck-bg: var(--base-transparent-0);
--control-progress-bar-track: var(--slate-500);
- --control-progress-bar-fill: var(--slate-100);
- --background-core-app: var(--base-white); /** Global application background. */
- --background-core-surface: var(--slate-50); /** Surface background for cards, panels. */
- --background-core-surface-subtle: var(--slate-100); /** Subtle section background. */
- --background-core-surface-strong: var(--slate-200); /** Higher contrast surface. */
- --background-elevation-elevation-0: var(--base-white); /** Flat surfaces. */
- --background-elevation-elevation-1: var(--base-white); /** Slightly elevated surfaces. */
- --background-elevation-elevation-2: var(--base-white); /** Card-like elements. */
- --background-elevation-elevation-3: var(--base-white); /** Popovers. */
- --background-elevation-elevation-4: var(--base-white); /** Dialogs, modals. */
+ --control-progress-bar-fill: var(--slate-200);
--text-primary: var(--slate-900); /** Main text color. */
--text-secondary: var(--slate-700); /** Secondary metadata text. */
--text-tertiary: var(--slate-600); /** Lowest priority text. */
--text-inverse: var(--base-white); /** Text on dark or accent backgrounds. */
--text-disabled: var(--slate-400); /** Disabled text. */
- --text-link: var(--blue-500); /** Hyperlinks and inline actions. */
--text-on-accent: var(--base-white); /** Text on dark or accent backgrounds. */
--avatar-palette-bg-1: var(--blue-100);
--avatar-palette-bg-2: var(--cyan-100);
--avatar-palette-bg-3: var(--green-100);
- --avatar-palette-bg-4: var(--purple-200);
- --avatar-palette-bg-5: var(--yellow-200);
+ --avatar-palette-bg-4: var(--purple-100);
+ --avatar-palette-bg-5: var(--yellow-100);
--avatar-palette-text-1: var(--blue-800);
--avatar-palette-text-2: var(--cyan-800);
--avatar-palette-text-3: var(--green-800);
--avatar-palette-text-4: var(--purple-800);
--avatar-palette-text-5: var(--yellow-800);
+ --device-radius: var(--radius-md);
--message-bubble-radius-group-top: var(--radius-2xl);
--message-bubble-radius-group-middle: var(--radius-2xl);
--message-bubble-radius-group-bottom: var(--radius-2xl);
@@ -354,55 +383,84 @@
--composer-radius-fixed: var(--radius-3xl);
--composer-radius-floating: var(--radius-3xl);
--composer-bg: var(--background-elevation-elevation-1); /** Composer container background. */
- --button-type-primary-bg: var(--accent-primary);
- --button-type-primary-text: var(--text-on-accent);
- --button-type-primary-border: var(--border-core-primary);
- --button-type-secondary-text: var(--text-primary);
- --button-type-secondary-border: var(--border-core-surface-subtle);
- --button-type-destructive-bg: var(--accent-error);
- --button-type-destructive-text-inverse: var(--accent-error);
- --button-type-destructive-border: var(--accent-error);
- --button-style-ghost-text-primary: var(--accent-primary);
- --button-style-ghost-text-secondary: var(--text-primary);
- --button-style-outline-border: var(--border-core-surface-subtle);
- --button-style-outline-text: var(--text-primary);
- --button-style-outline-border-on-chat-incoming: var(--border-core-surface);
- --button-style-liquid-glass-text-secondary: var(--text-primary);
- --border-utility-selected: var(--accent-primary); /** Focus ring or focus border. */
+ --button-primary-text-on-accent: var(--text-on-accent);
+ --button-primary-border: var(--color-brand-200);
+ --button-secondary-text: var(--text-primary);
+ --button-secondary-bg-liquid-glass: var(--background-elevation-elevation-0);
+ --button-secondary-border: var(--border-core-default);
+ --button-secondary-bg: var(--background-core-surface-subtle);
+ --button-secondary-text-on-accent: var(--text-primary);
+ --button-destructive-text: var(--color-accent-error);
+ --button-destructive-bg: var(--color-accent-error);
+ --button-destructive-text-on-accent: var(--text-on-accent);
+ --button-destructive-bg-liquid-glass: var(--background-elevation-elevation-0);
+ --button-destructive-border: var(--color-accent-error);
+ --color-accent-primary: var(--color-brand-500); /** Main brand accent for interactive elements. */
+ --background-core-app: var(--background-elevation-elevation-0); /** Global application background. */
+ --border-utility-focus: var(--color-brand-300); /** Focus ring or focus border. */
+ --border-utility-error: var(--color-accent-error); /** Error state. */
+ --border-utility-warning: var(--color-accent-warning); /** Warning borders. */
+ --border-utility-success: var(--color-accent-success); /** Success borders. */
+ --chat-bg-outgoing: var(--color-brand-100); /** Outgoing bubble background. */
+ --chat-bg-attachment-outgoing: var(--color-brand-150); /** Attachment card in outgoing bubble. */
+ --chat-bg-typing-indicator: var(--color-accent-neutral); /** Typing indicator chip. */
--chat-text-message: var(--text-primary); /** Message body text. */
--chat-text-timestamp: var(--text-tertiary); /** Time labels. */
--chat-text-username: var(--text-secondary); /** Username label. */
- --chat-text-mention: var(--text-link); /** Mention styling. */
- --chat-text-link: var(--text-link); /** Links inside message bubbles. */
--chat-text-reaction: var(--text-secondary); /** Reaction count text. */
--chat-text-system: var(--text-secondary); /** System messages like date separators. */
- --chat-waveform-bar-playing: var(--accent-primary);
- --chat-poll-progress-track-outgoing: var(--accent-primary);
- --chat-thread-connector-outgoing: var(--chat-bg-outgoing);
- --input-bg-bg-disabled: var(--state-bg-disabled); /** Disabled input background using your shared disabled surface. White in high-contrast, with the disabled border and text carrying most of the signal. */
- --input-border-default: var(--border-core-surface-subtle); /** Default border of the chat input. Uses the standard border role from foundations. In high-contrast always black. */
- --input-border-hover: var(--border-core-surface); /** Optional hover border when the input is hovered or highlighted. Slightly stronger than default. */
- --input-border-focus: var(--border-utility-focus); /** Focus border when the input is focused. Uses the shared focus state token (brand in normal modes, black in high-contrast). */
- --input-border-border-disabled: var(--border-core-subtle); /** Disabled input border. Low contrast in normal modes, black outline in high-contrast. */
- --input-send-icon: var(--accent-primary); /** Default send icon color in the input. Uses the brand accent. */
- --input-send-icon-disabled: var(--state-text-disabled); /** Send icon when disabled (e.g. empty input). */
+ --chat-border-on-chat-outgoing: var(--color-brand-300);
+ --chat-reply-indicator-outgoing: var(--color-brand-400); /** Reply indicator shading for outgoing. */
+ --chat-poll-progress-fill-outgoing: var(--color-brand-200);
+ --input-border-default: var(--border-core-default); /** Default border of the chat input. Uses the standard border role from foundations. In high-contrast always black. */
+ --input-border-hover: var(--border-core-strong); /** Optional hover border when the input is hovered or highlighted. Slightly stronger than default. */
+ --input-text-default: var(--text-primary); /** Main text inside the chat input. */
+ --input-text-placeholder: var(--text-tertiary); /** Placeholder text for the input. Lower emphasis than main text. */
+ --input-text-icon: var(--text-tertiary); /** Icons inside the input area (attach, emoji, camera, send when idle). Matches secondary text strength. */
+ --input-text-disabled: var(--text-disabled); /** Placeholder text for the input. Lower emphasis than main text. */
+ --input-send-icon-disabled: var(--text-disabled); /** Send icon when disabled (e.g. empty input). */
--reaction-bg: var(--background-elevation-elevation-1); /** Reaction bar background. */
- --reaction-border: var(--border-core-surface-subtle); /** Border around unselected reaction chips. Subtle in normal modes, strong in high-contrast for visibility. */
+ --reaction-border: var(--border-core-default); /** Border around unselected reaction chips. Subtle in normal modes, strong in high-contrast for visibility. */
--reaction-text: var(--text-primary); /** Count label next to the emoji inside the reaction chip. Uses secondary text so it does not compete with message text. */
--reaction-emoji: var(--text-primary); /** Emoji color inside reaction chips. Uses primary text color so the emoji stays clearly legible. */
- --presence-bg-online: var(--accent-success); /** The green online indicator. Uses success accent in normal themes. In high-contrast, color is dropped and replaced with strong black for maximum clarity. */
- --presence-bg-offline: var(--accent-neutral); /** The green online indicator. Uses success accent in normal themes. In high-contrast, color is dropped and replaced with strong black for maximum clarity. */
- --badge-bg-primary: var(--accent-primary);
- --badge-bg-error: var(--accent-error);
- --badge-bg-neutral: var(--accent-neutral);
- --control-radiocheck-border: var(--border-core-surface-subtle);
- --control-radiocheck-bg-selected: var(--accent-primary);
- --control-radiocheck-border-selected: var(--border-core-primary);
+ --presence-bg-online: var(--color-accent-success); /** The green online indicator. Uses success accent in normal themes. In high-contrast, color is dropped and replaced with strong black for maximum clarity. */
+ --presence-border: var(--border-core-on-dark); /** The thin outline around the presence dot. Matches the local surface behind the avatar. In high-contrast it uses the base surface. */
+ --presence-bg-offline: var(--color-accent-neutral); /** The green online indicator. Uses success accent in normal themes. In high-contrast, color is dropped and replaced with strong black for maximum clarity. */
+ --badge-border: var(--border-core-on-dark);
+ --badge-bg-error: var(--color-accent-error);
+ --badge-bg-neutral: var(--color-accent-neutral);
+ --badge-text: var(--text-on-accent);
+ --badge-text-inverse: var(--text-primary);
+ --badge-bg-default: var(--background-elevation-elevation-1);
+ --badge-bg-inverse: var(--color-accent-black);
+ --control-radiocheck-border: var(--border-core-default);
--control-radiocheck-icon-selected: var(--text-inverse);
- --control-radiocheck-border-disabled: var(--border-utility-border);
- --control-radiocheck-bg-selected-disabled: var(--state-bg-disabled);
- --control-radiocheck-icon-selected-disabled: var(--state-text-disabled);
- --control-remove-border: var(--border-core-on-dark);
+ --control-remove-control-bg: var(--color-accent-black);
+ --control-remove-control-icon: var(--text-on-accent);
+ --control-remove-control-border: var(--border-core-on-dark);
+ --control-play-control-bg: var(--background-elevation-elevation-1);
+ --control-play-control-icon: var(--text-primary);
+ --control-play-control-border: var(--border-core-default);
+ --control-play-control-bg-inverse: var(--color-accent-black);
+ --control-play-control-icon-inverse: var(--text-on-accent);
+ --control-toggle-switch-knob: var(--background-elevation-elevation-4);
+ --control-toggle-switch-bg: var(--background-core-surface-strong);
+ --control-toggle-switch-bg-disabled: var(--background-core-disabled);
--avatar-bg-default: var(--avatar-palette-bg-1);
--avatar-text-default: var(--avatar-palette-text-1);
+ --button-primary-bg: var(--color-accent-primary);
+ --button-primary-text: var(--color-accent-primary);
+ --border-utility-selected: var(--color-accent-primary); /** Focus ring or focus border. */
+ --chat-waveform-bar-playing: var(--color-accent-primary);
+ --chat-poll-progress-track-outgoing: var(--color-accent-primary);
+ --chat-thread-connector-outgoing: var(--chat-bg-outgoing);
+ --input-border-focus: var(--border-utility-focus); /** Focus border when the input is focused. Uses the shared focus state token (brand in normal modes, black in high-contrast). */
+ --input-send-icon: var(--color-accent-primary); /** Default send icon color in the input. Uses the brand accent. */
+ --system-caret: var(--color-accent-primary);
+ --badge-bg-primary: var(--color-accent-primary);
+ --control-radiocheck-bg-selected: var(--color-accent-primary);
+ --control-toggle-switch-bg-selected: var(--color-accent-primary);
+ --text-link: var(--color-accent-primary); /** Hyperlinks and inline actions. */
+ --chat-text-mention: var(--text-link); /** Mention styling. */
+ --chat-text-link: var(--text-link); /** Links inside message bubbles. */
}
diff --git a/yarn.lock b/yarn.lock
index e525c10b46..9dc1fd7072 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -12573,10 +12573,10 @@ stdin-discarder@^0.2.2:
resolved "https://registry.yarnpkg.com/stdin-discarder/-/stdin-discarder-0.2.2.tgz#390037f44c4ae1a1ae535c5fe38dc3aba8d997be"
integrity sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==
-stream-chat@^9.27.2:
- version "9.27.2"
- resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.27.2.tgz#5b41173e513f3606c47c93f391693b589e663968"
- integrity sha512-OdALDzg8lO8CAdl8deydJ1+O4wJ7mM9dPLeCwDppq/OQ4aFIS9X38P+IdXPcOCsgSS97UoVUuxD2/excC5PEeg==
+stream-chat@^9.30.1:
+ version "9.30.1"
+ resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.30.1.tgz#86d152e4d0894854370512d17530854541f7990b"
+ integrity sha512-8f58tCo3QfgzaNhWHpRQzEfglSPPn4lGRn74FFTr/pn53dMJwtcKDSohV6NTHBrkYWTXYObRnHgh2IhGFUKckw==
dependencies:
"@types/jsonwebtoken" "^9.0.8"
"@types/ws" "^8.5.14"