diff --git a/common/src/util/time.ts b/common/src/util/time.ts index f101f463..e4bad0d6 100644 --- a/common/src/util/time.ts +++ b/common/src/util/time.ts @@ -4,7 +4,13 @@ export const DAY_MS = 24 * HOUR_MS export const WEEK_MS = 7 * DAY_MS export const MONTH_MS = 30 * DAY_MS export const YEAR_MS = 365 * DAY_MS -export const HOUR_SECONDS = 60 * 60 + +export const MINUTE_SECONDS = MINUTE_MS / 1000 +export const HOUR_SECONDS = HOUR_MS / 1000 +export const DAY_SECONDS = DAY_MS / 1000 +export const WEEK_SECONDS = WEEK_MS / 1000 +export const MONTH_SECONDS = MONTH_MS / 1000 +export const YEAR_SECONDS = YEAR_MS / 1000 export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) diff --git a/web/components/photos-modal.tsx b/web/components/photos-modal.tsx index 319291f6..f46da746 100644 --- a/web/components/photos-modal.tsx +++ b/web/components/photos-modal.tsx @@ -2,66 +2,65 @@ import {getProfileOgImageUrl} from 'common/profiles/og-image' import {Profile} from 'common/profiles/profile' import {User} from 'common/user' import Image from 'next/image' -import {useEffect, useState} from 'react' +import {useState} from 'react' import {Button} from 'web/components/buttons/button' import {Col} from 'web/components/layout/col' -import {Modal, MODAL_CLASS} from 'web/components/layout/modal' -import {Row} from 'web/components/layout/row' +import {Modal} from 'web/components/layout/modal' import {ShareProfileButtons} from 'web/components/widgets/share-profile-button' import {useT} from 'web/lib/locale' -export const PhotosModal = (props: { - open: boolean - setOpen: (open: boolean) => void - photos: string[] -}) => { - const {open, setOpen, photos} = props - const [index, setIndex] = useState(0) - useEffect(() => { - if (!open) setTimeout(() => setIndex(0), 100) - }, [open]) - return ( - - - - - setIndex(index - 1)} disabled={index === 0}> - Previous - - setIndex(index + 1)} disabled={index === photos.length - 1}> - Next - - - - - ) -} - -export const ExpandablePhoto = (props: {src: string; width?: number; height?: number}) => { - const {src, width = 1000, height = 1000} = props - const [open, setOpen] = useState(false) - return ( - - setOpen(true)} - /> - - - - - ) -} +// export const PhotosModal = (props: { +// open: boolean +// setOpen: (open: boolean) => void +// photos: string[] +// }) => { +// const {open, setOpen, photos} = props +// const [index, setIndex] = useState(0) +// useEffect(() => { +// if (!open) setTimeout(() => setIndex(0), 100) +// }, [open]) +// return ( +// +// +// +// +// setIndex(index - 1)} disabled={index === 0}> +// Previous +// +// setIndex(index + 1)} disabled={index === photos.length - 1}> +// Next +// +// +// +// +// ) +// } +// +// export const ExpandablePhoto = (props: {src: string; width?: number; height?: number}) => { +// const {src, width = 1000, height = 1000} = props +// const [open, setOpen] = useState(false) +// return ( +// +// setOpen(true)} +// /> +// +// +// +// +// ) +// } export const ViewProfileCardButton = (props: { user: User diff --git a/web/components/profile-carousel.tsx b/web/components/profile-carousel.tsx index 30e39eb6..5603ed0f 100644 --- a/web/components/profile-carousel.tsx +++ b/web/components/profile-carousel.tsx @@ -6,6 +6,7 @@ import {Col} from 'web/components/layout/col' import {Modal} from 'web/components/layout/modal' import {Carousel} from 'web/components/widgets/carousel' import {useUser} from 'web/hooks/use-user' +import {isVideo} from 'web/lib/firebase/storage' import {SignUpButton} from './nav/sidebar' @@ -15,23 +16,8 @@ export default function ProfileCarousel(props: {profile: Profile; refreshProfile const [lightboxUrl, setLightboxUrl] = useState('') const [lightboxOpen, setLightboxOpen] = useState(false) - // const [isEditMode, setIsEditMode] = useState(false) - // const [addPhotosOpen, setAddPhotosOpen] = useState(false) - - // const [pinnedUrl, setPinnedUrl] = useState(profile.pinned_url) - // const [photoUrls, setPhotoUrls] = useState(profile.photo_urls ?? []) const currentUser = useUser() - // const isCurrentUser = currentUser?.id === profile.user_id - - // const handleSaveChanges = async () => { - // await updateProfile({ - // pinned_url: pinnedUrl ?? undefined, - // photo_urls: photoUrls, - // }) - // setIsEditMode(false) - // refreshProfile() - // } if (photoNums == 0 && !profile.pinned_url) return @@ -70,138 +56,61 @@ export default function ProfileCarousel(props: {profile: Profile; refreshProfile return ( <> - {/**/} - {/* {isCurrentUser && !isEditMode && (*/} - {/* setIsEditMode(true)}*/} - {/* color="gray-outline"*/} - {/* size="sm"*/} - {/* >*/} - {/* Edit photos*/} - {/* */} - {/* )}*/} - {/* {isCurrentUser && isEditMode && (*/} - {/* */} - {/* {*/} - {/* // TODO this is stale if you've saved*/} - {/* setPhotoUrls(profile.photo_urls ?? [])*/} - {/* setPinnedUrl(profile.pinned_url)*/} - {/* setIsEditMode(false)*/} - {/* }}*/} - {/* color="gray-outline"*/} - {/* size="sm"*/} - {/* >*/} - {/* Cancel*/} - {/* */} - {/* */} - {/* Save changes*/} - {/* */} - {/* */} - {/* )}*/} - {/**/} - - {/*{isEditMode ? (*/} - {/* */} - {/* {*/} - {/* const newPinnedUrl = newOrder[0]*/} - {/* const newPhotoUrls = newOrder.filter(*/} - {/* (url) => url !== newPinnedUrl*/} - {/* )*/} - {/* setPinnedUrl(newPinnedUrl)*/} - {/* setPhotoUrls(newPhotoUrls)*/} - {/* }}*/} - {/* onDelete={(url) => {*/} - {/* if (url === pinnedUrl) {*/} - {/* const newPhotos = photoUrls.filter((u) => u !== url)*/} - {/* setPinnedUrl(newPhotos[0] ?? null)*/} - {/* setPhotoUrls(newPhotos.slice(1))*/} - {/* } else {*/} - {/* setPhotoUrls(photoUrls.filter((u) => u !== url))*/} - {/* }*/} - {/* }}*/} - {/* onSetProfilePic={(url) => {*/} - {/* if (url === pinnedUrl) return*/} - {/* setPinnedUrl(url)*/} - {/* setPhotoUrls(*/} - {/* [...photoUrls.filter((u) => u !== url), pinnedUrl].filter(*/} - {/* Boolean*/} - {/* ) as string[]*/} - {/* )*/} - {/* }}*/} - {/* />*/} - {/* setAddPhotosOpen(true)}*/} - {/* color="gray-outline"*/} - {/* size="sm"*/} - {/* className="self-start"*/} - {/* >*/} - {/* */} - {/* Add photos*/} - {/* */} - {/* */} - {/*) : (*/} {buildArray(profile.pinned_url, profile.photo_urls).map((url, i) => ( - { - setLightboxUrl(url) - setLightboxOpen(true) - }} - /> + {isVideo(url) ? ( + { + setLightboxUrl(url) + setLightboxOpen(true) + }} + /> + ) : ( + { + setLightboxUrl(url) + setLightboxOpen(true) + }} + /> + )} {(profile.image_descriptions as Record)?.[url]} ))} - {/*{isCurrentUser && (profile.photo_urls?.length ?? 0) > 1 && (*/} - {/* setAddPhotosOpen(true)}*/} - {/* >*/} - {/* */} - {/* */} - {/*)}*/} - {/* )}*/} - + {isVideo(lightboxUrl) ? ( + + ) : ( + + )} - - {/*{isCurrentUser && (*/} - {/* */} - {/* */} - {/* */} - {/* */} - {/* setAddPhotosOpen(false)}*/} - {/* >*/} - {/* Done*/} - {/* */} - {/* */} - {/* */} - {/* */} - {/*)}*/} > ) } diff --git a/web/components/widgets/add-photos.tsx b/web/components/widgets/add-photos.tsx index 67031d29..622b1bb3 100644 --- a/web/components/widgets/add-photos.tsx +++ b/web/components/widgets/add-photos.tsx @@ -5,10 +5,11 @@ import {buildArray} from 'common/util/array' import {uniq} from 'lodash' import Image from 'next/image' import {useState} from 'react' +import toast from 'react-hot-toast' import {Button} from 'web/components/buttons/button' import {Col} from 'web/components/layout/col' import {Row} from 'web/components/layout/row' -import {uploadImage} from 'web/lib/firebase/storage' +import {isVideo, uploadImage} from 'web/lib/firebase/storage' import {useT} from 'web/lib/locale' export const AddPhotosWidget = (props: { @@ -48,6 +49,7 @@ export const AddPhotosWidget = (props: { selectedFiles.map((f) => uploadImage(username, f, 'love-images')), ).catch((e) => { console.error(e) + toast.error(e) return [] }) if (!pinned_url) setPinnedUrl(urls[0]) @@ -116,13 +118,26 @@ export const AddPhotosWidget = (props: { > - + {isVideo(url) ? ( + + ) : ( + + )} - file.type === 'image/heic' || file.type === 'image/heif' || /\.heic$/i.test(file.name) - export const uploadImage = async ( username: string, file: File, prefix?: string, onProgress?: (progress: number, isRunning: boolean) => void, ) => { + const fileType = await getFileType(file) + + const lastDot = file.name.lastIndexOf('.') + let ext = lastDot !== -1 ? file.name.slice(lastDot + 1) : undefined + ext ??= fileType?.split('/')[1] + ext ??= 'bin' + ext = ext.toLowerCase() + // Replace filename with a nanoid to avoid collisions - let [, ext] = file.name.split('.') const stem = nanoid(10) const ALLOWED_TYPES = [ + 'video/mp4', + 'video/webm', 'image/jpeg', 'image/png', 'image/webp', @@ -28,8 +34,8 @@ export const uploadImage = async ( 'image/heif', ] - if (!ALLOWED_TYPES.includes(file.type)) { - // throw new Error('Unsupported image format') + if (!ALLOWED_TYPES.includes(fileType || '')) { + captureMessage('Likely unsupported image format', {attributes: {type: file.type}}) console.warn('Likely unsupported image format', file.type) } @@ -44,6 +50,7 @@ export const uploadImage = async ( } const filename = `${stem}.${ext}` + console.log('filename', filename) const storageRef = ref( storage, `user-images/${username}${prefix ? '/' + prefix : ''}/${filename}`, @@ -68,7 +75,7 @@ export const uploadImage = async ( } const uploadTask = uploadBytesResumable(storageRef, file, { - cacheControl: `public, max-age=${ONE_YEAR_SECS}`, + cacheControl: `public, max-age=${YEAR_SECONDS}`, }) let resolvePromise: (url: string) => void @@ -130,6 +137,9 @@ export async function convertWebpToJpeg(file: File): Promise { }) } +const isHeic = (file: File) => + file.type === 'image/heic' || file.type === 'image/heif' || /\.heic$/i.test(file.name) + export async function convertHeicToJpeg(file: File): Promise { // Convert HEIC → JPEG immediately (as HEIC not rendered) // heic2any available in client only @@ -144,3 +154,31 @@ export async function convertHeicToJpeg(file: File): Promise { type: 'image/jpeg', }) } + +async function getFileType(file: File): Promise { + if (file.type) return file.type + + const buf = await file.slice(0, 12).arrayBuffer() + const bytes = new Uint8Array(buf) + + // MP4/MOV: bytes 4-8 = "ftyp" + if (bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70) + return 'video/mp4' + + // WebM: starts with 0x1A45DFA3 + if (bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf && bytes[3] === 0xa3) + return 'video/webm' + + // JPEG: FF D8 FF + if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg' + + // PNG: 89 50 4E 47 + if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) + return 'image/png' + + return undefined +} + +export function isVideo(url: string) { + return url.match(/\.(mp4|webm|mov|ogg)/) +}
{(profile.image_descriptions as Record)?.[url]}