diff --git a/web/apps/photos/src/components/Search/SearchBar/index.tsx b/web/apps/photos/src/components/Search/SearchBar/index.tsx deleted file mode 100644 index fa8929e4e6..0000000000 --- a/web/apps/photos/src/components/Search/SearchBar/index.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Collection } from "types/collection"; -import { SearchBarMobile } from "./searchBarMobile"; - -import { UpdateSearch } from "@/new/photos/services/search/types"; -import { EnteFile } from "@/new/photos/types/file"; -import SearchInput from "./searchInput"; -import { SearchBarWrapper } from "./styledComponents"; - -interface Props { - updateSearch: UpdateSearch; - collections: Collection[]; - files: EnteFile[]; - isInSearchMode: boolean; - setIsInSearchMode: (v: boolean) => void; -} - -export default function SearchBar({ - setIsInSearchMode, - isInSearchMode, - ...props -}: Props) { - const showSearchInput = () => setIsInSearchMode(true); - - return ( - - - - - ); -} diff --git a/web/apps/photos/src/components/Search/SearchBar/searchBarMobile.tsx b/web/apps/photos/src/components/Search/SearchBar/searchBarMobile.tsx deleted file mode 100644 index 466a5ef79e..0000000000 --- a/web/apps/photos/src/components/Search/SearchBar/searchBarMobile.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { FluidContainer } from "@ente/shared/components/Container"; -import SearchIcon from "@mui/icons-material/Search"; -import { IconButton } from "@mui/material"; -import { SearchMobileBox } from "./styledComponents"; - -export function SearchBarMobile({ show, showSearchInput }) { - if (!show) { - return <>; - } - return ( - - - - - - - - ); -} diff --git a/web/apps/photos/src/components/Search/SearchBar/searchInput/MenuWithPeople.tsx b/web/apps/photos/src/components/Search/SearchBar/searchInput/MenuWithPeople.tsx deleted file mode 100644 index 72ed38de99..0000000000 --- a/web/apps/photos/src/components/Search/SearchBar/searchInput/MenuWithPeople.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { PeopleList } from "@/new/photos/components/PeopleList"; -import { isMLEnabled } from "@/new/photos/services/ml"; -import { Suggestion, SuggestionType } from "@/new/photos/services/search/types"; -import { Row } from "@ente/shared/components/Container"; -import { Box, styled } from "@mui/material"; -import { t } from "i18next"; -import { components } from "react-select"; - -const { Menu } = components; - -const Legend = styled("span")` - font-size: 20px; - color: #ddd; - display: inline; - padding: 0px 12px; -`; - -const Caption = styled("span")` - font-size: 12px; - display: inline; - padding: 0px 12px; -`; - -const MenuWithPeople = (props) => { - // log.info("props.selectProps.options: ", selectRef); - const peopleSuggestions = props.selectProps.options.filter( - (o) => o.type === SuggestionType.PERSON, - ); - const people = peopleSuggestions.map((o) => o.value); - - const indexStatusSuggestion = props.selectProps.options.filter( - (o) => o.type === SuggestionType.INDEX_STATUS, - )[0] as Suggestion; - - const indexStatus = indexStatusSuggestion?.value; - return ( - - - {isMLEnabled() && - indexStatus && - (people && people.length > 0 ? ( - - {t("people")} - - ) : ( - - ))} - - {isMLEnabled() && indexStatus && ( - - {indexStatusSuggestion.label} - - )} - {people && people.length > 0 && ( - - { - props.selectRef.current.blur(); - props.setValue(peopleSuggestions[index]); - }} - /> - - )} - - {props.children} - - ); -}; - -export default MenuWithPeople; diff --git a/web/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx b/web/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx deleted file mode 100644 index 7ce05576f5..0000000000 --- a/web/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import { FileType } from "@/media/file-type"; -import { isMLEnabled } from "@/new/photos/services/ml"; -import type { - City, - SearchDateComponents, - SearchPerson, -} from "@/new/photos/services/search/types"; -import { - ClipSearchScores, - SearchOption, - SearchQuery, - SuggestionType, - UpdateSearch, -} from "@/new/photos/services/search/types"; -import type { LocationTag } from "@/new/photos/services/user-entity"; -import { EnteFile } from "@/new/photos/types/file"; -import CloseIcon from "@mui/icons-material/Close"; -import { IconButton } from "@mui/material"; -import { t } from "i18next"; -import memoize from "memoize-one"; -import pDebounce from "p-debounce"; -import { AppContext } from "pages/_app"; -import { useCallback, useContext, useEffect, useRef, useState } from "react"; -import { components } from "react-select"; -import AsyncSelect from "react-select/async"; -import { InputActionMeta } from "react-select/src/types"; -import { - getAutoCompleteSuggestions, - getDefaultOptions, -} from "services/searchService"; -import { Collection } from "types/collection"; -import { SelectStyles } from "../../../../styles/search"; -import { SearchInputWrapper } from "../styledComponents"; -import MenuWithPeople from "./MenuWithPeople"; -import { OptionWithInfo } from "./optionWithInfo"; -import { ValueContainerWithIcon } from "./valueContainerWithIcon"; - -interface Iprops { - isOpen: boolean; - updateSearch: UpdateSearch; - setIsOpen: (value: boolean) => void; - files: EnteFile[]; - collections: Collection[]; -} - -const createComponents = memoize((Option, ValueContainer, Menu, Input) => ({ - Option, - ValueContainer, - Menu, - Input, -})); - -const VisibleInput = (props) => ( - -); - -export default function SearchInput(props: Iprops) { - const selectRef = useRef(null); - const [value, setValue] = useState(null); - const appContext = useContext(AppContext); - const handleChange = (value: SearchOption) => { - setValue(value); - setQuery(value?.label); - - blur(); - }; - const handleInputChange = (value: string, actionMeta: InputActionMeta) => { - if (actionMeta.action === "input-change") { - setQuery(value); - } - }; - const [defaultOptions, setDefaultOptions] = useState([]); - const [query, setQuery] = useState(""); - - useEffect(() => { - search(value); - }, [value]); - - useEffect(() => { - refreshDefaultOptions(); - const t = setInterval(() => refreshDefaultOptions(), 2000); - return () => clearInterval(t); - }, []); - - async function refreshDefaultOptions() { - const defaultOptions = await getDefaultOptions(); - setDefaultOptions(defaultOptions); - } - - const resetSearch = () => { - if (props.isOpen) { - appContext.startLoading(); - props.updateSearch(null, null); - setTimeout(() => { - appContext.finishLoading(); - }, 10); - props.setIsOpen(false); - setValue(null); - setQuery(""); - } - }; - - const getOptions = useCallback( - pDebounce( - getAutoCompleteSuggestions(props.files, props.collections), - 250, - ), - [props.files, props.collections], - ); - - const blur = () => { - selectRef.current?.blur(); - }; - - const search = (selectedOption: SearchOption) => { - if (!selectedOption) { - return; - } - let search: SearchQuery; - switch (selectedOption.type) { - case SuggestionType.DATE: - search = { - date: selectedOption.value as SearchDateComponents, - }; - props.setIsOpen(true); - break; - case SuggestionType.LOCATION: - search = { - location: selectedOption.value as LocationTag, - }; - props.setIsOpen(true); - break; - case SuggestionType.CITY: - search = { - city: selectedOption.value as City, - }; - props.setIsOpen(true); - break; - case SuggestionType.COLLECTION: - search = { collection: selectedOption.value as number }; - setValue(null); - setQuery(""); - 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 }; - } - props.updateSearch(search, { - optionName: selectedOption.label, - fileCount: selectedOption.fileCount, - }); - }; - - // TODO: HACK as AsyncSelect does not support default options reloading on focus/click - // unwanted side effect: placeholder is not shown on focus/click - // https://github.com/JedWatson/react-select/issues/1879 - // for correct fix AsyncSelect can be extended to support default options reloading on focus/click - const handleOnFocus = () => { - refreshDefaultOptions(); - }; - - const MemoizedMenuWithPeople = useCallback( - (props) => ( - - ), - [setValue, selectRef], - ); - - const components = createComponents( - OptionWithInfo, - ValueContainerWithIcon, - MemoizedMenuWithPeople, - VisibleInput, - ); - - return ( - - {t("search_hint")}} - loadOptions={getOptions} - onChange={handleChange} - onFocus={handleOnFocus} - isClearable - inputValue={query} - onInputChange={handleInputChange} - escapeClearsValue - styles={SelectStyles} - defaultOptions={isMLEnabled() ? defaultOptions : null} - noOptionsMessage={() => null} - /> - - {props.isOpen && ( - resetSearch()} sx={{ ml: 1 }}> - - - )} - - ); -} diff --git a/web/apps/photos/src/components/Search/SearchBar/searchInput/optionWithInfo.tsx b/web/apps/photos/src/components/Search/SearchBar/searchInput/optionWithInfo.tsx deleted file mode 100644 index 2957e77318..0000000000 --- a/web/apps/photos/src/components/Search/SearchBar/searchInput/optionWithInfo.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { SearchOption } from "@/new/photos/services/search/types"; -import { labelForSuggestionType } from "@/new/photos/services/search/ui"; -import { - FreeFlowText, - SpaceBetweenFlex, -} from "@ente/shared/components/Container"; -import { Box, Divider, Stack, Typography } from "@mui/material"; -import CollectionCard from "components/Collections/CollectionCard"; -import { ResultPreviewTile } from "components/Collections/styledComponents"; -import { t } from "i18next"; - -import { components } from "react-select"; - -const { Option } = components; - -export const OptionWithInfo = (props) => ( - -); - -const LabelWithInfo = ({ data }: { data: SearchOption }) => { - return ( - !data.hide && ( - <> - - - {labelForSuggestionType(data.type)} - - - - - - {data.label} - - - - {t("photos_count", { count: data.fileCount })} - - - - - {data.previewFiles.map((file) => ( - null} - collectionTile={ResultPreviewTile} - /> - ))} - - - - - - ) - ); -}; diff --git a/web/apps/photos/src/components/Search/SearchBar/searchInput/valueContainerWithIcon.tsx b/web/apps/photos/src/components/Search/SearchBar/searchInput/valueContainerWithIcon.tsx deleted file mode 100644 index 75529a925f..0000000000 --- a/web/apps/photos/src/components/Search/SearchBar/searchInput/valueContainerWithIcon.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { - SearchOption, - SuggestionType, -} from "@/new/photos/services/search/types"; -import { FlexWrapper } from "@ente/shared/components/Container"; -import CalendarIcon from "@mui/icons-material/CalendarMonth"; -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/SearchOutlined"; -import { Box } from "@mui/material"; -import { components } from "react-select"; -import { SelectComponents } from "react-select/src/components"; - -const { ValueContainer } = components; - -const getIconByType = (type: SuggestionType) => { - switch (type) { - case SuggestionType.DATE: - return ; - case SuggestionType.LOCATION: - case SuggestionType.CITY: - return ; - case SuggestionType.COLLECTION: - return ; - case SuggestionType.FILE_NAME: - return ; - default: - return ; - } -}; - -export const ValueContainerWithIcon: SelectComponents< - SearchOption, - false ->["ValueContainer"] = (props) => ( - - - theme.colors.stroke.muted} - > - {getIconByType(props.getValue()[0]?.type)} - - {props.children} - - -); diff --git a/web/apps/photos/src/components/Search/SearchBar/styledComponents.tsx b/web/apps/photos/src/components/Search/SearchBar/styledComponents.tsx deleted file mode 100644 index d33c7c9490..0000000000 --- a/web/apps/photos/src/components/Search/SearchBar/styledComponents.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { - CenteredFlex, - FlexWrapper, - FluidContainer, -} from "@ente/shared/components/Container"; -import { css, styled } from "@mui/material"; -import { IMAGE_CONTAINER_MAX_WIDTH, MIN_COLUMNS } from "constants/gallery"; - -export const SearchBarWrapper = styled(FlexWrapper)` - padding: 0 24px; - @media (max-width: ${IMAGE_CONTAINER_MAX_WIDTH * MIN_COLUMNS}px) { - padding: 0 4px; - } -`; - -export const SearchMobileBox = styled(FluidContainer)` - display: flex; - cursor: pointer; - align-items: center; - justify-content: flex-end; - @media (min-width: 625px) { - display: none; - } -`; - -export const SearchInputWrapper = styled(CenteredFlex, { - shouldForwardProp: (propName) => propName != "isOpen", -})<{ isOpen: boolean }>` - background: ${({ theme }) => theme.colors.background.base}; - max-width: 484px; - margin: auto; - ${(props) => - !props.isOpen && - css` - @media (max-width: 624px) { - display: none; - } - `} -`; diff --git a/web/apps/photos/src/components/Search/SearchResultInfo.tsx b/web/apps/photos/src/components/Search/SearchResultInfo.tsx deleted file mode 100644 index f087bf1456..0000000000 --- a/web/apps/photos/src/components/Search/SearchResultInfo.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { SearchResultSummary } from "@/new/photos/services/search/types"; -import { Typography } from "@mui/material"; -import { CollectionInfo } from "components/Collections/CollectionInfo"; -import { CollectionInfoBarWrapper } from "components/Collections/styledComponents"; -import { t } from "i18next"; - -interface Iprops { - searchResultSummary: SearchResultSummary; -} -export default function SearchResultInfo({ searchResultSummary }: Iprops) { - if (!searchResultSummary) { - return <>; - } - - const { optionName, fileCount } = searchResultSummary; - - return ( - - - {t("search_results")} - - - - ); -} diff --git a/web/apps/photos/src/components/Search/SearchStatsContainer.tsx b/web/apps/photos/src/components/Search/SearchStatsContainer.tsx deleted file mode 100644 index 1e088b58f0..0000000000 --- a/web/apps/photos/src/components/Search/SearchStatsContainer.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { styled } from "@mui/material"; -const SearchStatsContainer = styled("div")( - ({ theme }) => ` - display: flex; - justify-content: center; - align-items: center; - color: #979797; - margin: ${theme.spacing(1, 0)}; -`, -); - -export default SearchStatsContainer; diff --git a/web/apps/photos/src/components/SearchBar.tsx b/web/apps/photos/src/components/SearchBar.tsx new file mode 100644 index 0000000000..218375cac8 --- /dev/null +++ b/web/apps/photos/src/components/SearchBar.tsx @@ -0,0 +1,459 @@ +import { FileType } from "@/media/file-type"; +import { PeopleList } from "@/new/photos/components/PeopleList"; +import { isMLEnabled } from "@/new/photos/services/ml"; +import type { + City, + SearchDateComponents, + SearchPerson, +} from "@/new/photos/services/search/types"; +import { + ClipSearchScores, + SearchOption, + SearchQuery, + Suggestion, + SuggestionType, + UpdateSearch, +} from "@/new/photos/services/search/types"; +import { labelForSuggestionType } from "@/new/photos/services/search/ui"; +import type { LocationTag } from "@/new/photos/services/user-entity"; +import { EnteFile } from "@/new/photos/types/file"; +import { + CenteredFlex, + FlexWrapper, + FluidContainer, + FreeFlowText, + Row, + SpaceBetweenFlex, +} from "@ente/shared/components/Container"; +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"; +import { + Box, + css, + Divider, + IconButton, + Stack, + styled, + Typography, +} from "@mui/material"; +import CollectionCard from "components/Collections/CollectionCard"; +import { ResultPreviewTile } from "components/Collections/styledComponents"; +import { IMAGE_CONTAINER_MAX_WIDTH, MIN_COLUMNS } from "constants/gallery"; +import { t } from "i18next"; +import memoize from "memoize-one"; +import pDebounce from "p-debounce"; +import { AppContext } from "pages/_app"; +import { useCallback, useContext, useEffect, useRef, useState } from "react"; +import { components } from "react-select"; +import AsyncSelect from "react-select/async"; +import { SelectComponents } from "react-select/src/components"; +import { InputActionMeta } from "react-select/src/types"; +import { + getAutoCompleteSuggestions, + getDefaultOptions, +} from "services/searchService"; +import { Collection } from "types/collection"; +import { SelectStyles } from "../styles/search"; + +const { Option, ValueContainer, Menu } = components; + +interface SearchBarProps { + updateSearch: UpdateSearch; + collections: Collection[]; + files: EnteFile[]; + isInSearchMode: boolean; + setIsInSearchMode: (v: boolean) => void; +} + +export default function SearchBar({ + setIsInSearchMode, + isInSearchMode, + ...props +}: SearchBarProps) { + const showSearchInput = () => setIsInSearchMode(true); + + return ( + + + + + ); +} + +const SearchBarWrapper = styled(FlexWrapper)` + padding: 0 24px; + @media (max-width: ${IMAGE_CONTAINER_MAX_WIDTH * MIN_COLUMNS}px) { + padding: 0 4px; + } +`; + +interface SearchInputProps { + isOpen: boolean; + updateSearch: UpdateSearch; + setIsOpen: (value: boolean) => void; + files: EnteFile[]; + collections: Collection[]; +} + +const createComponents = memoize((Option, ValueContainer, Menu, Input) => ({ + Option, + ValueContainer, + Menu, + Input, +})); + +export const SearchInput: React.FC = (props) => { + const selectRef = useRef(null); + const [value, setValue] = useState(null); + const appContext = useContext(AppContext); + const handleChange = (value: SearchOption) => { + setValue(value); + setQuery(value?.label); + + blur(); + }; + const handleInputChange = (value: string, actionMeta: InputActionMeta) => { + if (actionMeta.action === "input-change") { + setQuery(value); + } + }; + const [defaultOptions, setDefaultOptions] = useState([]); + const [query, setQuery] = useState(""); + + useEffect(() => { + search(value); + }, [value]); + + useEffect(() => { + refreshDefaultOptions(); + const t = setInterval(() => refreshDefaultOptions(), 2000); + return () => clearInterval(t); + }, []); + + async function refreshDefaultOptions() { + const defaultOptions = await getDefaultOptions(); + setDefaultOptions(defaultOptions); + } + + const resetSearch = () => { + if (props.isOpen) { + appContext.startLoading(); + props.updateSearch(null, null); + setTimeout(() => { + appContext.finishLoading(); + }, 10); + props.setIsOpen(false); + setValue(null); + setQuery(""); + } + }; + + const getOptions = useCallback( + pDebounce( + getAutoCompleteSuggestions(props.files, props.collections), + 250, + ), + [props.files, props.collections], + ); + + const blur = () => { + selectRef.current?.blur(); + }; + + const search = (selectedOption: SearchOption) => { + if (!selectedOption) { + return; + } + let search: SearchQuery; + switch (selectedOption.type) { + case SuggestionType.DATE: + search = { + date: selectedOption.value as SearchDateComponents, + }; + props.setIsOpen(true); + break; + case SuggestionType.LOCATION: + search = { + location: selectedOption.value as LocationTag, + }; + props.setIsOpen(true); + break; + case SuggestionType.CITY: + search = { + city: selectedOption.value as City, + }; + props.setIsOpen(true); + break; + case SuggestionType.COLLECTION: + search = { collection: selectedOption.value as number }; + setValue(null); + setQuery(""); + 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 }; + } + props.updateSearch(search, { + optionName: selectedOption.label, + fileCount: selectedOption.fileCount, + }); + }; + + // TODO: HACK as AsyncSelect does not support default options reloading on focus/click + // unwanted side effect: placeholder is not shown on focus/click + // https://github.com/JedWatson/react-select/issues/1879 + // for correct fix AsyncSelect can be extended to support default options reloading on focus/click + const handleOnFocus = () => { + refreshDefaultOptions(); + }; + + const MemoizedMenuWithPeople = useCallback( + (props) => ( + + ), + [setValue, selectRef], + ); + + const components = createComponents( + OptionWithInfo, + ValueContainerWithIcon, + MemoizedMenuWithPeople, + VisibleInput, + ); + + return ( + + {t("search_hint")}} + loadOptions={getOptions} + onChange={handleChange} + onFocus={handleOnFocus} + isClearable + inputValue={query} + onInputChange={handleInputChange} + escapeClearsValue + styles={SelectStyles} + defaultOptions={isMLEnabled() ? defaultOptions : null} + noOptionsMessage={() => null} + /> + + {props.isOpen && ( + resetSearch()} sx={{ ml: 1 }}> + + + )} + + ); +}; + +const SearchInputWrapper = styled(CenteredFlex, { + shouldForwardProp: (propName) => propName != "isOpen", +})<{ isOpen: boolean }>` + background: ${({ theme }) => theme.colors.background.base}; + max-width: 484px; + margin: auto; + ${(props) => + !props.isOpen && + css` + @media (max-width: 624px) { + display: none; + } + `} +`; + +const OptionWithInfo = (props) => ( + +); + +const LabelWithInfo = ({ data }: { data: SearchOption }) => { + return ( + !data.hide && ( + <> + + + {labelForSuggestionType(data.type)} + + + + + + {data.label} + + + + {t("photos_count", { count: data.fileCount })} + + + + + {data.previewFiles.map((file) => ( + null} + collectionTile={ResultPreviewTile} + /> + ))} + + + + + + ) + ); +}; + +const ValueContainerWithIcon: SelectComponents< + SearchOption, + false +>["ValueContainer"] = (props) => ( + + + theme.colors.stroke.muted} + > + {getIconByType(props.getValue()[0]?.type)} + + {props.children} + + +); + +const getIconByType = (type: SuggestionType) => { + switch (type) { + case SuggestionType.DATE: + return ; + case SuggestionType.LOCATION: + case SuggestionType.CITY: + return ; + case SuggestionType.COLLECTION: + return ; + case SuggestionType.FILE_NAME: + return ; + default: + return ; + } +}; + +export const MenuWithPeople = (props) => { + // log.info("props.selectProps.options: ", selectRef); + const peopleSuggestions = props.selectProps.options.filter( + (o) => o.type === SuggestionType.PERSON, + ); + const people = peopleSuggestions.map((o) => o.value); + + const indexStatusSuggestion = props.selectProps.options.filter( + (o) => o.type === SuggestionType.INDEX_STATUS, + )[0] as Suggestion; + + const indexStatus = indexStatusSuggestion?.value; + return ( + + + {isMLEnabled() && + indexStatus && + (people && people.length > 0 ? ( + + {t("people")} + + ) : ( + + ))} + + {isMLEnabled() && indexStatus && ( + + {indexStatusSuggestion.label} + + )} + {people && people.length > 0 && ( + + { + props.selectRef.current.blur(); + props.setValue(peopleSuggestions[index]); + }} + /> + + )} + + {props.children} + + ); +}; + +const Legend = styled("span")` + font-size: 20px; + color: #ddd; + display: inline; + padding: 0px 12px; +`; + +const Caption = styled("span")` + font-size: 12px; + display: inline; + padding: 0px 12px; +`; + +const VisibleInput = (props) => ( + +); + +function SearchBarMobile({ show, showSearchInput }) { + if (!show) { + return <>; + } + return ( + + + + + + + + ); +} + +const SearchMobileBox = styled(FluidContainer)` + display: flex; + cursor: pointer; + align-items: center; + justify-content: flex-end; + @media (min-width: 625px) { + display: none; + } +`; diff --git a/web/apps/photos/src/components/pages/gallery/Navbar.tsx b/web/apps/photos/src/components/pages/gallery/Navbar.tsx index dfb8a6339c..729a536351 100644 --- a/web/apps/photos/src/components/pages/gallery/Navbar.tsx +++ b/web/apps/photos/src/components/pages/gallery/Navbar.tsx @@ -5,7 +5,7 @@ import { FlexWrapper, HorizontalFlex } from "@ente/shared/components/Container"; import ArrowBack from "@mui/icons-material/ArrowBack"; import MenuIcon from "@mui/icons-material/Menu"; import { IconButton, Typography } from "@mui/material"; -import SearchBar from "components/Search/SearchBar"; +import SearchBar from "components/SearchBar"; import UploadButton from "components/Upload/UploadButton"; import { t } from "i18next"; import { Collection } from "types/collection"; diff --git a/web/apps/photos/src/pages/deduplicate/index.tsx b/web/apps/photos/src/pages/deduplicate.tsx similarity index 100% rename from web/apps/photos/src/pages/deduplicate/index.tsx rename to web/apps/photos/src/pages/deduplicate.tsx diff --git a/web/apps/photos/src/pages/gallery/index.tsx b/web/apps/photos/src/pages/gallery.tsx similarity index 98% rename from web/apps/photos/src/pages/gallery/index.tsx rename to web/apps/photos/src/pages/gallery.tsx index 8ab0eee90f..1c3b51671a 100644 --- a/web/apps/photos/src/pages/gallery/index.tsx +++ b/web/apps/photos/src/pages/gallery.tsx @@ -40,12 +40,14 @@ import type { User } from "@ente/shared/user/types"; import { Typography, styled } from "@mui/material"; import AuthenticateUserModal from "components/AuthenticateUserModal"; import Collections from "components/Collections"; +import { CollectionInfo } from "components/Collections/CollectionInfo"; import CollectionNamer, { CollectionNamerAttributes, } from "components/Collections/CollectionNamer"; import CollectionSelector, { CollectionSelectorAttributes, } from "components/Collections/CollectionSelector"; +import { CollectionInfoBarWrapper } from "components/Collections/styledComponents"; import ExportModal from "components/ExportModal"; import { FilesDownloadProgress, @@ -59,7 +61,6 @@ import GalleryEmptyState from "components/GalleryEmptyState"; import { LoadingOverlay } from "components/LoadingOverlay"; import PhotoFrame from "components/PhotoFrame"; import { ITEM_TYPE, TimeStampListItem } from "components/PhotoList"; -import SearchResultInfo from "components/Search/SearchResultInfo"; import Sidebar from "components/Sidebar"; import type { UploadTypeSelectorIntent } from "components/Upload/UploadTypeSelector"; import Uploader from "components/Upload/Uploader"; @@ -480,7 +481,7 @@ export default function Gallery() { setPhotoListHeader({ height: 104, item: ( - ), @@ -1252,3 +1253,26 @@ const mergeMaps = (map1: Map, map2: Map) => { }); return mergedMap; }; + +interface SearchResultSummaryHeaderProps { + searchResultSummary: SearchResultSummary; +} + +const SearchResultSummaryHeader: React.FC = ({ + searchResultSummary, +}) => { + if (!searchResultSummary) { + return <>; + } + + const { optionName, fileCount } = searchResultSummary; + + return ( + + + {t("search_results")} + + + + ); +}; diff --git a/web/apps/photos/src/pages/shared-albums/index.tsx b/web/apps/photos/src/pages/shared-albums.tsx similarity index 100% rename from web/apps/photos/src/pages/shared-albums/index.tsx rename to web/apps/photos/src/pages/shared-albums.tsx diff --git a/web/apps/photos/src/services/logout.ts b/web/apps/photos/src/services/logout.ts index 8ebf30aec9..fa36e4f8fa 100644 --- a/web/apps/photos/src/services/logout.ts +++ b/web/apps/photos/src/services/logout.ts @@ -3,6 +3,7 @@ import log from "@/base/log"; import DownloadManager from "@/new/photos/services/download"; import { clearFeatureFlagSessionState } from "@/new/photos/services/feature-flags"; import { logoutML, terminateMLWorker } from "@/new/photos/services/ml"; +import { logoutSearch } from "@/new/photos/services/search"; import exportService from "./export"; /** @@ -18,13 +19,13 @@ export const photosLogout = async () => { // - Workers - // Terminate any workers before clearing persistent state. - // See: [Note: Caching IDB instances in separate execution contexts]. + // Terminate any workers that might access the DB before clearing persistent + // state. See: [Note: Caching IDB instances in separate execution contexts]. try { await terminateMLWorker(); } catch (e) { - ignoreError("face", e); + ignoreError("ml/worker", e); } // - Remote logout and clear state @@ -47,6 +48,12 @@ export const photosLogout = async () => { ignoreError("download", e); } + try { + logoutSearch(); + } catch (e) { + ignoreError("search", e); + } + // - Desktop const electron = globalThis.electron; diff --git a/web/apps/photos/src/services/searchService.ts b/web/apps/photos/src/services/searchService.ts index cd19fb0c9f..037af63839 100644 --- a/web/apps/photos/src/services/searchService.ts +++ b/web/apps/photos/src/services/searchService.ts @@ -47,8 +47,8 @@ export const getAutoCompleteSuggestions = // - getClipSuggestion(searchPhrase) // - getDateSuggestion(searchPhrase), // - getLocationSuggestion(searchPhrase), + // - getFileTypeSuggestion(searchPhrase), ...(await createSearchQuery(searchPhrase)), - ...getFileTypeSuggestion(searchPhrase2), ...getCollectionSuggestion(searchPhrase2, collections), getFileNameSuggestion(searchPhrase2, files), getFileCaptionSuggestion(searchPhrase2, files), @@ -85,27 +85,6 @@ async function convertSuggestionsToOptions( } return previewImageAppendedOptions; } -function getFileTypeSuggestion(searchPhrase: string): Suggestion[] { - return [ - { - label: t("IMAGE"), - value: FileType.image, - type: SuggestionType.FILE_TYPE, - }, - { - label: t("VIDEO"), - value: FileType.video, - type: SuggestionType.FILE_TYPE, - }, - { - label: t("LIVE_PHOTO"), - value: FileType.livePhoto, - type: SuggestionType.FILE_TYPE, - }, - ].filter((suggestion) => - suggestion.label.toLowerCase().includes(searchPhrase), - ); -} export async function getAllPeopleSuggestion(): Promise> { try { diff --git a/web/packages/new/photos/services/ml/cgroups.ts b/web/packages/new/photos/services/ml/cgroups.ts index 49388eb990..77cdd0ee8f 100644 --- a/web/packages/new/photos/services/ml/cgroups.ts +++ b/web/packages/new/photos/services/ml/cgroups.ts @@ -1,3 +1,6 @@ +import { masterKeyFromSession } from "@/base/session-store"; +import { pullCGroups } from "../user-entity"; + /** * A cgroup ("cluster group") is a group of clusters (possibly containing a * single cluster) that the user has interacted with. @@ -18,11 +21,14 @@ * cluster, or they may hide an named {@link CGroup}. In both cases, we promote * the cluster to a CGroup if needed so that their request to hide gets synced. * + * cgroups are synced with remote. + * * While in our local representation we separately maintain clusters and link to * them from within CGroups by their clusterID, in the remote representation - * clusters themselves don't get synced. Instead, the "cgroup" entities synced - * with remote contain the clusters within themselves. So a group that gets - * synced with remote looks something like: + * clusters themselves don't get synced. Instead, the cgroup entities synced + * with remote contain the clusters within themselves. + * + * That is, a cgroup that gets synced with remote looks something like: * * { id, name, clusters: [{ clusterID, faceIDs }] } * @@ -63,7 +69,7 @@ export interface CGroup { isHidden: boolean; /** * The ID of the face that should be used as the cover photo for this - * cluster group (if the user has set one). + * cluster group. Optional. * * This is similar to the [@link displayFaceID}, the difference being: * @@ -73,6 +79,13 @@ export interface CGroup { * into effect if the user has not explicitly selected a face. */ avatarFaceID: string | undefined; +} + +/** + * A {@link CGroup} annotated with various in-memory state to make it easier for + * the upper layers of our code to directly use it. + */ +export type AnnotatedCGroup = CGroup & { /** * Locally determined ID of the "best" face that should be used as the * display face, to represent this cluster group in the UI. @@ -81,7 +94,7 @@ export interface CGroup { * {@link avatarFaceID}. */ displayFaceID: string | undefined; -} +}; /** * Syncronize the user's cluster groups with remote, running local clustering if @@ -109,7 +122,7 @@ export interface CGroup { * - They can hide a cluster. This creates an unnamed cgroup so that the * user's other clients know not to show it. */ -export const syncCGroups = () => { +export const syncCGroups = async () => { // 1. Fetch existing cgroups for the user from remote. // 2. Save them to DB. // 3. Prune stale faceIDs from the clusters in the DB. @@ -118,7 +131,10 @@ export const syncCGroups = () => { // // The user can see both the cgroups and clusters in the UI, but only the // cgroups are synced. - // const syncCGroupsWithRemote() + + const masterKey = await masterKeyFromSession(); + await pullCGroups(masterKey); + /* * After clustering, we also do some routine cleanup. Faces belonging to files * that have been deleted (including those in Trash) should be pruned off. diff --git a/web/packages/new/photos/services/ml/cluster.ts b/web/packages/new/photos/services/ml/cluster.ts index e00c084fef..919e602aee 100644 --- a/web/packages/new/photos/services/ml/cluster.ts +++ b/web/packages/new/photos/services/ml/cluster.ts @@ -3,7 +3,7 @@ import { newNonSecureID } from "@/base/id-worker"; import log from "@/base/log"; import { ensure } from "@/utils/ensure"; import type { EnteFile } from "../../types/file"; -import type { CGroup } from "./cgroups"; +import type { AnnotatedCGroup } from "./cgroups"; import { faceDirection, type Face, type FaceIndex } from "./face"; import { dotProduct } from "./math"; @@ -199,7 +199,7 @@ export const clusterFaces = ( // locally, so cgroups will be empty. Create a temporary (unsaved, unsynced) // cgroup, one per cluster. - const cgroups: CGroup[] = []; + const cgroups: AnnotatedCGroup[] = []; for (const cluster of sortedClusters) { const faces = cluster.faceIDs.map((id) => ensure(faceForFaceID.get(id)), diff --git a/web/packages/new/photos/services/ml/db.ts b/web/packages/new/photos/services/ml/db.ts index 86cc0afd03..833bcffbd3 100644 --- a/web/packages/new/photos/services/ml/db.ts +++ b/web/packages/new/photos/services/ml/db.ts @@ -32,17 +32,17 @@ import type { LocalFaceIndex } from "./face"; * In tandem, these serve as the underlying storage for the indexes maintained * in the ML database. * - * The cluster related object stores are the following: + * The face clustering related object stores are the following: * * - "face-cluster": Contains {@link FaceCluster} objects, one for each * cluster of faces that either the clustering algorithm produced locally or - * were synced from remote. It is indexed by the (cluster) ID. + * were synced from remote. It is indexed by the cluster ID. * * - "cluster-group": Contains {@link CGroup} objects, one for each group of * clusters that were synced from remote. The client can also locally * generate cluster groups on certain user interactions, but these too will * eventually get synced with remote. This object store is indexed by the - * (cgroup) ID. + * cgroup ID. */ interface MLDBSchema extends DBSchema { "file-status": { @@ -441,24 +441,3 @@ export const applyCGroupDiff = async (diff: (string | CGroup)[]) => { ); return tx.done; }; - -/** - * Add or overwrite the entry for the given {@link cgroup}, as identified by - * their {@link id}. - */ -// TODO-Cluster: Remove me -export const saveClusterGroup = async (cgroup: CGroup) => { - const db = await mlDB(); - const tx = db.transaction("cluster-group", "readwrite"); - await Promise.all([tx.store.put(cgroup), tx.done]); -}; - -/** - * Delete the entry (if any) for the cluster group with the given {@link id}. - */ -// TODO-Cluster: Remove me -export const deleteClusterGroup = async (id: string) => { - const db = await mlDB(); - const tx = db.transaction("cluster-group", "readwrite"); - await Promise.all([tx.store.delete(id), tx.done]); -}; diff --git a/web/packages/new/photos/services/search/index.ts b/web/packages/new/photos/services/search/index.ts index abd920c9e1..cfd7a0ca21 100644 --- a/web/packages/new/photos/services/search/index.ts +++ b/web/packages/new/photos/services/search/index.ts @@ -1,12 +1,15 @@ import { isDesktop } from "@/base/app"; import { masterKeyFromSession } from "@/base/session-store"; import { ComlinkWorker } from "@/base/worker/comlink-worker"; +import { FileType } from "@/media/file-type"; import i18n, { t } from "i18next"; import type { EnteFile } from "../../types/file"; import { clipMatches, isMLEnabled } from "../ml"; import { SuggestionType, type DateSearchResult, + type LabelledFileType, + type LocalizedSearchData, type SearchQuery, } from "./types"; import type { SearchWorker } from "./worker"; @@ -31,6 +34,17 @@ const createComlinkWorker = () => new Worker(new URL("worker.ts", import.meta.url)), ); +/** + * Perform any logout specific cleanup for the search subsystem. + */ +export const logoutSearch = () => { + if (_comlinkWorker) { + _comlinkWorker.terminate(); + _comlinkWorker = undefined; + } + _localizedSearchData = undefined; +}; + /** * Fetch any data that would be needed if the user were to search. */ @@ -59,7 +73,7 @@ export const createSearchQuery = async (searchString: string) => { // the search worker, then combine the two. const results = await Promise.all([ clipSuggestions(s, searchString).then((s) => s ?? []), - worker().then((w) => w.createSearchQuery(s, i18n.language, holidays())), + worker().then((w) => w.createSearchQuery(s, localizedSearchData())), ]); return results.flat(); }; @@ -85,12 +99,40 @@ export const search = async (search: SearchQuery) => worker().then((w) => w.search(search)); /** - * A list of holidays - their yearly dates and localized names. + * Cached value of {@link localizedSearchData}. + */ +let _localizedSearchData: LocalizedSearchData | undefined; + +/* + * For searching, the web worker needs a bunch of otherwise static data that has + * names and labels formed by localized strings. * - * We need to keep this on the main thread since it uses the t() function for - * localization (although I haven't tried that in a web worker, it might work - * there too). Also, it cannot be a const since it needs to be evaluated lazily - * for the t() to work. + * Since it would be tricky to get the t() function to work in a web worker, we + * instead pass this from the main thread (lazily initialized and cached). + * + * Note that these need to be evaluated at runtime, and cannot be static + * constants since t() depends on the user's locale. + * + * We currently clear the cached data on logout, but this is not necessary. The + * only point we necessarily need to clear this data is if the user changes their + * preferred locale, but currently we reload the page in such cases so any in + * memory state would be reset that way. + */ +const localizedSearchData = () => + (_localizedSearchData ??= { + locale: i18n.language, + holidays: holidays().map((h) => ({ + ...h, + lowercasedName: h.label.toLowerCase(), + })), + labelledFileTypes: labelledFileTypes().map((t) => ({ + ...t, + lowercasedName: t.label.toLowerCase(), + })), + }); + +/** + * A list of holidays - their yearly dates and localized names. */ const holidays = (): DateSearchResult[] => [ { components: { month: 12, day: 25 }, label: t("CHRISTMAS") }, @@ -98,3 +140,12 @@ const holidays = (): DateSearchResult[] => [ { components: { month: 1, day: 1 }, label: t("NEW_YEAR") }, { components: { month: 12, day: 31 }, label: t("NEW_YEAR_EVE") }, ]; + +/** + * A list of file types with their localized names. + */ +const labelledFileTypes = (): LabelledFileType[] => [ + { fileType: FileType.image, label: t("IMAGE") }, + { fileType: FileType.video, label: t("VIDEO") }, + { fileType: FileType.livePhoto, label: t("LIVE_PHOTO") }, +]; diff --git a/web/packages/new/photos/services/search/types.ts b/web/packages/new/photos/services/search/types.ts index 080803956a..b2130e4bbc 100644 --- a/web/packages/new/photos/services/search/types.ts +++ b/web/packages/new/photos/services/search/types.ts @@ -14,6 +14,35 @@ export interface DateSearchResult { label: string; } +export interface LabelledFileType { + fileType: FileType; + label: string; +} + +/** + * An annotated version of {@link T} that includes its searchable "lowercased" + * label or name. + * + * Precomputing these lowercased values saves us from doing the lowercasing + * during the search itself. + */ +export type Searchable = T & { + /** + * The name or label of T, lowercased. + */ + lowercasedName: string; +}; + +/** + * Various bits of static but locale specific data that the search worker needs + * during searching. + */ +export interface LocalizedSearchData { + locale: string; + holidays: Searchable[]; + labelledFileTypes: Searchable[]; +} + /** * A parsed version of a potential natural language date time string. * diff --git a/web/packages/new/photos/services/search/worker.ts b/web/packages/new/photos/services/search/worker.ts index 714453b419..ca842d40d6 100644 --- a/web/packages/new/photos/services/search/worker.ts +++ b/web/packages/new/photos/services/search/worker.ts @@ -9,42 +9,30 @@ import * as chrono from "chrono-node"; import { expose } from "comlink"; import { z } from "zod"; import { + pullLocationTags, savedLocationTags, - syncLocationTags, type LocationTag, } from "../user-entity"; import type { City, DateSearchResult, + LabelledFileType, + LocalizedSearchData, + Searchable, SearchDateComponents, SearchQuery, Suggestion, } from "./types"; import { SuggestionType } from "./types"; -type SearchableCity = City & { - /** - * Name of the city, lowercased. Precomputed to save an op during search. - */ - lowercasedName: string; -}; - -type SearchableLocationTag = LocationTag & { - /** - * Name of the location tag, lowercased. Precomputed to save an op during - * search. - */ - lowercasedName: string; -}; - /** * A web worker that runs the search asynchronously so that the main thread * remains responsive. */ export class SearchWorker { private enteFiles: EnteFile[] = []; - private locationTags: SearchableLocationTag[] = []; - private cities: SearchableCity[] = []; + private locationTags: Searchable[] = []; + private cities: Searchable[] = []; /** * Fetch any state we might need when the actual search happens. @@ -54,7 +42,7 @@ export class SearchWorker { */ async sync(masterKey: Uint8Array) { return Promise.all([ - syncLocationTags(masterKey) + pullLocationTags(masterKey) .then(() => savedLocationTags()) .then((ts) => { this.locationTags = ts.map((t) => ({ @@ -81,11 +69,10 @@ export class SearchWorker { /** * Convert a search string into a reusable query. */ - createSearchQuery(s: string, locale: string, holidays: DateSearchResult[]) { + createSearchQuery(s: string, localizedSearchData: LocalizedSearchData) { return createSearchQuery( s, - locale, - holidays, + localizedSearchData, this.locationTags, this.cities, ); @@ -103,20 +90,20 @@ expose(SearchWorker); const createSearchQuery = ( s: string, - locale: string, - holidays: DateSearchResult[], - locationTags: SearchableLocationTag[], - cities: SearchableCity[], + { locale, holidays, labelledFileTypes }: LocalizedSearchData, + locationTags: Searchable[], + cities: Searchable[], ): Suggestion[] => [ dateSuggestions(s, locale, holidays), locationSuggestions(s, locationTags, cities), + fileTypeSuggestions(s, labelledFileTypes), ].flat(); const dateSuggestions = ( s: string, locale: string, - holidays: DateSearchResult[], + holidays: Searchable[], ) => parseDateComponents(s, locale, holidays).map(({ components, label }) => ({ type: SuggestionType.DATE, @@ -141,12 +128,12 @@ const dateSuggestions = ( const parseDateComponents = ( s: string, locale: string, - holidays: DateSearchResult[], + holidays: Searchable[], ): DateSearchResult[] => [ parseChrono(s, locale), parseYearComponents(s), - parseHolidayComponents(s, holidays), + holidays.filter(searchableIncludes(s)), ].flat(); const parseChrono = (s: string, locale: string): DateSearchResult[] => @@ -195,8 +182,13 @@ const parseYearComponents = (s: string): DateSearchResult[] => { return []; }; -const parseHolidayComponents = (s: string, holidays: DateSearchResult[]) => - holidays.filter(({ label }) => label.toLowerCase().includes(s)); +/** + * A helper function to directly pass to filters on Searchable[]. + */ +const searchableIncludes = + (s: string) => + ({ lowercasedName }: { lowercasedName: string }) => + lowercasedName.includes(s); /** * Zod schema describing world_cities.json. @@ -223,12 +215,10 @@ const fetchCities = async () => { const locationSuggestions = ( s: string, - locationTags: SearchableLocationTag[], - cities: SearchableCity[], + locationTags: Searchable[], + cities: Searchable[], ) => { - const matchingLocationTags = locationTags.filter((t) => - t.lowercasedName.includes(s), - ); + const matchingLocationTags = locationTags.filter(searchableIncludes(s)); const matchingLocationTagLNames = new Set( matchingLocationTags.map((t) => t.lowercasedName), @@ -254,6 +244,18 @@ const locationSuggestions = ( ].flat(); }; +const fileTypeSuggestions = ( + s: string, + labelledFileTypes: Searchable[], +) => + labelledFileTypes + .filter(searchableIncludes(s)) + .map(({ fileType, label }) => ({ + label, + value: fileType, + type: SuggestionType.FILE_TYPE, + })); + /** * Return true if file satisfies the given {@link query}. */ diff --git a/web/packages/new/photos/services/user-entity.ts b/web/packages/new/photos/services/user-entity.ts index 7064d21d82..731a39d565 100644 --- a/web/packages/new/photos/services/user-entity.ts +++ b/web/packages/new/photos/services/user-entity.ts @@ -35,16 +35,16 @@ export type EntityType = | "cgroup"; /** - * Sync our local location tags with those on remote. + * Update our local location tags with changes from remote. * * This function fetches all the location tag user entities from remote and - * updates our local database. It uses local state to remember the last time it - * synced, so each subsequent sync is a lightweight diff. + * updates our local database. It uses local state to remember the latest entry + * the last time it did a pull, so each subsequent pull is a lightweight diff. * * @param masterKey The user's master key. This is used to encrypt and decrypt * the location tags specific entity key. */ -export const syncLocationTags = async (masterKey: Uint8Array) => { +export const pullLocationTags = async (masterKey: Uint8Array) => { const decoder = new TextDecoder(); const parse = (id: string, data: Uint8Array): LocationTag => ({ id, @@ -63,7 +63,7 @@ export const syncLocationTags = async (masterKey: Uint8Array) => { return saveLocationTags([...existingTagsByID.values()]); }; - return syncUserEntity("location", masterKey, processBatch); + return pullUserEntities("location", masterKey, processBatch); }; /** Zod schema for the tag that we get from or put to remote. */ @@ -89,7 +89,7 @@ const saveLocationTags = (tags: LocationTag[]) => /** * Return all the location tags that are present locally. * - * Use {@link syncLocationTags} to sync this list with remote. + * Use {@link pullLocationTags} to synchronize this list with remote. */ export const savedLocationTags = async () => LocalLocationTag.array().parse( @@ -97,7 +97,7 @@ export const savedLocationTags = async () => ); /** - * Sync the {@link CGroup} entities that we have locally with remote. + * Update our local cgroups with changes from remote. * * This fetches all the user entities corresponding to the "cgroup" entity type * from remote that have been created, updated or deleted since the last time we @@ -108,16 +108,15 @@ export const savedLocationTags = async () => * @param masterKey The user's master key. This is used to encrypt and decrypt * the cgroup specific entity key. */ -export const syncCGroups = (masterKey: Uint8Array) => { +export const pullCGroups = (masterKey: Uint8Array) => { const parse = async (id: string, data: Uint8Array): Promise => { - const rp = RemoteCGroup.parse(JSON.parse(await gunzip(data))); + const r = RemoteCGroup.parse(JSON.parse(await gunzip(data))); return { id, - name: rp.name, - clusterIDs: rp.assigned.map(({ id }) => id), - isHidden: rp.isHidden, - avatarFaceID: rp.avatarFaceID, - displayFaceID: undefined, + name: r.name, + clusterIDs: r.assigned.map(({ id }) => id), + isHidden: r.isHidden, + avatarFaceID: r.avatarFaceID, }; }; @@ -130,7 +129,7 @@ export const syncCGroups = (masterKey: Uint8Array) => { ), ); - return syncUserEntity("cgroup", masterKey, processBatch); + return pullUserEntities("cgroup", masterKey, processBatch); }; const RemoteCGroup = z.object({ @@ -141,6 +140,8 @@ const RemoteCGroup = z.object({ faces: z.string().array(), }), ), + // The remote cgroup also has a "rejected" property, but that is not + // currently used by any of the clients. isHidden: z.boolean(), avatarFaceID: z.string().nullish().transform(nullToUndefined), }); @@ -209,7 +210,7 @@ interface UserEntityChange { * The user's {@link masterKey} is used to decrypt (or encrypt, when generating * a new one) the entity key. */ -const syncUserEntity = async ( +const pullUserEntities = async ( type: EntityType, masterKey: Uint8Array, processBatch: (entities: UserEntityChange[]) => Promise,