chore(git): merge pull request #123 from fixes-120-117-84-104 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 51s

Reviewed-on: #123
This commit is contained in:
enes 2024-11-14 16:02:04 +00:00
commit df0c64e2c9
9 changed files with 124 additions and 76 deletions

View File

@ -1,3 +1,4 @@
import { Dots } from 'components/Spinner'
import { useReactions } from 'hooks' import { useReactions } from 'hooks'
import { Addressable } from 'types' import { Addressable } from 'types'
@ -19,15 +20,13 @@ export const Reactions = ({ addressable }: ReactionsProps) => {
aTag: addressable.aTag aTag: addressable.aTag
}) })
if (!isDataLoaded) return null
return ( return (
<> <>
<div <div
className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactUp ${ className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactUp ${
hasReactedPositively ? 'IBMSMSMBSS_D_CRUActive' : '' hasReactedPositively ? 'IBMSMSMBSS_D_CRUActive' : ''
}`} }`}
onClick={() => handleReaction(true)} onClick={isDataLoaded ? () => handleReaction(true) : undefined}
> >
<div className='IBMSMSMBSS_Details_CardVisual'> <div className='IBMSMSMBSS_Details_CardVisual'>
<svg <svg
@ -41,7 +40,9 @@ export const Reactions = ({ addressable }: ReactionsProps) => {
<path d='M0 190.9V185.1C0 115.2 50.52 55.58 119.4 44.1C164.1 36.51 211.4 51.37 244 84.02L256 96L267.1 84.02C300.6 51.37 347 36.51 392.6 44.1C461.5 55.58 512 115.2 512 185.1V190.9C512 232.4 494.8 272.1 464.4 300.4L283.7 469.1C276.2 476.1 266.3 480 256 480C245.7 480 235.8 476.1 228.3 469.1L47.59 300.4C17.23 272.1 .0003 232.4 .0003 190.9L0 190.9z'></path> <path d='M0 190.9V185.1C0 115.2 50.52 55.58 119.4 44.1C164.1 36.51 211.4 51.37 244 84.02L256 96L267.1 84.02C300.6 51.37 347 36.51 392.6 44.1C461.5 55.58 512 115.2 512 185.1V190.9C512 232.4 494.8 272.1 464.4 300.4L283.7 469.1C276.2 476.1 266.3 480 256 480C245.7 480 235.8 476.1 228.3 469.1L47.59 300.4C17.23 272.1 .0003 232.4 .0003 190.9L0 190.9z'></path>
</svg> </svg>
</div> </div>
<p className='IBMSMSMBSS_Details_CardText'>{likesCount}</p> <p className='IBMSMSMBSS_Details_CardText'>
{isDataLoaded ? likesCount : <Dots />}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'> <div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div> <div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div> </div>
@ -50,7 +51,7 @@ export const Reactions = ({ addressable }: ReactionsProps) => {
className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactDown ${ className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactDown ${
hasReactedNegatively ? 'IBMSMSMBSS_D_CRDActive' : '' hasReactedNegatively ? 'IBMSMSMBSS_D_CRDActive' : ''
}`} }`}
onClick={() => handleReaction()} onClick={isDataLoaded ? () => handleReaction() : undefined}
> >
<div className='IBMSMSMBSS_Details_CardVisual'> <div className='IBMSMSMBSS_Details_CardVisual'>
<svg <svg
@ -64,7 +65,9 @@ export const Reactions = ({ addressable }: ReactionsProps) => {
<path d='M512 440.1C512 479.9 479.7 512 439.1 512H71.92C32.17 512 0 479.8 0 440c0-35.88 26.19-65.35 60.56-70.85C43.31 356 32 335.4 32 312C32 272.2 64.25 240 104 240h13.99C104.5 228.2 96 211.2 96 192c0-35.38 28.56-64 63.94-64h16C220.1 128 256 92.12 256 48c0-17.38-5.784-33.35-15.16-46.47C245.8 .7754 250.9 0 256 0c53 0 96 43 96 96c0 11.25-2.288 22-5.913 32h5.879C387.3 128 416 156.6 416 192c0 19.25-8.59 36.25-22.09 48H408C447.8 240 480 272.2 480 312c0 23.38-11.38 44.01-28.63 57.14C485.7 374.6 512 404.3 512 440.1z'></path> <path d='M512 440.1C512 479.9 479.7 512 439.1 512H71.92C32.17 512 0 479.8 0 440c0-35.88 26.19-65.35 60.56-70.85C43.31 356 32 335.4 32 312C32 272.2 64.25 240 104 240h13.99C104.5 228.2 96 211.2 96 192c0-35.38 28.56-64 63.94-64h16C220.1 128 256 92.12 256 48c0-17.38-5.784-33.35-15.16-46.47C245.8 .7754 250.9 0 256 0c53 0 96 43 96 96c0 11.25-2.288 22-5.913 32h5.879C387.3 128 416 156.6 416 192c0 19.25-8.59 36.25-22.09 48H408C447.8 240 480 272.2 480 312c0 23.38-11.38 44.01-28.63 57.14C485.7 374.6 512 404.3 512 440.1z'></path>
</svg> </svg>
</div> </div>
<p className='IBMSMSMBSS_Details_CardText'>{disLikesCount}</p> <p className='IBMSMSMBSS_Details_CardText'>
{isDataLoaded ? disLikesCount : <Dots />}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'> <div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div> <div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div> </div>

View File

@ -0,0 +1,9 @@
import styles from '../styles/dotsSpinner.module.scss'
export const Spinner = () => (
<div className='spinner'>
<div className='spinnerCircle'></div>
</div>
)
export const Dots = () => <span className={styles.loading}></span>

View File

@ -1,4 +1,5 @@
import { NDKEvent } from '@nostr-dev-kit/ndk' import { NDKEvent } from '@nostr-dev-kit/ndk'
import { Dots, Spinner } from 'components/Spinner'
import { ZapPopUp } from 'components/Zap' import { ZapPopUp } from 'components/Zap'
import { formatDate } from 'date-fns' import { formatDate } from 'date-fns'
import { import {
@ -59,6 +60,15 @@ export const Comments = ({ addressable, setCommentCount }: Props) => {
author: AuthorFilterEnum.All_Comments author: AuthorFilterEnum.All_Comments
}) })
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
// Initial loading to indicate comments fetching (stop after 5 seconds)
const t = window.setTimeout(() => setIsLoading(false), 5000)
return () => {
window.clearTimeout(t)
}
}, [])
useEffect(() => { useEffect(() => {
setCommentCount(commentEvents.length) setCommentCount(commentEvents.length)
}, [commentEvents, setCommentCount]) }, [commentEvents, setCommentCount])
@ -175,8 +185,18 @@ export const Comments = ({ addressable, setCommentCount }: Props) => {
return true return true
} }
const handleDiscoveredClick = () => {
setVisible(commentEvents)
}
const [visible, setVisible] = useState<CommentEvent[]>([])
useEffect(() => {
if (isLoading) {
setVisible(commentEvents)
}
}, [commentEvents, isLoading])
const comments = useMemo(() => { const comments = useMemo(() => {
let filteredComments = commentEvents let filteredComments = visible
if (filterOptions.author === AuthorFilterEnum.Creator_Comments) { if (filterOptions.author === AuthorFilterEnum.Creator_Comments) {
filteredComments = filteredComments.filter( filteredComments = filteredComments.filter(
(comment) => comment.pubkey === addressable.author (comment) => comment.pubkey === addressable.author
@ -190,14 +210,28 @@ export const Comments = ({ addressable, setCommentCount }: Props) => {
} }
return filteredComments return filteredComments
}, [commentEvents, filterOptions, addressable.author]) }, [visible, filterOptions.author, filterOptions.sort, addressable.author])
const discoveredCount = commentEvents.length - visible.length
return ( return (
<div className='IBMSMSMBSSCommentsWrapper'> <div className='IBMSMSMBSSCommentsWrapper'>
<h4 className='IBMSMSMBSSTitle'>Comments</h4> <h4 className='IBMSMSMBSSTitle'>Comments</h4>
<div className='IBMSMSMBSSComments'> <div className='IBMSMSMBSSComments'>
{/* Hide comment form if aTag is missing */} {/* Hide comment form if aTag is missing */}
{!!addressable.aTag && <CommentForm handleSubmit={handleSubmit} />} {!!addressable.aTag && <CommentForm handleSubmit={handleSubmit} />}
<div>
{isLoading ? (
<Spinner />
) : (
<button
type='button'
className='btnMain'
onClick={discoveredCount ? handleDiscoveredClick : undefined}
>
<span>Load {discoveredCount} discovered comments</span>
</button>
)}
</div>
<Filter <Filter
filterOptions={filterOptions} filterOptions={filterOptions}
setFilterOptions={setFilterOptions} setFilterOptions={setFilterOptions}
@ -356,12 +390,12 @@ const Comment = (props: CommentEvent) => {
</div> </div>
<div className='IBMSMSMBSSCL_CommentTopDetailsWrapper'> <div className='IBMSMSMBSSCL_CommentTopDetailsWrapper'>
<div className='IBMSMSMBSSCL_CommentTopDetails'> <div className='IBMSMSMBSSCL_CommentTopDetails'>
<a className='IBMSMSMBSSCL_CTD_Name' href='profile.html'> <Link className='IBMSMSMBSSCL_CTD_Name' to={profileRoute}>
{profile?.displayName || profile?.name || ''}{' '} {profile?.displayName || profile?.name || ''}{' '}
</a> </Link>
<a className='IBMSMSMBSSCL_CTD_Address' href='profile.html'> <Link className='IBMSMSMBSSCL_CTD_Address' to={profileRoute}>
{hexToNpub(props.pubkey)} {hexToNpub(props.pubkey)}
</a> </Link>
</div> </div>
<div className='IBMSMSMBSSCL_CommentActionsDetails'> <div className='IBMSMSMBSSCL_CommentActionsDetails'>
<a className='IBMSMSMBSSCL_CADTime'> <a className='IBMSMSMBSSCL_CADTime'>
@ -447,15 +481,13 @@ const Reactions = (props: Event) => {
eTag: props.id eTag: props.id
}) })
if (!isDataLoaded) return null
return ( return (
<> <>
<div <div
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEUp ${ className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEUp ${
hasReactedPositively ? 'IBMSMSMBSSCL_CAEUpActive' : '' hasReactedPositively ? 'IBMSMSMBSSCL_CAEUpActive' : ''
}`} }`}
onClick={() => handleReaction(true)} onClick={isDataLoaded ? () => handleReaction(true) : undefined}
> >
<svg <svg
xmlns='http://www.w3.org/2000/svg' xmlns='http://www.w3.org/2000/svg'
@ -467,7 +499,9 @@ const Reactions = (props: Event) => {
> >
<path d='M0 190.9V185.1C0 115.2 50.52 55.58 119.4 44.1C164.1 36.51 211.4 51.37 244 84.02L256 96L267.1 84.02C300.6 51.37 347 36.51 392.6 44.1C461.5 55.58 512 115.2 512 185.1V190.9C512 232.4 494.8 272.1 464.4 300.4L283.7 469.1C276.2 476.1 266.3 480 256 480C245.7 480 235.8 476.1 228.3 469.1L47.59 300.4C17.23 272.1 .0003 232.4 .0003 190.9L0 190.9z'></path> <path d='M0 190.9V185.1C0 115.2 50.52 55.58 119.4 44.1C164.1 36.51 211.4 51.37 244 84.02L256 96L267.1 84.02C300.6 51.37 347 36.51 392.6 44.1C461.5 55.58 512 115.2 512 185.1V190.9C512 232.4 494.8 272.1 464.4 300.4L283.7 469.1C276.2 476.1 266.3 480 256 480C245.7 480 235.8 476.1 228.3 469.1L47.59 300.4C17.23 272.1 .0003 232.4 .0003 190.9L0 190.9z'></path>
</svg> </svg>
<p className='IBMSMSMBSSCL_CAElementText'>{likesCount}</p> <p className='IBMSMSMBSSCL_CAElementText'>
{isDataLoaded ? likesCount : <Dots />}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'> <div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div> <div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div> </div>
@ -476,7 +510,7 @@ const Reactions = (props: Event) => {
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEDown ${ className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEDown ${
hasReactedNegatively ? 'IBMSMSMBSSCL_CAEDownActive' : '' hasReactedNegatively ? 'IBMSMSMBSSCL_CAEDownActive' : ''
}`} }`}
onClick={() => handleReaction()} onClick={isDataLoaded ? () => handleReaction() : undefined}
> >
<svg <svg
xmlns='http://www.w3.org/2000/svg' xmlns='http://www.w3.org/2000/svg'
@ -488,7 +522,9 @@ const Reactions = (props: Event) => {
> >
<path d='M512 440.1C512 479.9 479.7 512 439.1 512H71.92C32.17 512 0 479.8 0 440c0-35.88 26.19-65.35 60.56-70.85C43.31 356 32 335.4 32 312C32 272.2 64.25 240 104 240h13.99C104.5 228.2 96 211.2 96 192c0-35.38 28.56-64 63.94-64h16C220.1 128 256 92.12 256 48c0-17.38-5.784-33.35-15.16-46.47C245.8 .7754 250.9 0 256 0c53 0 96 43 96 96c0 11.25-2.288 22-5.913 32h5.879C387.3 128 416 156.6 416 192c0 19.25-8.59 36.25-22.09 48H408C447.8 240 480 272.2 480 312c0 23.38-11.38 44.01-28.63 57.14C485.7 374.6 512 404.3 512 440.1z'></path> <path d='M512 440.1C512 479.9 479.7 512 439.1 512H71.92C32.17 512 0 479.8 0 440c0-35.88 26.19-65.35 60.56-70.85C43.31 356 32 335.4 32 312C32 272.2 64.25 240 104 240h13.99C104.5 228.2 96 211.2 96 192c0-35.38 28.56-64 63.94-64h16C220.1 128 256 92.12 256 48c0-17.38-5.784-33.35-15.16-46.47C245.8 .7754 250.9 0 256 0c53 0 96 43 96 96c0 11.25-2.288 22-5.913 32h5.879C387.3 128 416 156.6 416 192c0 19.25-8.59 36.25-22.09 48H408C447.8 240 480 272.2 480 312c0 23.38-11.38 44.01-28.63 57.14C485.7 374.6 512 404.3 512 440.1z'></path>
</svg> </svg>
<p className='IBMSMSMBSSCL_CAElementText'>{disLikesCount}</p> <p className='IBMSMSMBSSCL_CAElementText'>
{isDataLoaded ? disLikesCount : <Dots />}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'> <div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div> <div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div> </div>

View File

@ -21,7 +21,8 @@ import {
log, log,
LogType, LogType,
npubToHex, npubToHex,
orderEventsChronologically orderEventsChronologically,
timeout
} from 'utils' } from 'utils'
type FetchModsOptions = { type FetchModsOptions = {
@ -241,8 +242,11 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
hexKey: string, hexKey: string,
userRelaysType: UserRelaysType userRelaysType: UserRelaysType
): Promise<NDKEvent[]> => { ): Promise<NDKEvent[]> => {
// Find the user's relays. // Find the user's relays (10s timeout).
const relayUrls = await getRelayListForUser(hexKey, ndk) const relayUrls = await Promise.race([
getRelayListForUser(hexKey, ndk),
timeout(10000)
])
.then((ndkRelayList) => { .then((ndkRelayList) => {
if (ndkRelayList) return ndkRelayList[userRelaysType] if (ndkRelayList) return ndkRelayList[userRelaysType]
return [] // Return an empty array if ndkRelayList is undefined return [] // Return an empty array if ndkRelayList is undefined

View File

@ -5,7 +5,7 @@ import { Event, kinds, UnsignedEvent } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { UserRelaysType } from 'types' import { UserRelaysType } from 'types'
import { abbreviateNumber, log, LogType, now } from 'utils' import { abbreviateNumber, log, LogType, now, timeout } from 'utils'
type UseReactionsParams = { type UseReactionsParams = {
pubkey: string pubkey: string
@ -32,7 +32,11 @@ export const useReactions = (params: UseReactionsParams) => {
filter['#e'] = [params.eTag] filter['#e'] = [params.eTag]
} }
fetchEventsFromUserRelays(filter, params.pubkey, UserRelaysType.Read) // 1 minute timeout
Promise.race([
fetchEventsFromUserRelays(filter, params.pubkey, UserRelaysType.Read),
timeout(60000)
])
.then((events) => { .then((events) => {
setReactionEvents(events) setReactionEvents(events)
}) })

View File

@ -36,6 +36,7 @@ import 'swiper/css'
import 'swiper/css/navigation' import 'swiper/css/navigation'
import 'swiper/css/pagination' import 'swiper/css/pagination'
import { LoadingSpinner } from 'components/LoadingSpinner' import { LoadingSpinner } from 'components/LoadingSpinner'
import { Spinner } from 'components/Spinner'
export const HomePage = () => { export const HomePage = () => {
const navigate = useNavigate() const navigate = useNavigate()
@ -310,14 +311,6 @@ const DisplayLatestMods = () => {
) )
} }
const Spinner = () => {
return (
<div className='spinner'>
<div className='spinnerCircle'></div>
</div>
)
}
const DisplayLatestBlogs = () => { const DisplayLatestBlogs = () => {
const [blogs, setBlogs] = useState<Partial<BlogCardDetails>[]>() const [blogs, setBlogs] = useState<Partial<BlogCardDetails>[]>()
const { fetchEvents } = useNDKContext() const { fetchEvents } = useNDKContext()

View File

@ -14,17 +14,11 @@ import {
useNDKContext, useNDKContext,
useNSFWList useNSFWList
} from 'hooks' } from 'hooks'
import { kinds, nip19, UnsignedEvent } from 'nostr-tools' import { kinds, UnsignedEvent } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import { Link, useLoaderData, useNavigation } from 'react-router-dom'
useParams,
Navigate,
Link,
useLoaderData,
useNavigation
} from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { appRoutes, getProfilePageRoute } from 'routes' import { appRoutes } from 'routes'
import { import {
BlogCardDetails, BlogCardDetails,
FilterOptions, FilterOptions,
@ -38,8 +32,6 @@ import {
copyTextToClipboard, copyTextToClipboard,
DEFAULT_FILTER_OPTIONS, DEFAULT_FILTER_OPTIONS,
extractBlogCardDetails, extractBlogCardDetails,
log,
LogType,
now, now,
npubToHex, npubToHex,
scrollIntoView, scrollIntoView,
@ -52,23 +44,11 @@ import { BlogCard } from 'components/BlogCard'
export const ProfilePage = () => { export const ProfilePage = () => {
const { const {
profilePubkey,
profile, profile,
isBlocked: _isBlocked, isBlocked: _isBlocked,
isOwnProfile isOwnProfile
} = useLoaderData() as ProfilePageLoaderResult } = useLoaderData() as ProfilePageLoaderResult
// Try to decode nprofile parameter
const { nprofile } = useParams()
let profilePubkey: string | undefined
try {
const value = nprofile
? nip19.decode(nprofile as `nprofile1${string}`)
: undefined
profilePubkey = value?.data.pubkey
} catch (error) {
// Silently ignore and redirect to home or logged in user
log(true, LogType.Error, 'Failed to decode nprofile.', error)
}
const scrollTargetRef = useRef<HTMLDivElement>(null) const scrollTargetRef = useRef<HTMLDivElement>(null)
const { ndk, publish, fetchEventFromUserRelays, fetchMods } = useNDKContext() const { ndk, publish, fetchEventFromUserRelays, fetchMods } = useNDKContext()
const userState = useAppSelector((state) => state.user) const userState = useAppSelector((state) => state.user)
@ -292,22 +272,6 @@ export const ProfilePage = () => {
profilePubkey profilePubkey
) )
// Redirect route
let profileRoute = appRoutes.home
if (!nprofile && userState.auth && userState.user) {
// Redirect to user's profile is no profile is linked
const userHexKey = npubToHex(userState.user.npub as string)
if (userHexKey) {
profileRoute = getProfilePageRoute(
nip19.nprofileEncode({
pubkey: userHexKey
})
)
}
}
if (!profilePubkey) return <Navigate to={profileRoute} replace={true} />
return ( return (
<div className='InnerBodyMain'> <div className='InnerBodyMain'>
<div className='ContainerMain'> <div className='ContainerMain'>

View File

@ -4,9 +4,10 @@ import { LoaderFunctionArgs, redirect } from 'react-router-dom'
import { appRoutes, getProfilePageRoute } from 'routes' import { appRoutes, getProfilePageRoute } from 'routes'
import { store } from 'store' import { store } from 'store'
import { MuteLists, UserProfile } from 'types' import { MuteLists, UserProfile } from 'types'
import { log, LogType } from 'utils' import { log, LogType, npubToHex } from 'utils'
export interface ProfilePageLoaderResult { export interface ProfilePageLoaderResult {
profilePubkey: string
profile: UserProfile profile: UserProfile
isBlocked: boolean isBlocked: boolean
isOwnProfile: boolean isOwnProfile: boolean
@ -24,10 +25,25 @@ export const profileRouteLoader =
const { nprofile } = params const { nprofile } = params
let profilePubkey: string | undefined let profilePubkey: string | undefined
try { try {
const value = nprofile // Decode if it starts with nprofile1
? nip19.decode(nprofile as `nprofile1${string}`) if (nprofile?.startsWith('nprofile1')) {
: undefined const value = nprofile
profilePubkey = value?.data.pubkey ? nip19.decode(nprofile as `nprofile1${string}`)
: undefined
profilePubkey = value?.data.pubkey
} else if (nprofile?.startsWith('npub1')) {
// Try to get hex from the npub and encode it to nprofile
const value = npubToHex(nprofile)
if (value) {
return redirect(
getProfilePageRoute(
nip19.nprofileEncode({
pubkey: value
})
)
)
}
}
} catch (error) { } catch (error) {
// Silently ignore and redirect to home or logged in user // Silently ignore and redirect to home or logged in user
log(true, LogType.Error, 'Failed to decode nprofile.', error) log(true, LogType.Error, 'Failed to decode nprofile.', error)
@ -57,6 +73,7 @@ export const profileRouteLoader =
// Empty result // Empty result
const result: ProfilePageLoaderResult = { const result: ProfilePageLoaderResult = {
profilePubkey: profilePubkey,
profile: {}, profile: {},
isBlocked: false, isBlocked: false,
isOwnProfile: false, isOwnProfile: false,

View File

@ -0,0 +1,18 @@
.loading::after {
content: '.';
animation: dots 1.5s steps(4, end) infinite;
}
@keyframes dots {
0%,
20% {
content: '.\00a0\00a0';
}
40% {
content: '..\00a0';
}
60%,
100% {
content: '...';
}
}