[web] Collection APIs refactoring (#6272)

This commit is contained in:
Manav Rathi
2025-06-16 11:50:57 +05:30
committed by GitHub
6 changed files with 221 additions and 296 deletions

View File

@@ -29,7 +29,7 @@ import {
isArchivedCollection,
isPinnedCollection,
} from "ente-gallery/services/magic-metadata";
import type { Collection } from "ente-media/collection";
import { CollectionOrder, type Collection } from "ente-media/collection";
import { ItemVisibility } from "ente-media/file-metadata";
import {
GalleryItemsHeaderAdapter,
@@ -134,7 +134,7 @@ const CollectionOptions: React.FC<CollectionHeaderProps> = ({
const { showMiniDialog, onGenericError } = useBaseContext();
const { showLoadingBar, hideLoadingBar } = usePhotosAppContext();
const { syncWithRemote } = useContext(GalleryContext);
const overFlowMenuIconRef = useRef<SVGSVGElement>(null);
const overflowMenuIconRef = useRef<SVGSVGElement>(null);
const { show: showSortOrderMenu, props: sortOrderMenuVisibilityProps } =
useModalVisibility();
@@ -277,9 +277,13 @@ const CollectionOptions: React.FC<CollectionHeaderProps> = ({
setActiveCollectionID(ALL_SECTION);
});
const pinAlbum = wrap(() => changeCollectionOrder(activeCollection, 1));
const pinAlbum = wrap(() =>
changeCollectionOrder(activeCollection, CollectionOrder.pinned),
);
const unpinAlbum = wrap(() => changeCollectionOrder(activeCollection, 0));
const unpinAlbum = wrap(() =>
changeCollectionOrder(activeCollection, CollectionOrder.default),
);
const hideAlbum = wrap(async () => {
await changeCollectionVisibility(
@@ -506,13 +510,13 @@ const CollectionOptions: React.FC<CollectionHeaderProps> = ({
<OverflowMenu
ariaID="collection-options"
triggerButtonIcon={<MoreHorizIcon ref={overFlowMenuIconRef} />}
triggerButtonIcon={<MoreHorizIcon ref={overflowMenuIconRef} />}
>
{...menuOptions}
</OverflowMenu>
<CollectionSortOrderMenu
{...sortOrderMenuVisibilityProps}
overFlowMenuIconRef={overFlowMenuIconRef}
overflowMenuIconRef={overflowMenuIconRef}
onAscClick={changeSortOrderAsc}
onDescClick={changeSortOrderDesc}
/>
@@ -693,7 +697,7 @@ const DownloadOption: React.FC<
interface CollectionSortOrderMenuProps {
open: boolean;
onClose: () => void;
overFlowMenuIconRef: React.RefObject<SVGSVGElement>;
overflowMenuIconRef: React.RefObject<SVGSVGElement>;
onAscClick: () => void;
onDescClick: () => void;
}
@@ -701,24 +705,24 @@ interface CollectionSortOrderMenuProps {
const CollectionSortOrderMenu: React.FC<CollectionSortOrderMenuProps> = ({
open,
onClose,
overFlowMenuIconRef,
overflowMenuIconRef,
onAscClick,
onDescClick,
}) => {
const handleAscClick = () => {
onClose();
onAscClick();
onClose();
};
const handleDescClick = () => {
onClose();
onDescClick();
onClose();
};
return (
<Menu
id="collection-files-sort"
anchorEl={overFlowMenuIconRef.current}
anchorEl={overflowMenuIconRef.current}
open={open}
onClose={onClose}
slotProps={{

View File

@@ -1,17 +1,14 @@
import type { User } from "ente-accounts/services/user";
import { ensureLocalUser } from "ente-accounts/services/user";
import { encryptMetadataJSON, sharedCryptoWorker } from "ente-base/crypto";
import { sharedCryptoWorker } from "ente-base/crypto";
import { isDevBuild } from "ente-base/env";
import log from "ente-base/log";
import { apiURL } from "ente-base/origins";
import { ensureMasterKeyFromSession } from "ente-base/session";
import { UpdateMagicMetadataRequest } from "ente-gallery/services/file";
import { updateMagicMetadata } from "ente-gallery/services/magic-metadata";
import {
Collection,
CollectionMagicMetadata,
CollectionMagicMetadataProps,
CollectionPublicMagicMetadata,
CollectionSubType,
type CollectionType,
EncryptedCollection,
@@ -45,11 +42,7 @@ import HTTPService from "ente-shared/network/HTTPService";
import { getData } from "ente-shared/storage/localStorage";
import { getToken } from "ente-shared/storage/localStorage/helpers";
import { batch } from "ente-utils/array";
import {
changeCollectionSubType,
isQuickLinkCollection,
isValidMoveTarget,
} from "utils/collection";
import { isValidMoveTarget } from "utils/collection";
const uncategorizedCollectionName = "Uncategorized";
const defaultHiddenCollectionName = ".hidden";
@@ -349,159 +342,10 @@ export const deleteCollection = async (
}
};
export const updateCollectionMagicMetadata = async (
collection: Collection,
updatedMagicMetadata: CollectionMagicMetadata,
) => {
const token = getToken();
if (!token) {
return;
}
const { encryptedData, decryptionHeader } = await encryptMetadataJSON(
updatedMagicMetadata.data,
collection.key,
);
const reqBody: UpdateMagicMetadataRequest = {
id: collection.id,
magicMetadata: {
version: updatedMagicMetadata.version,
count: updatedMagicMetadata.count,
data: encryptedData,
header: decryptionHeader,
},
};
await HTTPService.put(
await apiURL("/collections/magic-metadata"),
reqBody,
null,
{ "X-Auth-Token": token },
);
const updatedCollection: Collection = {
...collection,
magicMetadata: {
...updatedMagicMetadata,
version: updatedMagicMetadata.version + 1,
},
};
return updatedCollection;
};
export const updateSharedCollectionMagicMetadata = async (
collection: Collection,
updatedMagicMetadata: CollectionMagicMetadata,
) => {
const token = getToken();
if (!token) {
return;
}
const { encryptedData, decryptionHeader } = await encryptMetadataJSON(
updatedMagicMetadata.data,
collection.key,
);
const reqBody: UpdateMagicMetadataRequest = {
id: collection.id,
magicMetadata: {
version: updatedMagicMetadata.version,
count: updatedMagicMetadata.count,
data: encryptedData,
header: decryptionHeader,
},
};
await HTTPService.put(
await apiURL("/collections/sharee-magic-metadata"),
reqBody,
null,
{ "X-Auth-Token": token },
);
const updatedCollection: Collection = {
...collection,
magicMetadata: {
...updatedMagicMetadata,
version: updatedMagicMetadata.version + 1,
},
};
return updatedCollection;
};
export const updatePublicCollectionMagicMetadata = async (
collection: Collection,
updatedPublicMagicMetadata: CollectionPublicMagicMetadata,
) => {
const token = getToken();
if (!token) {
return;
}
const { encryptedData, decryptionHeader } = await encryptMetadataJSON(
updatedPublicMagicMetadata.data,
collection.key,
);
const reqBody: UpdateMagicMetadataRequest = {
id: collection.id,
magicMetadata: {
version: updatedPublicMagicMetadata.version,
count: updatedPublicMagicMetadata.count,
data: encryptedData,
header: decryptionHeader,
},
};
await HTTPService.put(
await apiURL("/collections/public-magic-metadata"),
reqBody,
null,
{ "X-Auth-Token": token },
);
const updatedCollection: Collection = {
...collection,
pubMagicMetadata: {
...updatedPublicMagicMetadata,
version: updatedPublicMagicMetadata.version + 1,
},
};
return updatedCollection;
};
export const renameCollection = async (
collection: Collection,
newCollectionName: string,
) =>
enableC2()
? renameCollection2(await collection1To2(collection), newCollectionName)
: renameCollection1(collection, newCollectionName);
const renameCollection1 = async (
collection: Collection,
newCollectionName: string,
) => {
if (isQuickLinkCollection(collection)) {
// Convert quick link collection to normal collection on rename
await changeCollectionSubType(collection, CollectionSubType.default);
}
const token = getToken();
const cryptoWorker = await sharedCryptoWorker();
const { encryptedData: encryptedName, nonce: nameDecryptionNonce } =
await cryptoWorker.encryptBox(
new TextEncoder().encode(newCollectionName),
collection.key,
);
const collectionRenameRequest = {
collectionID: collection.id,
encryptedName,
nameDecryptionNonce,
};
await HTTPService.post(
await apiURL("/collections/rename"),
collectionRenameRequest,
null,
{ "X-Auth-Token": token },
);
};
) => renameCollection2(await collection1To2(collection), newCollectionName);
/**
* Return the user's own favorites collection, if any.

View File

@@ -2,11 +2,9 @@ import type { User } from "ente-accounts/services/user";
import { ensureElectron } from "ente-base/electron";
import { joinPath } from "ente-base/file-name";
import log from "ente-base/log";
import { updateMagicMetadata } from "ente-gallery/services/magic-metadata";
import {
type Collection,
CollectionMagicMetadataProps,
CollectionPublicMagicMetadataProps,
type CollectionOrder,
CollectionSubType,
} from "ente-media/collection";
import { EnteFile } from "ente-media/file";
@@ -15,11 +13,15 @@ import {
DEFAULT_HIDDEN_COLLECTION_USER_FACING_NAME,
HIDDEN_ITEMS_SECTION,
addToCollection,
collection1To2,
findDefaultHiddenCollectionIDs,
isHiddenCollection,
isIncomingShare,
moveToCollection,
restoreToCollection,
updateCollectionOrder,
updateCollectionSortOrder,
updateCollectionVisibility,
} from "ente-new/photos/services/collection";
import {
getAllLocalCollections,
@@ -35,9 +37,6 @@ import {
createAlbum,
removeFromCollection,
unhideToCollection,
updateCollectionMagicMetadata,
updatePublicCollectionMagicMetadata,
updateSharedCollectionMagicMetadata,
} from "services/collectionService";
import {
SetFilesDownloadProgressAttributes,
@@ -185,105 +184,17 @@ async function createCollectionDownloadFolder(
export const changeCollectionVisibility = async (
collection: Collection,
visibility: ItemVisibility,
) => {
try {
const updatedMagicMetadataProps: CollectionMagicMetadataProps = {
visibility,
};
) => updateCollectionVisibility(await collection1To2(collection), visibility);
const user: User = getData("user");
if (collection.owner.id === user.id) {
const updatedMagicMetadata = await updateMagicMetadata(
updatedMagicMetadataProps,
collection.magicMetadata,
collection.key,
);
await updateCollectionMagicMetadata(
collection,
updatedMagicMetadata,
);
} else {
const updatedMagicMetadata = await updateMagicMetadata(
updatedMagicMetadataProps,
collection.sharedMagicMetadata,
collection.key,
);
await updateSharedCollectionMagicMetadata(
collection,
updatedMagicMetadata,
);
}
} catch (e) {
log.error("change collection visibility failed", e);
throw e;
}
};
export const changeCollectionOrder = async (
collection: Collection,
order: CollectionOrder,
) => updateCollectionOrder(await collection1To2(collection), order);
export const changeCollectionSortOrder = async (
collection: Collection,
asc: boolean,
) => {
try {
const updatedPublicMagicMetadataProps: CollectionPublicMagicMetadataProps =
{ asc };
const updatedPubMagicMetadata = await updateMagicMetadata(
updatedPublicMagicMetadataProps,
collection.pubMagicMetadata,
collection.key,
);
await updatePublicCollectionMagicMetadata(
collection,
updatedPubMagicMetadata,
);
} catch (e) {
log.error("change collection sort order failed", e);
}
};
export const changeCollectionOrder = async (
collection: Collection,
order: number,
) => {
try {
const updatedMagicMetadataProps: CollectionMagicMetadataProps = {
order,
};
const updatedMagicMetadata = await updateMagicMetadata(
updatedMagicMetadataProps,
collection.magicMetadata,
collection.key,
);
await updateCollectionMagicMetadata(collection, updatedMagicMetadata);
} catch (e) {
log.error("change collection order failed", e);
}
};
export const changeCollectionSubType = async (
collection: Collection,
subType: CollectionSubType,
) => {
try {
const updatedMagicMetadataProps: CollectionMagicMetadataProps = {
subType: subType,
};
const updatedMagicMetadata = await updateMagicMetadata(
updatedMagicMetadataProps,
collection.magicMetadata,
collection.key,
);
await updateCollectionMagicMetadata(collection, updatedMagicMetadata);
} catch (e) {
log.error("change collection subType failed", e);
throw e;
}
};
) => updateCollectionSortOrder(await collection1To2(collection), asc);
export const getUserOwnedCollections = (collections: Collection[]) => {
const user: User = getData("user");
@@ -293,7 +204,7 @@ export const getUserOwnedCollections = (collections: Collection[]) => {
return collections.filter((collection) => collection.owner.id === user.id);
};
export const isQuickLinkCollection = (collection: Collection) =>
const isQuickLinkCollection = (collection: Collection) =>
collection.magicMetadata?.data.subType == CollectionSubType.quicklink;
export function isIncomingViewerShare(collection: Collection, user: User) {

View File

@@ -118,11 +118,16 @@ interface OverflowMenuOptionProps {
export const OverflowMenuOption: React.FC<
React.PropsWithChildren<OverflowMenuOptionProps>
> = ({ onClick, color = "primary", startIcon, endIcon, children }) => {
const menuContext = useContext(OverflowMenuContext)!;
const menuContext = useContext(OverflowMenuContext);
const handleClick = () => {
onClick();
menuContext.close();
// We might've already been closed as a result of our containing menu
// getting closed. An example of this is the "Sort by" option in the
// album options overflow menu, where the `onClick` above will result in
// `onClose` being called on our parent menu, so `menuContext` will be
// undefined when we get here.
menuContext?.close();
};
return (

View File

@@ -108,7 +108,9 @@ export interface Collection2 {
pubMagicMetadata?: MagicMetadata<CollectionPublicMagicMetadataData>;
/**
* Private mutable metadata associated with the collection that is only
* visible to the current user, if they're not the owner.
* visible to the current user if they're not the owner of the collection.
*
* Sometimes also referred to as "shareeMagicMetadata".
*
* This is metadata associated with each "share", and is only visible to
* (and editable by) the user with which the collection has been shared, not
@@ -387,10 +389,14 @@ export const RemoteCollection = z.looseObject({
owner: RemoteCollectionUser,
encryptedKey: z.string(),
/**
* Remote will set this to a blank string for albums which have been shared
* with the user (the decryption pipeline for those doesn't use the nonce).
* The nonce to use when decrypting the {@link encryptedKey} when the album
* is owned by the user.
*
* Not set for shared albums (the decryption for uses the keypair instead).
*
* Remote might set this to blank to indicate absence.
*/
keyDecryptionNonce: z.string(),
keyDecryptionNonce: z.string().nullish().transform(nullToUndefined),
/**
* Expected to be present (along with {@link nameDecryptionNonce}), but it
* is still optional since it might not be present if {@link name} is present.
@@ -631,6 +637,29 @@ export const CollectionSubType = {
export type CollectionSubType =
(typeof CollectionSubType)[keyof typeof CollectionSubType];
/**
* Ordering of the collection - Whether it is pinned or not.
*/
export const CollectionOrder = {
/**
* The default / normal value. No special semantics, behaves "unpinned" and
* will retain its natural sort position.
*/
default: 0,
/**
* The collection is "pinned" by moving to the beginning of the sort order.
*
* Multiple collections can be pinned, in which case they'll be sorted
* amongst themselves under the otherwise applicable sort order.
*
* -- [pinned collections] -- [other collections] --
*/
pinned: 1,
} as const;
export type CollectionOrder =
(typeof CollectionOrder)[keyof typeof CollectionOrder];
/**
* Mutable private metadata associated with a {@link Collection}.
*
@@ -640,6 +669,12 @@ export type CollectionSubType =
* See: [Note: Private magic metadata is called magic metadata on remote]
*/
export interface CollectionPrivateMagicMetadataData {
/**
* The subtype of the collection type (if applicable).
*
* Expected to be one of {@link CollectionSubType}.
*/
subType?: number;
/**
* The (owner specific) visibility of the collection.
*
@@ -654,20 +689,10 @@ export interface CollectionPrivateMagicMetadataData {
* instead of the expected enum.
*/
visibility?: number;
/**
* The subtype of the collection type (if applicable).
*
* Expected to be one of {@link CollectionSubType}.
*/
subType?: number;
/**
* An overrride to the sort ordering used for the collection.
*
* - For pinned collections, this will be set to `1`. Pinned collections
* will be moved to the beginning of the sort order.
*
* - Otherwise, the collection is a normal (unpinned) collection, and will
* retain its natural sort position.
* Expected to be one of {@link CollectionOrder}.
*/
order?: number;
}
@@ -678,8 +703,8 @@ export interface CollectionPrivateMagicMetadataData {
* See: [Note: Use looseObject for metadata Zod schemas]
*/
const CollectionPrivateMagicMetadataData = z.looseObject({
visibility: z.number().nullish().transform(nullToUndefined),
subType: z.number().nullish().transform(nullToUndefined),
visibility: z.number().nullish().transform(nullToUndefined),
order: z.number().nullish().transform(nullToUndefined),
});
@@ -693,10 +718,14 @@ const CollectionPrivateMagicMetadataData = z.looseObject({
*/
export interface CollectionPublicMagicMetadataData {
/**
* The ordering of the files within the collection.
*
* The default is desc ("Newest first").
*
* If true, then the files within the collection are sorted in ascending
* order of their time ("Oldest first").
*
* The default is desc ("Newest first").
* To reset to the default, set this to false.
*/
asc?: boolean;
/**

View File

@@ -17,7 +17,10 @@ import {
type Collection,
type Collection2,
type CollectionNewParticipantRole,
type CollectionOrder,
type CollectionPrivateMagicMetadataData,
type CollectionPublicMagicMetadataData,
type CollectionShareeMagicMetadataData,
type CollectionType,
type PublicURL,
} from "ente-media/collection";
@@ -176,9 +179,9 @@ export const decryptCollectionKey = async (
const { owner, encryptedKey, keyDecryptionNonce } = collection;
if (owner.id == ensureLocalUser().id) {
// The collection key of collections owned by the user is encrypted with
// the user's master key.
// the user's master key. The nonce will be present in such cases.
return decryptBox(
{ encryptedData: encryptedKey, nonce: keyDecryptionNonce },
{ encryptedData: encryptedKey, nonce: keyDecryptionNonce! },
await ensureMasterKeyFromSession(),
);
} else {
@@ -436,15 +439,70 @@ const postCollectionsRename = async (renameRequest: RenameRequest) =>
}),
);
/**
* Change the visibility (normal, archived, hidden) of a collection on remote.
*
* Remote only, does not modify local state.
*
* This function works with both collections owned by the user, and collections
* shared with the user.
*
* @param collection The collection whose visibility we want to change.
*
* @param visibility The new visibility (normal, archived, hidden).
*/
export const updateCollectionVisibility = async (
collection: Collection2,
visibility: ItemVisibility,
) =>
collection.owner.id == ensureLocalUser().id
? updateCollectionPrivateMagicMetadata(collection, { visibility })
: updateCollectionShareeMagicMetadata(collection, { visibility });
/**
* Change the pinned state of a collection on remote.
*
* Remote only, does not modify local state.
*
* This function works only for collections owned by the user.
*
* @param collection The collection whose order we want to change.
*
* @param order Whether on not the collection is pinned.
*/
export const updateCollectionOrder = async (
collection: Collection2,
order: CollectionOrder,
) => updateCollectionPrivateMagicMetadata(collection, { order });
/**
* Change the sort order of the files with a collection on remote.
*
* Remote only, does not modify local state.
*
* This function works only for collections owned by the user.
*
* @param collection The collection whose file sort order we want to change.
*
* @param asc If true, then the files are sorted ascending (oldest first).
* Otherwise they are sorted descending (newest first).
*/
export const updateCollectionSortOrder = async (
collection: Collection2,
asc: boolean,
) => updateCollectionPublicMagicMetadata(collection, { asc });
/**
* Update the private magic metadata contents of a collection on remote.
*
* Remote only, does not modify local state.
*
* @param collection The collection whose magic metadata we want to update. In
* particular, the existing magic metadata of this collection is used both to
* obtain the current magic metadata version, and the existing contents on top
* of which the updates are applied.
* @param collection The collection whose magic metadata we want to update.
*
* The existing magic metadata of this collection is used both to obtain the
* current magic metadata version, and the existing contents on top of which the
* updates are applied, so it is imperative that both these values are up to
* sync with remote otherwise the update will fail.
*
* @param updates A non-empty subset of
* {@link CollectionPrivateMagicMetadataData} entries.
@@ -501,6 +559,80 @@ const putCollectionsMagicMetadata = async (
}),
);
/**
* Update the public magic metadata contents of a collection on remote.
*
* Remote only, does not modify local state.
*
* This is a variant of {@link updateCollectionPrivateMagicMetadata} that works
* with the {@link pubMagicMetadata} of a collection.
*/
const updateCollectionPublicMagicMetadata = async (
{ id, key, pubMagicMetadata }: Collection2,
updates: CollectionPublicMagicMetadataData,
) =>
putCollectionsPublicMagicMetadata({
id,
magicMetadata: await encryptMagicMetadata(
createMagicMetadata(
{ ...pubMagicMetadata?.data, ...updates },
pubMagicMetadata?.version,
),
key,
),
});
/**
* Update the public magic metadata of a single collection on remote.
*/
const putCollectionsPublicMagicMetadata = async (
updateRequest: UpdateCollectionMagicMetadataRequest,
) =>
ensureOk(
await fetch(await apiURL("/collections/public-magic-metadata"), {
method: "PUT",
headers: await authenticatedRequestHeaders(),
body: JSON.stringify(updateRequest),
}),
);
/**
* Update the per-sharee magic metadata contents of a collection on remote.
*
* Remote only, does not modify local state.
*
* This is a variant of {@link updateCollectionPrivateMagicMetadata} that works
* with the {@link sharedMagicMetadata} of a collection.
*/
const updateCollectionShareeMagicMetadata = async (
{ id, key, sharedMagicMetadata }: Collection2,
updates: CollectionShareeMagicMetadataData,
) =>
putCollectionsShareeMagicMetadata({
id,
magicMetadata: await encryptMagicMetadata(
createMagicMetadata(
{ ...sharedMagicMetadata?.data, ...updates },
sharedMagicMetadata?.version,
),
key,
),
});
/**
* Update the sharee magic metadata of a single shared collection on remote.
*/
const putCollectionsShareeMagicMetadata = async (
updateRequest: UpdateCollectionMagicMetadataRequest,
) =>
ensureOk(
await fetch(await apiURL("/collections/sharee-magic-metadata"), {
method: "PUT",
headers: await authenticatedRequestHeaders(),
body: JSON.stringify(updateRequest),
}),
);
/**
* Share the provided collection with another Ente user.
*