[desktop] People grouping - Finishing touches (#3817)

This commit is contained in:
Manav Rathi
2024-10-23 16:32:02 +05:30
committed by GitHub
12 changed files with 191 additions and 114 deletions

View File

@@ -38,7 +38,6 @@ import {
getLocalTrashedFiles,
sortFiles,
} from "@/new/photos/services/files";
import type { Person } from "@/new/photos/services/ml/people";
import {
filterSearchableFiles,
setSearchCollectionsAndFiles,
@@ -855,11 +854,6 @@ export default function Gallery() {
});
};
const handleSelectPerson = (person: Person | undefined) =>
person
? dispatch({ type: "showPerson", personID: person.id })
: dispatch({ type: "showPeople" });
const handleOpenCollectionSelector = useCallback(
(attributes: CollectionSelectorAttributes) => {
setCollectionSelectorAttributes(attributes);
@@ -879,7 +873,7 @@ export default function Gallery() {
}
// `peopleState` will be undefined only when ML is disabled, otherwise it'll
// be contain empty arrays (even if people are loading).
// be present, with empty arrays, even if people data is still syncing.
const showPeopleSectionButton = peopleState !== undefined;
return (
@@ -981,7 +975,10 @@ export default function Gallery() {
onShowSearchInput: () =>
dispatch({ type: "enterSearchMode" }),
onSelectSearchOption: handleSelectSearchOption,
onSelectPerson: handleSelectPerson,
onSelectPeople: () =>
dispatch({ type: "showPeople" }),
onSelectPerson: (personID) =>
dispatch({ type: "showPerson", personID }),
}}
/>
)}
@@ -1004,7 +1001,8 @@ export default function Gallery() {
? state.view.visiblePeople
: undefined) ?? [],
activePerson,
onSelectPerson: handleSelectPerson,
onSelectPerson: (personID) =>
dispatch({ type: "showPerson", personID }),
setCollectionNamerAttributes,
setPhotoListHeader,
setFilesDownloadProgressAttributesCreator,
@@ -1060,8 +1058,8 @@ export default function Gallery() {
<GalleryEmptyState openUploader={openUploader} />
) : !isInSearchMode &&
!isFirstLoad &&
barMode == "people" &&
!activePerson ? (
state.view.type == "people" &&
!state.view.activePerson ? (
<PeopleEmptyState />
) : (
<PhotoFrame

View File

@@ -247,7 +247,7 @@ export const AttributedMiniDialog: React.FC<
)}
{children}
<Stack
sx={{ paddingBlockStart: "24px", gap: "12px" }}
sx={{ paddingBlockStart: "24px", gap: "8px" }}
direction={attributes.buttonDirection ?? "column"}
>
{errorIndicator}

View File

