diff --git a/auth/lib/models/code.dart b/auth/lib/models/code.dart index a5e45ac5c0..f9803cbe58 100644 --- a/auth/lib/models/code.dart +++ b/auth/lib/models/code.dart @@ -83,6 +83,7 @@ class Code { final Type updatedType = type ?? this.type; final int updatedCounter = counter ?? this.counter; final CodeDisplay updatedDisplay = display ?? this.display; + final String encodedIssuer = Uri.encodeQueryComponent(updateIssuer); return Code( updateAccount, @@ -94,7 +95,7 @@ class Code { updatedType, updatedCounter, "otpauth://${updatedType.name}/$updateIssuer:$updateAccount?algorithm=${updatedAlgo.name}" - "&digits=$updatedDigits&issuer=$updateIssuer" + "&digits=$updatedDigits&issuer=$encodedIssuer" "&period=$updatePeriod&secret=$updatedSecret${updatedType == Type.hotp ? "&counter=$updatedCounter" : ""}", generatedID: generatedID, display: updatedDisplay, @@ -109,6 +110,7 @@ class Code { CodeDisplay? display, int digits, ) { + final String encodedIssuer = Uri.encodeQueryComponent(issuer); return Code( account, issuer, @@ -118,7 +120,7 @@ class Code { Algorithm.sha1, type, 0, - "otpauth://${type.name}/$issuer:$account?algorithm=SHA1&digits=$digits&issuer=$issuer&period=30&secret=$secret", + "otpauth://${type.name}/$issuer:$account?algorithm=SHA1&digits=$digits&issuer=$encodedIssuer&period=30&secret=$secret", display: display ?? CodeDisplay(), ); } diff --git a/auth/lib/onboarding/view/setup_enter_secret_key_page.dart b/auth/lib/onboarding/view/setup_enter_secret_key_page.dart index 83d4e92bc2..5ce7ec77a3 100644 --- a/auth/lib/onboarding/view/setup_enter_secret_key_page.dart +++ b/auth/lib/onboarding/view/setup_enter_secret_key_page.dart @@ -38,7 +38,7 @@ class _SetupEnterSecretKeyPageState extends State { late TextEditingController _secretController; late TextEditingController _notesController; late bool _secretKeyObscured; - late List tags = [...?widget.code?.display.tags]; + late List selectedTags = [...?widget.code?.display.tags]; List allTags = []; StreamSubscription? _streamSubscription; @@ -272,13 +272,6 @@ class _SetupEnterSecretKeyPageState extends State { ), ], ), - const SizedBox(height: 24), - Text( - l10n.tags, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), const SizedBox(height: 10), Wrap( spacing: 12, @@ -288,14 +281,14 @@ class _SetupEnterSecretKeyPageState extends State { (e) => TagChip( label: e, action: TagChipAction.check, - state: tags.contains(e) + state: selectedTags.contains(e) ? TagChipState.selected : TagChipState.unselected, onTap: () { - if (tags.contains(e)) { - tags.remove(e); + if (selectedTags.contains(e)) { + selectedTags.remove(e); } else { - tags.add(e); + selectedTags.add(e); } setState(() {}); }, @@ -308,11 +301,12 @@ class _SetupEnterSecretKeyPageState extends State { builder: (BuildContext context) { return AddTagDialog( onTap: (tag) { - if (allTags.contains(tag) && tags.contains(tag)) { - return; + final exist = allTags.contains(tag); + if (exist && selectedTags.contains(tag)) { + return Navigator.pop(context); } - allTags.add(tag); - tags.add(tag); + if (!exist) allTags.add(tag); + selectedTags.add(tag); setState(() {}); Navigator.pop(context); }, @@ -366,7 +360,8 @@ class _SetupEnterSecretKeyPageState extends State { final isStreamCode = issuer.toLowerCase() == "steam" || issuer.toLowerCase().contains('steampowered.com'); final CodeDisplay display = - widget.code?.display.copyWith(tags: tags) ?? CodeDisplay(tags: tags); + widget.code?.display.copyWith(tags: selectedTags) ?? + CodeDisplay(tags: selectedTags); display.note = notes; if (widget.code != null && widget.code!.secret != secret) { ButtonResult? result = await showChoiceActionSheet( diff --git a/mobile/lib/ui/components/home_header_widget.dart b/mobile/lib/ui/components/home_header_widget.dart index 7f2519a190..88471e251a 100644 --- a/mobile/lib/ui/components/home_header_widget.dart +++ b/mobile/lib/ui/components/home_header_widget.dart @@ -86,8 +86,8 @@ class _HomeHeaderWidgetState extends State { unawaited( routeToPage( context, - BackupFolderSelectionPage( - buttonText: S.of(context).backup, + const BackupFolderSelectionPage( + isFirstBackup: false, ), ), ); diff --git a/mobile/lib/ui/home/loading_photos_widget.dart b/mobile/lib/ui/home/loading_photos_widget.dart index 2980fd34bb..29fe1c7b50 100644 --- a/mobile/lib/ui/home/loading_photos_widget.dart +++ b/mobile/lib/ui/home/loading_photos_widget.dart @@ -25,7 +25,7 @@ class LoadingPhotosWidget extends StatefulWidget { class _LoadingPhotosWidgetState extends State { late StreamSubscription _firstImportEvent; - late StreamSubscription _importProgressEvent; + StreamSubscription? _importProgressEvent; int _currentPage = 0; late String _loadingMessage; final PageController _pageController = PageController( @@ -38,7 +38,6 @@ class _LoadingPhotosWidgetState extends State { @override void initState() { super.initState(); - _loadingMessage = S.of(context).loadingYourPhotos; Future.delayed(const Duration(seconds: 60), () { oneMinuteOnScreen.value = true; }); @@ -51,21 +50,15 @@ class _LoadingPhotosWidgetState extends State { // ignore: unawaited_futures routeToPage( context, - BackupFolderSelectionPage( + const BackupFolderSelectionPage( isOnboarding: true, - buttonText: S.of(context).startBackup, + isFirstBackup: true, ), ); } } }); - _importProgressEvent = - Bus.instance.on().listen((event) { - _loadingMessage = S.of(context).processingImport(event.folderName); - if (mounted) { - setState(() {}); - } - }); + _didYouKnowTimer = Timer.periodic(const Duration(seconds: 5), (Timer timer) { if (!mounted) { @@ -85,10 +78,26 @@ class _LoadingPhotosWidgetState extends State { }); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_importProgressEvent != null) { + _importProgressEvent!.cancel(); + } else { + _importProgressEvent = + Bus.instance.on().listen((event) { + _loadingMessage = S.of(context).processingImport(event.folderName); + if (mounted) { + setState(() {}); + } + }); + } + } + @override void dispose() { _firstImportEvent.cancel(); - _importProgressEvent.cancel(); + _importProgressEvent?.cancel(); _didYouKnowTimer.cancel(); oneMinuteOnScreen.dispose(); super.dispose(); @@ -96,6 +105,9 @@ class _LoadingPhotosWidgetState extends State { @override Widget build(BuildContext context) { + if (_importProgressEvent == null) { + _loadingMessage = S.of(context).loadingYourPhotos; + } _setupLoadingMessages(context); final isLightMode = Theme.of(context).brightness == Brightness.light; return Scaffold( diff --git a/mobile/lib/ui/home/start_backup_hook_widget.dart b/mobile/lib/ui/home/start_backup_hook_widget.dart index f331cd586b..64dfa1fa21 100644 --- a/mobile/lib/ui/home/start_backup_hook_widget.dart +++ b/mobile/lib/ui/home/start_backup_hook_widget.dart @@ -49,8 +49,8 @@ class StartBackupHookWidget extends StatelessWidget { // ignore: unawaited_futures routeToPage( context, - BackupFolderSelectionPage( - buttonText: S.of(context).startBackup, + const BackupFolderSelectionPage( + isFirstBackup: true, ), ); } diff --git a/mobile/lib/ui/settings/backup/backup_folder_selection_page.dart b/mobile/lib/ui/settings/backup/backup_folder_selection_page.dart index 783a509c12..d657c60950 100644 --- a/mobile/lib/ui/settings/backup/backup_folder_selection_page.dart +++ b/mobile/lib/ui/settings/backup/backup_folder_selection_page.dart @@ -19,14 +19,14 @@ import 'package:photos/ui/viewer/file/thumbnail_widget.dart'; import 'package:photos/utils/dialog_util.dart'; class BackupFolderSelectionPage extends StatefulWidget { + final bool isFirstBackup; final bool isOnboarding; - final String buttonText; const BackupFolderSelectionPage({ - required this.buttonText, + required this.isFirstBackup, this.isOnboarding = false, - Key? key, - }) : super(key: key); + super.key, + }); @override State createState() => @@ -173,7 +173,11 @@ class _BackupFolderSelectionPageState extends State { : () async { await updateFolderSettings(); }, - child: Text(widget.buttonText), + child: Text( + widget.isFirstBackup + ? S.of(context).startBackup + : S.of(context).backup, + ), ), ), widget.isOnboarding diff --git a/mobile/lib/ui/settings/backup/backup_section_widget.dart b/mobile/lib/ui/settings/backup/backup_section_widget.dart index 15d885389c..904f899d57 100644 --- a/mobile/lib/ui/settings/backup/backup_section_widget.dart +++ b/mobile/lib/ui/settings/backup/backup_section_widget.dart @@ -42,8 +42,8 @@ class BackupSectionWidgetState extends State { onTap: () async { await routeToPage( context, - BackupFolderSelectionPage( - buttonText: S.of(context).backup, + const BackupFolderSelectionPage( + isFirstBackup: false, ), ); }, diff --git a/mobile/lib/ui/viewer/search/tab_empty_state.dart b/mobile/lib/ui/viewer/search/tab_empty_state.dart index ab072dc6f8..d8ab31c240 100644 --- a/mobile/lib/ui/viewer/search/tab_empty_state.dart +++ b/mobile/lib/ui/viewer/search/tab_empty_state.dart @@ -38,8 +38,8 @@ class SearchTabEmptyState extends StatelessWidget { // ignore: unawaited_futures routeToPage( context, - BackupFolderSelectionPage( - buttonText: S.of(context).backup, + const BackupFolderSelectionPage( + isFirstBackup: false, ), ); }, diff --git a/web/apps/photos/src/components/Collections/CollectionCard.tsx b/web/apps/photos/src/components/Collections/CollectionCard.tsx index 7d757561ba..44f6fac84a 100644 --- a/web/apps/photos/src/components/Collections/CollectionCard.tsx +++ b/web/apps/photos/src/components/Collections/CollectionCard.tsx @@ -1,11 +1,12 @@ -import downloadManager from "@/new/photos/services/download"; -import { EnteFile } from "@/new/photos/types/file"; import { LoadingThumbnail, StaticThumbnail, -} from "components/PlaceholderThumbnails"; +} from "@/new/photos/components/PlaceholderThumbnails"; +import downloadManager from "@/new/photos/services/download"; +import { EnteFile } from "@/new/photos/types/file"; import { useEffect, useState } from "react"; +/** See also: {@link ItemCard}. */ export default function CollectionCard(props: { children?: any; coverFile: EnteFile; diff --git a/web/apps/photos/src/components/Collections/styledComponents.ts b/web/apps/photos/src/components/Collections/styledComponents.ts index d7bfcbde94..91dfdc9c58 100644 --- a/web/apps/photos/src/components/Collections/styledComponents.ts +++ b/web/apps/photos/src/components/Collections/styledComponents.ts @@ -35,6 +35,7 @@ export const ScrollContainer = styled("div")` gap: 4px; `; +/** See also: {@link ItemTile}. */ export const CollectionTile = styled("div")` display: flex; position: relative; @@ -67,11 +68,6 @@ export const AllCollectionTile = styled(CollectionTile)` height: 150px; `; -export const ResultPreviewTile = styled(CollectionTile)` - width: 48px; - height: 48px; -`; - export const CollectionBarTileText = styled(Overlay)` padding: 4px; background: linear-gradient( diff --git a/web/apps/photos/src/components/ExportPendingList.tsx b/web/apps/photos/src/components/ExportPendingList.tsx index 88abf66b22..7b1267903a 100644 --- a/web/apps/photos/src/components/ExportPendingList.tsx +++ b/web/apps/photos/src/components/ExportPendingList.tsx @@ -1,3 +1,4 @@ +import { ResultPreviewTile } from "@/new/photos/components/ItemCards"; import { EnteFile } from "@/new/photos/types/file"; import { FlexWrapper } from "@ente/shared/components/Container"; import DialogBoxV2 from "@ente/shared/components/DialogBoxV2"; @@ -5,7 +6,6 @@ import { Box, styled } from "@mui/material"; import ItemList from "components/ItemList"; import { t } from "i18next"; import CollectionCard from "./Collections/CollectionCard"; -import { ResultPreviewTile } from "./Collections/styledComponents"; interface Iprops { isOpen: boolean; diff --git a/web/apps/photos/src/components/pages/gallery/PreviewCard.tsx b/web/apps/photos/src/components/pages/gallery/PreviewCard.tsx index deb212ef77..718fb628d1 100644 --- a/web/apps/photos/src/components/pages/gallery/PreviewCard.tsx +++ b/web/apps/photos/src/components/pages/gallery/PreviewCard.tsx @@ -1,5 +1,9 @@ import log from "@/base/log"; import { FileType } from "@/media/file-type"; +import { + LoadingThumbnail, + StaticThumbnail, +} from "@/new/photos/components/PlaceholderThumbnails"; import DownloadManager from "@/new/photos/services/download"; import { EnteFile } from "@/new/photos/types/file"; import { Overlay } from "@ente/shared/components/Container"; @@ -12,10 +16,6 @@ import { GAP_BTW_TILES, IMAGE_CONTAINER_MAX_WIDTH, } from "components/PhotoList/constants"; -import { - LoadingThumbnail, - StaticThumbnail, -} from "components/PlaceholderThumbnails"; import i18n from "i18next"; import { DeduplicateContext } from "pages/deduplicate"; import { GalleryContext } from "pages/gallery"; diff --git a/web/apps/photos/src/pages/gallery.tsx b/web/apps/photos/src/pages/gallery.tsx index 6e2dd613bd..fafaedbb87 100644 --- a/web/apps/photos/src/pages/gallery.tsx +++ b/web/apps/photos/src/pages/gallery.tsx @@ -3,6 +3,10 @@ import { NavbarBase } from "@/base/components/Navbar"; import { useIsMobileWidth } from "@/base/hooks"; import log from "@/base/log"; import type { Collection } from "@/media/collection"; +import { + SearchBar, + type SearchBarProps, +} from "@/new/photos/components/SearchBar"; import { WhatsNew } from "@/new/photos/components/WhatsNew"; import { shouldShowWhatsNew } from "@/new/photos/services/changelog"; import downloadManager from "@/new/photos/services/download"; @@ -15,10 +19,7 @@ import { filterSearchableFiles, setSearchableData, } from "@/new/photos/services/search"; -import { - SearchQuery, - SearchResultSummary, -} from "@/new/photos/services/search/types"; +import type { SearchOption } from "@/new/photos/services/search/types"; import { EnteFile } from "@/new/photos/types/file"; import { mergeMetadata } from "@/new/photos/utils/file"; import { @@ -74,7 +75,6 @@ import GalleryEmptyState from "components/GalleryEmptyState"; import { LoadingOverlay } from "components/LoadingOverlay"; import PhotoFrame from "components/PhotoFrame"; import { ITEM_TYPE, TimeStampListItem } from "components/PhotoList"; -import { SearchBar, type UpdateSearch } from "components/SearchBar"; import Sidebar from "components/Sidebar"; import { type UploadTypeSelectorIntent } from "components/Upload/UploadTypeSelector"; import Uploader from "components/Upload/Uploader"; @@ -206,12 +206,8 @@ export default function Gallery() { const [collectionNamerAttributes, setCollectionNamerAttributes] = useState(null); const [collectionNamerView, setCollectionNamerView] = useState(false); - const [searchQuery, setSearchQuery] = useState(null); const [shouldDisableDropzone, setShouldDisableDropzone] = useState(false); const [isPhotoSwipeOpen, setIsPhotoSwipeOpen] = useState(false); - // TODO(MR): This is never true currently, this is the WIP ability to show - // what's new dialog on desktop app updates. The UI is done, need to hook - // this up to logic to trigger it. const [openWhatsNew, setOpenWhatsNew] = useState(false); const { @@ -249,9 +245,12 @@ export default function Gallery() { accept: ".zip", }); + // If we're in "search mode". See: [Note: "search mode"]. const [isInSearchMode, setIsInSearchMode] = useState(false); - const [searchResultSummary, setSetSearchResultSummary] = - useState(null); + // The option selected by the user selected from the search bar dropdown. + const [selectedSearchOption, setSelectedSearchOption] = useState< + SearchOption | undefined + >(); const syncInProgress = useRef(true); const syncInterval = useRef(); const resync = useRef<{ force: boolean; silent: boolean }>(); @@ -492,18 +491,18 @@ export default function Gallery() { }, [router.isReady]); useEffect(() => { - if (isInSearchMode && searchResultSummary) { + if (isInSearchMode && selectedSearchOption) { setPhotoListHeader({ height: 104, item: ( - ), itemType: ITEM_TYPE.HEADER, }); } - }, [isInSearchMode, searchResultSummary]); + }, [isInSearchMode, selectedSearchOption]); const activeCollection = useMemo(() => { if (!collections || !hiddenCollections) { @@ -527,7 +526,7 @@ export default function Gallery() { return; } - if (activeCollectionID === TRASH_SECTION && !isInSearchMode) { + if (activeCollectionID === TRASH_SECTION && !selectedSearchOption) { return getUniqueFiles([ ...trashedFiles, ...files.filter((file) => tempDeletedFileIds?.has(file.id)), @@ -535,8 +534,10 @@ export default function Gallery() { } let filteredFiles: EnteFile[] = []; - if (isInSearchMode) { - filteredFiles = await filterSearchableFiles(searchQuery); + if (selectedSearchOption) { + filteredFiles = await filterSearchableFiles( + selectedSearchOption.suggestion, + ); } else { filteredFiles = getUniqueFiles( (isInHiddenSection ? hiddenFiles : files).filter((item) => { @@ -596,11 +597,6 @@ export default function Gallery() { }), ); } - if (searchQuery?.clip) { - return filteredFiles.sort((a, b) => { - return searchQuery.clip.get(b.id) - searchQuery.clip.get(a.id); - }); - } const sortAsc = activeCollection?.pubMagicMetadata?.data?.asc ?? false; if (sortAsc) { return sortFiles(filteredFiles, true); @@ -614,7 +610,7 @@ export default function Gallery() { tempDeletedFileIds, tempHiddenFileIds, hiddenFileIds, - searchQuery, + selectedSearchOption, activeCollectionID, archivedCollections, ]); @@ -980,16 +976,18 @@ export default function Gallery() { }); }; - const updateSearch: UpdateSearch = (newSearch, summary) => { - if (newSearch?.collection) { - setActiveCollectionID(newSearch?.collection); + const handleSelectSearchOption = ( + searchOption: SearchOption | undefined, + ) => { + if (searchOption?.suggestion.type == "collection") { setIsInSearchMode(false); + setSelectedSearchOption(undefined); + setActiveCollectionID(searchOption.suggestion.collectionID); } else { - setSearchQuery(newSearch); - setIsInSearchMode(!!newSearch); - setSetSearchResultSummary(summary); + setIsInSearchMode(!!searchOption); + setSelectedSearchOption(searchOption); } - setIsClipSearchResult(!!newSearch?.clip); + setIsClipSearchResult(searchOption?.suggestion.type == "clip"); }; const openUploader = (intent?: UploadTypeSelectorIntent) => { @@ -1098,7 +1096,7 @@ export default function Gallery() { openUploader={openUploader} isInSearchMode={isInSearchMode} setIsInSearchMode={setIsInSearchMode} - updateSearch={updateSearch} + onSelectSearchOption={handleSelectSearchOption} /> )} @@ -1271,29 +1269,20 @@ const mergeMaps = (map1: Map, map2: Map) => { return mergedMap; }; -interface NormalNavbarContentsProps { +type NormalNavbarContentsProps = SearchBarProps & { openSidebar: () => void; openUploader: () => void; - isInSearchMode: boolean; - setIsInSearchMode: (v: boolean) => void; - updateSearch: UpdateSearch; -} +}; const NormalNavbarContents: React.FC = ({ openSidebar, openUploader, - isInSearchMode, - setIsInSearchMode, - updateSearch, + ...props }) => ( <> - {!isInSearchMode && } - - {!isInSearchMode && } + {!props.isInSearchMode && } + + {!props.isInSearchMode && } ); @@ -1352,25 +1341,20 @@ const HiddenSectionNavbarContents: React.FC< ); -interface SearchResultSummaryHeaderProps { - searchResultSummary: SearchResultSummary; +interface SearchResultsHeaderProps { + selectedOption: SearchOption; } -const SearchResultSummaryHeader: React.FC = ({ - searchResultSummary, -}) => { - if (!searchResultSummary) { - return <>; - } - - const { optionName, fileCount } = searchResultSummary; - - return ( - - - {t("search_results")} - - - - ); -}; +const SearchResultsHeader: React.FC = ({ + selectedOption, +}) => ( + + + {t("search_results")} + + + +); diff --git a/web/apps/photos/src/types/image/index.ts b/web/apps/photos/src/types/image/index.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/web/apps/photos/src/types/search/index.ts b/web/apps/photos/src/types/search/index.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/web/packages/new/photos/components/ItemCards.tsx b/web/packages/new/photos/components/ItemCards.tsx new file mode 100644 index 0000000000..4487351ac2 --- /dev/null +++ b/web/packages/new/photos/components/ItemCards.tsx @@ -0,0 +1,69 @@ +import { + LoadingThumbnail, + StaticThumbnail, +} from "@/new/photos/components/PlaceholderThumbnails"; +import downloadManager from "@/new/photos/services/download"; +import { type EnteFile } from "@/new/photos/types/file"; +import { styled } from "@mui/material"; +import React, { useEffect, useState } from "react"; + +interface ItemCardProps { + /** The file whose thumbnail (if any) should be should be shown. */ + coverFile: EnteFile; + /** One of the *Tile components to use as the top level element. */ + TileComponent: React.FC; +} + +/** + * A simplified variant of {@link CollectionCard}, meant to be used for + * representing either collections and files. + */ +export const ItemCard: React.FC = ({ + coverFile, + TileComponent, +}) => { + const [coverImageURL, setCoverImageURL] = useState(""); + + useEffect(() => { + const main = async () => { + const url = await downloadManager.getThumbnailForPreview(coverFile); + if (url) setCoverImageURL(url); + }; + void main(); + }, [coverFile]); + + return ( + + {coverFile.metadata.hasStaticThumbnail ? ( + + ) : coverImageURL ? ( + + ) : ( + + )} + + ); +}; + +/** + * A verbatim copy of CollectionTile, meant to be used with ItemCards. + */ +export const ItemTile = styled("div")` + display: flex; + position: relative; + border-radius: 4px; + overflow: hidden; + cursor: pointer; + & > img { + object-fit: cover; + width: 100%; + height: 100%; + pointer-events: none; + } + user-select: none; +`; + +export const ResultPreviewTile = styled(ItemTile)` + width: 48px; + height: 48px; +`; diff --git a/web/packages/new/photos/components/PeopleList.tsx b/web/packages/new/photos/components/PeopleList.tsx index 7c42d157dd..6de6dadd1b 100644 --- a/web/packages/new/photos/components/PeopleList.tsx +++ b/web/packages/new/photos/components/PeopleList.tsx @@ -1,14 +1,14 @@ +import type { Person } from "@/new/photos/services/ml"; import { faceCrop, unidentifiedFaceIDs } from "@/new/photos/services/ml"; import type { EnteFile } from "@/new/photos/types/file"; import { Skeleton, Typography, styled } from "@mui/material"; import { t } from "i18next"; import React, { useEffect, useState } from "react"; -import type { SearchPerson } from "../services/search/types"; export interface PeopleListProps { - people: SearchPerson[]; + people: Person[]; maxRows: number; - onSelect?: (person: SearchPerson, index: number) => void; + onSelect?: (person: Person, index: number) => void; } export const PeopleList: React.FC = ({ @@ -60,7 +60,7 @@ const FaceChip = styled("div")<{ clickable?: boolean }>` export interface PhotoPeopleListProps { file: EnteFile; - onSelect?: (person: SearchPerson, index: number) => void; + onSelect?: (person: Person, index: number) => void; } export function PhotoPeopleList() { diff --git a/web/apps/photos/src/components/PlaceholderThumbnails.tsx b/web/packages/new/photos/components/PlaceholderThumbnails.tsx similarity index 93% rename from web/apps/photos/src/components/PlaceholderThumbnails.tsx rename to web/packages/new/photos/components/PlaceholderThumbnails.tsx index 3ea247a15d..0fcab06569 100644 --- a/web/apps/photos/src/components/PlaceholderThumbnails.tsx +++ b/web/packages/new/photos/components/PlaceholderThumbnails.tsx @@ -3,6 +3,7 @@ import { Overlay } from "@ente/shared/components/Container"; import PhotoOutlined from "@mui/icons-material/PhotoOutlined"; import PlayCircleOutlineOutlined from "@mui/icons-material/PlayCircleOutlineOutlined"; import { styled } from "@mui/material"; +import React from "react"; interface Iprops { fileType: FileType; @@ -14,7 +15,7 @@ const CenteredOverlay = styled(Overlay)` align-items: center; `; -export const StaticThumbnail = (props: Iprops) => { +export const StaticThumbnail: React.FC = (props) => { return ( ({ diff --git a/web/apps/photos/src/components/SearchBar.tsx b/web/packages/new/photos/components/SearchBar.tsx similarity index 65% rename from web/apps/photos/src/components/SearchBar.tsx rename to web/packages/new/photos/components/SearchBar.tsx index 37149b00a2..276dff408b 100644 --- a/web/apps/photos/src/components/SearchBar.tsx +++ b/web/packages/new/photos/components/SearchBar.tsx @@ -1,33 +1,16 @@ import { assertionFailed } from "@/base/assert"; import { useIsMobileWidth } from "@/base/hooks"; -import { FileType } from "@/media/file-type"; +import { ItemCard, ResultPreviewTile } from "@/new/photos/components/ItemCards"; import { isMLSupported, mlStatusSnapshot, mlStatusSubscribe, } from "@/new/photos/services/ml"; -import { getAutoCompleteSuggestions } from "@/new/photos/services/search"; -import type { - City, - SearchDateComponents, - SearchPerson, - SearchResultSummary, -} from "@/new/photos/services/search/types"; -import { - ClipSearchScores, - SearchOption, - SearchQuery, - SuggestionType, -} from "@/new/photos/services/search/types"; -import { labelForSuggestionType } from "@/new/photos/services/search/ui"; -import type { LocationTag } from "@/new/photos/services/user-entity"; -import { - FreeFlowText, - SpaceBetweenFlex, -} from "@ente/shared/components/Container"; +import { searchOptionsForString } from "@/new/photos/services/search"; +import type { SearchOption } from "@/new/photos/services/search/types"; +import { nullToUndefined } from "@/utils/transform"; import CalendarIcon from "@mui/icons-material/CalendarMonth"; import CloseIcon from "@mui/icons-material/Close"; -import FolderIcon from "@mui/icons-material/Folder"; import ImageIcon from "@mui/icons-material/Image"; import LocationIcon from "@mui/icons-material/LocationOn"; import SearchIcon from "@mui/icons-material/Search"; @@ -41,29 +24,21 @@ import { useTheme, type Theme, } from "@mui/material"; -import CollectionCard from "components/Collections/CollectionCard"; -import { ResultPreviewTile } from "components/Collections/styledComponents"; import { t } from "i18next"; import pDebounce from "p-debounce"; -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - useSyncExternalStore, -} from "react"; +import React, { useMemo, useRef, useState, useSyncExternalStore } from "react"; import { components as SelectComponents, type ControlProps, type InputActionMeta, type InputProps, type OptionProps, + type SelectInstance, type StylesConfig, } from "react-select"; import AsyncSelect from "react-select/async"; -interface SearchBarProps { +export interface SearchBarProps { /** * [Note: "Search mode"] * @@ -76,22 +51,20 @@ interface SearchBarProps { * * When we're in search mode, * - * 1. Other icons from the navbar are hidden + * 1. Other icons from the navbar are hidden. * 2. Next to the search input there is a cancel button to exit search mode. */ isInSearchMode: boolean; /** * Enter or exit "search mode". */ - setIsInSearchMode: (v: boolean) => void; - updateSearch: UpdateSearch; + setIsInSearchMode: (b: boolean) => void; + /** + * Set or clear the selected {@link SearchOption}. + */ + onSelectSearchOption: (o: SearchOption | undefined) => void; } -export type UpdateSearch = ( - search: SearchQuery, - summary: SearchResultSummary, -) => void; - /** * The search bar is a styled "select" element that allow the user to type in * the attached input field, and shows a list of matching suggestions in a @@ -110,7 +83,7 @@ export type UpdateSearch = ( export const SearchBar: React.FC = ({ setIsInSearchMode, isInSearchMode, - ...props + onSelectSearchOption, }) => { const isMobileWidth = useIsMobileWidth(); @@ -121,7 +94,7 @@ export const SearchBar: React.FC = ({ {isMobileWidth && !isInSearchMode ? ( ) : ( - + )} ); @@ -140,112 +113,77 @@ const MobileSearchArea: React.FC = ({ onSearch }) => ( ); -interface SearchInputProps { - isInSearchMode: boolean; - updateSearch: UpdateSearch; -} - -const SearchInput: React.FC = ({ +const SearchInput: React.FC> = ({ isInSearchMode, - updateSearch, + onSelectSearchOption, }) => { // A ref to the top level Select. - const selectRef = useRef(null); + const selectRef = useRef | null>(null); // The currently selected option. - const [value, setValue] = useState(); + // + // We need to use `null` instead of `undefined` to indicate missing values, + // because using `undefined` instead moves the Select from being a controlled + // component to an uncontrolled component. + const [value, setValue] = useState(null); // The contents of the input field associated with the select. const [inputValue, setInputValue] = useState(""); const theme = useTheme(); - const styles = useMemo(() => useSelectStyles(theme), [theme]); + const styles = useMemo(() => createSelectStyles(theme), [theme]); + const components = useMemo(() => ({ Control, Input, Option }), []); - useEffect(() => { - search(value); - }, [value]); + const handleChange = (value: SearchOption | null) => { + // Collection suggestions are handled differently - our caller will + // switch to the collection view, dismissing search. + if (value?.suggestion.type == "collection") { + setValue(null); + setInputValue(""); + } else { + setValue(value); + setInputValue(value?.suggestion.label ?? ""); + } + + // Let our parent know the selection was changed. + onSelectSearchOption(nullToUndefined(value)); - const handleChange = (value: SearchOption) => { - setValue(value); - setInputValue(value?.label); // The Select has a blurInputOnSelect prop, but that makes the input // field lose focus, not the entire menu (e.g. when pressing twice). // // We anyways need the ref so that we can blur on selecting a person - // from the default options. + // from the default options. So also use it to blur the entire Select + // (including the menu) when the user selects an option. selectRef.current?.blur(); }; const handleInputChange = (value: string, actionMeta: InputActionMeta) => { - if (actionMeta.action === "input-change") { - setInputValue(value); - } + if (actionMeta.action == "input-change") setInputValue(value); }; const resetSearch = () => { - updateSearch(null, null); setValue(null); setInputValue(""); - }; - - const getOptions = useCallback( - pDebounce(getAutoCompleteSuggestions(), 250), - [], - ); - - const search = (selectedOption: SearchOption) => { - if (!selectedOption) { - return; - } - let search: SearchQuery; - switch (selectedOption.type) { - case SuggestionType.DATE: - search = { - date: selectedOption.value as SearchDateComponents, - }; - break; - case SuggestionType.LOCATION: - search = { - location: selectedOption.value as LocationTag, - }; - break; - case SuggestionType.CITY: - search = { - city: selectedOption.value as City, - }; - break; - case SuggestionType.COLLECTION: - search = { collection: selectedOption.value as number }; - setValue(null); - setInputValue(""); - break; - case SuggestionType.FILE_NAME: - search = { files: selectedOption.value as number[] }; - break; - case SuggestionType.FILE_CAPTION: - search = { files: selectedOption.value as number[] }; - break; - case SuggestionType.PERSON: - search = { person: selectedOption.value as SearchPerson }; - break; - case SuggestionType.FILE_TYPE: - search = { fileType: selectedOption.value as FileType }; - break; - case SuggestionType.CLIP: - search = { clip: selectedOption.value as ClipSearchScores }; - } - updateSearch(search, { - optionName: selectedOption.label, - fileCount: selectedOption.fileCount, - }); + onSelectSearchOption(undefined); }; const handleSelectCGroup = (value: SearchOption) => { // Dismiss the search menu. selectRef.current?.blur(); setValue(value); + onSelectSearchOption(undefined); }; - const components = useMemo(() => ({ Option, Control, Input }), []); + const handleFocus = () => { + // A workaround to show the suggestions again for the current non-empty + // search string if the user focuses back on the input field after + // moving focus elsewhere. + if (inputValue) { + selectRef.current?.onInputChange(inputValue, { + action: "set-value", + prevInputValue: "", + }); + } + }; return ( @@ -254,14 +192,14 @@ const SearchInput: React.FC = ({ value={value} components={components} styles={styles} - placeholder={t("search_hint")} - loadOptions={getOptions} + loadOptions={loadOptions} onChange={handleChange} - isMulti={false} - isClearable - escapeClearsValue inputValue={inputValue} onInputChange={handleInputChange} + isClearable + escapeClearsValue + onFocus={handleFocus} + placeholder={t("search_hint")} noOptionsMessage={({ inputValue }) => shouldShowEmptyState(inputValue) ? ( @@ -270,7 +208,7 @@ const SearchInput: React.FC = ({ /> {isInSearchMode && ( - resetSearch()} sx={{ ml: 1 }}> + )} @@ -283,12 +221,15 @@ const SearchInputWrapper = styled(Box)` width: 100%; align-items: center; justify-content: center; + gap: 8px; background: ${({ theme }) => theme.colors.background.base}; max-width: 484px; margin: auto; `; -const useSelectStyles = ({ +const loadOptions = pDebounce(searchOptionsForString, 250); + +const createSelectStyles = ({ colors, }: Theme): StylesConfig => ({ container: (style) => ({ ...style, flex: 1 }), @@ -316,9 +257,9 @@ const useSelectStyles = ({ "& :hover": { cursor: "pointer", }, - "& .main": { - backgroundColor: isFocused && colors.background.elevated2, - }, + "& .option-contents": isFocused + ? { backgroundColor: colors.background.elevated2 } + : {}, "&:last-child .MuiDivider-root": { display: "none", }, @@ -353,29 +294,37 @@ const Control = ({ children, ...props }: ControlProps) => ( color: (theme) => theme.colors.stroke.muted, }} > - {iconForOptionType(props.getValue()[0]?.type)} + {iconForOption(props.getValue()[0])} {children} ); -const iconForOptionType = (type: SuggestionType | undefined) => { - switch (type) { - case SuggestionType.DATE: - return ; - case SuggestionType.LOCATION: - case SuggestionType.CITY: - return ; - case SuggestionType.COLLECTION: - return ; - case SuggestionType.FILE_NAME: +const iconForOption = (option: SearchOption | undefined) => { + switch (option?.suggestion.type) { + case "fileName": return ; + case "date": + return ; + case "location": + case "city": + return ; default: return ; } }; +/** + * A custom input for react-select that is always visible. + * + * This is a workaround to allow the search string to be always displayed, and + * editable, even after the user has moved focus away from it. + */ +const Input: React.FC> = (props) => ( + +); + /** * A preflight check for whether or not we should show the EmptyState. * @@ -391,7 +340,7 @@ const shouldShowEmptyState = (inputValue: string) => { // Don't show empty state if there is no ML related information. if (!isMLSupported) return false; - const status = isMLSupported && mlStatusSnapshot(); + const status = mlStatusSnapshot(); if (!status || status.phase == "disabled") return false; // Show it otherwise. @@ -434,6 +383,9 @@ const EmptyState: React.FC = () => { break; } + // TODO-Cluster this is where it'll go. + // const people = wipPersons(); + return ( @@ -447,7 +399,7 @@ const EmptyState: React.FC = () => { // const peopleSuggestions = options.filter( // (o) => o.type === SuggestionType.PERSON, // ); - // const people = peopleSuggestions.map((o) => o.value as SearchPerson); + // const people = peopleSuggestions.map((o) => o.value as Person); // return ( // // @@ -508,7 +460,7 @@ export async function getAllPeopleSuggestion(): Promise> { } async function getAllPeople(limit: number = undefined) { - return (await wipSearchPersons()).slice(0, limit); + return (await wipPersons()).slice(0, limit); // TODO-Clustetr // if (done) return []; @@ -519,7 +471,7 @@ async function getAllPeople(limit: number = undefined) { // log.debug(() => ["people", { people }]); // } - // let people: Array = []; // await mlIDbStorage.getAllPeople(); + // let people: Array = []; // await mlIDbStorage.getAllPeople(); // people = await wipCluster(); // // await mlPeopleStore.iterate((person) => { // // people.push(person); @@ -532,54 +484,73 @@ async function getAllPeople(limit: number = undefined) { // return result; } - */ const Option: React.FC> = (props) => ( - + + ); -const LabelWithInfo = ({ data }: { data: SearchOption }) => { - return ( - <> - - - {labelForSuggestionType(data.type)} +const OptionContents = ({ data: option }: { data: SearchOption }) => ( + + {labelForOption(option)} + + + + {option.suggestion.label} + + + {t("photos_count", { count: option.fileCount })} - - - - - {data.label} - - - - {t("photos_count", { count: data.fileCount })} - - - - - {data.previewFiles.map((file) => ( - null} - collectionTile={ResultPreviewTile} - /> - ))} - - - - - ); -}; -// A custom input for react-select that is always visible. This is a roundabout -// hack the existing code used to display the search string when showing the -// results page; likely there should be a better way. -const Input: React.FC> = (props) => ( - + + {option.previewFiles.map((file) => ( + + ))} + + + ); + +const labelForOption = (option: SearchOption) => { + switch (option.suggestion.type) { + case "collection": + return t("album"); + + case "fileType": + return t("file_type"); + + case "fileName": + return t("file_name"); + + case "fileCaption": + return t("description"); + + case "date": + return t("date"); + case "location": + return t("location"); + + case "city": + return t("location"); + + case "clip": + return t("magic"); + + case "person": + return t("person"); + } +}; diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index f230460f78..a36bdc02a1 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -17,7 +17,6 @@ import { throttled } from "@/utils/promise"; import { proxy, transfer } from "comlink"; import { isInternalUser } from "../feature-flags"; import { getRemoteFlag, updateRemoteFlag } from "../remote-store"; -import type { SearchPerson } from "../search/types"; import type { UploadItem } from "../upload/types"; import { type ClusterFace, @@ -59,19 +58,31 @@ class MLState { comlinkWorker: Promise> | undefined; /** - * Subscriptions to {@link MLStatus}. + * Subscriptions to {@link MLStatus} updates. * * See {@link mlStatusSubscribe}. */ mlStatusListeners: (() => void)[] = []; /** - * Snapshot of {@link MLStatus}. - * - * See {@link mlStatusSnapshot}. + * Snapshot of the {@link MLStatus} returned by the {@link mlStatusSnapshot} + * function. */ mlStatusSnapshot: MLStatus | undefined; + /** + * Subscriptions to updates to the list of {@link Person}s we know about. + * + * See {@link peopleSubscribe}. + */ + peopleListeners: (() => void)[] = []; + + /** + * Snapshot of the {@link Person}s returned by the {@link peopleSnapshot} + * function. + */ + peopleSnapshot: Person[] | undefined; + /** * In flight face crop regeneration promises indexed by the IDs of the files * whose faces we are regenerating. @@ -329,7 +340,7 @@ export const wipClusterEnable = async (): Promise => // // TODO-Cluster temporary state here let _wip_isClustering = false; -let _wip_searchPersons: SearchPerson[] | undefined; +let _wip_people: Person[] | undefined; let _wip_hasSwitchedOnce = false; export const wipHasSwitchedOnceCmpAndSet = () => { @@ -338,11 +349,6 @@ export const wipHasSwitchedOnceCmpAndSet = () => { return false; }; -export const wipSearchPersons = async () => { - if (!(await wipClusterEnable())) return []; - return _wip_searchPersons ?? []; -}; - export interface ClusterPreviewWithFile { clusterSize: number; faces: ClusterPreviewFaceWithFile[]; @@ -374,7 +380,7 @@ export const wipClusterDebugPageContents = async ( log.info("clustering", opts); _wip_isClustering = true; - _wip_searchPersons = undefined; + _wip_people = undefined; triggerStatusUpdate(); const { @@ -407,7 +413,7 @@ export const wipClusterDebugPageContents = async ( const clusterByID = new Map(clusters.map((c) => [c.id, c])); - const searchPersons = cgroups + const people = cgroups .map((cgroup) => { const faceID = ensure(cgroup.displayFaceID); const fileID = ensure(fileIDFromFaceID(faceID)); @@ -432,7 +438,7 @@ export const wipClusterDebugPageContents = async ( .sort((a, b) => b.faceIDs.length - a.faceIDs.length); _wip_isClustering = false; - _wip_searchPersons = searchPersons; + _wip_people = people; triggerStatusUpdate(); return { @@ -514,7 +520,7 @@ export const mlStatusSnapshot = (): MLStatus | undefined => { */ const triggerStatusUpdate = () => void updateMLStatusSnapshot(); -/** Unconditionally update of the {@link MLStatus} snapshot. */ +/** Unconditional update of the {@link MLStatus} snapshot. */ const updateMLStatusSnapshot = async () => setMLStatusSnapshot(await getMLStatus()); @@ -581,13 +587,111 @@ const setInterimScheduledStatus = () => { const workerDidProcessFileOrIdle = throttled(updateMLStatusSnapshot, 2000); +/** + * A massaged version of {@link CGroup} suitable for being shown in the UI. + * + * While cgroups are synced with remote, they do not directly correspond to + * "people" (this is ignoring the other issue that the cluster groups may be for + * non-human faces too). CGroups represent both positive and negative feedback, + * and the negations are specifically meant so that they're not shown in the UI. + * + * So while each person has an underlying cgroups, not all cgroups have a + * corresponding person. + * + * Beyond this, a {@link Person} object has data converted into a format that + * the UI can use directly and efficiently (as compared to a {@link CGroup}, + * which is tailored for transmission and storage). + */ +export interface Person { + /** Unique ID (nanoid) of the underlying {@link CGroup}. */ + id: string; + /** If this is a named person, then their name. */ + name?: string; + /** The files in which this face occurs. */ + files: number[]; + /** + * The face that should be used as the "cover" face to represent this + * {@link Person} in the UI. + */ + displayFaceID: string; + /** + * The {@link EnteFile} which contains the display face. + */ + displayFaceFile: EnteFile; +} + +/** + * A function that can be used to subscribe to updates to {@link Person}s. + * + * This, along with {@link peopleSnapshot}, is meant to be used as arguments to + * React's {@link useSyncExternalStore}. + * + * @param callback A function that will be invoked whenever the result of + * {@link peopleSnapshot} changes. + * + * @returns A function that can be used to clear the subscription. + */ +export const peopleSubscribe = (onChange: () => void): (() => void) => { + _state.peopleListeners.push(onChange); + return () => { + _state.peopleListeners = _state.peopleListeners.filter( + (l) => l != onChange, + ); + }; +}; + +/** + * Return the last known, cached {@link people}. + * + * This, along with {@link peopleSnapshot}, is meant to be used as arguments to + * React's {@link useSyncExternalStore}. + * + * A return value of `undefined` indicates that we're either still loading the + * initial list of people, or that the user has ML disabled and thus doesn't + * have any people (this is distinct from the case where the user has ML enabled + * but doesn't have any named "person" clusters so far). + */ +export const peopleSnapshot = (): Person[] | undefined => { + const result = _state.peopleSnapshot; + // We don't have it yet, trigger an update. + if (!result) triggerPeopleUpdate(); + return result; +}; + +/** + * Trigger an asynchronous and unconditional update of the people snapshot. + */ +const triggerPeopleUpdate = () => void updatePeopleSnapshot(); + +/** Unconditional update of the people snapshot. */ +const updatePeopleSnapshot = async () => setPeopleSnapshot(await getPeople()); + +const setPeopleSnapshot = (snapshot: Person[] | undefined) => { + _state.peopleSnapshot = snapshot; + _state.peopleListeners.forEach((l) => l()); +}; + +/** + * Compute the list of people. + * + * TODO-Cluster this is a placeholder function and might not be needed since + * people might be updated in a push based manner. + */ +const getPeople = async (): Promise => { + if (!_state.isMLEnabled) return undefined; + // TODO-Cluster additional check for now as it is heavily WIP. + if (!process.env.NEXT_PUBLIC_ENTE_WIP_CL) return undefined; + if (!(await wipClusterEnable())) return []; + return _wip_people; +}; + /** * Use CLIP to perform a natural language search over image embeddings. * * @param searchPhrase Normalized (trimmed and lowercased) search phrase. * - * It returns file (IDs) that should be shown in the search results, along with - * their scores. + * It returns file (IDs) that should be shown in the search results, each + * annotated with its score. * * The result can also be `undefined`, which indicates that the download for the * ML model is still in progress (trying again later should succeed). diff --git a/web/packages/new/photos/services/search/index.ts b/web/packages/new/photos/services/search/index.ts index af0dca4a23..21322c6648 100644 --- a/web/packages/new/photos/services/search/index.ts +++ b/web/packages/new/photos/services/search/index.ts @@ -1,25 +1,16 @@ -import { isDesktop } from "@/base/app"; import log from "@/base/log"; import { masterKeyFromSession } from "@/base/session-store"; import { ComlinkWorker } from "@/base/worker/comlink-worker"; import { FileType } from "@/media/file-type"; -import type { LocationTag } from "@/new/photos/services/user-entity"; import i18n, { t } from "i18next"; -import { clipMatches, isMLEnabled } from "../ml"; +import { clipMatches, isMLEnabled, isMLSupported } from "../ml"; import type { - City, - ClipSearchScores, - DateSearchResult, LabelledFileType, + LabelledSearchDateComponents, LocalizedSearchData, SearchableData, - SearchDateComponents, - SearchOption, - SearchPerson, - SearchQuery, - Suggestion, + SearchSuggestion, } from "./types"; -import { SuggestionType } from "./types"; import type { SearchWorker } from "./worker"; /** @@ -66,12 +57,23 @@ export const setSearchableData = (data: SearchableData) => void worker().then((w) => w.setSearchableData(data)); /** - * Convert a search string into a suggestions that can be shown in the search - * results, and can also be used filter the searchable files. + * Convert a search string into (annotated) suggestions that can be shown in the + * search results dropdown. * * @param searchString The string we want to search for. */ -export const suggestionsForString = async (searchString: string) => { +export const searchOptionsForString = async (searchString: string) => { + const t = Date.now(); + const suggestions = await suggestionsForString(searchString); + const options = await suggestionsToOptions(suggestions); + log.debug(() => [ + "search", + { searchString, options, duration: `${Date.now() - t} ms` }, + ]); + return options; +}; + +const suggestionsForString = async (searchString: string) => { // Normalize it by trimming whitespace and converting to lowercase. const s = searchString.trim().toLowerCase(); if (s.length == 0) return []; @@ -80,7 +82,7 @@ export const suggestionsForString = async (searchString: string) => { // separately, in parallel with the rest of the search query construction in // the search worker, then combine the two. const results = await Promise.all([ - clipSuggestions(s, searchString).then((s) => s ?? []), + clipSuggestion(s, searchString).then((s) => s ?? []), worker().then((w) => w.suggestionsForString(s, searchString, localizedSearchData()), ), @@ -88,26 +90,44 @@ export const suggestionsForString = async (searchString: string) => { return results.flat(); }; -const clipSuggestions = async (s: string, searchString: string) => { - if (!isDesktop) return undefined; +const clipSuggestion = async ( + s: string, + searchString: string, +): Promise => { + if (!isMLSupported) return undefined; if (!isMLEnabled()) return undefined; const matches = await clipMatches(s); if (!matches) return undefined; - return { - type: SuggestionType.CLIP, - value: matches, - label: searchString, - }; + return { type: "clip", clipScoreForFileID: matches, label: searchString }; }; +const suggestionsToOptions = (suggestions: SearchSuggestion[]) => + filterSearchableFilesMulti(suggestions).then((res) => + res.map(([files, suggestion]) => ({ + suggestion, + fileCount: files.length, + previewFiles: files.slice(0, 3), + })), + ); + /** * Return the list of {@link EnteFile}s (from amongst the previously set * {@link SearchableData}) that match the given search {@link suggestion}. */ -export const filterSearchableFiles = async (suggestion: SearchQuery) => +export const filterSearchableFiles = async (suggestion: SearchSuggestion) => worker().then((w) => w.filterSearchableFiles(suggestion)); +/** + * A batched variant of {@link filterSearchableFiles}. + * + * This has drastically (10x) better performance when filtering files for a + * large number of suggestions (e.g. single letter searches that lead to a large + * number of city prefix matches), likely because of reduced worker IPC. + */ +const filterSearchableFilesMulti = async (suggestions: SearchSuggestion[]) => + worker().then((w) => w.filterSearchableFilesMulti(suggestions)); + /** * Cached value of {@link localizedSearchData}. */ @@ -144,7 +164,7 @@ const localizedSearchData = () => /** * A list of holidays - their yearly dates and localized names. */ -const holidays = (): DateSearchResult[] => [ +const holidays = (): LabelledSearchDateComponents[] => [ { components: { month: 12, day: 25 }, label: t("CHRISTMAS") }, { components: { month: 12, day: 24 }, label: t("CHRISTMAS_EVE") }, { components: { month: 1, day: 1 }, label: t("NEW_YEAR") }, @@ -159,81 +179,3 @@ const labelledFileTypes = (): LabelledFileType[] => [ { fileType: FileType.video, label: t("VIDEO") }, { fileType: FileType.livePhoto, label: t("LIVE_PHOTO") }, ]; - -// TODO-Cluster -- AUDIT BELOW THIS - -// Suggestions shown in the search dropdown when the user has typed something. -export const getAutoCompleteSuggestions = - () => - async (searchPhrase: string): Promise => { - log.debug(() => ["getAutoCompleteSuggestions"]); - try { - const suggestions: Suggestion[] = - await suggestionsForString(searchPhrase); - return convertSuggestionsToOptions(suggestions); - } catch (e) { - log.error("getAutoCompleteSuggestions failed", e); - return []; - } - }; - -async function convertSuggestionsToOptions( - suggestions: Suggestion[], -): Promise { - const previewImageAppendedOptions: SearchOption[] = []; - for (const suggestion of suggestions) { - const searchQuery = convertSuggestionToSearchQuery(suggestion); - const resultFiles = await filterSearchableFiles(searchQuery); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (searchQuery?.clip) { - resultFiles.sort((a, b) => { - const aScore = searchQuery.clip?.get(a.id) ?? 0; - const bScore = searchQuery.clip?.get(b.id) ?? 0; - return bScore - aScore; - }); - } - if (resultFiles.length) { - previewImageAppendedOptions.push({ - ...suggestion, - fileCount: resultFiles.length, - previewFiles: resultFiles.slice(0, 3), - }); - } - } - return previewImageAppendedOptions; -} - -function convertSuggestionToSearchQuery(option: Suggestion): SearchQuery { - switch (option.type) { - case SuggestionType.DATE: - return { - date: option.value as SearchDateComponents, - }; - - case SuggestionType.LOCATION: - return { - location: option.value as LocationTag, - }; - - case SuggestionType.CITY: - return { city: option.value as City }; - - case SuggestionType.COLLECTION: - return { collection: option.value as number }; - - case SuggestionType.FILE_NAME: - return { files: option.value as number[] }; - - case SuggestionType.FILE_CAPTION: - return { files: option.value as number[] }; - - case SuggestionType.PERSON: - return { person: option.value as SearchPerson }; - - case SuggestionType.FILE_TYPE: - return { fileType: option.value as FileType }; - - case SuggestionType.CLIP: - return { clip: option.value as ClipSearchScores }; - } -} diff --git a/web/packages/new/photos/services/search/types.ts b/web/packages/new/photos/services/search/types.ts index 7c91facf04..c22e462282 100644 --- a/web/packages/new/photos/services/search/types.ts +++ b/web/packages/new/photos/services/search/types.ts @@ -6,9 +6,44 @@ import type { Location } from "@/base/types"; import type { Collection } from "@/media/collection"; import { FileType } from "@/media/file-type"; +import type { Person } from "@/new/photos/services/ml"; import type { EnteFile } from "@/new/photos/types/file"; import type { LocationTag } from "../user-entity"; +/** + * A search suggestion. + * + * These (wrapped up in {@link SearchOption}s) are shown in the search results + * dropdown, and can also be used to filter the list of files that are shown. + */ +export type SearchSuggestion = { label: string } & ( + | { type: "collection"; collectionID: number } + | { type: "fileType"; fileType: FileType } + | { type: "fileName"; fileIDs: number[] } + | { type: "fileCaption"; fileIDs: number[] } + | { type: "date"; dateComponents: SearchDateComponents } + | { type: "location"; locationTag: LocationTag } + | { type: "city"; city: City } + | { type: "clip"; clipScoreForFileID: Map } + | { type: "person"; person: Person } +); + +/** + * An option shown in the the search bar's select dropdown. + * + * The {@link SearchOption} wraps a {@link SearchSuggestion} with some metadata + * used when showing a corresponding entry in the dropdown, and in the results + * header. + * + * If the user selects the option, then we will re-run the search using the + * {@link suggestion} to filter the list of files shown to the user. + */ +export interface SearchOption { + suggestion: SearchSuggestion; + fileCount: number; + previewFiles: EnteFile[]; +} + /** * The base data over which we should search. */ @@ -17,7 +52,7 @@ export interface SearchableData { files: EnteFile[]; } -export interface DateSearchResult { +export interface LabelledSearchDateComponents { components: SearchDateComponents; label: string; } @@ -47,7 +82,7 @@ export type Searchable = T & { */ export interface LocalizedSearchData { locale: string; - holidays: Searchable[]; + holidays: Searchable[]; labelledFileTypes: Searchable[]; } @@ -83,18 +118,6 @@ export interface SearchDateComponents { hour?: number; } -/** - * A massaged version of {@link CGroup} suitable for being shown in search - * results. - */ -export interface SearchPerson { - id: string; - name?: string; - files: number[]; - displayFaceID: string; - displayFaceFile: EnteFile; -} - /** * A city as identified by a static dataset. * @@ -105,72 +128,3 @@ export type City = Location & { /** Name of the city. */ name: string; }; - -// TODO-cgroup: Audit below - -export enum SuggestionType { - DATE = "DATE", - LOCATION = "LOCATION", - COLLECTION = "COLLECTION", - FILE_NAME = "FILE_NAME", - PERSON = "PERSON", - FILE_CAPTION = "FILE_CAPTION", - FILE_TYPE = "FILE_TYPE", - CLIP = "CLIP", - CITY = "CITY", -} - -export interface Suggestion { - type: SuggestionType; - label: string; - value: - | SearchDateComponents - | number[] - | SearchPerson - | LocationTag - | City - | FileType - | ClipSearchScores; -} - -export interface SearchQuery { - suggestion?: SearchSuggestion; - date?: SearchDateComponents; - location?: LocationTag; - city?: City; - collection?: number; - files?: number[]; - person?: SearchPerson; - fileType?: FileType; - clip?: ClipSearchScores; -} - -export interface SearchResultSummary { - optionName: string; - fileCount: number; -} - -export type SearchSuggestion = { label: string } & ( - | { type: "collection"; collectionID: number } - | { type: "files"; fileIDs: number[] } - | { type: "fileType"; fileType: FileType } - | { type: "date"; dateComponents: SearchDateComponents } - | { type: "location"; locationTag: LocationTag } - | { type: "city"; city: City } - | { type: "clip"; clipScoreForFileID: Map } - | { type: "cgroup"; cgroup: SearchPerson } -); - -/** - * An option shown in the the search bar's select dropdown. - * - * The option includes essential data that is necessary to show a corresponding - * entry in the dropdown. If the user selects the option, then we will re-run - * the search, using the data to filter the list of files shown to the user. - */ -export interface SearchOption extends Suggestion { - fileCount: number; - previewFiles: EnteFile[]; -} - -export type ClipSearchScores = Map; diff --git a/web/packages/new/photos/services/search/ui.ts b/web/packages/new/photos/services/search/ui.ts deleted file mode 100644 index eeeac84d8c..0000000000 --- a/web/packages/new/photos/services/search/ui.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { t } from "i18next"; -import { SuggestionType } from "./types"; - -/** - * Return a localized label for the given suggestion {@link type}. - */ -export const labelForSuggestionType = (type: SuggestionType) => { - switch (type) { - case SuggestionType.DATE: - return t("date"); - case SuggestionType.LOCATION: - return t("location"); - case SuggestionType.CITY: - return t("location"); - case SuggestionType.COLLECTION: - return t("album"); - case SuggestionType.FILE_NAME: - return t("file_name"); - case SuggestionType.PERSON: - return t("person"); - case SuggestionType.FILE_CAPTION: - return t("description"); - case SuggestionType.FILE_TYPE: - return t("file_type"); - case SuggestionType.CLIP: - return t("magic"); - } -}; diff --git a/web/packages/new/photos/services/search/worker.ts b/web/packages/new/photos/services/search/worker.ts index aa31c7f16c..24f351e058 100644 --- a/web/packages/new/photos/services/search/worker.ts +++ b/web/packages/new/photos/services/search/worker.ts @@ -3,6 +3,7 @@ import type { Location } from "@/base/types"; import type { Collection } from "@/media/collection"; import { fileCreationPhotoDate, fileLocation } from "@/media/file-metadata"; import type { EnteFile } from "@/new/photos/types/file"; +import { ensure } from "@/utils/ensure"; import { nullToUndefined } from "@/utils/transform"; import { getPublicMagicMetadataSync } from "@ente/shared/file-metadata"; import type { Component } from "chrono-node"; @@ -16,16 +17,14 @@ import { } from "../user-entity"; import type { City, - DateSearchResult, LabelledFileType, + LabelledSearchDateComponents, LocalizedSearchData, Searchable, SearchableData, SearchDateComponents, - SearchQuery, - Suggestion, + SearchSuggestion, } from "./types"; -import { SuggestionType } from "./types"; /** * A web worker that runs the search asynchronously so that the main thread @@ -89,10 +88,18 @@ export class SearchWorker { /** * Return {@link EnteFile}s that satisfy the given {@link suggestion}. */ - filterSearchableFiles(suggestion: SearchQuery) { - return this.searchableData.files.filter((f) => - isMatchingFile(f, suggestion), - ); + filterSearchableFiles(suggestion: SearchSuggestion) { + return filterSearchableFiles(this.searchableData.files, suggestion); + } + + /** + * Batched variant of {@link filterSearchableFiles}. + */ + filterSearchableFilesMulti(suggestions: SearchSuggestion[]) { + const files = this.searchableData.files; + return suggestions + .map((sg) => [filterSearchableFiles(files, sg), sg] as const) + .filter(([files]) => files.length); } } @@ -109,59 +116,83 @@ const suggestionsForString = ( { locale, holidays, labelledFileTypes }: LocalizedSearchData, locationTags: Searchable[], cities: Searchable[], -): Suggestion[] => +): SearchSuggestion[] => [ + // <-- caption suggestions will be inserted here by our caller. fileTypeSuggestions(s, labelledFileTypes), dateSuggestions(s, locale, holidays), locationSuggestions(s, locationTags, cities), collectionSuggestions(s, collections), - suggestionForFiles(fileNameMatches(s, files), searchString), - suggestionForFiles(fileCaptionMatches(s, files), searchString), + fileNameSuggestion(s, searchString, files), + fileCaptionSuggestion(s, searchString, files), ].flat(); -const collectionSuggestions = (s: string, collections: Collection[]) => +const collectionSuggestions = ( + s: string, + collections: Collection[], +): SearchSuggestion[] => collections .filter(({ name }) => name.toLowerCase().includes(s)) .map(({ id, name }) => ({ - type: SuggestionType.COLLECTION, - value: id, + type: "collection", + collectionID: id, label: name, })); -const fileNameMatches = (s: string, files: EnteFile[]) => { +const fileTypeSuggestions = ( + s: string, + labelledFileTypes: Searchable[], +): SearchSuggestion[] => + labelledFileTypes + .filter(({ lowercasedName }) => lowercasedName.startsWith(s)) + .map(({ fileType, label }) => ({ type: "fileType", fileType, label })); + +const fileNameSuggestion = ( + s: string, + searchString: string, + files: EnteFile[], +): SearchSuggestion[] => { // Convert the search string to a number. This allows searching a file by // its exact (integral) ID. const sn = Number(s) || undefined; - return files.filter( - ({ id, metadata }) => - id === sn || metadata.title.toLowerCase().includes(s), - ); + const fileIDs = files + .filter( + ({ id, metadata }) => + id === sn || metadata.title.toLowerCase().includes(s), + ) + .map((f) => f.id); + + return fileIDs.length + ? [{ type: "fileName", fileIDs, label: searchString }] + : []; }; -const suggestionForFiles = (matchingFiles: EnteFile[], searchString: string) => - matchingFiles.length - ? { - type: SuggestionType.FILE_NAME, - value: matchingFiles.map((f) => f.id), - label: searchString, - } - : []; +const fileCaptionSuggestion = ( + s: string, + searchString: string, + files: EnteFile[], +): SearchSuggestion[] => { + const fileIDs = files + .filter((file) => + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + file.pubMagicMetadata?.data?.caption?.toLowerCase().includes(s), + ) + .map((f) => f.id); -const fileCaptionMatches = (s: string, files: EnteFile[]) => - files.filter((file) => - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - file.pubMagicMetadata?.data?.caption?.toLowerCase().includes(s), - ); + return fileIDs.length + ? [{ type: "fileCaption", fileIDs, label: searchString }] + : []; +}; const dateSuggestions = ( s: string, locale: string, - holidays: Searchable[], -) => + holidays: Searchable[], +): SearchSuggestion[] => parseDateComponents(s, locale, holidays).map(({ components, label }) => ({ - type: SuggestionType.DATE, - value: components, + type: "date", + dateComponents: components, label, })); @@ -182,15 +213,18 @@ const dateSuggestions = ( const parseDateComponents = ( s: string, locale: string, - holidays: Searchable[], -): DateSearchResult[] => + holidays: Searchable[], +): LabelledSearchDateComponents[] => [ parseChrono(s, locale), parseYearComponents(s), holidays.filter(searchableIncludes(s)), ].flat(); -const parseChrono = (s: string, locale: string): DateSearchResult[] => +const parseChrono = ( + s: string, + locale: string, +): LabelledSearchDateComponents[] => chrono .parse(s) .map((result) => { @@ -224,7 +258,7 @@ const parseChrono = (s: string, locale: string): DateSearchResult[] => .filter((x) => x !== undefined); /** chrono does not parse years like "2024", so do it manually. */ -const parseYearComponents = (s: string): DateSearchResult[] => { +const parseYearComponents = (s: string): LabelledSearchDateComponents[] => { // s is already trimmed. if (s.length == 4) { const year = parseInt(s); @@ -271,7 +305,7 @@ const locationSuggestions = ( s: string, locationTags: Searchable[], cities: Searchable[], -) => { +): SearchSuggestion[] => { const matchingLocationTags = locationTags.filter(searchableIncludes(s)); const matchingLocationTagLNames = new Set( @@ -285,77 +319,77 @@ const locationSuggestions = ( ); return [ - matchingLocationTags.map((t) => ({ - type: SuggestionType.LOCATION, - value: t, - label: t.name, - })), - matchingCities.map((c) => ({ - type: SuggestionType.CITY, - value: c, - label: c.name, - })), + matchingLocationTags.map( + (locationTag): SearchSuggestion => ({ + type: "location", + locationTag, + label: locationTag.name, + }), + ), + matchingCities.map( + (city): SearchSuggestion => ({ + type: "city", + city, + label: city.name, + }), + ), ].flat(); }; -const fileTypeSuggestions = ( - s: string, - labelledFileTypes: Searchable[], +const filterSearchableFiles = ( + files: EnteFile[], + suggestion: SearchSuggestion, ) => - labelledFileTypes - .filter(searchableIncludes(s)) - .map(({ fileType, label }) => ({ - label, - value: fileType, - type: SuggestionType.FILE_TYPE, - })); + sortMatchesIfNeeded( + files.filter((f) => isMatchingFile(f, suggestion)), + suggestion, + ); /** * Return true if file satisfies the given {@link query}. */ -const isMatchingFile = (file: EnteFile, query: SearchQuery) => { - if (query.collection) { - return query.collection === file.collectionID; +const isMatchingFile = (file: EnteFile, suggestion: SearchSuggestion) => { + switch (suggestion.type) { + case "collection": + return suggestion.collectionID === file.collectionID; + + case "fileType": + return suggestion.fileType === file.metadata.fileType; + + case "fileName": + return suggestion.fileIDs.includes(file.id); + + case "fileCaption": + return suggestion.fileIDs.includes(file.id); + + case "date": + return isDateComponentsMatch( + suggestion.dateComponents, + fileCreationPhotoDate(file, getPublicMagicMetadataSync(file)), + ); + + case "location": { + const location = fileLocation(file); + if (!location) return false; + + return isInsideLocationTag(location, suggestion.locationTag); + } + + case "city": { + const location = fileLocation(file); + if (!location) return false; + + return isInsideCity(location, suggestion.city); + } + + case "clip": + return suggestion.clipScoreForFileID.has(file.id); + + case "person": + // return query.person.files.includes(file.id); + // TODO-Cluster implement me + return false; } - - if (query.date) { - return isDateComponentsMatch( - query.date, - fileCreationPhotoDate(file, getPublicMagicMetadataSync(file)), - ); - } - - if (query.location) { - const location = fileLocation(file); - if (!location) return false; - - return isInsideLocationTag(location, query.location); - } - - if (query.city) { - const location = fileLocation(file); - if (!location) return false; - - return isInsideCity(location, query.city); - } - - if (query.files) { - return query.files.includes(file.id); - } - - if (query.person) { - return query.person.files.includes(file.id); - } - - if (typeof query.fileType !== "undefined") { - return query.fileType === file.metadata.fileType; - } - - if (typeof query.clip !== "undefined") { - return query.clip.has(file.id); - } - - return false; }; const isDateComponentsMatch = ( @@ -412,3 +446,21 @@ const isWithinRadius = ( * major axis (a) has to be scaled by the secant of the latitude. */ const radiusScaleFactor = (lat: number) => 1 / Math.cos(lat * (Math.PI / 180)); + +/** + * Sort the files if necessary. + * + * Currently, only the CLIP results are sorted (by their score), in the other + * cases the files are displayed chronologically (when displaying them in search + * results) or arbitrarily (when showing them in the search option preview). + */ +const sortMatchesIfNeeded = ( + files: EnteFile[], + suggestion: SearchSuggestion, +) => { + if (suggestion.type != "clip") return files; + // Sort CLIP matches by their corresponding scores. + const score = ({ id }: EnteFile) => + ensure(suggestion.clipScoreForFileID.get(id)); + return files.sort((a, b) => score(b) - score(a)); +}; diff --git a/web/packages/shared/components/CodeBlock/index.tsx b/web/packages/shared/components/CodeBlock/index.tsx index f0a5ac803e..e556638702 100644 --- a/web/packages/shared/components/CodeBlock/index.tsx +++ b/web/packages/shared/components/CodeBlock/index.tsx @@ -1,20 +1,14 @@ -import { FreeFlowText } from "@ente/shared/components/Container"; import EnteSpinner from "@ente/shared/components/EnteSpinner"; -import type { BoxProps } from "@mui/material"; +import { type BoxProps, styled } from "@mui/material"; import React from "react"; import CopyButton from "./CopyButton"; import { CodeWrapper, CopyButtonWrapper, Wrapper } from "./styledComponents"; type Iprops = React.PropsWithChildren<{ code: string | null; - wordBreak?: "normal" | "break-all" | "keep-all" | "break-word"; }>; -export default function CodeBlock({ - code, - wordBreak, - ...props -}: BoxProps<"div", Iprops>) { +export default function CodeBlock({ code, ...props }: BoxProps<"div", Iprops>) { if (!code) { return ( @@ -25,9 +19,7 @@ export default function CodeBlock({ return ( - - {code} - + {code} @@ -35,3 +27,9 @@ export default function CodeBlock({ ); } + +const FreeFlowText = styled("div")` + word-break: break-word; + min-width: 30%; + text-align: left; +`; diff --git a/web/packages/shared/components/Container.tsx b/web/packages/shared/components/Container.tsx index 517e058b5a..cd0dca4914 100644 --- a/web/packages/shared/components/Container.tsx +++ b/web/packages/shared/components/Container.tsx @@ -31,12 +31,6 @@ export const FlexWrapper = styled(Box)` align-items: center; `; -export const FreeFlowText = styled("div")` - word-break: break-word; - min-width: 30%; - text-align: left; -`; - export const SpaceBetweenFlex = styled(FlexWrapper)` justify-content: space-between; `;