diff --git a/src/components/App/AppBar/index.tsx b/src/components/App/AppBar/index.tsx index 3555e1104..a9670f8ad 100644 --- a/src/components/App/AppBar/index.tsx +++ b/src/components/App/AppBar/index.tsx @@ -58,7 +58,7 @@ const Header = styled(Flex).attrs({ left: 64px; right: 32px; transition: opacity 1s; - z-index: 1; + z-index: 99; padding: 20px 23px; ` diff --git a/src/components/App/MainToolbar/index.tsx b/src/components/App/MainToolbar/index.tsx index 6677dbca6..93bc08256 100644 --- a/src/components/App/MainToolbar/index.tsx +++ b/src/components/App/MainToolbar/index.tsx @@ -114,7 +114,7 @@ const Wrapper = styled(Flex).attrs({ justify: 'flex-start', })` flex: 0 0 64px; - z-index: 1; + z-index: 31; transition: opacity 1s; background: ${colors.BG2}; position: relative; diff --git a/src/components/App/SecondarySidebar/index.tsx b/src/components/App/SecondarySidebar/index.tsx index 2ca6052a1..8a38dbbe7 100644 --- a/src/components/App/SecondarySidebar/index.tsx +++ b/src/components/App/SecondarySidebar/index.tsx @@ -40,7 +40,7 @@ const Wrapper = styled(Flex)(({ theme }) => ({ height: '100vh', padding: '16px 20px', width: '100%', - zIndex: 1, + zIndex: 30, display: 'flex', [theme.breakpoints.up('sm')]: { width: MENU_WIDTH, diff --git a/src/components/App/SideBar/Trending/index.tsx b/src/components/App/SideBar/Trending/index.tsx index 8fcc51e24..0ce680ce6 100644 --- a/src/components/App/SideBar/Trending/index.tsx +++ b/src/components/App/SideBar/Trending/index.tsx @@ -2,6 +2,7 @@ import { Button } from '@mui/material' import clsx from 'clsx' import { useCallback, useEffect, useRef, useState } from 'react' import { useFormContext } from 'react-hook-form' +import { useNavigate } from 'react-router-dom' import { ClipLoader } from 'react-spinners' import styled from 'styled-components' import HashTag from '~/components/Icons/HashTag' @@ -10,6 +11,7 @@ import PlayIcon from '~/components/Icons/PlayIcon' import PlusIcon from '~/components/Icons/PlusIcon' import ReloadIcon from '~/components/Icons/ReloadIcon' import SentimentDataIcon from '~/components/Icons/SentimentDataIcon' +import { useBriefDescriptionStore } from '~/components/ModalsContainer/BriefDescriptionModal' import { Flex } from '~/components/common/Flex' import { getTrends } from '~/network/trends' import { useAppStore } from '~/stores/useAppStore' @@ -18,8 +20,6 @@ import { useModal } from '~/stores/useModalStore' import { Trending as TrendingType } from '~/types' import { getTrendingTopic, showPlayButton } from '~/utils' import { colors } from '~/utils/colors' -import { BriefDescription } from './BriefDescriptionModal' -import { useNavigate } from 'react-router-dom' const TRENDING_TOPICS = ['Drivechain', 'Ordinals', 'L402', 'Nostr', 'AI'] @@ -27,7 +27,6 @@ export const Trending = () => { const { open: openContentAddModal } = useModal('addContent') const [loading, setLoading] = useState(false) const [readyToUpdate, setReadyToUpdate] = useState(false) - const [selectedTrend, setSelectedTrend] = useState(null) const audioRef = useRef(null) const [currentFileIndex, setCurrentFileIndex] = useState(0) const [playing, setPlaying] = useState(false) @@ -40,6 +39,8 @@ export const Trending = () => { const { setValue } = useFormContext() + const { setTrend } = useBriefDescriptionStore() + const init = useCallback(async () => { setLoading(true) setReadyToUpdate(false) @@ -91,15 +92,11 @@ export const Trending = () => { e.currentTarget.blur() if (trending?.tldr) { - setSelectedTrend(trending) + setTrend(trending) open() } } - const hideModal = () => { - setSelectedTrend(null) - } - const handleClick = (e: React.MouseEvent) => { e.stopPropagation() e.currentTarget.blur() @@ -226,7 +223,6 @@ export const Trending = () => { )} - {selectedTrend && } ) } diff --git a/src/components/App/SideBar/index.tsx b/src/components/App/SideBar/index.tsx index 71031f3e0..5c756ad9a 100644 --- a/src/components/App/SideBar/index.tsx +++ b/src/components/App/SideBar/index.tsx @@ -66,7 +66,7 @@ export const SideBar = () => { return ( <> - + @@ -80,7 +80,7 @@ const Wrapper = styled(Flex)(({ theme }) => ({ background: colors.BG1, height: '100vh', width: '100%', - zIndex: 1, + zIndex: 30, [theme.breakpoints.up('sm')]: { width: MENU_WIDTH, }, diff --git a/src/components/App/SideBar/Trending/BriefDescriptionModal/index.tsx b/src/components/ModalsContainer/BriefDescriptionModal/BriefDescriptionContent/index.tsx similarity index 93% rename from src/components/App/SideBar/Trending/BriefDescriptionModal/index.tsx rename to src/components/ModalsContainer/BriefDescriptionModal/BriefDescriptionContent/index.tsx index a18fab5c8..e0b30baa8 100644 --- a/src/components/App/SideBar/Trending/BriefDescriptionModal/index.tsx +++ b/src/components/ModalsContainer/BriefDescriptionModal/BriefDescriptionContent/index.tsx @@ -1,193 +1,185 @@ -import { Button } from '@mui/material' -import clsx from 'clsx' -import { FC, useCallback, useEffect, useRef, useState } from 'react' -import Markdown from 'react-markdown' -import styled from 'styled-components' -import BubbleChartIcon from '~/components/Icons/BubbleChartIcon' -import PauseIcon from '~/components/Icons/PauseIcon' -import SoundIcon from '~/components/Icons/SoundIcon' -import { BaseModal } from '~/components/Modal' -import { Flex } from '~/components/common/Flex' -import { Text } from '~/components/common/Text' -import { useAppStore } from '~/stores/useAppStore' -import { useModal } from '~/stores/useModalStore' -import { Trending } from '~/types' -import { colors } from '~/utils' -import { useUserStore } from '~/stores/useUserStore' -import { useDataStore } from '~/stores/useDataStore' - -type Props = { - trend: Trending - onClose: () => void -} - -export const BriefDescription: FC = ({ trend, onClose }) => { - const [isPlaying, setIsPlaying] = useState(false) - const { close } = useModal('briefDescription') - - const { currentPlayingAudio, setCurrentPlayingAudio } = useAppStore((s) => s) - - const [setBudget] = useUserStore((s) => [s.setBudget]) - const { fetchData, setAbortRequests } = useDataStore((s) => s) - - const audioRef = useRef(null) - - const handleLearnMore = async () => { - handleClose() - await fetchData(setBudget, setAbortRequests, trend.tldr_topic ?? trend.name) - } - - const handleClose = useCallback(() => { - onClose() - close() - }, [onClose, close]) - - const handleToggle = () => { - if (audioRef.current) { - if (isPlaying) { - audioRef.current.pause() - } else { - audioRef.current.play() - } - - setIsPlaying(!isPlaying) - } - } - - const togglePlay = () => { - const isBackgroundAudioPlaying = !currentPlayingAudio?.current?.paused - - if (isBackgroundAudioPlaying) { - currentPlayingAudio?.current?.pause() - setCurrentPlayingAudio(null) - } - - if (currentPlayingAudio?.current?.src !== trend.audio_EN || !isBackgroundAudioPlaying) { - handleToggle() - } - } - - useEffect(() => { - const audioElement = audioRef.current - - const onAudioPlaybackComplete = () => { - setIsPlaying(false) - setCurrentPlayingAudio(null) - } - - if (audioElement) { - audioElement.addEventListener('ended', onAudioPlaybackComplete) - } - - return () => { - if (audioElement) { - audioElement.removeEventListener('ended', onAudioPlaybackComplete) - } - } - }, [setCurrentPlayingAudio]) - - const showPlayBtn = - (currentPlayingAudio?.current?.src === trend.audio_EN && !currentPlayingAudio?.current?.paused) || isPlaying - - return ( - - {trend.audio_EN ? ( - <> - - : } - > - {showPlayBtn ? 'Pause' : 'Listen'} - - - }> - Learn More - - - - - - - ) : null} - - {trend.tldr_topic ?? trend.name} - - - {trend.tldr && {trend.tldr}} - - - - - ) -} - -const ScrollableContent = styled.div` - max-height: 310px; - overflow-y: auto; - margin: 8px 0; - padding: 0 20px; -` - -const StyledText = styled(Text)` - font-size: 18px; - font-weight: 400; - font-family: 'Barlow'; - * { - all: revert; - } -` - -const Title = styled(Text)` - font-weight: 600; - font-size: 20px; - padding: 0 20px; -` - -const StyledAudio = styled.audio` - display: none; -` - -const StyleButton = styled(Button)` - && { - &.default { - font-size: 13px; - font-weight: 500; - font-family: Barlow; - padding: 12px, 16px, 12px, 10px; - color: ${colors.white}; - - &:hover { - color: ${colors.GRAY3}; - } - - &.play { - color: ${colors.BG3}; - background-color: ${colors.white}; - } - } - } -` - -const StyledHeader = styled(Flex)` - top: 0px; - position: absolute; - border-radius: 16px 16px 0px 0px; - padding: 0px 12px; - width: 100%; - height: 60px; - display: flex; - flex-direction: row; - align-items: center; - background-color: ${colors.BG3}; - gap: 10px; -` +import { Button } from '@mui/material' +import clsx from 'clsx' +import { FC, useCallback, useEffect, useRef, useState } from 'react' +import Markdown from 'react-markdown' +import styled from 'styled-components' +import BubbleChartIcon from '~/components/Icons/BubbleChartIcon' +import PauseIcon from '~/components/Icons/PauseIcon' +import SoundIcon from '~/components/Icons/SoundIcon' +import { Flex } from '~/components/common/Flex' +import { Text } from '~/components/common/Text' +import { useAppStore } from '~/stores/useAppStore' +import { useDataStore } from '~/stores/useDataStore' +import { useModal } from '~/stores/useModalStore' +import { useUserStore } from '~/stores/useUserStore' +import { Trending } from '~/types' +import { colors } from '~/utils' + +type Props = { + trend: Trending + onClose: () => void +} + +export const BriefDescriptionContent: FC = ({ trend, onClose }) => { + const [isPlaying, setIsPlaying] = useState(false) + const { close } = useModal('briefDescription') + + const { currentPlayingAudio, setCurrentPlayingAudio } = useAppStore((s) => s) + + const [setBudget] = useUserStore((s) => [s.setBudget]) + const { fetchData, setAbortRequests } = useDataStore((s) => s) + + const audioRef = useRef(null) + + const handleLearnMore = async () => { + handleClose() + await fetchData(setBudget, setAbortRequests, trend.tldr_topic ?? trend.name) + } + + const handleClose = useCallback(() => { + onClose() + close() + }, [onClose, close]) + + const handleToggle = () => { + if (audioRef.current) { + if (isPlaying) { + audioRef.current.pause() + } else { + audioRef.current.play() + } + + setIsPlaying(!isPlaying) + } + } + + const togglePlay = () => { + const isBackgroundAudioPlaying = !currentPlayingAudio?.current?.paused + + if (isBackgroundAudioPlaying) { + currentPlayingAudio?.current?.pause() + setCurrentPlayingAudio(null) + } + + if (currentPlayingAudio?.current?.src !== trend.audio_EN || !isBackgroundAudioPlaying) { + handleToggle() + } + } + + useEffect(() => { + const audioElement = audioRef.current + + const onAudioPlaybackComplete = () => { + setIsPlaying(false) + setCurrentPlayingAudio(null) + } + + if (audioElement) { + audioElement.addEventListener('ended', onAudioPlaybackComplete) + } + + return () => { + if (audioElement) { + audioElement.removeEventListener('ended', onAudioPlaybackComplete) + } + } + }, [setCurrentPlayingAudio]) + + const showPlayBtn = + (currentPlayingAudio?.current?.src === trend.audio_EN && !currentPlayingAudio?.current?.paused) || isPlaying + + return ( + <> + {trend.audio_EN ? ( + <> + + : } + > + {showPlayBtn ? 'Pause' : 'Listen'} + + + }> + Learn More + + + + + + + ) : null} + + {trend.tldr_topic ?? trend.name} + + + {trend.tldr && {trend.tldr}} + + + + + ) +} + +const ScrollableContent = styled.div` + max-height: 310px; + overflow-y: auto; + margin: 8px 0; + padding: 0 20px; +` + +const StyledText = styled(Text)` + font-size: 18px; + font-weight: 400; + font-family: 'Barlow'; + * { + all: revert; + } +` + +const Title = styled(Text)` + font-weight: 600; + font-size: 20px; + padding: 0 20px; +` + +const StyledAudio = styled.audio` + display: none; +` + +const StyleButton = styled(Button)` + && { + &.default { + font-size: 13px; + font-weight: 500; + font-family: Barlow; + padding: 12px, 16px, 12px, 10px; + color: ${colors.white}; + + &:hover { + color: ${colors.GRAY3}; + } + + &.play { + color: ${colors.BG3}; + background-color: ${colors.white}; + } + } + } +` + +const StyledHeader = styled(Flex)` + top: 0px; + position: absolute; + border-radius: 16px 16px 0px 0px; + padding: 0px 12px; + width: 100%; + height: 60px; + display: flex; + flex-direction: row; + align-items: center; + background-color: ${colors.BG3}; + gap: 10px; +` diff --git a/src/components/App/SideBar/Trending/BriefDescriptionModal/__tests__/index.tsx b/src/components/ModalsContainer/BriefDescriptionModal/__tests__/index.tsx similarity index 76% rename from src/components/App/SideBar/Trending/BriefDescriptionModal/__tests__/index.tsx rename to src/components/ModalsContainer/BriefDescriptionModal/__tests__/index.tsx index 90c78d5ad..c85a99898 100644 --- a/src/components/App/SideBar/Trending/BriefDescriptionModal/__tests__/index.tsx +++ b/src/components/ModalsContainer/BriefDescriptionModal/__tests__/index.tsx @@ -1,139 +1,137 @@ -/* eslint-disable padding-line-between-statements */ -import '@testing-library/jest-dom' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import React from 'react' -import { BriefDescription } from '..' -import { useAppStore } from '../../../../../../stores/useAppStore' - -window.React = React - -jest.mock('~/stores/useModalStore', () => ({ - ...jest.requireActual('~/stores/useModalStore'), - useModal: (id: string) => ({ - close: jest.fn(), - open: jest.fn(), - visible: id === 'briefDescription', - }), -})) - -jest.mock('~/stores/useAppStore', () => ({ - useAppStore: jest.fn(), -})) - -jest.mock('~/components/Icons/PauseIcon', () => jest.fn(() =>
)) -jest.mock('~/components/Icons/SoundIcon', () => jest.fn(() =>
)) - -const useAppStoreMock = useAppStore as jest.MockedFunction - -describe('BriefDescription Component Tests', () => { - const trendMock = { - count: 1, - name: 'trend', - audio_EN: 'fake-audio-url', - tldr_topic: 'Test Topic', - tldr: '', - } - - const props = { - onClose: jest.fn(), - trend: trendMock, - } - - beforeEach(() => { - useAppStoreMock.mockReturnValue({ currentPlayingAudio: { current: null }, setCurrentPlayingAudio: jest.fn() }) - }) - - afterEach(() => { - jest.clearAllMocks() - }) - - it('renders title, audio button, and tldr', () => { - render() - - expect(screen.getByText('Test Topic')).toBeInTheDocument() - - expect(screen.getByText('Listen')).toBeInTheDocument() - }) - - it('toggles play/pause on audio button click', () => { - render() - - const handleClick = jest.fn() - - const audioButton = screen.getByText('Listen').closest('button') as HTMLButtonElement - - fireEvent.click(audioButton) - - setTimeout(() => { - expect(handleClick).toHaveBeenCalled() - }, 0) - }) - - it('ensures that listen Icon only display if the audio is not currently playing in the background', () => { - const { getByTestId, getByText } = render() - - expect(getByTestId('listen-icon')).toBeInTheDocument() - expect(getByText('Listen')).toBeInTheDocument() - }) - - it('ensures that pause icon only displays if the audio is playing in the background', () => { - const mockCurrentPlayingAudio = { current: { src: trendMock.audio_EN, paused: false } } - - useAppStoreMock.mockReturnValue({ currentPlayingAudio: mockCurrentPlayingAudio, setCurrentPlayingAudio: jest.fn() }) - - const { getByTestId, getByText } = render() - - expect(getByTestId('pause-icon')).toBeInTheDocument() - expect(getByText('Pause')).toBeInTheDocument() - }) - - it('ensures that clicking play in TLDR modal stops current background audio and starts new audio', async () => { - const setCurrentPlayingAudioMock = jest.fn() - - useAppStoreMock.mockReturnValue({ - currentPlayingAudio: { current: { src: 'random-audio-file', paused: false, pause: jest.fn() } }, - setCurrentPlayingAudio: setCurrentPlayingAudioMock, - }) - - const { getByRole, container } = render() - - const listenButton = getByRole('button', { name: /Listen/i }) - - expect(listenButton).toBeInTheDocument() - - fireEvent.click(listenButton) - - waitFor(async () => { - expect(setCurrentPlayingAudioMock).toHaveBeenCalledWith(null) - expect(container.querySelector('audio')?.paused).toBe(true) - }) - }) - - it('should call onClose when closing the modal', () => { - const onCloseMock = jest.fn() - - render() - - fireEvent.keyDown(window, { key: 'Escape' }) - - setTimeout(() => { - expect(onCloseMock).toHaveBeenCalled() - }, 0) - }) - - it('does not close the modal on Command+Control+3 or Command+Control+4', () => { - const { getByTestId } = render() - - waitFor(() => { - const modal = getByTestId('brief-description-modal') - - // With Command+Control+3 - fireEvent.keyDown(window, { key: '3', ctrlKey: true, metaKey: true }) - expect(modal).toBeInTheDocument() - - // With Command+Control+4 - fireEvent.keyDown(window, { key: '4', ctrlKey: true, metaKey: true }) - expect(modal).toBeInTheDocument() - }) - }) -}) +import '@testing-library/jest-dom' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import React from 'react' +import { BriefDescription, useBriefDescriptionStore } from '~/components/ModalsContainer/BriefDescriptionModal' +import { useAppStore } from '~/stores/useAppStore' + +window.React = React + +jest.mock('~/stores/useModalStore', () => ({ + ...jest.requireActual('~/stores/useModalStore'), + useModal: (id: string) => ({ + close: jest.fn(), + open: jest.fn(), + visible: id === 'briefDescription', + }), +})) + +jest.mock('~/stores/useAppStore', () => ({ + useAppStore: jest.fn(), +})) + +jest.mock('~/components/Icons/PauseIcon', () => jest.fn(() =>
)) +jest.mock('~/components/Icons/SoundIcon', () => jest.fn(() =>
)) + +const useAppStoreMock = useAppStore as jest.MockedFunction + +describe('BriefDescription Component Tests', () => { + const trendMock = { + count: 1, + name: 'trend', + audio_EN: 'fake-audio-url', + tldr_topic: 'Test Topic', + tldr: '', + } + + beforeEach(() => { + useAppStoreMock.mockReturnValue({ + currentPlayingAudio: { current: null }, + setCurrentPlayingAudio: jest.fn(), + }) + + useBriefDescriptionStore.setState({ trend: trendMock }) + }) + + afterEach(() => { + jest.clearAllMocks() + useBriefDescriptionStore.setState({ trend: null }) + }) + + it('renders title, audio button, and tldr', () => { + render() + + expect(screen.getByText('Test Topic')).toBeInTheDocument() + expect(screen.getByText('Listen')).toBeInTheDocument() + }) + + it('toggles play/pause on audio button click', () => { + render() + + const handleClick = jest.fn() + const audioButton = screen.getByText('Listen').closest('button') as HTMLButtonElement + + fireEvent.click(audioButton) + + setTimeout(() => { + expect(handleClick).toHaveBeenCalled() + }, 0) + }) + + it('ensures that listen Icon only display if the audio is not currently playing in the background', () => { + const { getByTestId, getByText } = render() + + expect(getByTestId('listen-icon')).toBeInTheDocument() + expect(getByText('Listen')).toBeInTheDocument() + }) + + it('ensures that pause icon only displays if the audio is playing in the background', () => { + const mockCurrentPlayingAudio = { current: { src: trendMock.audio_EN, paused: false } } + + useAppStoreMock.mockReturnValue({ currentPlayingAudio: mockCurrentPlayingAudio, setCurrentPlayingAudio: jest.fn() }) + + const { getByTestId, getByText } = render() + + expect(getByTestId('pause-icon')).toBeInTheDocument() + expect(getByText('Pause')).toBeInTheDocument() + }) + + it('ensures that clicking play in TLDR modal stops current background audio and starts new audio', async () => { + const setCurrentPlayingAudioMock = jest.fn() + + useAppStoreMock.mockReturnValue({ + currentPlayingAudio: { current: { src: 'random-audio-file', paused: false, pause: jest.fn() } }, + setCurrentPlayingAudio: setCurrentPlayingAudioMock, + }) + + const { getByRole, container } = render() + + const listenButton = getByRole('button', { name: /Listen/i }) + + expect(listenButton).toBeInTheDocument() + + fireEvent.click(listenButton) + + waitFor(async () => { + expect(setCurrentPlayingAudioMock).toHaveBeenCalledWith(null) + expect(container.querySelector('audio')?.paused).toBe(true) + }) + }) + + it('should clear trend and close modal when closing', () => { + jest.spyOn(useBriefDescriptionStore.getState(), 'setTrend') + + render() + + const { setTrend } = useBriefDescriptionStore.getState() + + setTrend(null) + + expect(useBriefDescriptionStore.getState().trend).toBeNull() + }) + + it('does not close the modal on Command+Control+3 or Command+Control+4', () => { + const { getByTestId } = render() + + waitFor(() => { + const modal = getByTestId('brief-description-modal') + + // With Command+Control+3 + fireEvent.keyDown(window, { key: '3', ctrlKey: true, metaKey: true }) + expect(modal).toBeInTheDocument() + + // With Command+Control+4 + fireEvent.keyDown(window, { key: '4', ctrlKey: true, metaKey: true }) + expect(modal).toBeInTheDocument() + }) + }) +}) diff --git a/src/components/ModalsContainer/BriefDescriptionModal/index.tsx b/src/components/ModalsContainer/BriefDescriptionModal/index.tsx new file mode 100644 index 000000000..858a5a818 --- /dev/null +++ b/src/components/ModalsContainer/BriefDescriptionModal/index.tsx @@ -0,0 +1,43 @@ +import { FC } from 'react' +import { create } from 'zustand' +import { BaseModal } from '~/components/Modal' +import { useModal } from '~/stores/useModalStore' +import { Trending } from '~/types' +import { BriefDescriptionContent } from './BriefDescriptionContent' + +type BriefDescriptionStore = { + trend: Trending | null + setTrend: (trend: Trending | null) => void +} + +export const useBriefDescriptionStore = create((set) => ({ + trend: null, + setTrend: (trend) => set({ trend }), +})) + +export const BriefDescription: FC = () => { + const { close } = useModal('briefDescription') + const { trend, setTrend } = useBriefDescriptionStore() + + const handleClose = () => { + setTrend(null) + close() + } + + if (!trend) { + return null + } + + return ( + + + + ) +} diff --git a/src/components/ModalsContainer/index.tsx b/src/components/ModalsContainer/index.tsx index c40fd3a60..4bbad81a4 100644 --- a/src/components/ModalsContainer/index.tsx +++ b/src/components/ModalsContainer/index.tsx @@ -50,6 +50,10 @@ const LazyOnboardingModal = lazy(() => import('./OnboardingFlow').then(({ OnboardingModal }) => ({ default: OnboardingModal })), ) +const LazyBriefDescriptionModal = lazy(() => + import('./BriefDescriptionModal').then(({ BriefDescription }) => ({ default: BriefDescription })), +) + export const ModalsContainer = () => ( <> @@ -65,5 +69,6 @@ export const ModalsContainer = () => ( + )