@@ -12,7 +12,7 @@ export function useCreateMediaMessage( type: string, modelType: 'video' | 'image' | 'audio' | 'voice', device: Device, - setMessages: Dispatch>, + setMessages: Dispatch>, mobileScrollContainer: RefObject ) { const [isComplete, setIsComplete] = useState(false) @@ -40,7 +40,7 @@ export function useCreateMediaMessage( dispatch(getUserBalance(data?.access)) - setMessages((prev: Message[]) => { + setMessages((prev) => { if (!prev || !prev.length) return messages const prevUids = new Set(prev.map((m) => m.uid)) @@ -1 +1,6 @@ -export * from './use-images-bot-pagination' \ No newline at end of file +export * from './use-images-bot-pagination' +export * from './use-image-bot-pagination' +export * from './use-video-bot-pagination' +export * from './use-audio-bot-pagination' +export * from './use-voice-bot-pagination' +export * from './use-media-model-pagination' \ No newline at end of file @@ -0,0 +1,7 @@ +import { Device } from '#/shared/lib/types/entities' + +import { useMediaModelPagination } from './use-media-model-pagination' + +export function useAudioBotPagination(slug: string, deviceType: Device) { + return useMediaModelPagination(slug, deviceType, 'audio') +} @@ -0,0 +1,7 @@ +import { Device } from '#/shared/lib/types/entities' + +import { useMediaModelPagination } from './use-media-model-pagination' + +export function useImageBotPagination(slug: string, deviceType: Device) { + return useMediaModelPagination(slug, deviceType, 'image') +} @@ -0,0 +1,143 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useSession } from 'next-auth/react' + +import { getImagesBySlug, Message } from '#/entities/message' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' +import { Device } from '#/shared/lib/types/entities' + +const PAGE_SIZE = 10 + +type MediaType = 'image' | 'video' | 'audio' | 'voice' + +function appendUniqueByUid(prev: Message[], incoming: Message[]): Message[] { + const seen = new Set(prev.map((message) => message.uid)) + const additions = incoming.filter((message) => !seen.has(message.uid)) + if (additions.length === 0) { + return prev + } + return [...prev, ...additions] +} + +function prependUniqueByUid(prev: Message[], incoming: Message[]): Message[] { + const prevUids = new Set(prev.map((message) => message.uid)) + const additions = incoming.filter((message) => !prevUids.has(message.uid)) + if (additions.length === 0) { + return prev + } + return [...additions, ...prev] +} + +export function useMediaModelPagination(slug: string, deviceType: Device, mediaType: MediaType) { + const mobileScrollContainer = useRef(null) + const { data } = useSession() + const { showMessage } = useShowDataStore() + + const [messages, setMessages] = useState(null) + const [loadedSlug, setLoadedSlug] = useState(null) + const [loading, setLoading] = useState(true) + const [paginationLoading, setPaginationLoading] = useState(false) + const [offset, setOffset] = useState(0) + const [hasMore, setHasMore] = useState(true) + + useLayoutEffect(() => { + setLoadedSlug(null) + setHasMore(true) + }, [slug]) + + useEffect(() => { + if (!data?.access || !slug) { + return + } + + setMessages(null) + setOffset(0) + setHasMore(true) + + let cancelled = false + + ;(async () => { + setLoading(true) + + try { + const { data: answer, ...response } = await getImagesBySlug(slug, data.access, mediaType, 0, PAGE_SIZE) + + if (cancelled) { + return + } + + if (response.status >= 400 || !Array.isArray(answer)) { + showMessage('Ошибка загрузки') + setMessages([]) + setHasMore(false) + setLoadedSlug(slug) + return + } + + if (deviceType === 'desktop') { + setMessages(answer) + } else { + setMessages([...answer].reverse()) + } + + setOffset(answer.length) + setHasMore(answer.length === PAGE_SIZE) + setLoadedSlug(slug) + } finally { + if (!cancelled) { + setLoading(false) + } + } + })() + + return () => { + cancelled = true + } + }, [slug, data?.access, deviceType, mediaType, showMessage]) + + const getMessagesPagination = useCallback(async () => { + if (!slug || !data?.access || paginationLoading || loading || !hasMore) { + return + } + + setPaginationLoading(true) + + try { + const { data: answer, ...response } = await getImagesBySlug(slug, data.access, mediaType, offset, PAGE_SIZE) + + if (response.status >= 400 || !Array.isArray(answer) || answer.length === 0) { + setHasMore(false) + return + } + + if (answer.length < PAGE_SIZE) { + setHasMore(false) + } + + if (deviceType === 'desktop') { + setMessages((prev) => (prev ? appendUniqueByUid(prev, answer) : answer)) + } else { + const olderFirst = [...answer].reverse() + setMessages((prev) => (prev ? prependUniqueByUid(prev, olderFirst) : olderFirst)) + } + + setOffset((prev) => prev + answer.length) + } catch (error) { + console.error(`[useMediaModelPagination] getMessagesPagination: request failed`, { slug, mediaType, error }) + showMessage('Ошибка загрузки сообщений') + } finally { + setPaginationLoading(false) + } + }, [slug, data?.access, deviceType, mediaType, offset, paginationLoading, loading, hasMore, showMessage]) + + const displayMessages = loadedSlug === slug ? messages : null + + return { + messages: displayMessages, + loading, + paginationLoading, + hasMore, + getMessagesPagination, + setMessages, + mobileScrollContainer, + } +} @@ -0,0 +1,7 @@ +import { Device } from '#/shared/lib/types/entities' + +import { useMediaModelPagination } from './use-media-model-pagination' + +export function useVideoBotPagination(slug: string, deviceType: Device) { + return useMediaModelPagination(slug, deviceType, 'video') +} @@ -0,0 +1,7 @@ +import { Device } from '#/shared/lib/types/entities' + +import { useMediaModelPagination } from './use-media-model-pagination' + +export function useVoiceBotPagination(slug: string, deviceType: Device) { + return useMediaModelPagination(slug, deviceType, 'voice') +} @@ -1,41 +1,86 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Swiper as SwiperCore } from 'swiper' export const useLibrarySwiper = (onSlideFalse: ((...args: any) => any) | undefined, reverse: boolean) => { const [swiper, setSwiper] = useState(null) + const swiperRef = useRef(null) + const onSlideFalseRef = useRef(onSlideFalse) + const reverseRef = useRef(reverse) + swiperRef.current = swiper + onSlideFalseRef.current = onSlideFalse + reverseRef.current = reverse - const keydown = (e: KeyboardEvent) => { - if (e.key === 'ArrowRight') { - slideNext() - } - if (e.key === 'ArrowLeft') { - slidePrev() + const requestMoreIfNeeded = (direction: 'prev' | 'next') => { + const shouldPaginate = + (direction === 'prev' && !reverseRef.current) || (direction === 'next' && reverseRef.current) + + if (shouldPaginate && onSlideFalseRef.current) { + void onSlideFalseRef.current() } } - const slidePrev = function () { - const result = swiper?.slidePrev() + const slidePrev = () => { + const instance = swiperRef.current + if (!instance) { + return + } - if (!result && onSlideFalse && !reverse) { - onSlideFalse() + const atBeginning = instance.isBeginning || instance.activeIndex <= 0 + if (atBeginning) { + requestMoreIfNeeded('prev') + return + } + + const prevIndex = instance.activeIndex + instance.slidePrev() + + if (instance.activeIndex === prevIndex) { + requestMoreIfNeeded('prev') } } - const slideNext = function () { - const result = swiper?.slideNext() + const slideNext = () => { + const instance = swiperRef.current + if (!instance) { + return + } - if (!result && onSlideFalse && reverse) { - onSlideFalse() + const lastIndex = Math.max(instance.slides.length - 1, 0) + const atEnd = instance.isEnd || instance.activeIndex >= lastIndex + if (atEnd) { + requestMoreIfNeeded('next') + return + } + + const prevIndex = instance.activeIndex + instance.slideNext() + + if (instance.activeIndex === prevIndex) { + requestMoreIfNeeded('next') } } + const slidePrevRef = useRef(slidePrev) + const slideNextRef = useRef(slideNext) + slidePrevRef.current = slidePrev + slideNextRef.current = slideNext + useEffect(() => { + const keydown = (e: KeyboardEvent) => { + if (e.key === 'ArrowRight') { + slideNextRef.current() + } + if (e.key === 'ArrowLeft') { + slidePrevRef.current() + } + } + document.addEventListener('keydown', keydown, true) return () => { document.removeEventListener('keydown', keydown, true) } - }, [swiper]) + }, []) return { swiper, @@ -69,9 +69,14 @@ export default function FullScreenModal({ const delta = len - prevLen const id = window.setTimeout(() => { + swiper.update() + if (reverse) { - swiper.update() + // Desktop: older items are appended at the end — jump to the first newly loaded slide + const firstNewIndex = Math.min(prevLen, len - 1) + swiper.slideTo(firstNewIndex, 0) } else { + // Mobile: older items are prepended — keep the same visual position const nextIndex = swiper.activeIndex + delta const clamped = Math.max(0, Math.min(nextIndex, len - 1)) swiper.slideTo(clamped, 0) @@ -0,0 +1,116 @@ +.overlay { + position: fixed; + z-index: 1201; + inset: 0; + width: 100%; + height: 100%; + display: none; +} + +.overlayVisible { + display: block; +} + +.backdrop { + position: absolute; + z-index: 101; + width: 100%; + height: 100%; + background-color: #000000; + opacity: 0.9; +} + +.actions { + position: absolute; + z-index: 105; + cursor: pointer; + right: 25px; + top: 25px; + display: flex; + align-items: center; + gap: 15px; +} + +.videoBlock { + position: absolute; + padding: 0 20px; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 105; + margin: auto; + width: fit-content; + height: fit-content; +} + +.arrow { + position: absolute; + z-index: 1205; + cursor: pointer; + top: 50%; + width: 50px; + height: 50px; + background: transparent; + border: none; + outline: none; + + svg { + fill: white; + width: 50px; + height: 50px; + } + + &_left { + transform: translateY(-50%) rotate(90deg); + left: -40px; + + @media screen and (max-width: 768px) { + left: -3px; + } + } + + &_right { + transform: translateY(-50%) rotate(-90deg); + right: -50px; + + @media screen and (max-width: 768px) { + right: -10px; + } + } +} + +.swiper { + position: relative; + width: 100%; + max-width: 80vw; + max-height: 1000px; + height: 80vh; +} + +.slide { + width: 80vw !important; + display: flex !important; + align-items: center; + justify-content: center; + height: 100% !important; + max-height: unset; +} + +.slideInner { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.video { + width: 100%; + height: 100%; + max-width: 80vw; + max-height: 80vh; + object-fit: contain; + background: #000; +} @@ -1,199 +1,298 @@ -import React, { useEffect, useRef } from 'react'; -import { Close } from '@mui/icons-material'; -import { - Box, - Dialog, - DialogContent, - IconButton, -} from '@mui/material'; +import { useEffect, useMemo, useRef, useState } from 'react' +import { ArrowDropDown } from '@mui/icons-material' +import { Swiper, SwiperSlide } from 'swiper/react' +import 'swiper/css' + +import { Message } from '#/entities/message' +import { useLibrarySwiper } from '#/features/image-modal/model/use-swiper' +import { c } from '#/shared' +import { useImageIcons } from '#/widgets/messages' + +import styles from './video-player-modal.module.scss' export interface VideoPlayerModalProps { - open: boolean; - onClose: () => void; - videoUrl: string; - title?: string; - autoPlay?: boolean; - } + open: boolean + onClose: () => void + videos: Message[] + current: string | null + onSlideFalse?: () => void | Promise + reverse?: boolean + autoPlay?: boolean + /** @deprecated use videos + current */ + videoUrl?: string + /** @deprecated unused */ + title?: string +} + +function VideoSlide({ + file, + isActive, + autoPlay, +}: { + file: string + isActive: boolean + autoPlay: boolean +}) { + const videoRef = useRef(null) + + useEffect(() => { + const video = videoRef.current + if (!video) { + return + } + + if (isActive && autoPlay) { + video.currentTime = 0 + void video.play().catch(() => undefined) + return + } + + video.pause() + }, [isActive, autoPlay, file]) + + return ( +
e.stopPropagation()}> + +
+ ) +} const VideoPlayerModal: React.FC = ({ - open, - onClose, - videoUrl, - title = 'Видео', - autoPlay = true, + open, + onClose, + videos, + current, + onSlideFalse, + reverse = false, + autoPlay = true, + videoUrl, }) => { - const videoRef = useRef(null); - - useEffect(() => { - if (videoRef.current && open) { - const video = videoRef.current; - - // Принудительно останавливаем автовоспроизведение при паузе - const handlePause = () => { - if (!video.paused) { - video.pause(); - } - }; - - const handlePlay = () => { - // Убеждаемся, что видео действительно воспроизводится - if (video.paused) { - video.play().catch(() => { - // Игнорируем ошибки воспроизведения - }); - } - }; - - // Дополнительная обработка для предотвращения автоматического воспроизведения после паузы - const handleTimeUpdate = () => { - // Если видео приостановлено, но время продолжает обновляться, принудительно останавливаем - if (video.paused && video.currentTime !== video.currentTime) { - video.pause(); - } - }; - - // Обработка изменения состояния загрузки - const handleLoadStart = () => { - video.pause(); - }; - - // Обработка видимости страницы (для мобильных устройств) - const handleVisibilityChange = () => { - if (document.hidden && !video.paused) { - video.pause(); - } - }; - - // Обработка потери фокуса (для мобильных устройств) - const handleBlur = () => { - if (!video.paused) { - video.pause(); - } - }; - - video.addEventListener('pause', handlePause); - video.addEventListener('play', handlePlay); - video.addEventListener('timeupdate', handleTimeUpdate); - video.addEventListener('loadstart', handleLoadStart); - document.addEventListener('visibilitychange', handleVisibilityChange); - window.addEventListener('blur', handleBlur); - - // Настройка для мобильных устройств - video.setAttribute('playsinline', 'true'); - video.setAttribute('webkit-playsinline', 'true'); - - // Устанавливаем preload для лучшего контроля - video.preload = 'metadata'; - - return () => { - video.removeEventListener('pause', handlePause); - video.removeEventListener('play', handlePlay); - video.removeEventListener('timeupdate', handleTimeUpdate); - video.removeEventListener('loadstart', handleLoadStart); - document.removeEventListener('visibilitychange', handleVisibilityChange); - window.removeEventListener('blur', handleBlur); - }; - } - }, [open, videoUrl]); - - // Приостанавливаем видео при закрытии модального окна - const handleClose = () => { - if (videoRef.current && !videoRef.current.paused) { - videoRef.current.pause(); - } - onClose(); - }; - - return ( - - {/* Контент с видео */} - - {/* Кнопка закрытия */} - - - - - - - - - - Ваш браузер не поддерживает видео. - - - - - ); -}; - -export default VideoPlayerModal; \ No newline at end of file + const libraryVideos = useMemo(() => { + if (videos?.length) { + return videos + } + + if (videoUrl) { + return [ + { + uid: 'legacy-video', + file: videoUrl, + content: '', + created_at: '', + elapsed_time: '', + from_model: true, + info: null, + is_favourite: false, + is_sent: true, + model: '', + } satisfies Message, + ] + } + + return [] + }, [videos, videoUrl]) + + const currentIndex = useMemo(() => { + const index = libraryVideos.findIndex((item) => item.file === current || item.file === videoUrl) + return index >= 0 ? index : 0 + }, [libraryVideos, current, videoUrl]) + + const prevVideosLengthRef = useRef(0) + const [activeIndex, setActiveIndex] = useState(currentIndex) + + const { swiper, setSwiper, slideNext, slidePrev } = useLibrarySwiper(onSlideFalse, reverse) + const { downloadFile } = useImageIcons() + + useEffect(() => { + if (open) { + setActiveIndex(currentIndex) + } + }, [open, currentIndex]) + + useEffect(() => { + if (!open) { + prevVideosLengthRef.current = 0 + return + } + if (!swiper) { + return + } + + const len = libraryVideos.length + const prevLen = prevVideosLengthRef.current + + if (prevLen === 0) { + prevVideosLengthRef.current = len + return + } + + if (len === prevLen) { + return + } + + if (len < prevLen) { + prevVideosLengthRef.current = len + return + } + + const delta = len - prevLen + + const id = window.setTimeout(() => { + swiper.update() + + if (reverse) { + const firstNewIndex = Math.min(prevLen, len - 1) + swiper.slideTo(firstNewIndex, 0) + setActiveIndex(firstNewIndex) + } else { + const nextIndex = swiper.activeIndex + delta + const clamped = Math.max(0, Math.min(nextIndex, len - 1)) + swiper.slideTo(clamped, 0) + setActiveIndex(clamped) + } + + prevVideosLengthRef.current = len + }, 0) + + return () => clearTimeout(id) + }, [open, libraryVideos.length, reverse, swiper]) + + useEffect(() => { + if (!open) { + return + } + + const onEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + onClose() + } + } + + window.addEventListener('keydown', onEsc) + return () => window.removeEventListener('keydown', onEsc) + }, [open, onClose]) + + return ( +
+
+
+
+ { + e.stopPropagation() + if (!swiper) return + const item = libraryVideos[swiper.activeIndex] + if (!item?.file) return + downloadFile(item.file, item.content) + }} + width='24' + height='24' + viewBox='0 0 15 15' + fill='none' + xmlns='http://www.w3.org/2000/svg' + > + + +
+
+ { + e.stopPropagation() + if (!swiper) return + const item = libraryVideos[swiper.activeIndex] + if (!item?.file) return + window.open(item.file, '_blank') + }} + width='24' + height='24' + viewBox='0 0 15 15' + fill='none' + xmlns='http://www.w3.org/2000/svg' + > + + +
+
+ + + +
+
+ +
+ + + {open && libraryVideos.length > 0 && ( + setSwiper(instance)} + onSlideChange={(instance) => { + setActiveIndex(instance.activeIndex) + }} + > + {libraryVideos.map((video, index) => ( + + + + ))} + + )} + + +
+
+ ) +} + +export default VideoPlayerModal @@ -14,7 +14,7 @@ import BotParamsMap from '#/features/bot-params/bot-params-map' import { useCreateMediaMessage } from '#/features/create-media-message' import { useImagesBotFilters } from '#/features/image-bot-filters' import { useImagesUniqInput } from '#/features/image-bot-input' -import { useMediaBotPagination } from '#/features/image-bot-pagination' +import { useAudioBotPagination } from '#/features/image-bot-pagination' import { ModelInput } from '#/features/model-input' import { usePredictPrice } from '#/features/predict-price/model/use-predict-price' import Title from '#/features/title/title' @@ -43,8 +43,9 @@ const AudioModelPage: NextPageWithLayout = () => { const { botParams, version, modelType, fetchBotParams, resetParams, setDefaultParams, setVersion } = useImageBot(query.slug as string) const { openFiltersMobile, setOpenFiltersMobile, params, setParams, includeParams } = useImagesBotFilters() - const { refScrollMobile, refScrollDesktop, mobileScrollContainer, onObserverMounted, setMessages, fetchMessages, loading, offset, messages } = - useMediaBotPagination(deviceType, 'audio') + const routeSlug = typeof query.slug === 'string' ? query.slug : '' + const { mobileScrollContainer, setMessages, getMessagesPagination, loading, paginationLoading, hasMore, messages } = + useAudioBotPagination(routeSlug, deviceType) const { createImage, isComplete, createLoading } = useCreateMediaMessage( showMessage, modelType, @@ -72,7 +73,6 @@ const AudioModelPage: NextPageWithLayout = () => { useEffect(() => { if (!session) return onFetch() - onObserverMounted() }, [session?.access]) // Подготавливаем данные для API вкладки @@ -129,6 +129,8 @@ const AudioModelPage: NextPageWithLayout = () => { [includeParams, version] ) + const isContentLoading = messages === null || loading + const predictedPrice = usePredictPrice({ modelSlug: modelType, content: prompt, @@ -247,23 +249,26 @@ const AudioModelPage: NextPageWithLayout = () => { /> - setPrompt(content)} /> + {isContentLoading ? ( + + + + ) : ( + setPrompt(content)} + /> + )} - ) : ( -
+
@@ -281,8 +286,15 @@ const AudioModelPage: NextPageWithLayout = () => { className={'smallScroll'} > -
- setPrompt(content)} /> + setPrompt(content)} + />
@@ -12,7 +12,7 @@ import BotParamsMap from '#/features/bot-params/bot-params-map' import { useCreateMediaMessage } from '#/features/create-media-message' import { useImagesBotFilters } from '#/features/image-bot-filters' import { useImagesUniqInput } from '#/features/image-bot-input' -import { useMediaBotPagination } from '#/features/image-bot-pagination' +import { useImageBotPagination } from '#/features/image-bot-pagination' import { ModelInput } from '#/features/model-input' import { usePredictPrice } from '#/features/predict-price/model/use-predict-price' import Title from '#/features/title/title' @@ -40,8 +40,9 @@ const ImageModelPage: NextPageWithLayout = () => { const { desktop } = useDeviceType(deviceType, deviceOs) const { showMessage } = useShowDataStore() const { openFiltersMobile, setOpenFiltersMobile, params, setParams, includeParams } = useImagesBotFilters() - const { refScrollMobile, refScrollDesktop, mobileScrollContainer, onObserverMounted, setMessages, fetchMessages, loading, offset, messages } = - useMediaBotPagination(deviceType, 'image') + const routeSlug = typeof query.slug === 'string' ? query.slug : '' + const { mobileScrollContainer, setMessages, getMessagesPagination, loading, paginationLoading, hasMore, messages } = + useImageBotPagination(routeSlug, deviceType) const { createImage, isComplete, createLoading } = useCreateMediaMessage( showMessage, modelType, @@ -66,7 +67,6 @@ const ImageModelPage: NextPageWithLayout = () => { useEffect(() => { if (!session) return onFetch() - onObserverMounted() }, [session?.access]) // Подготавливаем данные для API вкладки @@ -120,6 +120,8 @@ const ImageModelPage: NextPageWithLayout = () => { [includeParams, version] ) + const isContentLoading = messages === null || loading + const predictedPrice = usePredictPrice({ modelSlug: modelType, content: prompt, @@ -227,31 +229,28 @@ const ImageModelPage: NextPageWithLayout = () => { - setPrompt(content)} - /> - + {isContentLoading ? ( + + + + ) : ( + setPrompt(content)} + /> + )} ) : ( -
+
@@ -269,17 +268,17 @@ const ImageModelPage: NextPageWithLayout = () => { className={'smallScroll'} > - -
setPrompt(content)} - /> + />
@@ -14,7 +14,7 @@ import BotParamsMap from '#/features/bot-params/bot-params-map' import { useCreateMediaMessage } from '#/features/create-media-message' import { useImagesBotFilters } from '#/features/image-bot-filters' import { useImagesUniqInput } from '#/features/image-bot-input' -import { useMediaBotPagination } from '#/features/image-bot-pagination' +import { useVideoBotPagination } from '#/features/image-bot-pagination' import { ModelInput } from '#/features/model-input' import { usePredictPrice } from '#/features/predict-price/model/use-predict-price' import Title from '#/features/title/title' @@ -42,8 +42,9 @@ const VideoModelPage: NextPageWithLayout = () => { const { botParams, version, modelType, fetchBotParams, resetParams, setDefaultParams, setVersion } = useImageBot(query.slug as string) const { openFiltersMobile, setOpenFiltersMobile, params, setParams, includeParams } = useImagesBotFilters() - const { refScrollMobile, refScrollDesktop, mobileScrollContainer, onObserverMounted, setMessages, fetchMessages, loading, offset, messages } = - useMediaBotPagination(deviceType, 'video') + const routeSlug = typeof query.slug === 'string' ? query.slug : '' + const { mobileScrollContainer, setMessages, getMessagesPagination, loading, paginationLoading, hasMore, messages } = + useVideoBotPagination(routeSlug, deviceType) const { createImage, isComplete, createLoading } = useCreateMediaMessage( showMessage, modelType, @@ -73,7 +74,6 @@ const VideoModelPage: NextPageWithLayout = () => { useEffect(() => { if (!session) return onFetch() - onObserverMounted() }, [session?.access]) // Подготавливаем данные для API вкладки @@ -127,6 +127,8 @@ const VideoModelPage: NextPageWithLayout = () => { [filteredParams, version] ) + const isContentLoading = messages === null || loading + const predictedPrice = usePredictPrice({ modelSlug: modelType, content: prompt, @@ -239,29 +241,26 @@ const VideoModelPage: NextPageWithLayout = () => { - setPrompt(content)} - /> + {isContentLoading ? ( + + + + ) : ( + setPrompt(content)} + /> + )} - ) : ( -
+
@@ -279,12 +278,13 @@ const VideoModelPage: NextPageWithLayout = () => { className={'smallScroll'} > -
setPrompt(content)} />
@@ -14,7 +14,7 @@ import BotParamsMap from '#/features/bot-params/bot-params-map' import { useCreateMediaMessage } from '#/features/create-media-message' import { useImagesBotFilters } from '#/features/image-bot-filters' import { useImagesUniqInput } from '#/features/image-bot-input' -import { useMediaBotPagination } from '#/features/image-bot-pagination' +import { useVoiceBotPagination } from '#/features/image-bot-pagination' import { ModelInput } from '#/features/model-input' import { usePredictPrice } from '#/features/predict-price/model/use-predict-price' import Title from '#/features/title/title' @@ -54,8 +54,9 @@ const VoiceCloningModelPage: NextPageWithLayout = () => { const { botParams, version, modelType, fetchBotParams, resetParams, setDefaultParams, setVersion } = useImageBot(query.slug as string) const { openFiltersMobile, setOpenFiltersMobile, params, setParams, includeParams } = useImagesBotFilters() - const { refScrollMobile, refScrollDesktop, mobileScrollContainer, onObserverMounted, setMessages, fetchMessages, loading, offset, messages } = - useMediaBotPagination(deviceType, 'voice') + const routeSlug = typeof query.slug === 'string' ? query.slug : '' + const { mobileScrollContainer, setMessages, getMessagesPagination, loading, paginationLoading, hasMore, messages } = + useVoiceBotPagination(routeSlug, deviceType) const { createImage, isComplete, createLoading } = useCreateMediaMessage( showMessage, modelType, @@ -135,7 +136,6 @@ const VoiceCloningModelPage: NextPageWithLayout = () => { useEffect(() => { if (!session) return onFetch() - onObserverMounted() }, [session?.access]) useEffect(() => { @@ -219,8 +219,9 @@ const VoiceCloningModelPage: NextPageWithLayout = () => { token: session?.access, enabled: !!modelType && !!session?.access && scope === 'playground', }) - const hasGenerations = (messages?.length ?? 0) > 0 - const generationsInitialLoading = loading && offset.current === 0 + const isContentLoading = messages === null || loading + const hasGenerations = !isContentLoading && (messages?.length ?? 0) > 0 + const generationsInitialLoading = isContentLoading const mobileScrollAreaHeight = `calc(100dvh - 116px - 61px - 15px ${botParams?.blocked ? '- 17px' : '- 110px'})` const compactPlaygroundEmpty = scope === 'playground' && !hasGenerations /** Меню + отступы layout + шапка страницы + табы — чтобы основной блок доходил до низа экрана без серой полосы */ @@ -357,23 +358,15 @@ const VoiceCloningModelPage: NextPageWithLayout = () => { setPrompt(content)} /> ) : ( )} - ) : ( @@ -419,12 +412,14 @@ const VoiceCloningModelPage: NextPageWithLayout = () => { className={'smallScroll'} > -
{hasGenerations ? ( setPrompt(content)} /> ) : ( @@ -1,41 +1,86 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Swiper as SwiperCore } from 'swiper' export const useLibrarySwiper = (onSlideFalse: ((...args: any) => any) | undefined, reverse: boolean) => { const [swiper, setSwiper] = useState(null) + const swiperRef = useRef(null) + const onSlideFalseRef = useRef(onSlideFalse) + const reverseRef = useRef(reverse) + swiperRef.current = swiper + onSlideFalseRef.current = onSlideFalse + reverseRef.current = reverse - const keydown = (e: KeyboardEvent) => { - if (e.key === 'ArrowRight') { - slideNext() - } - if (e.key === 'ArrowLeft') { - slidePrev() + const requestMoreIfNeeded = (direction: 'prev' | 'next') => { + const shouldPaginate = + (direction === 'prev' && !reverseRef.current) || (direction === 'next' && reverseRef.current) + + if (shouldPaginate && onSlideFalseRef.current) { + void onSlideFalseRef.current() } } - const slidePrev = function () { - const result = swiper?.slidePrev() + const slidePrev = () => { + const instance = swiperRef.current + if (!instance) { + return + } - if (!result && onSlideFalse && !reverse) { - onSlideFalse() + const atBeginning = instance.isBeginning || instance.activeIndex <= 0 + if (atBeginning) { + requestMoreIfNeeded('prev') + return + } + + const prevIndex = instance.activeIndex + instance.slidePrev() + + if (instance.activeIndex === prevIndex) { + requestMoreIfNeeded('prev') } } - const slideNext = function () { - const result = swiper?.slideNext() + const slideNext = () => { + const instance = swiperRef.current + if (!instance) { + return + } - if (!result && onSlideFalse && reverse) { - onSlideFalse() + const lastIndex = Math.max(instance.slides.length - 1, 0) + const atEnd = instance.isEnd || instance.activeIndex >= lastIndex + if (atEnd) { + requestMoreIfNeeded('next') + return + } + + const prevIndex = instance.activeIndex + instance.slideNext() + + if (instance.activeIndex === prevIndex) { + requestMoreIfNeeded('next') } } + const slidePrevRef = useRef(slidePrev) + const slideNextRef = useRef(slideNext) + slidePrevRef.current = slidePrev + slideNextRef.current = slideNext + useEffect(() => { + const keydown = (e: KeyboardEvent) => { + if (e.key === 'ArrowRight') { + slideNextRef.current() + } + if (e.key === 'ArrowLeft') { + slidePrevRef.current() + } + } + document.addEventListener('keydown', keydown, true) return () => { document.removeEventListener('keydown', keydown, true) } - }, [swiper]) + }, []) return { swiper, @@ -69,9 +69,14 @@ export default function FullScreenModal({ const delta = len - prevLen const id = window.setTimeout(() => { + swiper.update() + if (reverse) { - swiper.update() + // Desktop: older items are appended at the end — jump to the first newly loaded slide + const firstNewIndex = Math.min(prevLen, len - 1) + swiper.slideTo(firstNewIndex, 0) } else { + // Mobile: older items are prepended — keep the same visual position const nextIndex = swiper.activeIndex + delta const clamped = Math.max(0, Math.min(nextIndex, len - 1)) swiper.slideTo(clamped, 0) @@ -23,6 +23,47 @@ function getStreamingModelUid(inputMessageUuid: string) { return `streaming:${inputMessageUuid}` } +/** Keeps first occurrence of each uid (chronological lists). */ +function dedupeMessagesByUid(messages: Message[]): Message[] { + const seen = new Set() + const result: Message[] = [] + + for (const message of messages) { + if (!message.uid || seen.has(message.uid)) { + continue + } + seen.add(message.uid) + result.push(message) + } + + return result +} + +/** + * Prepend older messages and drop duplicates by uid. + * Also drops local streaming placeholders when the real server copy of the pair arrives. + */ +function prependUniqueMessages(prev: Message[], incoming: Message[]): Message[] { + const existingUids = new Set(prev.map((message) => message.uid)) + + const toPrepend = incoming.filter((message) => !existingUids.has(message.uid)) + if (toPrepend.length === 0) { + return prev + } + + const incomingUids = new Set(incoming.map((message) => message.uid)) + const withoutResolvedStreaming = prev.filter((message) => { + if (!message.uid.startsWith('streaming:')) { + return true + } + const inputUuid = message.uid.slice('streaming:'.length) + // User message from this stream is already on the server page → drop placeholder model bubble + return !incomingUids.has(inputUuid) + }) + + return dedupeMessagesByUid([...toPrepend, ...withoutResolvedStreaming]) +} + function removeStreamingModelMessage(messages: Message[] | null, inputMessageUuid?: string | null) { const uidsToRemove = new Set([getStreamingModelUid(STREAMING_PENDING_UID)]) @@ -227,6 +268,7 @@ export function useChatModel( const [loading, setLoading] = useState(false) const [paginationLoading, setPaginationLoading] = useState(false) const [offset, setOffset] = useState(0) + const paginationLockRef = useRef(false) const abortRef = useRef(null) const isSendingRef = useRef(false) @@ -637,10 +679,11 @@ export function useChatModel( }, [currentChat, streaming, data?.access, tryReconnectOnLoad]) const getMessagesPagination = useCallback(async () => { - if (!currentChat || isSendingRef.current || paginationLoading) { + if (!currentChat || isSendingRef.current || paginationLoading || paginationLockRef.current) { return } + paginationLockRef.current = true setPaginationLoading(true) try { @@ -650,17 +693,14 @@ export function useChatModel( return } - const newMessages = answer.reverse() + const newMessages = [...answer].reverse() setMessages((prev) => { - const existingUids = new Set(prev?.map((message) => message.uid) ?? []) - const toPrepend = newMessages.filter((message) => !existingUids.has(message.uid)) - - if (toPrepend.length === 0) { - return prev ?? null + if (!prev) { + return dedupeMessagesByUid(newMessages) } - return [...toPrepend, ...(prev ?? [])] + return prependUniqueMessages(prev, newMessages) }) setOffset((prev) => prev + answer.length) @@ -668,6 +708,7 @@ export function useChatModel( console.error('[useChatModel] getMessagesPagination: request failed', { chatUid: currentChat, error }) showMessage('Ошибка загрузки сообщений') } finally { + paginationLockRef.current = false setPaginationLoading(false) } }, [currentChat, data?.access, offset, paginationLoading, showMessage]) @@ -727,7 +768,9 @@ export function useChatModel( showMessage('Непредвиденная ошибка, попробуйте еще раз') } } else { - setMessages((prev) => [...(prev ?? []), ...(result as Message[])]) + const resultMessages = result as Message[] + setMessages((prev) => dedupeMessagesByUid([...(prev ?? []), ...resultMessages])) + setOffset((prev) => prev + resultMessages.length) } } catch (error) { console.error('[useChatModel] sendMessage: request failed', { chatUid: currentChat, error }) @@ -793,7 +836,7 @@ export function useChatModel( const inputUuid = inputMessageUuidRef.current ?? readChatStreamSession(currentChat)?.inputMessageUuid ?? STREAMING_PENDING_UID - await reconnectToStream( + const reconnected = await reconnectToStream( currentChat, inputUuid, streamRuntimeRef.current.lastOffset, @@ -801,6 +844,10 @@ export function useChatModel( streamRuntimeRef.current.modelContent, { silent: true } ) + + if (reconnected) { + setOffset((prev) => prev + 2) + } return } @@ -814,6 +861,8 @@ export function useChatModel( markOptimisticUserMessageFailed() setMessages((prev) => removeStreamingModelMessage(prev)) showMessage('Непредвиденная ошибка, попробуйте еще раз') + } else if (result.streamCompleted && !result.streamFailed) { + setOffset((prev) => prev + 2) } } catch (error) { if (abortController.signal.aborted) { @@ -823,9 +872,12 @@ export function useChatModel( const session = readChatStreamSession(currentChat) if (session) { const inputUuid = inputMessageUuidRef.current ?? session.inputMessageUuid - await reconnectToStream(currentChat, inputUuid, session.lastOffset, undefined, session.modelContent, { + const reconnected = await reconnectToStream(currentChat, inputUuid, session.lastOffset, undefined, session.modelContent, { silent: true, }) + if (reconnected) { + setOffset((prev) => prev + 2) + } return } @@ -1,6 +1,5 @@ -import React, { memo, useCallback, useEffect, useRef, useState } from 'react' -import { Typography } from '@mui/material' -import Box from '@mui/material/Box' +import React, { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { Box, CircularProgress, Typography } from '@mui/material' import Image from 'next/image' import { Message } from '#/entities/message' @@ -24,6 +23,9 @@ interface MessagesList { device: Device audios: Message[] | null | undefined getMessagesPagination?: () => Promise + paginationLoading?: boolean + hasMore?: boolean + scrollContainerRef?: React.RefObject onPromptClick?: (content: string) => void } @@ -103,6 +105,7 @@ const AudioMessageItem = memo( return ( onMouseLeave(message.uid)} sx={{ display: 'flex', flexDirection: 'column', gap: '12px' }} > @@ -186,12 +189,103 @@ const AudioMessageItem = memo( AudioMessageItem.displayName = 'AudioMessageItem' -export const AudioMessagesList = memo(({ device, audios, onPromptClick }: MessagesList) => { +export const AudioMessagesList = memo( + ({ + device, + audios, + getMessagesPagination, + paginationLoading = false, + hasMore = false, + scrollContainerRef, + onPromptClick, + }: MessagesList) => { const [currentlyPlaying, setCurrentlyPlaying] = useState(null) const audioRefs = useRef>({}) const slowConnectionToastShownRef = useRef(false) const { showMessage } = useShowDataStore() + const paginationSentinelRef = useRef(null) + const intersectionObserverRef = useRef(null) + const prevAudiosSnapshotRef = useRef<{ + length: number + firstUid?: string + lastUid?: string + } | null>(null) + const scrollAnchorRef = useRef<{ + uid: string + topOffset: number + scrollTop: number + scrollHeight: number + } | null>(null) + const prependRestoreObserverRef = useRef(null) + const prependRestoreTimeoutRef = useRef | null>(null) + const isRestoringScrollRef = useRef(false) + + const getMessageTopInViewport = (block: HTMLDivElement, el: HTMLElement) => + el.getBoundingClientRect().top - block.getBoundingClientRect().top + + const captureScrollAnchor = (block: HTMLDivElement) => { + const anchorUid = audios?.[0]?.uid + if (!anchorUid) { + return + } + + const el = block.querySelector(`[data-message-uid="${CSS.escape(anchorUid)}"]`) as HTMLElement | null + + scrollAnchorRef.current = { + uid: anchorUid, + topOffset: el ? getMessageTopInViewport(block, el) : 0, + scrollTop: block.scrollTop, + scrollHeight: block.scrollHeight, + } + } + + const restorePrependScroll = (block: HTMLDivElement) => { + const anchor = scrollAnchorRef.current + if (!anchor) { + return + } + + const apply = () => { + block.scrollTop = anchor.scrollTop + (block.scrollHeight - anchor.scrollHeight) + + const el = block.querySelector(`[data-message-uid="${CSS.escape(anchor.uid)}"]`) as HTMLElement | null + if (el) { + const currentTop = getMessageTopInViewport(block, el) + block.scrollTop = block.scrollTop + currentTop - anchor.topOffset + } + } + + prependRestoreObserverRef.current?.disconnect() + if (prependRestoreTimeoutRef.current) { + clearTimeout(prependRestoreTimeoutRef.current) + } + + isRestoringScrollRef.current = true + apply() + + const observer = new ResizeObserver(() => { + apply() + }) + prependRestoreObserverRef.current = observer + observer.observe(block) + + requestAnimationFrame(() => { + apply() + requestAnimationFrame(() => { + apply() + isRestoringScrollRef.current = false + }) + }) + + prependRestoreTimeoutRef.current = setTimeout(() => { + observer.disconnect() + prependRestoreObserverRef.current = null + scrollAnchorRef.current = null + isRestoringScrollRef.current = false + }, 3000) + } + const handleSlowConnectionDetected = useCallback(() => { if (device !== 'mobile' || slowConnectionToastShownRef.current) return @@ -212,8 +306,128 @@ export const AudioMessagesList = memo(({ device, audios, onPromptClick }: Messag } } + useEffect(() => { + return () => { + prependRestoreObserverRef.current?.disconnect() + intersectionObserverRef.current?.disconnect() + if (prependRestoreTimeoutRef.current) { + clearTimeout(prependRestoreTimeoutRef.current) + } + } + }, []) + + useEffect(() => { + if (!audios?.length) { + prevAudiosSnapshotRef.current = null + scrollAnchorRef.current = null + } + }, [audios]) + + useLayoutEffect(() => { + if (device !== 'mobile' || !audios?.length) { + return + } + + const block = scrollContainerRef?.current + if (!block) { + return + } + + const snapshot = { + length: audios.length, + firstUid: audios[0]?.uid, + lastUid: audios[audios.length - 1]?.uid, + } + const prev = prevAudiosSnapshotRef.current + + if (!prev) { + block.scrollTop = block.scrollHeight + prevAudiosSnapshotRef.current = snapshot + return + } + + const prepended = snapshot.length > prev.length && snapshot.lastUid === prev.lastUid + const appended = snapshot.lastUid !== prev.lastUid && snapshot.length >= prev.length && !prepended + + if (prepended && scrollAnchorRef.current) { + restorePrependScroll(block) + } else if (appended) { + block.scrollTo({ + top: block.scrollHeight, + behavior: 'smooth', + }) + } + + prevAudiosSnapshotRef.current = snapshot + }, [audios, device, scrollContainerRef]) + + useEffect(() => { + if (device !== 'mobile' || !scrollContainerRef?.current || !getMessagesPagination) { + return + } + + const block = scrollContainerRef.current + + const handleScroll = () => { + if (!audios?.length || block.scrollTop !== 0 || paginationLoading || isRestoringScrollRef.current) { + return + } + + captureScrollAnchor(block) + void getMessagesPagination() + } + + block.addEventListener('scroll', handleScroll, { passive: true }) + return () => block.removeEventListener('scroll', handleScroll) + }, [device, scrollContainerRef, audios, getMessagesPagination, paginationLoading]) + + useEffect(() => { + if (device !== 'desktop' || !getMessagesPagination || !hasMore || !audios?.length) { + return + } + + const sentinel = paginationSentinelRef.current + if (!sentinel) { + return + } + + intersectionObserverRef.current?.disconnect() + + const observer = new IntersectionObserver( + (entries) => { + if (!entries[0]?.isIntersecting || paginationLoading || !hasMore) { + return + } + + void getMessagesPagination() + }, + { rootMargin: '400px' } + ) + + intersectionObserverRef.current = observer + observer.observe(sentinel) + + return () => observer.disconnect() + }, [device, getMessagesPagination, paginationLoading, hasMore, audios?.length]) + return ( - + + {device === 'mobile' && paginationLoading && ( + + + + )} + + + {device === 'desktop' && hasMore && ( + <> + {paginationLoading && ( + + + + )} + + + )} ) -}) + } +) AudioMessagesList.displayName = 'AudioMessagesList' @@ -1,7 +1,6 @@ -import React, { memo, useState } from 'react' +import React, { memo, useEffect, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' -import { Skeleton, Typography } from '@mui/material' -import Box from '@mui/material/Box' +import { Box, CircularProgress, Skeleton, Typography } from '@mui/material' import Image from 'next/image' import Link from 'next/link' @@ -15,163 +14,379 @@ import { Message } from '#/entities/message' import { ImageModal } from '#/features/image-modal' import { ClientOnly, TooltipCustom } from '#/shared' import { c } from '#/shared/lib/helpers' -import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' interface MessagesList { device: 'mobile' | 'desktop' - images: Message[] + images: Message[] | null getMessagesPagination?: () => Promise - isComplete: boolean + paginationLoading?: boolean + hasMore?: boolean + scrollContainerRef?: React.RefObject onPinImageFromUrl?: (url: string) => void canAttachFile?: boolean onPromptClick?: (content: string) => void } -export const ImageMessagesList: React.FC = memo(({ device, images, getMessagesPagination, onPinImageFromUrl, canAttachFile, onPromptClick }) => { - const { showMessage } = useShowDataStore() - const [modal, setModal] = useState(false) - - const { chosenImage, setChosenImage, loaded, setLoaded, computedLibraryImages } = useMessages(images) - - return ( - <> - - {createPortal( - getMessagesPagination && getMessagesPagination()} - setModal={setModal} - reverse={device === 'desktop'} - current={chosenImage} - />, - document.getElementById('modal-container')! - )} - - - - - ГЕНЕРАЦИИ - - - - {images?.length !== 0 && - Array.isArray(images) && - images.map((message) => { - //@ts-ignore - const isZip = message.file && message.file.includes('.zip') - //@ts-ignore - const isSvg = message.file && message.file.includes('.svg') - return ( - - {isZip ? ( - - - Эта генерация является архивом - - Скачать - - - - ) : ( - +export const ImageMessagesList: React.FC = memo( + ({ + device, + images, + getMessagesPagination, + paginationLoading = false, + hasMore = false, + scrollContainerRef, + onPinImageFromUrl, + canAttachFile, + onPromptClick, + }) => { + const [modal, setModal] = useState(false) + const paginationSentinelRef = useRef(null) + const intersectionObserverRef = useRef(null) + + const prevImagesSnapshotRef = useRef<{ + length: number + firstUid?: string + lastUid?: string + } | null>(null) + const scrollAnchorRef = useRef<{ + uid: string + topOffset: number + scrollTop: number + scrollHeight: number + } | null>(null) + const prependRestoreObserverRef = useRef(null) + const prependRestoreTimeoutRef = useRef | null>(null) + const isRestoringScrollRef = useRef(false) + + const { chosenImage, setChosenImage, loaded, setLoaded, computedLibraryImages } = useMessages(images ?? []) + + const getMessageTopInViewport = (block: HTMLDivElement, el: HTMLElement) => + el.getBoundingClientRect().top - block.getBoundingClientRect().top + + const captureScrollAnchor = (block: HTMLDivElement) => { + const anchorUid = images?.[0]?.uid + if (!anchorUid) { + return + } + + const el = block.querySelector(`[data-message-uid="${CSS.escape(anchorUid)}"]`) as HTMLElement | null + + scrollAnchorRef.current = { + uid: anchorUid, + topOffset: el ? getMessageTopInViewport(block, el) : 0, + scrollTop: block.scrollTop, + scrollHeight: block.scrollHeight, + } + } + + const restorePrependScroll = (block: HTMLDivElement) => { + const anchor = scrollAnchorRef.current + if (!anchor) { + return + } + + const apply = () => { + block.scrollTop = anchor.scrollTop + (block.scrollHeight - anchor.scrollHeight) + + const el = block.querySelector(`[data-message-uid="${CSS.escape(anchor.uid)}"]`) as HTMLElement | null + if (el) { + const currentTop = getMessageTopInViewport(block, el) + block.scrollTop = block.scrollTop + currentTop - anchor.topOffset + } + } + + prependRestoreObserverRef.current?.disconnect() + if (prependRestoreTimeoutRef.current) { + clearTimeout(prependRestoreTimeoutRef.current) + } + + isRestoringScrollRef.current = true + apply() + + const observer = new ResizeObserver(() => { + apply() + }) + prependRestoreObserverRef.current = observer + observer.observe(block) + + requestAnimationFrame(() => { + apply() + requestAnimationFrame(() => { + apply() + isRestoringScrollRef.current = false + }) + }) + + prependRestoreTimeoutRef.current = setTimeout(() => { + observer.disconnect() + prependRestoreObserverRef.current = null + scrollAnchorRef.current = null + isRestoringScrollRef.current = false + }, 3000) + } + + useEffect(() => { + return () => { + prependRestoreObserverRef.current?.disconnect() + intersectionObserverRef.current?.disconnect() + if (prependRestoreTimeoutRef.current) { + clearTimeout(prependRestoreTimeoutRef.current) + } + } + }, []) + + useEffect(() => { + if (!images?.length) { + prevImagesSnapshotRef.current = null + scrollAnchorRef.current = null + } + }, [images]) + + useLayoutEffect(() => { + if (device !== 'mobile' || !images?.length) { + return + } + + const block = scrollContainerRef?.current + if (!block) { + return + } + + const snapshot = { + length: images.length, + firstUid: images[0]?.uid, + lastUid: images[images.length - 1]?.uid, + } + const prev = prevImagesSnapshotRef.current + + if (!prev) { + block.scrollTop = block.scrollHeight + prevImagesSnapshotRef.current = snapshot + return + } + + const prepended = snapshot.length > prev.length && snapshot.lastUid === prev.lastUid + const appended = snapshot.lastUid !== prev.lastUid && snapshot.length >= prev.length && !prepended + + if (prepended && scrollAnchorRef.current) { + restorePrependScroll(block) + } else if (appended) { + block.scrollTo({ + top: block.scrollHeight, + behavior: 'smooth', + }) + } + + prevImagesSnapshotRef.current = snapshot + }, [images, device, scrollContainerRef]) + + useEffect(() => { + if (device !== 'mobile' || !scrollContainerRef?.current || !getMessagesPagination) { + return + } + + const block = scrollContainerRef.current + + const handleScroll = () => { + if (!images?.length || block.scrollTop !== 0 || paginationLoading || isRestoringScrollRef.current) { + return + } + + captureScrollAnchor(block) + void getMessagesPagination() + } + + block.addEventListener('scroll', handleScroll, { passive: true }) + return () => block.removeEventListener('scroll', handleScroll) + }, [device, scrollContainerRef, images, getMessagesPagination, paginationLoading]) + + useEffect(() => { + if (device !== 'desktop' || !getMessagesPagination || !hasMore || !images?.length) { + return + } + + const sentinel = paginationSentinelRef.current + if (!sentinel) { + return + } + + intersectionObserverRef.current?.disconnect() + + const observer = new IntersectionObserver( + (entries) => { + if (!entries[0]?.isIntersecting || paginationLoading || !hasMore) { + return + } + + void getMessagesPagination() + }, + { rootMargin: '400px' } + ) + + intersectionObserverRef.current = observer + observer.observe(sentinel) + + return () => observer.disconnect() + }, [device, getMessagesPagination, paginationLoading, hasMore, images?.length]) + + return ( + <> + + {createPortal( + { + if (hasMore && getMessagesPagination) { + void getMessagesPagination() + } + }} + setModal={setModal} + reverse={device === 'desktop'} + current={chosenImage} + />, + document.getElementById('modal-container')! + )} + + + + {device === 'mobile' && paginationLoading && ( + + + + )} + + + ГЕНЕРАЦИИ + + + + {images?.length !== 0 && + Array.isArray(images) && + images.map((message) => { + const file = message.file?.toString() ?? '' + const isZip = file.includes('.zip') + const isSvg = file.includes('.svg') + return ( + + {isZip ? ( - {!isSvg ? ( - <> - { - setChosenImage(message.file as string) - setModal(true) - }} - onLoadingComplete={() => setLoaded(true)} - width={500} - height={500} - src={(message.file as unknown as string) || ''} - alt={'К сожалению, изображение не загрузилось'} - /> - setLoaded(true)} - width={10} - height={10} - src={(message.file as unknown as string) || ''} - alt='' - /> - - ) : ( - <> - { - setChosenImage(message.file as string) - setModal(true) + + Эта генерация является архивом + + Скачать + + + + ) : ( + + + {!isSvg ? ( + <> + { + setChosenImage(message.file as string) + setModal(true) + }} + onLoadingComplete={() => setLoaded(true)} + width={500} + height={500} + src={(message.file as unknown as string) || ''} + alt={'К сожалению, изображение не загрузилось'} + /> + setLoaded(true)} + width={10} + height={10} + src={(message.file as unknown as string) || ''} + alt='' + /> + + ) : ( + <> + { + setChosenImage(message.file as string) + setModal(true) + }} + src={(message.file as unknown as string) || ''} + alt='К сожалению, изображение не загрузилось' + /> + К сожалению, изображение не загрузилось + + )} + + {!loaded && ( + - К сожалению, изображение не загрузилось - - )} + )} - {!loaded && ( - - )} - = memo(({ device, images, : undefined } /> - + - - message.content.length > 0 && onPromptClick?.(message.content)} - sx={{ - fontSize: '15px', - width: '100%', - marginTop: '12px', - color: '#A4AAB5', - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - cursor: onPromptClick && message.content.length > 0 ? 'pointer' : 'default', - }} + - {!loaded - ? '' - : message.content.length > 0 - ? message?.content.replaceAll('"', '').slice(0, device === 'mobile' ? 35 : 30) - : 'описание отсутствует'} - {message?.content.length > (device === 'mobile' ? 35 : 30) && loaded && '...'} - - - - )} + message.content.length > 0 && onPromptClick?.(message.content)} + sx={{ + fontSize: '15px', + width: '100%', + marginTop: '12px', + color: '#A4AAB5', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + cursor: onPromptClick && message.content.length > 0 ? 'pointer' : 'default', + }} + > + {!loaded + ? '' + : message.content.length > 0 + ? message?.content.replaceAll('"', '').slice(0, device === 'mobile' ? 35 : 30) + : 'описание отсутствует'} + {message?.content.length > (device === 'mobile' ? 35 : 30) && loaded && '...'} + + + + )} + + ) + })} + + + {device === 'desktop' && hasMore && ( + <> + {paginationLoading && ( + + - ) - })} + )} + + + )} - - - ) -}) + + ) + } +) ImageMessagesList.displayName = 'ImageMessagesList' @@ -1,88 +1,320 @@ -import React, { memo, useRef,useState } from 'react' -import { Skeleton, Typography } from '@mui/material' -import Box from '@mui/material/Box' +import React, { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { Box, CircularProgress, Skeleton, Typography } from '@mui/material' import { ImageIcons } from './image-icons' import styles from './image-messages-list.module.scss' -import { VideoMessage } from '#/entities/message' +import { Message, VideoMessage } from '#/entities/message' import VideoPlayerModal from '#/features/video-player/video-player-modal' -import { TooltipCustom } from '#/shared' +import { ClientOnly, TooltipCustom } from '#/shared' interface MessagesList { device: 'mobile' | 'desktop' videos: VideoMessage[] | null | undefined getMessagesPagination?: () => Promise - isComplete: boolean + paginationLoading?: boolean + hasMore?: boolean + scrollContainerRef?: React.RefObject onPromptClick?: (content: string) => void } -export const VideoMessagesList: React.FC = memo(({ device, videos, getMessagesPagination, onPromptClick }) => { - const [loadedVideos, setLoadedVideos] = useState>(new Set()) - const videoRefs = useRef<{ [key: string]: HTMLVideoElement | null }>({}) - const [isModalOpen, setIsModalOpen] = useState(false) - const [selectedVideoUrl, setSelectedVideoUrl] = useState('') - const [selectedVideoTitle, setSelectedVideoTitle] = useState('') +export const VideoMessagesList: React.FC = memo( + ({ + device, + videos, + getMessagesPagination, + paginationLoading = false, + hasMore = false, + scrollContainerRef, + onPromptClick, + }) => { + const [loadedVideos, setLoadedVideos] = useState>(new Set()) + const videoRefs = useRef<{ [key: string]: HTMLVideoElement | null }>({}) + const [isModalOpen, setIsModalOpen] = useState(false) + const [selectedVideoUrl, setSelectedVideoUrl] = useState(null) - const handleVideoLoad = (uid: string) => { - setLoadedVideos(prev => new Set([...prev, uid])) - } + const libraryVideos = useMemo[]>(() => { + if (!videos?.length) { + return [] + } - const handleVideoClick = (uid: string, videoUrl: string, content: string) => { - setSelectedVideoUrl(videoUrl) - setSelectedVideoTitle(content || 'Видео') - setIsModalOpen(true) - } + return videos.filter( + (message): message is VideoMessage => + typeof message.file === 'string' && message.file.trim() !== '' + ) as Message[] + }, [videos]) - const handleCloseModal = () => { - setIsModalOpen(false) - setSelectedVideoUrl('') - setSelectedVideoTitle('') - } + const paginationSentinelRef = useRef(null) + const intersectionObserverRef = useRef(null) + const prevVideosSnapshotRef = useRef<{ + length: number + firstUid?: string + lastUid?: string + } | null>(null) + const scrollAnchorRef = useRef<{ + uid: string + topOffset: number + scrollTop: number + scrollHeight: number + } | null>(null) + const prependRestoreObserverRef = useRef(null) + const prependRestoreTimeoutRef = useRef | null>(null) + const isRestoringScrollRef = useRef(false) + + const getMessageTopInViewport = (block: HTMLDivElement, el: HTMLElement) => + el.getBoundingClientRect().top - block.getBoundingClientRect().top + + const captureScrollAnchor = (block: HTMLDivElement) => { + const anchorUid = videos?.[0]?.uid + if (!anchorUid) { + return + } + + const el = block.querySelector(`[data-message-uid="${CSS.escape(anchorUid)}"]`) as HTMLElement | null + + scrollAnchorRef.current = { + uid: anchorUid, + topOffset: el ? getMessageTopInViewport(block, el) : 0, + scrollTop: block.scrollTop, + scrollHeight: block.scrollHeight, + } + } + + const restorePrependScroll = (block: HTMLDivElement) => { + const anchor = scrollAnchorRef.current + if (!anchor) { + return + } + + const apply = () => { + block.scrollTop = anchor.scrollTop + (block.scrollHeight - anchor.scrollHeight) + + const el = block.querySelector(`[data-message-uid="${CSS.escape(anchor.uid)}"]`) as HTMLElement | null + if (el) { + const currentTop = getMessageTopInViewport(block, el) + block.scrollTop = block.scrollTop + currentTop - anchor.topOffset + } + } + + prependRestoreObserverRef.current?.disconnect() + if (prependRestoreTimeoutRef.current) { + clearTimeout(prependRestoreTimeoutRef.current) + } + + isRestoringScrollRef.current = true + apply() + + const observer = new ResizeObserver(() => { + apply() + }) + prependRestoreObserverRef.current = observer + observer.observe(block) + + requestAnimationFrame(() => { + apply() + requestAnimationFrame(() => { + apply() + isRestoringScrollRef.current = false + }) + }) + + prependRestoreTimeoutRef.current = setTimeout(() => { + observer.disconnect() + prependRestoreObserverRef.current = null + scrollAnchorRef.current = null + isRestoringScrollRef.current = false + }, 3000) + } + + const handleVideoLoad = (uid: string) => { + setLoadedVideos((prev) => new Set([...prev, uid])) + } + + const handleVideoClick = (_uid: string, videoUrl: string) => { + setSelectedVideoUrl(videoUrl) + setIsModalOpen(true) + } + + const handleCloseModal = () => { + setIsModalOpen(false) + setSelectedVideoUrl(null) + } + + useEffect(() => { + return () => { + prependRestoreObserverRef.current?.disconnect() + intersectionObserverRef.current?.disconnect() + if (prependRestoreTimeoutRef.current) { + clearTimeout(prependRestoreTimeoutRef.current) + } + } + }, []) + + useEffect(() => { + if (!videos?.length) { + prevVideosSnapshotRef.current = null + scrollAnchorRef.current = null + } + }, [videos]) + + useLayoutEffect(() => { + if (device !== 'mobile' || !videos?.length) { + return + } + + const block = scrollContainerRef?.current + if (!block) { + return + } + + const snapshot = { + length: videos.length, + firstUid: videos[0]?.uid, + lastUid: videos[videos.length - 1]?.uid, + } + const prev = prevVideosSnapshotRef.current - return ( - <> - - - - - ГЕНЕРАЦИИ - - - - {videos?.length !== 0 && videos?.map((message) => { - // Если нет файла или файл пустой, не показываем сообщение + if (!prev) { + block.scrollTop = block.scrollHeight + prevVideosSnapshotRef.current = snapshot + return + } + + const prepended = snapshot.length > prev.length && snapshot.lastUid === prev.lastUid + const appended = snapshot.lastUid !== prev.lastUid && snapshot.length >= prev.length && !prepended + + if (prepended && scrollAnchorRef.current) { + restorePrependScroll(block) + } else if (appended) { + block.scrollTo({ + top: block.scrollHeight, + behavior: 'smooth', + }) + } + + prevVideosSnapshotRef.current = snapshot + }, [videos, device, scrollContainerRef]) + + useEffect(() => { + if (device !== 'mobile' || !scrollContainerRef?.current || !getMessagesPagination) { + return + } + + const block = scrollContainerRef.current + + const handleScroll = () => { + if (!videos?.length || block.scrollTop !== 0 || paginationLoading || isRestoringScrollRef.current) { + return + } + + captureScrollAnchor(block) + void getMessagesPagination() + } + + block.addEventListener('scroll', handleScroll, { passive: true }) + return () => block.removeEventListener('scroll', handleScroll) + }, [device, scrollContainerRef, videos, getMessagesPagination, paginationLoading]) + + useEffect(() => { + if (device !== 'desktop' || !getMessagesPagination || !hasMore || !videos?.length) { + return + } + + const sentinel = paginationSentinelRef.current + if (!sentinel) { + return + } + + intersectionObserverRef.current?.disconnect() + + const observer = new IntersectionObserver( + (entries) => { + if (!entries[0]?.isIntersecting || paginationLoading || !hasMore) { + return + } + + void getMessagesPagination() + }, + { rootMargin: '400px' } + ) + + intersectionObserverRef.current = observer + observer.observe(sentinel) + + return () => observer.disconnect() + }, [device, getMessagesPagination, paginationLoading, hasMore, videos?.length]) + + return ( + <> + + {createPortal( + { + if (hasMore && getMessagesPagination) { + void getMessagesPagination() + } + }} + />, + document.getElementById('modal-container')! + )} + + + + {device === 'mobile' && paginationLoading && ( + + + + )} + + + ГЕНЕРАЦИИ + + + + {videos?.length !== 0 && + videos?.map((message) => { if (!message.file || (typeof message.file === 'string' && message.file.trim() === '')) { return null } const fileUrl = message.file as string return ( - + = memo(({ device, videos, videoRefs.current[message.uid] = el }} className={styles.background_video} - onClick={() => handleVideoClick(message.uid, fileUrl, message.content)} + onClick={() => handleVideoClick(message.uid, fileUrl)} onLoadedData={() => handleVideoLoad(message.uid)} preload='auto' muted @@ -126,7 +358,13 @@ export const VideoMessagesList: React.FC = memo(({ device, videos, variant='rectangular' /> )} - + = memo(({ device, videos, {!loadedVideos.has(message.uid) ? '' : message.content.length > 0 - ? message?.content.replaceAll('"', '') - : 'описание отсутствует'} + ? message?.content.replaceAll('"', '') + : 'описание отсутствует'} ) })} + + + {device === 'desktop' && hasMore && ( + <> + {paginationLoading && ( + + + + )} + + + )} -
- - ) -}) + + ) + } +) VideoMessagesList.displayName = 'VideoMessagesList'