[web] Public albums internal code improvements - Part 2 (#6430)

This commit is contained in:
Manav Rathi
2025-07-01 18:35:27 +05:30
committed by GitHub
5 changed files with 238 additions and 191 deletions

View File

@@ -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<SharingDetailsProps> = ({
);
};
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>) => () => void;
@@ -956,51 +936,55 @@ const ManageParticipant: React.FC<ManageParticipantProps> = ({
onClose();
};
const handleRoleChange = (role: string) => () => {
if (role !== selectedParticipant.role) {
changeRolePermission(selectedParticipant.email, role);
}
};
const confirmChangeRolePermission = useCallback(
(
selectedEmail: string,
newRole: CollectionNewParticipantRole,
action: () => Promise<void>,
) => {
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 = (
<Trans
i18nKey="change_permission_to_viewer"
values={{ selectedEmail }}
/>
);
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 = (
<Trans
i18nKey="change_permission_to_viewer"
values={{ selectedEmail }}
/>
);
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<ManageParticipantProps> = ({
<RowButtonGroup>
<RowButton
fontWeight="regular"
onClick={handleRoleChange("COLLABORATOR")}
onClick={createOnRoleChange("COLLABORATOR")}
label={"Collaborator"}
startIcon={<ModeEditIcon />}
endIcon={
@@ -1057,7 +1041,7 @@ const ManageParticipant: React.FC<ManageParticipantProps> = ({
<RowButton
fontWeight="regular"
onClick={handleRoleChange("VIEWER")}
onClick={createOnRoleChange("VIEWER")}
label={"Viewer"}
startIcon={<PhotoIcon />}
endIcon={

View File

@@ -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)) {

View File

@@ -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<string> => {
return (
(await localForage.getItem<string>(
getPublicCollectionPasswordKey(collectionUID),
)) || ""
);
};
export const savePublicCollectionPassword = async (
collectionUID: string,
passToken: string,
): Promise<string> => {
return await localForage.setItem<string>(
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<number>(
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<LocalSavedPublicCollectionFiles[]>(
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);
};

View File

@@ -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<Collection[]> =>
const savedPublicCollections = async (): Promise<Collection[]> =>
// TODO:
//
// See: [Note: strict mode migration]
@@ -34,10 +35,57 @@ export const savedPublicCollections = async (): Promise<Collection[]> =>
*
* 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<Collection | undefined> =>
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<EnteFile[]> => {
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<ES>("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<void> => {
// See: [Note: Avoiding Zod parsing for large DB arrays].
const entries = await localForage.getItem<ES>("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<void> => {
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`),
);

View File

@@ -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