@@ -1,6 +1,6 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
-export type ToastVariant = 'error' | 'success'
+export type ToastVariant = 'error' | 'success' | 'warning'
export interface ToastState {
message: string
@@ -2,6 +2,11 @@
margin-top: 2.5px;
}
+.warningIcon {
+ margin-top: 2.5px;
+ filter: invert(78%) sepia(90%) saturate(750%) hue-rotate(359deg) brightness(103%) contrast(101%);
+}
+
.errorSnackbar {
z-index: 100000;
max-width: 335px;
@@ -1,11 +1,13 @@
import React, { useRef } from 'react'
+import { CSSTransition } from 'react-transition-group'
import { Alert, Snackbar, Typography } from '@mui/material'
-import { getDeviceType } from '#/shared'
import Image from 'next/image'
-import { CSSTransition } from 'react-transition-group'
-import { useShowDataStore } from '../lib/hooks/use-show-data'
import SuccessSvg from '#/assets/svg/success.svg?react'
+import { getDeviceType } from '#/shared'
+
+import type { Variant } from '../lib/hooks/use-show-data'
+import { useShowDataStore } from '../lib/hooks/use-show-data'
import styles from './error.module.scss'
@@ -14,51 +16,63 @@ export interface IError {
}
const ErrorIcon = () => {
- return
+ return
+}
+
+const WarningIcon = () => {
+ return (
+
+ )
+}
+
+const ALERT_CONFIG: Record<
+ Variant,
+ {
+ icon: React.ReactNode
+ border: string
+ }
+> = {
+ error: {
+ icon: ,
+ border: '1px solid #F15179',
+ },
+ success: {
+ icon: ,
+ border: '1px solid #5ef151',
+ },
+ warning: {
+ icon: ,
+ border: '1px solid #FFB800',
+ },
}
export const Error: React.FC = ({ handleClose }) => {
- const { message, variant, isOpened, showMessage } = useShowDataStore()
+ const { message, variant, isOpened } = useShowDataStore()
const nodeRef = useRef(null)
- const isDesktop = getDeviceType() === 'desktop';
+ const isDesktop = getDeviceType() === 'desktop'
+ const { icon, border } = ALERT_CONFIG[variant]
return (
- {variant == 'error' ? (
- }
- severity='error'
- sx={{
- backgroundColor: '#404040',
- border: '1px solid #F15179',
- color: '#C7C7C7',
- borderRadius: '12px',
- }}
- >
- {message}
-
- ) : (
- }
- severity='error'
- sx={{
- backgroundColor: '#404040',
- border: '1px solid #5ef151',
- color: '#C7C7C7',
- borderRadius: '12px',
- }}
- >
- {message}
-
- )}
+
+ {message}
+
)
@@ -1,2 +1,3 @@
export * from './use-images-pagination'
export * from './use-image-icons'
+export * from './use-audio-file-load'
@@ -0,0 +1,91 @@
+import { useEffect, useRef, useState } from 'react'
+
+const SLOW_SPEED_BYTES_PER_SEC = 128 * 1024
+const SLOW_CHECK_DELAY_MS = 1500
+
+interface UseAudioFileLoadResult {
+ progress: number
+ playbackUrl: string
+ isDownloaded: boolean
+ isSlowConnection: boolean
+}
+
+export const useAudioFileLoad = (url: string): UseAudioFileLoadResult => {
+ const [progress, setProgress] = useState(0)
+ const [playbackUrl, setPlaybackUrl] = useState(url)
+ const [isDownloaded, setIsDownloaded] = useState(false)
+ const [isSlowConnection, setIsSlowConnection] = useState(false)
+ const blobUrlRef = useRef(null)
+ const startTimeRef = useRef(0)
+
+ useEffect(() => {
+ setProgress(0)
+ setPlaybackUrl(url)
+ setIsDownloaded(false)
+ setIsSlowConnection(false)
+ startTimeRef.current = Date.now()
+
+ const xhr = new XMLHttpRequest()
+ let totalBytes: number | null = null
+
+ xhr.open('GET', url)
+ xhr.responseType = 'blob'
+
+ xhr.onreadystatechange = () => {
+ if (xhr.readyState === XMLHttpRequest.HEADERS_RECEIVED) {
+ const contentLength = xhr.getResponseHeader('Content-Length')
+ if (contentLength) {
+ totalBytes = parseInt(contentLength, 10)
+ }
+ }
+ }
+
+ xhr.onprogress = (event) => {
+ const total = event.lengthComputable && event.total > 0 ? event.total : totalBytes
+ if (total && total > 0) {
+ setProgress(Math.min(99, (event.loaded / total) * 100))
+ }
+
+ const elapsedMs = Date.now() - startTimeRef.current
+ if (elapsedMs >= SLOW_CHECK_DELAY_MS && event.loaded > 0) {
+ const speed = event.loaded / (elapsedMs / 1000)
+ if (speed < SLOW_SPEED_BYTES_PER_SEC) {
+ setIsSlowConnection(true)
+ }
+ }
+ }
+
+ xhr.onload = () => {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ if (blobUrlRef.current) {
+ URL.revokeObjectURL(blobUrlRef.current)
+ }
+ const objectUrl = URL.createObjectURL(xhr.response)
+ blobUrlRef.current = objectUrl
+ setPlaybackUrl(objectUrl)
+ setProgress(100)
+ }
+ setIsDownloaded(true)
+ }
+
+ xhr.onerror = () => {
+ setIsDownloaded(true)
+ }
+
+ xhr.onabort = () => {
+ setIsDownloaded(true)
+ }
+
+ xhr.send()
+
+ return () => {
+ xhr.abort()
+ if (blobUrlRef.current) {
+ URL.revokeObjectURL(blobUrlRef.current)
+ blobUrlRef.current = null
+ }
+ }
+ }, [url])
+
+ return { progress, playbackUrl, isDownloaded, isSlowConnection }
+}
@@ -2,3 +2,24 @@
width: 100%;
height: 48px;
}
+
+.loaderRow {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+}
+
+.loaderContent {
+ flex: 1;
+ min-width: 0;
+}
+
+.slowConnectionIcon {
+ width: 20px;
+ height: 20px;
+ flex-shrink: 0;
+ margin-top: 2px;
+ cursor: pointer;
+ filter: invert(78%) sepia(90%) saturate(750%) hue-rotate(359deg) brightness(103%) contrast(101%);
+}
@@ -1,50 +1,219 @@
-import React, { memo, useRef, useState } from 'react'
-import { Skeleton, Typography } from '@mui/material'
+import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
+import { Typography } from '@mui/material'
import Box from '@mui/material/Box'
+import Image from 'next/image'
+
+import { Message } from '#/entities/message'
+import { TooltipCustom } from '#/shared'
+import { useShowDataStore } from '#/shared/lib/hooks/use-show-data'
+import ProgressLoader from '#/widgets/loaders/progress-loader-props'
+
+import { useAudioFileLoad } from '../model/use-audio-file-load'
import { AudioPlayer, AudioPlayerRef } from './audio-player'
import styles from './audio-messages-list.module.scss'
-import { Message } from '#/entities/message'
-import { TooltipCustom } from '#/shared'
+type Device = 'mobile' | 'desktop'
+type AudioRef = HTMLAudioElement & AudioPlayerRef
+
+const SLOW_CONNECTION_TOAST =
+ 'У вас низкая скорость интернет-соединения, аудио может грузиться дольше обычного'
interface MessagesList {
- device: 'mobile' | 'desktop'
+ device: Device
audios: Message[] | null | undefined
getMessagesPagination?: () => Promise
onPromptClick?: (content: string) => void
}
-export const AudioMessagesList: React.FC = memo(({ device, audios, onPromptClick }) => {
- const [loadedAudios, setLoadedAudios] = useState>(new Set())
- const [currentlyPlaying, setCurrentlyPlaying] = useState(null)
- const audioRefs = useRef<{ [key: string]: (HTMLAudioElement & AudioPlayerRef) | null }>({})
+interface AudioMessageItemProps {
+ message: Message
+ device: Device
+ onPromptClick?: (content: string) => void
+ onPlay: (uid: string) => void
+ onPause: (uid: string) => void
+ onMouseLeave: (uid: string) => void
+ registerAudioRef: (uid: string, el: AudioRef | null) => void
+ onSlowConnectionDetected: () => void
+}
+
+function hasValidFile(file: Message['file']): file is string {
+ return Boolean(file && typeof file === 'string' && file.trim() !== '')
+}
+
+function AudioMessageContent({
+ content,
+ device,
+ onPromptClick,
+}: {
+ content: string
+ device: Device
+ onPromptClick?: (content: string) => void
+}) {
+ if (!content) return null
+
+ const cleanContent = content.replaceAll('"', '')
+ const isDesktop = device === 'desktop'
+ const hasMoreContent = isDesktop ? cleanContent.includes('\n') : cleanContent.length > 50
+
+ return (
+
+ onPromptClick?.(content)}
+ sx={{
+ fontSize: '15px',
+ color: '#D4D4D4',
+ marginBottom: '8px',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ width: isDesktop ? '100%' : '90%',
+ cursor: onPromptClick ? 'pointer' : 'default',
+ }}
+ >
+ {cleanContent}
+ {!isDesktop && hasMoreContent && '...'}
+
+
+ )
+}
+
+const AudioMessageItem = memo(
+ ({
+ message,
+ device,
+ onPromptClick,
+ onPlay,
+ onPause,
+ onMouseLeave,
+ registerAudioRef,
+ onSlowConnectionDetected,
+ }: AudioMessageItemProps) => {
+ const fileUrl = message.file as string
+ const { progress, playbackUrl, isDownloaded, isSlowConnection } = useAudioFileLoad(fileUrl)
+ const [isPlayerReady, setIsPlayerReady] = useState(false)
+ const isReady = isDownloaded && isPlayerReady
+
+ useEffect(() => {
+ if (isSlowConnection) {
+ onSlowConnectionDetected()
+ }
+ }, [isSlowConnection, onSlowConnectionDetected])
- const handleAudioLoad = (uid: string) => {
- setLoadedAudios((prev) => new Set([...prev, uid]))
+ return (
+ onMouseLeave(message.uid)}
+ sx={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
+ >
+
+ {!isReady && (
+
+
+
+
+
+ {isSlowConnection && (
+
+
+
+ )}
+
+
+ )}
+
+ {isDownloaded && (
+ registerAudioRef(message.uid, el as AudioRef | null)}
+ onLoadedData={() => setIsPlayerReady(true)}
+ onPlayStart={onPlay}
+ onPauseStart={onPause}
+ uid={message.uid}
+ url={fileUrl}
+ content={message.content}
+ buttonText='Скачать аудио'
+ device={device}
+ className={styles.audioPlayer}
+ style={isReady ? undefined : { visibility: 'hidden' }}
+ >
+
+
+
+ Ваш браузер не поддерживает аудио.
+
+ )}
+
+
+
+
+ )
}
+)
+
+AudioMessageItem.displayName = 'AudioMessageItem'
+
+export const AudioMessagesList = memo(({ device, audios, onPromptClick }: MessagesList) => {
+ const [currentlyPlaying, setCurrentlyPlaying] = useState(null)
+ const audioRefs = useRef>({})
+ const slowConnectionToastShownRef = useRef(false)
+ const { showMessage } = useShowDataStore()
+
+ const handleSlowConnectionDetected = useCallback(() => {
+ if (device !== 'mobile' || slowConnectionToastShownRef.current) return
+
+ slowConnectionToastShownRef.current = true
+ showMessage(SLOW_CONNECTION_TOAST, 'warning')
+ }, [device, showMessage])
const handlePlay = (uid: string) => {
- // Если уже играет другая запись, останавливаем её
if (currentlyPlaying && currentlyPlaying !== uid) {
- const previousAudio = audioRefs.current[currentlyPlaying]
- if (previousAudio) {
- previousAudio.pause()
- }
+ audioRefs.current[currentlyPlaying]?.pause()
}
setCurrentlyPlaying(uid)
}
const handlePause = (uid: string) => {
- // Если ставится на паузу текущая запись, очищаем состояние
if (currentlyPlaying === uid) {
setCurrentlyPlaying(null)
}
}
return (
-
+
= memo(({ device, audios,
ГЕНЕРАЦИИ
-
- {audios?.length !== 0 &&
- audios?.map((message) => {
- // Если нет файла или файл пустой, не показываем сообщение
- if (!message.file || (typeof message.file === 'string' && message.file.trim() === '')) {
- return null
- }
-
- const fileUrl = message.file as string
- return (
- {
- audioRefs.current[message.uid]?.setVolumeHovered(false)
- }}
- sx={{
- display: 'flex',
- flexDirection: 'column',
- gap: '12px',
- }}
- >
-
- {!loadedAudios.has(message.uid) && (
-
- )}
- {
- audioRefs.current[message.uid] = el as (HTMLAudioElement & AudioPlayerRef) | null
- }}
- onLoadedData={() => handleAudioLoad(message.uid)}
- onPlayStart={handlePlay}
- onPauseStart={handlePause}
- uid={message.uid}
- url={fileUrl}
- content={message.content}
- buttonText='Скачать аудио'
- device={device}
- className={styles.audioPlayer}
- >
-
-
-
- Ваш браузер не поддерживает аудио.
-
- {!loadedAudios.has(message.uid) && (
-
- )}
-
- {message.content && message.content.length > 0 && (() => {
- const cleanContent = message.content.replaceAll('"', '')
- const isDesktop = device === 'desktop'
-
- const displayText = cleanContent
-
- const hasMoreContent = isDesktop
- ? cleanContent.includes('\n')
- : cleanContent.length > 50
-
- const fullTextForTooltip = cleanContent
-
- return (
-
- onPromptClick?.(message.content)}
- sx={{
- fontSize: '15px',
- color: '#D4D4D4',
- marginBottom: '8px',
- overflow: 'hidden',
- textOverflow: 'ellipsis',
- whiteSpace: 'nowrap',
- width: isDesktop ? '100%' : '90%',
- cursor: onPromptClick ? 'pointer' : 'default',
- }}
- >
- {displayText}
- {!isDesktop && hasMoreContent && '...'}
-
-
- )
- })()}
-
- )
- })}
+
+ {audios?.map((message) =>
+ hasValidFile(message.file) ? (
+ audioRefs.current[uid]?.setVolumeHovered(false)}
+ registerAudioRef={(uid, el) => {
+ audioRefs.current[uid] = el
+ }}
+ onSlowConnectionDetected={handleSlowConnectionDetected}
+ />
+ ) : null
+ )}
)
})
AudioMessagesList.displayName = 'AudioMessagesList'
-