diff --git a/web/apps/photos/src/components/Collections/CollectionShare.tsx b/web/apps/photos/src/components/Collections/CollectionShare.tsx index d49f9e389e..0bd3871d59 100644 --- a/web/apps/photos/src/components/Collections/CollectionShare.tsx +++ b/web/apps/photos/src/components/Collections/CollectionShare.tsx @@ -68,7 +68,6 @@ import { } from "ente-new/photos/services/collection"; import type { CollectionSummary } from "ente-new/photos/services/collection-summary"; import { usePhotosAppContext } from "ente-new/photos/types/context"; -import { CustomError, parseSharingErrorCodes } from "ente-shared/error"; import { wait } from "ente-utils/promise"; import { useFormik } from "formik"; import { t } from "i18next"; @@ -300,25 +299,6 @@ const SharingDetails: React.FC = ({ ); }; -const handleSharingErrors = (error) => { - const parsedError = parseSharingErrorCodes(error); - let errorMessage = ""; - switch (parsedError.message) { - case CustomError.BAD_REQUEST: - errorMessage = t("sharing_album_not_allowed"); - break; - case CustomError.SUBSCRIPTION_NEEDED: - errorMessage = t("sharing_disabled_for_free_accounts"); - break; - case CustomError.NOT_FOUND: - errorMessage = t("sharing_user_does_not_exist"); - break; - default: - errorMessage = `${t("generic_error_retry")} ${parsedError.message}`; - } - return errorMessage; -}; - type EmailShareProps = { onRootClose: () => void; wrap: (f: () => Promise) => () => void; @@ -956,51 +936,55 @@ const ManageParticipant: React.FC = ({ onClose(); }; - const handleRoleChange = (role: string) => () => { - if (role !== selectedParticipant.role) { - changeRolePermission(selectedParticipant.email, role); - } - }; + const confirmChangeRolePermission = useCallback( + ( + selectedEmail: string, + newRole: CollectionNewParticipantRole, + action: () => Promise, + ) => { + let message: React.ReactNode; + let buttonText: string; - const updateCollectionRole = async (selectedEmail, newRole) => { - try { - await shareCollection(collection, selectedEmail, newRole); - selectedParticipant.role = newRole; - await onRemotePull({ silent: true }); - } catch (e) { - log.error(handleSharingErrors(e), e); - } - }; + if (newRole == "VIEWER") { + message = ( + + ); - const changeRolePermission = (selectedEmail, newRole) => { - let contentText; - let buttonText; + buttonText = t("confirm_convert_to_viewer"); + } else if (newRole == "COLLABORATOR") { + message = t("change_permission_to_collaborator", { + selectedEmail, + }); + buttonText = t("confirm_convert_to_collaborator"); + } - if (newRole == "VIEWER") { - contentText = ( - - ); - - buttonText = t("confirm_convert_to_viewer"); - } else if (newRole == "COLLABORATOR") { - contentText = t("change_permission_to_collaborator", { - selectedEmail, + showMiniDialog({ + title: t("change_permission_title"), + message: message, + continue: { text: buttonText, color: "critical", action }, }); - buttonText = t("confirm_convert_to_collaborator"); - } + }, + [showMiniDialog], + ); - showMiniDialog({ - title: t("change_permission_title"), - message: contentText, - continue: { - text: buttonText, - color: "critical", - action: () => updateCollectionRole(selectedEmail, newRole), - }, - }); + const updateCollectionRole = async ( + selectedEmail: string, + newRole: CollectionNewParticipantRole, + ) => { + await shareCollection(collection, selectedEmail, newRole); + selectedParticipant.role = newRole; + await onRemotePull({ silent: true }); + }; + + const createOnRoleChange = (role: CollectionNewParticipantRole) => () => { + if (role == selectedParticipant.role) return; + const { email } = selectedParticipant; + confirmChangeRolePermission(email, role, () => + updateCollectionRole(email, role), + ); }; const removeParticipant = () => { @@ -1044,7 +1028,7 @@ const ManageParticipant: React.FC = ({ } endIcon={ @@ -1057,7 +1041,7 @@ const ManageParticipant: React.FC = ({ } endIcon={ diff --git a/web/apps/photos/src/pages/shared-albums.tsx b/web/apps/photos/src/pages/shared-albums.tsx index 23230f49ce..34d82c9f34 100644 --- a/web/apps/photos/src/pages/shared-albums.tsx +++ b/web/apps/photos/src/pages/shared-albums.tsx @@ -51,8 +51,12 @@ import { sortFiles } from "ente-gallery/utils/file"; import type { Collection } from "ente-media/collection"; import { type EnteFile } from "ente-media/file"; import { + removePublicCollectionAccessTokenJWT, savedLastPublicCollectionReferralCode, + savedPublicCollectionAccessTokenJWT, + savedPublicCollectionByKey, savedPublicCollectionFiles, + savePublicCollectionAccessTokenJWT, } from "ente-new/albums/services/public-albums-fdb"; import { verifyPublicAlbumPassword } from "ente-new/albums/services/public-collection"; import { @@ -68,13 +72,10 @@ import { useRouter } from "next/router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type FileWithPath } from "react-dropzone"; import { - getLocalPublicCollection, - getLocalPublicCollectionPassword, getPublicCollection, getPublicCollectionUID, removePublicCollectionWithFiles, removePublicFiles, - savePublicCollectionPassword, syncPublicFiles, } from "services/publicCollectionService"; import { uploadManager } from "services/upload-manager"; @@ -216,7 +217,7 @@ export default function PublicCollectionGallery() { } collectionKey.current = ck; url.current = window.location.href; - const localCollection = await getLocalPublicCollection( + const localCollection = await savedPublicCollectionByKey( collectionKey.current, ); const accessToken = t; @@ -230,13 +231,12 @@ export default function PublicCollectionGallery() { const isPasswordProtected = localCollection?.publicURLs?.[0]?.passwordEnabled; setIsPasswordProtected(isPasswordProtected); - const collectionUID = getPublicCollectionUID(accessToken); const localFiles = await savedPublicCollectionFiles(accessToken); const localPublicFiles = sortFiles(localFiles, sortAsc); setPublicFiles(localPublicFiles); accessTokenJWT = - await getLocalPublicCollectionPassword(collectionUID); + await savedPublicCollectionAccessTokenJWT(accessToken); } credentials.current = { accessToken, accessTokenJWT }; downloadManager.setPublicAlbumsCredentials(credentials.current); @@ -316,7 +316,7 @@ export default function PublicCollectionGallery() { if (!isPasswordProtected && credentials.current.accessTokenJWT) { credentials.current.accessTokenJWT = undefined; downloadManager.setPublicAlbumsCredentials(credentials.current); - savePublicCollectionPassword(collectionUID, null); + removePublicCollectionAccessTokenJWT(collectionUID); } if ( @@ -395,7 +395,10 @@ export default function PublicCollectionGallery() { const collectionUID = getPublicCollectionUID( credentials.current.accessToken, ); - await savePublicCollectionPassword(collectionUID, accessTokenJWT); + await savePublicCollectionAccessTokenJWT( + collectionUID, + accessTokenJWT, + ); } catch (e) { log.error("Failed to verifyLinkPassword", e); if (isHTTP401Error(e)) { diff --git a/web/apps/photos/src/services/publicCollectionService.ts b/web/apps/photos/src/services/publicCollectionService.ts index 8ade970689..e51653efd0 100644 --- a/web/apps/photos/src/services/publicCollectionService.ts +++ b/web/apps/photos/src/services/publicCollectionService.ts @@ -9,97 +9,29 @@ import type { import type { EnteFile, RemoteEnteFile } from "ente-media/file"; import { decryptRemoteFile } from "ente-media/file"; import { + removePublicCollectionAccessTokenJWT, + removePublicCollectionByKey, + removePublicCollectionFiles, + removePublicCollectionLastSyncTime, savedPublicCollectionFiles, - savedPublicCollections, + savedPublicCollectionLastSyncTime, saveLastPublicCollectionReferralCode, + savePublicCollection, savePublicCollectionFiles, + savePublicCollectionLastSyncTime, } from "ente-new/albums/services/public-albums-fdb"; import { CustomError, parseSharingErrorCodes } from "ente-shared/error"; import HTTPService from "ente-shared/network/HTTPService"; -import localForage from "ente-shared/storage/localForage"; - -const PUBLIC_COLLECTION_FILES_TABLE = "public-collection-files"; -const PUBLIC_COLLECTIONS_TABLE = "public-collections"; // Fix this once we can trust the types. // eslint-disable-next-line @typescript-eslint/no-unnecessary-template-expression export const getPublicCollectionUID = (token: string) => `${token}`; -const getPublicCollectionLastSyncTimeKey = (collectionUID: string) => - `public-${collectionUID}-time`; - -const getPublicCollectionPasswordKey = (collectionUID: string) => - `public-${collectionUID}-passkey`; - export interface LocalSavedPublicCollectionFiles { collectionUID: string; files: EnteFile[]; } -export const getLocalPublicCollectionPassword = async ( - collectionUID: string, -): Promise => { - return ( - (await localForage.getItem( - getPublicCollectionPasswordKey(collectionUID), - )) || "" - ); -}; - -export const savePublicCollectionPassword = async ( - collectionUID: string, - passToken: string, -): Promise => { - return await localForage.setItem( - getPublicCollectionPasswordKey(collectionUID), - passToken, - ); -}; - -export const getLocalPublicCollection = async (collectionKey: string) => { - const localCollections = await savedPublicCollections(); - const publicCollection = - localCollections.find( - (localSavedPublicCollection) => - localSavedPublicCollection.key === collectionKey, - ) || null; - return publicCollection; -}; - -export const savePublicCollection = async (collection: Collection) => { - const publicCollections = await savedPublicCollections(); - await localForage.setItem( - PUBLIC_COLLECTIONS_TABLE, - dedupeCollections([collection, ...publicCollections]), - ); -}; - -const dedupeCollections = (collections: Collection[]) => { - const keySet = new Set([]); - return collections.filter((collection) => { - if (!keySet.has(collection.key)) { - keySet.add(collection.key); - return true; - } else { - return false; - } - }); -}; - -const getPublicCollectionLastSyncTime = async (collectionUID: string) => - (await localForage.getItem( - getPublicCollectionLastSyncTimeKey(collectionUID), - )) ?? 0; - -const savePublicCollectionLastSyncTime = async ( - collectionUID: string, - time: number, -) => - await localForage.setItem( - getPublicCollectionLastSyncTimeKey(collectionUID), - time, - ); - export const syncPublicFiles = async ( token: string, passwordToken: string, @@ -118,7 +50,7 @@ export const syncPublicFiles = async ( return sortFiles(files, sortAsc); } const lastSyncTime = - await getPublicCollectionLastSyncTime(collectionUID); + (await savedPublicCollectionLastSyncTime(collectionUID)) ?? 0; if (collection.updationTime === lastSyncTime) { return sortFiles(files, sortAsc); } @@ -306,31 +238,12 @@ export const removePublicCollectionWithFiles = async ( collectionUID: string, collectionKey: string, ) => { - const publicCollections = await savedPublicCollections(); - await localForage.setItem( - PUBLIC_COLLECTIONS_TABLE, - publicCollections.filter( - (collection) => collection.key !== collectionKey, - ), - ); + await removePublicCollectionByKey(collectionKey); await removePublicFiles(collectionUID); }; export const removePublicFiles = async (collectionUID: string) => { - await localForage.removeItem(getPublicCollectionPasswordKey(collectionUID)); - await localForage.removeItem( - getPublicCollectionLastSyncTimeKey(collectionUID), - ); - - const publicCollectionFiles = - (await localForage.getItem( - PUBLIC_COLLECTION_FILES_TABLE, - )) ?? []; - await localForage.setItem( - PUBLIC_COLLECTION_FILES_TABLE, - publicCollectionFiles.filter( - (collectionFiles) => - collectionFiles.collectionUID !== collectionUID, - ), - ); + await removePublicCollectionAccessTokenJWT(collectionUID); + await removePublicCollectionLastSyncTime(collectionUID); + await removePublicCollectionFiles(collectionUID); }; diff --git a/web/packages/new/albums/services/public-albums-fdb.ts b/web/packages/new/albums/services/public-albums-fdb.ts index 7c25ff2502..5a20984e1b 100644 --- a/web/packages/new/albums/services/public-albums-fdb.ts +++ b/web/packages/new/albums/services/public-albums-fdb.ts @@ -5,6 +5,7 @@ import { LocalCollections, LocalEnteFiles, + LocalTimestamp, transformFilesIfNeeded, } from "ente-gallery/services/files-db"; import { type Collection } from "ente-media/collection"; @@ -18,7 +19,7 @@ import { z } from "zod/v4"; * * Use {@link savePublicCollections} to update the database. */ -export const savedPublicCollections = async (): Promise => +const savedPublicCollections = async (): Promise => // TODO: // // See: [Note: strict mode migration] @@ -34,10 +35,57 @@ export const savedPublicCollections = async (): Promise => * * This is the setter corresponding to {@link savedPublicCollections}. */ -export const savePublicCollections = (collections: Collection[]) => +const savePublicCollections = (collections: Collection[]) => localForage.setItem("public-collections", collections); -const LocalReferralCode = z.string().nullish().transform(nullToUndefined); +/** + * Return the saved public collection with the given {@link key} if present in + * our local database. + * + * Use {@link savePublicCollection} to save collections in our local database. + * + * @param key The collection key that can be used to identify the public album + * we want from amongst all the locally saved public albums. + */ +export const savedPublicCollectionByKey = async ( + collectionKey: string, +): Promise => + savedPublicCollections().then((cs) => + cs.find((c) => c.key == collectionKey), + ); + +/** + * Save a public collection to our local database. + * + * The collection can later be retrieved using {@link savedPublicCollection}. + * The collection can be removed using {@link removePublicCollection}. + */ +export const savePublicCollection = async (collection: Collection) => { + const collections = await savedPublicCollections(); + await savePublicCollections([ + collection, + ...collections.filter((c) => c.id != collection.id), + ]); +}; + +/** + * Remove a public collection, identified using its collection key, from our + * local database. + * + * @param key The collection key that can be used to identify the public album + * we want to remove. + */ +export const removePublicCollectionByKey = async (collectionKey: string) => { + const collections = await savedPublicCollections(); + await savePublicCollections([ + ...collections.filter((c) => c.key != collectionKey), + ]); +}; + +/** + * Zod schema for a nullish string, with `null` transformed to `undefined`. + */ +const LocalString = z.string().nullish().transform(nullToUndefined); /** * Return the last saved referral code present in our local database. @@ -55,7 +103,7 @@ const LocalReferralCode = z.string().nullish().transform(nullToUndefined); * out a new value using {@link saveLastPublicCollectionReferralCode}. */ export const savedLastPublicCollectionReferralCode = async () => - LocalReferralCode.parse(await localForage.getItem("public-referral-code")); + LocalString.parse(await localForage.getItem("public-referral-code")); /** * Update the referral code present in our local database. @@ -83,24 +131,35 @@ type LocalSavedPublicCollectionFilesEntry = z.infer< typeof LocalSavedPublicCollectionFilesEntry >; -// A purely synactic and local alias to avoid the code from looking scary. -type ES = LocalSavedPublicCollectionFilesEntry[]; - /** * Return all files for a public collection present in our local database. * - * Use {@link savePublicCollectionFiles} to update the database. + * Use {@link savePublicCollectionFiles} to update the list of files in the + * database, and {@link removePublicCollectionFiles} to remove them. * - * @param accessToken The access token of the public album whose files we want. + * @param accessToken The access token that identifies the public album whose + * files we want. */ export const savedPublicCollectionFiles = async ( accessToken: string, ): Promise => { + const entry = (await pcfEntries()).find( + (e) => e.collectionUID == accessToken, + ); + return transformFilesIfNeeded(entry ? entry.files : []); +}; + +/** + * A convenience routine to read the DB entries for "public-collection-files". + */ +const pcfEntries = async () => { + // A local alias to avoid the code from looking scary. + type ES = LocalSavedPublicCollectionFilesEntry[]; + // See: [Note: Avoiding Zod parsing for large DB arrays] for why we use an // (implied) cast here instead of parsing using the Zod schema. const entries = await localForage.getItem("public-collection-files"); - const entry = (entries ?? []).find((e) => e.collectionUID == accessToken); - return transformFilesIfNeeded(entry ? entry.files : []); + return entries ?? []; }; /** @@ -108,8 +167,8 @@ export const savedPublicCollectionFiles = async ( * * This is the setter corresponding to {@link savedPublicCollectionFiles}. * - * @param accessToken The access token of the public album whose files we want - * to replace. + * @param accessToken The access token that identifies the public album whose + * files we want to update. * * @param files The files to save. */ @@ -117,15 +176,99 @@ export const savePublicCollectionFiles = async ( accessToken: string, files: EnteFile[], ): Promise => { - // See: [Note: Avoiding Zod parsing for large DB arrays]. - const entries = await localForage.getItem("public-collection-files"); await localForage.setItem("public-collection-files", [ { collectionUID: accessToken, files }, - ...(entries ?? []).filter((e) => e.collectionUID != accessToken), + ...(await pcfEntries()).filter((e) => e.collectionUID != accessToken), ]); }; -const LocalUploaderName = z.string().nullish().transform(nullToUndefined); +/** + * Remove the list of files, in any, in our local database for the given + * collection (identified by its {@link accessToken}). + */ +export const removePublicCollectionFiles = async ( + accessToken: string, +): Promise => { + await localForage.setItem("public-collection-files", [ + ...(await pcfEntries()).filter((e) => e.collectionUID != accessToken), + ]); +}; + +/** + * Return the locally persisted "last sync time" for a public collection that we + * have pulled from remote. This can be used to perform a paginated delta pull + * from the saved time onwards. + * + * Use {@link savePublic CollectionLastSyncTime} to update the value saved in + * the database, and {@link removePublicCollectionLastSyncTime} to remove the + * saved value from the database. + * + * @param accessToken The access token that identifies the public album whose + * last sync time we want. + */ +export const savedPublicCollectionLastSyncTime = async (accessToken: string) => + LocalTimestamp.parse( + await localForage.getItem(`public-${accessToken}-time`), + ); + +/** + * Update the locally persisted timestamp that will be returned by subsequent + * calls to {@link savedPublicCollectionLastSyncTime}. + */ +export const savePublicCollectionLastSyncTime = async ( + accessToken: string, + time: number, +) => { + await localForage.setItem(`public-${accessToken}-time`, time); +}; + +/** + * Remove the locally persisted timestamp, if any, previously saved for a + * collection using {@link savedPublicCollectionLastSyncTime}. + */ +export const removePublicCollectionLastSyncTime = async ( + accessToken: string, +) => { + await localForage.removeItem(`public-${accessToken}-time`); +}; + +/** + * Return the access token JWT, if any, present in our local database for the + * given public collection (as identified by its {@link accessToken}). + * + * Use {@link savePublicCollectionAccessTokenJWT} to save the value, and + * {@link removePublicCollectionAccessTokenJWT} to remove it. + */ +export const savedPublicCollectionAccessTokenJWT = async ( + accessToken: string, +) => + LocalString.parse( + await localForage.getItem(`public-${accessToken}-passkey`), + ); + +/** + * Update the access token JWT in our local database for the given public + * collection (as identified by its {@link accessToken}). + * + * This is the setter corresponding to + * {@link savedPublicCollectionAccessTokenJWT}. + */ +export const savePublicCollectionAccessTokenJWT = async ( + accessToken: string, + passwordJWT: string, +) => { + await localForage.setItem(`public-${accessToken}-passkey`, passwordJWT); +}; + +/** + * Remove the access token JWT in our local database for the given public + * collection (as identified by its {@link accessToken}). + */ +export const removePublicCollectionAccessTokenJWT = async ( + accessToken: string, +) => { + await localForage.removeItem(`public-${accessToken}-passkey`); +}; /** * Return the previously saved uploader name, if any, present in our local @@ -143,11 +286,11 @@ const LocalUploaderName = z.string().nullish().transform(nullToUndefined); * public collection, in the local database so that it can prefill it the next * time there is an upload from the same client. * - * @param accessToken The access token of the public album whose persisted - * uploader name we we want. + * @param accessToken The access token that identifies the public album whose + * saved uploader name we want. */ export const savedPublicCollectionUploaderName = async (accessToken: string) => - LocalUploaderName.parse( + LocalString.parse( await localForage.getItem(`public-${accessToken}-uploaderName`), ); diff --git a/web/packages/new/photos/services/photos-fdb.ts b/web/packages/new/photos/services/photos-fdb.ts index 8c549c2a2c..4c5329d94e 100644 --- a/web/packages/new/photos/services/photos-fdb.ts +++ b/web/packages/new/photos/services/photos-fdb.ts @@ -188,8 +188,12 @@ export const saveCollectionFiles = async (files: EnteFile[]) => { }; /** - * Return the locally persisted {@link updationTime} of the latest file from the - * given {@link collection} that we have pulled from remote. + * Return the locally persisted "last sync time" for a collection that we have + * pulled from remote. This can be used to perform a paginated delta pull from + * the saved time onwards. + * + * > Specifically, this is the {@link updationTime} of the latest file from the + * > {@link collection}, or the the collection itself if it is fully synced. * * Use {@link saveCollectionLastSyncTime} to update the value saved in the * database, and {@link removeCollectionIDLastSyncTime} to remove the saved