@@ -8,7 +8,7 @@ import { UnstyledButton } from "./UnstyledButton";
export interface SearchPeopleListProps {
people: Person[];
onSelectPerson: (person: Person) => void;
onSelectPerson: (personID: string) => void;
}
/**
@@ -26,7 +26,7 @@ export const SearchPeopleList: React.FC<SearchPeopleListProps> = ({
{people.slice(0, isSmallWidth ? 6 : 7).map((person) => (
<SearchPersonButton
key={person.id}
onClick={() => onSelectPerson(person)}
onClick={() => onSelectPerson(person.id)}
>
<FaceCropImageView
faceID={person.displayFaceID}
@@ -149,7 +149,7 @@ export const SuggestionFaceList: React.FC<SuggestionFaceListProps> = ({
{faces.map(({ file, faceID }) => (
<SuggestionFace key={faceID}>
<FaceCropImageView
placeholderDimension={65}
placeholderDimension={87}
{...{ file, faceID }}
/>
</SuggestionFace>

View File

@@ -2,7 +2,6 @@ import { assertionFailed } from "@/base/assert";
import { useIsSmallWidth } from "@/base/hooks";
import { ItemCard, PreviewItemTile } from "@/new/photos/components/Tiles";
import { isMLSupported, mlStatusSnapshot } from "@/new/photos/services/ml";
import type { Person } from "@/new/photos/services/ml/people";
import { searchOptionsForString } from "@/new/photos/services/search";
import type { SearchOption } from "@/new/photos/services/search/types";
import { nullToUndefined } from "@/utils/transform";
@@ -69,13 +68,14 @@ export interface SearchBarProps {
*/
onSelectSearchOption: (o: SearchOption | undefined) => void;
/**
* Called when the user selects a person shown in the empty state view, or
* clicks the people list header itself.
*
* @param person The selected person, or `undefined` if the user clicked the
* generic people header.
* Called when the user selects the generic "People" header in the empty
* state view.
*/
onSelectPerson: (person: Person | undefined) => void;
onSelectPeople: () => void;
/**
* Called when the user selects a person shown in the empty state view.
*/
onSelectPerson: (personID: string | undefined) => void;
}
/**
@@ -127,6 +127,7 @@ const MobileSearchArea: React.FC<MobileSearchAreaProps> = ({ onSearch }) => (
const SearchInput: React.FC<Omit<SearchBarProps, "onShowSearchInput">> = ({
isInSearchMode,
onSelectSearchOption,
onSelectPeople,
onSelectPerson,
}) => {
// A ref to the top level Select.
@@ -185,9 +186,14 @@ const SearchInput: React.FC<Omit<SearchBarProps, "onShowSearchInput">> = ({
onSelectSearchOption(undefined);
};
const handleSelectPerson = (person: Person | undefined) => {
const handleSelectPeople = () => {
resetSearch();
onSelectPerson(person);
onSelectPeople();
};
const handleSelectPerson = (personID: string | undefined) => {
resetSearch();
onSelectPerson(personID);
};
const handleFocus = () => {
@@ -219,7 +225,10 @@ const SearchInput: React.FC<Omit<SearchBarProps, "onShowSearchInput">> = ({
placeholder={t("search_hint")}
noOptionsMessage={({ inputValue }) =>
shouldShowEmptyState(inputValue) ? (
<EmptyState onSelectPerson={handleSelectPerson} />
<EmptyState
onSelectPeople={handleSelectPeople}
onSelectPerson={handleSelectPerson}
/>
) : null
}
/>
@@ -373,9 +382,9 @@ const shouldShowEmptyState = (inputValue: string) => {
* The view shown in the menu area when the user has not typed anything in the
* search box.
*/
const EmptyState: React.FC<Pick<SearchBarProps, "onSelectPerson">> = ({
onSelectPerson,
}) => {
const EmptyState: React.FC<
Pick<SearchBarProps, "onSelectPeople" | "onSelectPerson">
> = ({ onSelectPeople, onSelectPerson }) => {
const mlStatus = useMLStatusSnapshot();
const people = usePeopleStateSnapshot()?.visiblePeople;
@@ -397,7 +406,6 @@ const EmptyState: React.FC<Pick<SearchBarProps, "onSelectPerson">> = ({
label = t("indexing_fetching", mlStatus);
break;
case "clustering":
// TODO-Cluster
label = t("indexing_people", mlStatus);
break;
case "done":
@@ -409,9 +417,7 @@ const EmptyState: React.FC<Pick<SearchBarProps, "onSelectPerson">> = ({
<Box sx={{ textAlign: "left" }}>
{people && people.length > 0 && (
<>
<SearchPeopleHeader
onClick={() => onSelectPerson(undefined)}
/>
<SearchPeopleHeader onClick={onSelectPeople} />
<SearchPeopleList {...{ people, onSelectPerson }} />
</>
)}

View File

@@ -95,10 +95,9 @@ export interface GalleryBarImplProps {
*/
activePerson: Person | undefined;
/**
* Called when the selection should be moved to a new person in the bar, or
* reset to the default state (when {@link person} is `undefined`).
* Called when the selection should be moved to a new person in the bar.
*/
onSelectPerson: (person: Person | undefined) => void;
onSelectPerson: (personID: string) => void;
}
export const GalleryBarImpl: React.FC<GalleryBarImplProps> = ({
@@ -423,7 +422,7 @@ type ItemData =
type: "people";
people: Person[];
activePerson: Person | undefined;
onSelectPerson: (person: Person) => void;
onSelectPerson: (personID: string) => void;
};
const getItemCount = (data: ItemData) => {
@@ -578,7 +577,7 @@ const ActiveIndicator = styled("div")`
interface PersonCardProps {
person: Person;
activePerson: Person | undefined;
onSelectPerson: (person: Person) => void;
onSelectPerson: (personID: string) => void;
}
const PersonCard: React.FC<PersonCardProps> = ({
@@ -591,7 +590,7 @@ const PersonCard: React.FC<PersonCardProps> = ({
TileComponent={BarItemTile}
coverFile={person.displayFaceFile}
coverFaceID={person.displayFaceID}
onClick={() => onSelectPerson(person)}
onClick={() => onSelectPerson(person.id)}
>
{person.name && <CardText>{person.name}</CardText>}
</ItemCard>

View File

@@ -40,6 +40,7 @@ import HideImageOutlinedIcon from "@mui/icons-material/HideImageOutlined";
import ListAltOutlinedIcon from "@mui/icons-material/ListAltOutlined";
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
import RestoreIcon from "@mui/icons-material/Restore";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import {
Dialog,
DialogActions,
@@ -90,26 +91,27 @@ export const PeopleHeader: React.FC<PeopleHeaderProps> = ({
<GalleryItemsHeaderAdapter>
<SpaceBetweenFlex>
{person.type == "cgroup" ? (
<CGroupPersonHeader
person={person}
{...{ onSelectPerson }}
/>
person.isHidden ? (
<IgnoredPersonHeader person={person} />
) : (
<CGroupPersonHeader person={person} />
)
) : (
<ClusterPersonHeader person={person} {...{ people }} />
<ClusterPersonHeader
person={person}
{...{ people, onSelectPerson }}
/>
)}
</SpaceBetweenFlex>
</GalleryItemsHeaderAdapter>
);
};
type CGroupPersonHeaderProps = Pick<PeopleHeaderProps, "onSelectPerson"> & {
interface CGroupPersonHeaderProps {
person: CGroupPerson;
};
}
const CGroupPersonHeader: React.FC<CGroupPersonHeaderProps> = ({
person,
onSelectPerson,
}) => {
const CGroupPersonHeader: React.FC<CGroupPersonHeaderProps> = ({ person }) => {
const cgroup = person.cgroup;
const { showMiniDialog } = useAppContext();
@@ -130,17 +132,13 @@ const CGroupPersonHeader: React.FC<CGroupPersonHeaderProps> = ({
continue: {
text: t("reset"),
color: "primary",
action: async () => {
await deleteCGroup(cgroup);
// Reset the selection to the default state.
onSelectPerson(undefined);
},
action: () => deleteCGroup(cgroup),
},
});
// While technically it is possible for the cgroup not to have a name, logic
// wise we shouldn't be ending up here without a name (this state is
// expected to be reached only for named persons).
// expected to be reached only for unignored named persons).
const name = cgroup.data.name ?? "";
return (
@@ -198,12 +196,50 @@ const CGroupPersonHeader: React.FC<CGroupPersonHeaderProps> = ({
);
};
type ClusterPersonHeaderProps = Pick<PeopleHeaderProps, "people"> & {
interface IgnoredPersonHeaderProps {
person: CGroupPerson;
}
const IgnoredPersonHeader: React.FC<IgnoredPersonHeaderProps> = ({
person,
}) => {
const cgroup = person.cgroup;
const handleUndoIgnore = useWrapAsyncOperation(() => deleteCGroup(cgroup));
return (
<>
<GalleryItemsSummary
name={pt("Ignored")}
nameProps={{ color: "text.muted" }}
fileCount={person.fileIDs.length}
/>
<OverflowMenu
ariaControls={"person-options"}
triggerButtonIcon={<MoreHorizIcon />}
>
<OverflowMenuOption
startIcon={<VisibilityOutlinedIcon />}
centerAlign
onClick={handleUndoIgnore}
>
{pt("Show person")}
</OverflowMenuOption>
</OverflowMenu>
</>
);
};
type ClusterPersonHeaderProps = Pick<
PeopleHeaderProps,
"people" | "onSelectPerson"
> & {
person: ClusterPerson;
};
const ClusterPersonHeader: React.FC<ClusterPersonHeaderProps> = ({
people,
onSelectPerson,
person,
}) => {
const cluster = person.cluster;
@@ -264,22 +300,19 @@ const ClusterPersonHeader: React.FC<ClusterPersonHeaderProps> = ({
<AddPersonDialog
{...addPersonVisibilityProps}
{...{ people, cluster }}
{...{ people, onSelectPerson, cluster }}
/>
</>
);
};
type AddPersonDialogProps = ModalVisibilityProps & {
/**
* The list of people from show the existing named people.
*/
people: Person[];
/**
* The cluster to add to the selected person (existing or new).
*/
cluster: FaceCluster;
};
type AddPersonDialogProps = ModalVisibilityProps &
Pick<PeopleHeaderProps, "people" | "onSelectPerson"> & {
/**
* The cluster to add to the selected person (existing or new).
*/
cluster: FaceCluster;
};
/**
* A dialog allowing the user to select one of the existing named persons they
@@ -290,6 +323,7 @@ const AddPersonDialog: React.FC<AddPersonDialogProps> = ({
open,
onClose,
people,
onSelectPerson,
cluster,
}) => {
const isFullScreen = useMediaQuery("(max-width: 490px)");
@@ -302,14 +336,19 @@ const AddPersonDialog: React.FC<AddPersonDialogProps> = ({
const handleAddPerson = () => setOpenNameInput(true);
const handleSelectPerson = useWrapAsyncOperation((id: string) =>
addClusterToCGroup(
ensure(cgroupPeople.find((p) => p.id == id)).cgroup,
cluster,
),
const handleAddPersonBySelect = useWrapAsyncOperation(
async (personID: string) => {
onClose();
const person = ensure(cgroupPeople.find((p) => p.id == personID));
await addClusterToCGroup(person.cgroup, cluster);
onSelectPerson(personID);
},
);
const handleAddPersonWithName = (name: string) => addCGroup(name, cluster);
const handleAddPersonWithName = async (name: string) => {
const personID = await addCGroup(name, cluster);
onSelectPerson(personID);
};
// [Note: Calling setState during rendering]
//
@@ -350,7 +389,7 @@ const AddPersonDialog: React.FC<AddPersonDialogProps> = ({
<PersonButton
key={person.id}
person={person}
onPersonClick={handleSelectPerson}
onPersonClick={handleAddPersonBySelect}
/>
))}
</DialogContent_>

View File

@@ -50,7 +50,7 @@ export const PeopleEmptyState: React.FC = () => {
const message =
mlStatus?.phase == "done"
? pt(
"People will be shown here after there are sufficient photos of a person",
"People will be shown here when there are sufficient photos of a person",
)
: pt("Syncing...");

View File

@@ -585,8 +585,28 @@ const galleryReducer: React.Reducer<GalleryState, GalleryAction> = (
state.archivedCollectionIDs,
),
});
case "setPeopleState":
return { ...state, peopleState: action.peopleState };
case "setPeopleState": {
const peopleState = action.peopleState;
if (state.view?.type != "people") return { ...state, peopleState };
const { view, extraVisiblePerson } = derivePeopleView(
peopleState,
state.tempDeletedFileIDs,
state.tempHiddenFileIDs,
state.selectedPersonID,
state.extraVisiblePerson,
);
const filteredFiles = derivePeopleFilteredFiles(state.files, view);
return {
...state,
peopleState,
selectedPersonID: view.activePerson?.id,
extraVisiblePerson,
view,
filteredFiles,
};
}
case "markTempDeleted":
return refreshingFilteredFilesIfShowingAlbumsOrHiddenAlbums({
...state,
@@ -1101,6 +1121,15 @@ const derivePeopleView = (
visiblePeople = filterTemp(visiblePeople);
}
// We might have an extraVisiblePerson that is now part of the visible ones
// when the user un-ignores a person. If that's the case (which we can
// detect by its absence from the list of underlying people, since its ID
// would've changed), clear it out, otherwise we'll end up with two entries.
if (extraVisiblePerson) {
if (!people.find((p) => p.id == extraVisiblePerson?.id))
extraVisiblePerson = undefined;
}
const findByIDIn = (ps: Person[]) =>
ps.find((p) => p.id == selectedPersonID);
let activePerson = findByIDIn(visiblePeople);
@@ -1212,8 +1241,8 @@ const deriveAlbumsFilteredFiles = (
};
/**
* Return a new state by recomputing the {@link filteredFiles} property if when
* we're showing the "hidden-albums" view.
* Return a new state by recomputing the {@link filteredFiles} property if we're
* showing the "hidden-albums" view.
*
* See {@link refreshingFilteredFilesIfShowingAlbums} for more details.
*/

View File

@@ -704,10 +704,12 @@ const regenerateFaceCropsIfNeeded = async (file: EnteFile) => {
* @param name Name of the new cgroup user entity.
*
* @param cluster The underlying cluster to use to populate the cgroup.
*
* @returns The entity ID of the newly created cgroup.
*/
export const addCGroup = async (name: string, cluster: FaceCluster) => {
const masterKey = await masterKeyFromSession();
await addUserEntity(
const id = await addUserEntity(
"cgroup",
{
name,
@@ -716,7 +718,8 @@ export const addCGroup = async (name: string, cluster: FaceCluster) => {
},
masterKey,
);
return mlSync();
await mlSync();
return id;
};
/**

View File

@@ -110,7 +110,7 @@ export interface CGroupUserEntityData {
* transmission and storage.
*/
export type Person = (
| { type: "cgroup"; cgroup: CGroup }
| { type: "cgroup"; cgroup: CGroup; isHidden: boolean }
| { type: "cluster"; cluster: FaceCluster }
) & {
/**
@@ -252,15 +252,13 @@ export const reconstructPeopleState = async (): Promise<PeopleState> => {
const cgroups = await savedCGroups();
const cgroupPeople: Interim = cgroups.map((cgroup) => {
const { id, data } = cgroup;
const { name, isHidden, assigned } = data;
const { name, assigned } = data;
// Hidden cgroups are clusters specifically marked so as to not be shown
// in the UI.
if (isHidden) return undefined;
let isHidden = data.isHidden;
// Older versions of the mobile app marked hidden cgroups by setting
// their name to an empty string.
if (!name) return undefined;
if (!name) isHidden = true;
// Person faces from all the clusters assigned to this cgroup, sorted by
// recency (then score).
@@ -301,6 +299,7 @@ export const reconstructPeopleState = async (): Promise<PeopleState> => {
fileIDs,
displayFaceID,
displayFaceFile,
isHidden,
};
});
@@ -332,8 +331,21 @@ export const reconstructPeopleState = async (): Promise<PeopleState> => {
const people = sorted(cgroupPeople).concat(sorted(clusterPeople));
const visiblePeople = people.filter((p) => {
// Ignore local only clusters with too few visible faces.
if (p.type == "cluster" && p.cluster.faces.length < 10) return false;
switch (p.type) {
case "cgroup":
// Hidden cgroups are clusters specifically marked so as to not
// be shown in the UI. The user can still see them from within
// file info if they wish.
if (p.isHidden) return false;
break;
case "cluster":
// Ignore local only clusters with too few visible faces.
if (p.cluster.faces.length < 10) return false;
break;
}
// Show it.
return true;
});
@@ -418,7 +430,6 @@ export const _suggestionsAndChoicesForPerson = async (
const startTime = Date.now();
const personClusters = person.cgroup.data.assigned;
const personClusterIDs = new Set(personClusters.map(({ id }) => id));
const rejectedClusterIDs = new Set(
await savedRejectedClustersForCGroup(person.cgroup.id),
);
@@ -461,9 +472,6 @@ export const _suggestionsAndChoicesForPerson = async (
// Ignore singleton clusters.
if (faces.length < 2) continue;
// TODO-Cluster sanity check, remove after dev
if (personClusterIDs.has(id)) assertionFailed();
const sampledOtherEmbeddings = randomSample(faces, 50)
.map((id) => embeddingByFaceID.get(id))
.filter((e) => !!e);
@@ -676,21 +684,11 @@ export const _applyPersonSuggestionUpdates = async (
for (const [clusterID, assigned] of updates.entries()) {
switch (assigned) {
case true /* assign */:
// TODO-Cluster sanity check, remove after wrapping up dev
if (assignedClusters.find(({ id }) => id == clusterID)) {
assertionFailed();
}
assign(clusterID);
unrejectIfNeeded(clusterID);
break;
case false /* reject */:
// TODO-Cluster sanity check, remove after wrapping up dev
if (rejectedClusterIDs.includes(clusterID)) {
assertionFailed();
}
unassignIfNeeded(clusterID);
reject(clusterID);
break;

View File

@@ -173,6 +173,8 @@ const isGzipped = (type: EntityType) => type == "cgroup";
*
* @param masterKey The user's masterKey, which is is used to encrypt and
* decrypt the entity key.
*
* @returns The ID of the newly created entity.
*/
export const addUserEntity = async (
type: EntityType,

View File

@@ -150,22 +150,25 @@ export const userEntityDiff = async (
/**
* Create a new user entity with the given {@link type} on remote.
*
* @returns The ID of the newly created entity.
*/
export const postUserEntity = async (
type: EntityType,
{ encryptedData, decryptionHeader }: EncryptedBlobB64,
) =>
ensureOk(
await fetch(await apiURL("/user-entity/entity"), {
method: "POST",
headers: await authenticatedRequestHeaders(),
body: JSON.stringify({
type,
encryptedData: encryptedData,
header: decryptionHeader,
}),
) => {
const res = await fetch(await apiURL("/user-entity/entity"), {
method: "POST",
headers: await authenticatedRequestHeaders(),
body: JSON.stringify({
type,
encryptedData: encryptedData,
header: decryptionHeader,
}),
);
});
ensureOk(res);
return z.object({ id: z.string() }).parse(await res.json()).id;
};
/**
* Update an existing remote user entity with the given {@link id} and