@@ -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 && ( + + {SLOW_CONNECTION_TOAST} + + )} + + + )} + + {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' -