diff --git a/src/components/home/update-username.tsx b/src/components/home/update-username.tsx index 1ba0dc44..a65e05a1 100644 --- a/src/components/home/update-username.tsx +++ b/src/components/home/update-username.tsx @@ -20,6 +20,7 @@ export function UpdateUsername(): JSX.Element { const [available, setAvailable] = useState(false); const [loading, setLoading] = useState(false); const [visited, setVisited] = useState(false); + const [searching, setSearching] = useState(false); const [inputValue, setInputValue] = useState(''); const [errorMessage, setErrorMessage] = useState(''); @@ -28,6 +29,8 @@ export function UpdateUsername(): JSX.Element { useEffect(() => { const checkAvailability = async (value: string): Promise => { + setSearching(true); + const empty = await checkUsernameAvailability(value); if (empty) setAvailable(true); @@ -35,6 +38,8 @@ export function UpdateUsername(): JSX.Element { setAvailable(false); setErrorMessage('This username has been taken. Please choose another.'); } + + setSearching(false); }; if (!visited && inputValue.length > 0) setVisited(true); @@ -63,6 +68,8 @@ export function UpdateUsername(): JSX.Element { if (!available) return; + if (searching) return; + setLoading(true); await sleep(500); @@ -82,6 +89,7 @@ export function UpdateUsername(): JSX.Element { const cancelUpdateUsername = (): void => { closeModal(); + if (!alreadySet) void updateUsername(user?.id as string); }; diff --git a/src/components/input/image-preview.tsx b/src/components/input/image-preview.tsx index ad53df47..3d204e9b 100644 --- a/src/components/input/image-preview.tsx +++ b/src/components/input/image-preview.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import cn from 'clsx'; import { useModal } from '@lib/hooks/useModal'; @@ -50,6 +50,8 @@ export function ImagePreview({ const [selectedIndex, setSelectedIndex] = useState(0); const [selectedImage, setSelectedImage] = useState(null); + const videoRef = useRef(null); + const { open, openModal, closeModal } = useModal(); useEffect(() => { @@ -58,7 +60,13 @@ export function ImagePreview({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedIndex]); - const handleSelectedImage = (index: number) => () => { + const handleVideoStop = (): void => { + if (videoRef.current) videoRef.current.pause(); + }; + + const handleSelectedImage = (index: number, isVideo?: boolean) => () => { + if (isVideo) handleVideoStop(); + setSelectedIndex(index); openModal(); }; @@ -106,52 +114,84 @@ export function ImagePreview({ /> - {imagesPreview.map(({ id, src, alt }, index) => ( - - { + const isVideo = imagesPreview[index].type?.includes('video'); + + return ( + + {isVideo ? ( + <> + + - ))} + onClick={preventBubbling(removeImage(id))} + > + + + + )} + + ); + })} ); diff --git a/src/components/input/input-options.tsx b/src/components/input/input-options.tsx index e2746c21..0e35c8aa 100644 --- a/src/components/input/input-options.tsx +++ b/src/components/input/input-options.tsx @@ -89,7 +89,7 @@ export function InputOptions({ ([]); const [loading, setLoading] = useState(true); - const { src, alt } = imageData; + const { src, alt, type } = imageData; + + const isVideo = type?.includes('video'); const requireArrows = handleNextIndex && previewCount > 1; @@ -51,9 +53,14 @@ export function ImageModal({ setIndexes([...indexes, selectedIndex]); } - const image = new Image(); - image.src = src; - image.onload = (): void => setLoading(false); + const media = isVideo ? document.createElement('video') : new Image(); + + media.src = src; + + const handleLoadingCompleted = (): void => setLoading(false); + + if (isVideo) media.onloadeddata = handleLoadingCompleted; + else media.onload = handleLoadingCompleted; }, [...(tweet && previewCount > 1 ? [src] : [])]); useEffect(() => { @@ -103,28 +110,45 @@ export function ImageModal({ ) : ( - - - {alt} - - {alt} - - + {isVideo ? ( +
+ +
+ ) : ( + + + {alt} + + {alt} + + + )}
diff --git a/src/lib/context/auth-context.tsx b/src/lib/context/auth-context.tsx index 6df78aa2..9612c314 100644 --- a/src/lib/context/auth-context.tsx +++ b/src/lib/context/auth-context.tsx @@ -19,6 +19,7 @@ import { userBookmarksCollection } from '@lib/firebase/collections'; import { getRandomId, getRandomInt } from '@lib/random'; +import { checkUsernameAvailability } from '@lib/firebase/utils'; import type { ReactNode } from 'react'; import type { User as AuthUser } from 'firebase/auth'; import type { WithFieldValue } from 'firebase/firestore'; @@ -67,11 +68,11 @@ export function AuthContextProvider({ randomUsername = `${normalizeName as string}${randomInt}`; - const randomUserSnapshot = await getDoc( - doc(usersCollection, randomUsername) + const isUsernameAvailable = await checkUsernameAvailability( + randomUsername ); - if (!randomUserSnapshot.exists()) available = true; + if (isUsernameAvailable) available = true; } const userData: WithFieldValue = { diff --git a/src/lib/firebase/utils.ts b/src/lib/firebase/utils.ts index 5fdd7eb7..2d82e8ba 100644 --- a/src/lib/firebase/utils.ts +++ b/src/lib/firebase/utils.ts @@ -132,20 +132,15 @@ export async function uploadImages( const imagesPreview = await Promise.all( files.map(async (file) => { - let src: string; + const { id, name: alt, type } = file; - const { id, name: alt } = file; + const storageRef = ref(storage, `images/${userId}/${id}`); - const storageRef = ref(storage, `images/${userId}/${alt}`); + await uploadBytesResumable(storageRef, file); - try { - src = await getDownloadURL(storageRef); - } catch { - await uploadBytesResumable(storageRef, file); - src = await getDownloadURL(storageRef); - } + const src = await getDownloadURL(storageRef); - return { id, src, alt }; + return { id, src, alt, type }; }) ); diff --git a/src/lib/types/file.ts b/src/lib/types/file.ts index 4610ad01..d61efbac 100644 --- a/src/lib/types/file.ts +++ b/src/lib/types/file.ts @@ -1,6 +1,7 @@ export type ImageData = { src: string; alt: string; + type?: string; }; export type ImagesPreview = (ImageData & { diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 73866292..d4ed538d 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -17,6 +17,17 @@ const IMAGE_EXTENSIONS = [ type ImageExtensions = typeof IMAGE_EXTENSIONS[number]; +const MEDIA_EXTENSIONS = [ + ...IMAGE_EXTENSIONS, + 'mp4', + 'mov', + 'avi', + 'mkv', + 'webm' +] as const; + +type MediaExtensions = typeof MEDIA_EXTENSIONS[number]; + function isValidImageExtension( extension: string ): extension is ImageExtensions { @@ -25,10 +36,22 @@ function isValidImageExtension( ); } +function isValidMediaExtension( + extension: string +): extension is MediaExtensions { + return MEDIA_EXTENSIONS.includes( + extension.split('.').pop()?.toLowerCase() as MediaExtensions + ); +} + export function isValidImage(name: string, bytes: number): boolean { return isValidImageExtension(name) && bytes < 20 * Math.pow(1024, 2); } +export function isValidMedia(name: string, size: number): boolean { + return isValidMediaExtension(name) && size < 50 * Math.pow(1024, 2); +} + export function isValidUsername( username: string, value: string @@ -50,9 +73,14 @@ type ImagesData = { selectedImagesData: FilesWithId; }; +type ImagesDataOptions = { + currentFiles?: number; + allowUploadingVideos?: boolean; +}; + export function getImagesData( files: FileList | null, - currentFiles?: number + { currentFiles, allowUploadingVideos }: ImagesDataOptions = {} ): ImagesData | null { if (!files || !files.length) return null; @@ -61,7 +89,11 @@ export function getImagesData( const rawImages = singleEditingMode || !(currentFiles === 4 || files.length > 4 - currentFiles) - ? Array.from(files).filter(({ name, size }) => isValidImage(name, size)) + ? Array.from(files).filter(({ name, size }) => + allowUploadingVideos + ? isValidMedia(name, size) + : isValidImage(name, size) + ) : null; if (!rawImages || !rawImages.length) return null; @@ -77,7 +109,8 @@ export function getImagesData( const imagesPreviewData = rawImages.map((image, index) => ({ id: imagesId[index].id, src: URL.createObjectURL(image), - alt: imagesId[index].name ?? image.name + alt: imagesId[index].name ?? image.name, + type: image.type })); const selectedImagesData = rawImages.map((image, index) => diff --git a/storage.rules b/storage.rules index e74a7c17..8ce74199 100644 --- a/storage.rules +++ b/storage.rules @@ -9,14 +9,14 @@ service firebase.storage { return request.auth != null && (userId == request.auth.uid || isAdmin()); } - function isValidImage() { - return request.resource.contentType.matches('image/.*') - && request.resource.size < 20 * 1024 * 1024; + function isValidMedia() { + return request.resource.contentType.matches('image/.*|video/.*') + && request.resource.size < 50 * 1024 * 1024; } match /images/{userId}/{fileName} { allow read: if request.auth != null; - allow create: if isAuthorized(userId) && isValidImage(); + allow create: if isAuthorized(userId) && isValidMedia(); allow update, delete: if false; } }