diff --git a/README.md b/README.md index 33bfd5ea5..5b8acb0b1 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,9 @@ An important part of our journey is to build better software by consistently lis
---- +## 🙇 Attributions -Cross-browser testing provided by +- Cross-browser testing provided by [](https://www.browserstack.com/open-source) + +- Location search powered by [Simple Maps](https://simplemaps.com/data/world-cities) diff --git a/apps/auth/public/locales/en/translation.json b/apps/auth/public/locales/en/translation.json index 0b315b9d6..67684d46e 100644 --- a/apps/auth/public/locales/en/translation.json +++ b/apps/auth/public/locales/en/translation.json @@ -206,6 +206,7 @@ "SEARCH_TYPE": { "COLLECTION": "Album", "LOCATION": "Location", + "CITY": "Location", "DATE": "Date", "FILE_NAME": "File name", "THING": "Content", diff --git a/apps/photos/public/locales/en/translation.json b/apps/photos/public/locales/en/translation.json index 884a3d91a..48718e8e2 100644 --- a/apps/photos/public/locales/en/translation.json +++ b/apps/photos/public/locales/en/translation.json @@ -210,6 +210,7 @@ "SEARCH_TYPE": { "COLLECTION": "Album", "LOCATION": "Location", + "CITY": "Location", "DATE": "Date", "FILE_NAME": "File name", "THING": "Content", diff --git a/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx b/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx index 7db8d94ad..c8e9bb289 100644 --- a/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx +++ b/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx @@ -30,6 +30,7 @@ import { LocationTagData } from 'types/entity'; import { FILE_TYPE } from 'constants/file'; import { InputActionMeta } from 'react-select/src/types'; import { components } from 'react-select'; +import { City } from 'services/locationSearchService'; interface Iprops { isOpen: boolean; @@ -78,7 +79,7 @@ export default function SearchInput(props: Iprops) { }, []); async function refreshDefaultOptions() { - const defaultOptions = await getDefaultOptions(props.files); + const defaultOptions = await getDefaultOptions(); setDefaultOptions(defaultOptions); } @@ -95,9 +96,12 @@ export default function SearchInput(props: Iprops) { } }; - const getOptions = pDebounce( - getAutoCompleteSuggestions(props.files, props.collections), - 250 + const getOptions = useCallback( + pDebounce( + getAutoCompleteSuggestions(props.files, props.collections), + 250 + ), + [props.files, props.collections] ); const blur = () => { @@ -122,6 +126,12 @@ export default function SearchInput(props: Iprops) { }; 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); diff --git a/apps/photos/src/components/Search/SearchBar/searchInput/valueContainerWithIcon.tsx b/apps/photos/src/components/Search/SearchBar/searchInput/valueContainerWithIcon.tsx index 8bf92a310..9ebe3cd58 100644 --- a/apps/photos/src/components/Search/SearchBar/searchInput/valueContainerWithIcon.tsx +++ b/apps/photos/src/components/Search/SearchBar/searchInput/valueContainerWithIcon.tsx @@ -17,6 +17,7 @@ const getIconByType = (type: SuggestionType) => { case SuggestionType.DATE: return ; case SuggestionType.LOCATION: + case SuggestionType.CITY: return ; case SuggestionType.COLLECTION: return ; diff --git a/apps/photos/src/pages/gallery/index.tsx b/apps/photos/src/pages/gallery/index.tsx index dda10a5fc..22bf87778 100644 --- a/apps/photos/src/pages/gallery/index.tsx +++ b/apps/photos/src/pages/gallery/index.tsx @@ -120,7 +120,6 @@ import GalleryEmptyState from 'components/GalleryEmptyState'; import AuthenticateUserModal from 'components/AuthenticateUserModal'; import useMemoSingleThreaded from '@ente/shared/hooks/useMemoSingleThreaded'; import { isArchivedFile } from 'utils/magicMetadata'; -import { isSameDayAnyYear, isInsideLocationTag } from 'utils/search'; import { getSessionExpiredMessage } from 'utils/ui'; import { syncEntities } from 'services/entityService'; import { constructUserIDToEmailMap } from 'services/collectionService'; @@ -131,6 +130,9 @@ import { ClipService } from 'services/clipService'; import isElectron from 'is-electron'; import downloadManager from 'services/download'; import { APPS } from '@ente/shared/apps/constants'; +import locationSearchService from 'services/locationSearchService'; +import ComlinkSearchWorker from 'utils/comlink/ComlinkSearchWorker'; +import useEffectSingleThreaded from '@ente/shared/hooks/useEffectSingleThreaded'; export const DeadCenter = styled('div')` flex: 1; @@ -345,6 +347,7 @@ export default function Gallery() { setIsFirstLoad(false); setJustSignedUp(false); setIsFirstFetch(false); + locationSearchService.loadCities(); syncInterval.current = setInterval(() => { syncWithRemote(false, true); }, SYNC_INTERVAL_IN_MICROSECONDS); @@ -365,6 +368,14 @@ export default function Gallery() { }; }, []); + useEffectSingleThreaded( + async ([files]: [files: EnteFile[]]) => { + const searchWorker = await ComlinkSearchWorker.getInstance(); + await searchWorker.setFiles(files); + }, + [files] + ); + useEffect(() => { if (!user || !files || !collections || !hiddenFiles || !trashedFiles) { return; @@ -470,7 +481,9 @@ export default function Gallery() { ); }, [collections, activeCollectionID]); - const filteredData = useMemoSingleThreaded((): EnteFile[] => { + const filteredData = useMemoSingleThreaded(async (): Promise< + EnteFile[] + > => { if ( !files || !user || @@ -488,118 +501,70 @@ export default function Gallery() { ]); } - const filteredFiles = getUniqueFiles( - (isInHiddenSection ? hiddenFiles : files).filter((item) => { - if (tempDeletedFileIds?.has(item.id)) { - return false; - } + const searchWorker = await ComlinkSearchWorker.getInstance(); - if (!isInHiddenSection && tempHiddenFileIds?.has(item.id)) { - return false; - } + let filteredFiles: EnteFile[] = []; + if (isInSearchMode) { + filteredFiles = getUniqueFiles(await searchWorker.search(search)); + } else { + filteredFiles = getUniqueFiles( + (isInHiddenSection ? hiddenFiles : files).filter((item) => { + if (tempDeletedFileIds?.has(item.id)) { + return false; + } - // SEARCH MODE - if (isInSearchMode) { - if ( - search?.date && - !isSameDayAnyYear(search.date)( - new Date(item.metadata.creationTime / 1000) - ) - ) { + if (!isInHiddenSection && tempHiddenFileIds?.has(item.id)) { return false; } - if ( - search?.location && - !isInsideLocationTag( - { - latitude: item.metadata.latitude, - longitude: item.metadata.longitude, - }, - search.location - ) - ) { - return false; - } - if ( - search?.person && - search.person.files.indexOf(item.id) === -1 - ) { - return false; - } - if ( - search?.thing && - search.thing.files.indexOf(item.id) === -1 - ) { - return false; - } - if ( - search?.text && - search.text.files.indexOf(item.id) === -1 - ) { - return false; - } - if (search?.files && search.files.indexOf(item.id) === -1) { - return false; - } - if ( - typeof search?.fileType !== 'undefined' && - search.fileType !== item.metadata.fileType - ) { - return false; - } - if (search?.clip && search.clip.has(item.id) === false) { - return false; - } - return true; - } - // archived collections files can only be seen in their respective collection - if (archivedCollections.has(item.collectionID)) { + // archived collections files can only be seen in their respective collection + if (archivedCollections.has(item.collectionID)) { + if (activeCollectionID === item.collectionID) { + return true; + } else { + return false; + } + } + + // HIDDEN ITEMS SECTION - show all individual hidden files + if ( + activeCollectionID === HIDDEN_ITEMS_SECTION && + defaultHiddenCollectionIDs.has(item.collectionID) + ) { + return true; + } + + // Archived files can only be seen in archive section or their respective collection + if (isArchivedFile(item)) { + if ( + activeCollectionID === ARCHIVE_SECTION || + activeCollectionID === item.collectionID + ) { + return true; + } else { + return false; + } + } + + // ALL SECTION - show all files + if (activeCollectionID === ALL_SECTION) { + // show all files except the ones in hidden collections + if (hiddenFileIds.has(item.id)) { + return false; + } else { + return true; + } + } + + // COLLECTION SECTION - show files in the active collection if (activeCollectionID === item.collectionID) { return true; } else { return false; } - } - - // HIDDEN ITEMS SECTION - show all individual hidden files - if ( - activeCollectionID === HIDDEN_ITEMS_SECTION && - defaultHiddenCollectionIDs.has(item.collectionID) - ) { - return true; - } - - // Archived files can only be seen in archive section or their respective collection - if (isArchivedFile(item)) { - if ( - activeCollectionID === ARCHIVE_SECTION || - activeCollectionID === item.collectionID - ) { - return true; - } else { - return false; - } - } - - // ALL SECTION - show all files - if (activeCollectionID === ALL_SECTION) { - // show all files except the ones in hidden collections - if (hiddenFileIds.has(item.id)) { - return false; - } else { - return true; - } - } - - // COLLECTION SECTION - show files in the active collection - if (activeCollectionID === item.collectionID) { - return true; - } else { - return false; - } - }) - ); + }) + ); + } if (search?.clip) { return filteredFiles.sort((a, b) => { return search.clip.get(b.id) - search.clip.get(a.id); diff --git a/apps/photos/src/services/locationSearchService.ts b/apps/photos/src/services/locationSearchService.ts new file mode 100644 index 000000000..eebe44fac --- /dev/null +++ b/apps/photos/src/services/locationSearchService.ts @@ -0,0 +1,97 @@ +import { CITIES_URL } from '@ente/shared/constants/urls'; +import { logError } from '@ente/shared/sentry'; +import { LocationTagData } from 'types/entity'; +import { Location } from 'types/upload'; + +export interface City { + city: string; + country: string; + lat: number; + lng: number; +} + +const DEFAULT_CITY_RADIUS = 10; +const KMS_PER_DEGREE = 111.16; + +class LocationSearchService { + private cities: Array = []; + private citiesPromise: Promise; + + async loadCities() { + try { + if (this.citiesPromise) { + return; + } + this.citiesPromise = fetch(CITIES_URL).then((response) => { + return response.json().then((data) => { + this.cities = data['data']; + }); + }); + await this.citiesPromise; + } catch (e) { + logError(e, 'LocationSearchService loadCities failed'); + this.citiesPromise = null; + } + } + + async searchCities(searchTerm: string) { + try { + if (!this.citiesPromise) { + this.loadCities(); + } + await this.citiesPromise; + return this.cities.filter((city) => { + return city.city + .toLowerCase() + .startsWith(searchTerm.toLowerCase()); + }); + } catch (e) { + logError(e, 'LocationSearchService searchCities failed'); + throw e; + } + } +} + +export default new LocationSearchService(); + +export function isInsideLocationTag( + location: Location, + locationTag: LocationTagData +) { + return isLocationCloseToPoint( + location, + locationTag.centerPoint, + locationTag.radius + ); +} + +export function isInsideCity(location: Location, city: City) { + return isLocationCloseToPoint( + { latitude: city.lat, longitude: city.lng }, + location, + DEFAULT_CITY_RADIUS + ); +} + +function isLocationCloseToPoint( + centerPoint: Location, + location: Location, + radius: number +) { + const a = (radius * _scaleFactor(centerPoint.latitude)) / KMS_PER_DEGREE; + const b = radius / KMS_PER_DEGREE; + const x = centerPoint.latitude - location.latitude; + const y = centerPoint.longitude - location.longitude; + if ((x * x) / (a * a) + (y * y) / (b * b) <= 1) { + return true; + } + return false; +} + +///The area bounded by the location tag becomes more elliptical with increase +///in the magnitude of the latitude on the caritesian plane. When latitude is +///0 degrees, the ellipse is a circle with a = b = r. When latitude incrases, +///the major axis (a) has to be scaled by the secant of the latitude. +function _scaleFactor(lat: number) { + return 1 / Math.cos(lat * (Math.PI / 180)); +} diff --git a/apps/photos/src/services/searchService.ts b/apps/photos/src/services/searchService.ts index 90665dd0b..d257fde4a 100644 --- a/apps/photos/src/services/searchService.ts +++ b/apps/photos/src/services/searchService.ts @@ -16,11 +16,7 @@ import { ClipSearchScores, } from 'types/search'; import ObjectService from './machineLearning/objectService'; -import { - getFormattedDate, - isInsideLocationTag, - isSameDayAnyYear, -} from 'utils/search'; +import { getFormattedDate } from 'utils/search'; import { Person, Thing } from 'types/machineLearning'; import { getUniqueFiles } from 'utils/file'; import { getLatestEntities } from './entityService'; @@ -31,15 +27,17 @@ import { ClipService, computeClipMatchScore } from './clipService'; import { CustomError } from '@ente/shared/error'; import { Model } from 'types/embedding'; import { getLocalEmbeddings } from './embeddingService'; +import locationSearchService, { City } from './locationSearchService'; +import ComlinkSearchWorker from 'utils/comlink/ComlinkSearchWorker'; const DIGITS = new Set(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']); const CLIP_SCORE_THRESHOLD = 0.23; -export const getDefaultOptions = async (files: EnteFile[]) => { +export const getDefaultOptions = async () => { return [ await getIndexStatusSuggestion(), - ...convertSuggestionsToOptions(await getAllPeopleSuggestion(), files), + ...(await convertSuggestionsToOptions(await getAllPeopleSuggestion())), ].filter((t) => !!t); }; @@ -60,47 +58,42 @@ export const getAutoCompleteSuggestions = ...getCollectionSuggestion(searchPhrase, collections), getFileNameSuggestion(searchPhrase, files), getFileCaptionSuggestion(searchPhrase, files), - ...(await getLocationTagSuggestions(searchPhrase)), + ...(await getLocationSuggestions(searchPhrase)), ...(await getThingSuggestion(searchPhrase)), ].filter((suggestion) => !!suggestion); - return convertSuggestionsToOptions(suggestions, files); + return convertSuggestionsToOptions(suggestions); } catch (e) { logError(e, 'getAutoCompleteSuggestions failed'); return []; } }; -function convertSuggestionsToOptions( - suggestions: Suggestion[], - files: EnteFile[] -) { - const previewImageAppendedOptions: SearchOption[] = suggestions - .map((suggestion) => ({ - suggestion, - searchQuery: convertSuggestionToSearchQuery(suggestion), - })) - .map(({ suggestion, searchQuery }) => { - const resultFiles = getUniqueFiles( - files.filter((file) => isSearchedFile(file, searchQuery)) - ); - - if (searchQuery?.clip) { - resultFiles.sort((a, b) => { - const aScore = searchQuery.clip.get(a.id); - const bScore = searchQuery.clip.get(b.id); - return bScore - aScore; - }); - } - - return { +async function convertSuggestionsToOptions( + suggestions: Suggestion[] +): Promise { + const searchWorker = await ComlinkSearchWorker.getInstance(); + const previewImageAppendedOptions: SearchOption[] = []; + for (const suggestion of suggestions) { + const searchQuery = convertSuggestionToSearchQuery(suggestion); + const resultFiles = getUniqueFiles( + await searchWorker.search(searchQuery) + ); + if (searchQuery?.clip) { + resultFiles.sort((a, b) => { + const aScore = searchQuery.clip.get(a.id); + const bScore = searchQuery.clip.get(b.id); + return bScore - aScore; + }); + } + if (resultFiles.length) { + previewImageAppendedOptions.push({ ...suggestion, fileCount: resultFiles.length, previewFiles: resultFiles.slice(0, 3), - }; - }) - .filter((option) => option.fileCount); - + }); + } + } return previewImageAppendedOptions; } function getFileTypeSuggestion(searchPhrase: string): Suggestion[] { @@ -266,10 +259,9 @@ function getFileCaptionSuggestion( }; } -async function getLocationTagSuggestions(searchPhrase: string) { - const searchResults = await searchLocationTag(searchPhrase); - - return searchResults.map( +async function getLocationSuggestions(searchPhrase: string) { + const locationTagResults = await searchLocationTag(searchPhrase); + const locationTagSuggestions = locationTagResults.map( (locationTag) => ({ type: SuggestionType.LOCATION, @@ -277,6 +269,28 @@ async function getLocationTagSuggestions(searchPhrase: string) { label: locationTag.data.name, } as Suggestion) ); + const locationTagNames = new Set( + locationTagSuggestions.map((result) => result.label) + ); + + const citySearchResults = await locationSearchService.searchCities( + searchPhrase + ); + + const nonConflictingCityResult = citySearchResults.filter( + (city) => !locationTagNames.has(city.city) + ); + + const citySearchSuggestions = nonConflictingCityResult.map( + (city) => + ({ + type: SuggestionType.CITY, + value: city, + label: city.city, + } as Suggestion) + ); + + return [...locationTagSuggestions, ...citySearchSuggestions]; } async function getThingSuggestion(searchPhrase: string): Promise { @@ -406,48 +420,6 @@ async function searchClip(searchPhrase: string): Promise { return clipSearchResult; } -function isSearchedFile(file: EnteFile, search: Search) { - if (search?.collection) { - return search.collection === file.collectionID; - } - - if (search?.date) { - return isSameDayAnyYear(search.date)( - new Date(file.metadata.creationTime / 1000) - ); - } - if (search?.location) { - return isInsideLocationTag( - { - latitude: file.metadata.latitude, - longitude: file.metadata.longitude, - }, - search.location - ); - } - if (search?.files) { - return search.files.indexOf(file.id) !== -1; - } - if (search?.person) { - return search.person.files.indexOf(file.id) !== -1; - } - - if (search?.thing) { - return search.thing.files.indexOf(file.id) !== -1; - } - - if (search?.text) { - return search.text.files.indexOf(file.id) !== -1; - } - if (typeof search?.fileType !== 'undefined') { - return search.fileType === file.metadata.fileType; - } - if (typeof search?.clip !== 'undefined') { - return search.clip.has(file.id); - } - return false; -} - function convertSuggestionToSearchQuery(option: Suggestion): Search { switch (option.type) { case SuggestionType.DATE: @@ -460,6 +432,9 @@ function convertSuggestionToSearchQuery(option: Suggestion): Search { location: option.value as LocationTagData, }; + case SuggestionType.CITY: + return { city: option.value as City }; + case SuggestionType.COLLECTION: return { collection: option.value as number }; diff --git a/apps/photos/src/types/search/index.ts b/apps/photos/src/types/search/index.ts index 1e41d53f1..2e6c94f48 100644 --- a/apps/photos/src/types/search/index.ts +++ b/apps/photos/src/types/search/index.ts @@ -3,6 +3,7 @@ import { IndexStatus } from 'types/machineLearning/ui'; import { EnteFile } from 'types/file'; import { LocationTagData } from 'types/entity'; import { FILE_TYPE } from 'constants/file'; +import { City } from 'services/locationSearchService'; export enum SuggestionType { DATE = 'DATE', @@ -16,6 +17,7 @@ export enum SuggestionType { FILE_CAPTION = 'FILE_CAPTION', FILE_TYPE = 'FILE_TYPE', CLIP = 'CLIP', + CITY = 'CITY', } export interface DateValue { @@ -35,6 +37,7 @@ export interface Suggestion { | Thing | WordGroup | LocationTagData + | City | FILE_TYPE | ClipSearchScores; hide?: boolean; @@ -43,6 +46,7 @@ export interface Suggestion { export type Search = { date?: DateValue; location?: LocationTagData; + city?: City; collection?: number; files?: number[]; person?: Person; diff --git a/apps/photos/src/utils/comlink/ComlinkSearchWorker.ts b/apps/photos/src/utils/comlink/ComlinkSearchWorker.ts new file mode 100644 index 000000000..927c2d1bc --- /dev/null +++ b/apps/photos/src/utils/comlink/ComlinkSearchWorker.ts @@ -0,0 +1,30 @@ +import { Remote } from 'comlink'; +import { runningInBrowser } from 'utils/common'; +import { ComlinkWorker } from '@ente/shared/worker/comlinkWorker'; +import { DedicatedSearchWorker } from 'worker/search.worker'; + +class ComlinkSearchWorker { + private comlinkWorkerInstance: Remote; + + async getInstance() { + if (!this.comlinkWorkerInstance) { + this.comlinkWorkerInstance = await getDedicatedSearchWorker() + .remote; + } + return this.comlinkWorkerInstance; + } +} + +export const getDedicatedSearchWorker = () => { + if (runningInBrowser()) { + const cryptoComlinkWorker = new ComlinkWorker< + typeof DedicatedSearchWorker + >( + 'ente-search-worker', + new Worker(new URL('worker/search.worker.ts', import.meta.url)) + ); + return cryptoComlinkWorker; + } +}; + +export default new ComlinkSearchWorker(); diff --git a/apps/photos/src/utils/search/index.ts b/apps/photos/src/utils/search/index.ts index 891c99254..6392e4840 100644 --- a/apps/photos/src/utils/search/index.ts +++ b/apps/photos/src/utils/search/index.ts @@ -1,6 +1,4 @@ -import { LocationTagData } from 'types/entity'; import { DateValue } from 'types/search'; -import { Location } from 'types/upload'; export const isSameDayAnyYear = (baseDate: DateValue) => (compareDate: Date) => { @@ -28,18 +26,3 @@ export function getFormattedDate(date: DateValue) { new Date(date.year ?? 1, date.month ?? 1, date.date ?? 1) ); } - -export function isInsideLocationTag( - location: Location, - locationTag: LocationTagData -) { - const { centerPoint, aSquare, bSquare } = locationTag; - const { latitude, longitude } = location; - const x = Math.abs(centerPoint.latitude - latitude); - const y = Math.abs(centerPoint.longitude - longitude); - if ((x * x) / aSquare + (y * y) / bSquare <= 1) { - return true; - } else { - return false; - } -} diff --git a/apps/photos/src/worker/search.worker.ts b/apps/photos/src/worker/search.worker.ts new file mode 100644 index 000000000..f64b5031a --- /dev/null +++ b/apps/photos/src/worker/search.worker.ts @@ -0,0 +1,75 @@ +import * as Comlink from 'comlink'; +import { + isInsideLocationTag, + isInsideCity, +} from 'services/locationSearchService'; +import { EnteFile } from 'types/file'; +import { isSameDayAnyYear } from 'utils/search'; +import { Search } from 'types/search'; + +export class DedicatedSearchWorker { + private files: EnteFile[] = []; + + setFiles(files: EnteFile[]) { + this.files = files; + } + + search(search: Search) { + return this.files.filter((file) => { + return isSearchedFile(file, search); + }); + } +} + +Comlink.expose(DedicatedSearchWorker, self); + +function isSearchedFile(file: EnteFile, search: Search) { + if (search?.collection) { + return search.collection === file.collectionID; + } + + if (search?.date) { + return isSameDayAnyYear(search.date)( + new Date(file.metadata.creationTime / 1000) + ); + } + if (search?.location) { + return isInsideLocationTag( + { + latitude: file.metadata.latitude, + longitude: file.metadata.longitude, + }, + search.location + ); + } + if (search?.city) { + return isInsideCity( + { + latitude: file.metadata.latitude, + longitude: file.metadata.longitude, + }, + search.city + ); + } + if (search?.files) { + return search.files.indexOf(file.id) !== -1; + } + if (search?.person) { + return search.person.files.indexOf(file.id) !== -1; + } + + if (search?.thing) { + return search.thing.files.indexOf(file.id) !== -1; + } + + if (search?.text) { + return search.text.files.indexOf(file.id) !== -1; + } + if (typeof search?.fileType !== 'undefined') { + return search.fileType === file.metadata.fileType; + } + if (typeof search?.clip !== 'undefined') { + return search.clip.has(file.id); + } + return false; +} diff --git a/packages/shared/constants/urls.ts b/packages/shared/constants/urls.ts index 291704e98..122785cf5 100644 --- a/packages/shared/constants/urls.ts +++ b/packages/shared/constants/urls.ts @@ -17,3 +17,5 @@ export const WEB_ROADMAP_URL = 'https://github.com/ente-io/photos-web/issues'; export const DESKTOP_ROADMAP_URL = 'https://github.com/ente-io/photos-desktop/issues'; + +export const CITIES_URL = 'https://static.ente.io/world_cities.json'; diff --git a/packages/shared/hooks/useEffectSingleThreaded.tsx b/packages/shared/hooks/useEffectSingleThreaded.tsx new file mode 100644 index 000000000..3bdebed5a --- /dev/null +++ b/packages/shared/hooks/useEffectSingleThreaded.tsx @@ -0,0 +1,33 @@ +import { useEffect, useRef } from 'react'; +import { isPromise } from '../utils'; + +// useEffectSingleThreaded is a useEffect that will only run one at a time, and will +// caches the latest deps of requests that come in while it is running, and will +// run that after the current run is complete. +export default function useEffectSingleThreaded( + fn: (deps) => void | Promise, + deps: any[] +): void { + const updateInProgress = useRef(false); + const nextRequestDepsRef = useRef(null); + useEffect(() => { + const main = async (deps) => { + if (updateInProgress.current) { + nextRequestDepsRef.current = deps; + return; + } + updateInProgress.current = true; + const result = fn(deps); + if (isPromise(result)) { + await result; + } + updateInProgress.current = false; + if (nextRequestDepsRef.current) { + const deps = nextRequestDepsRef.current; + nextRequestDepsRef.current = null; + setTimeout(() => main(deps), 0); + } + }; + main(deps); + }, deps); +} diff --git a/packages/shared/utils/index.ts b/packages/shared/utils/index.ts index f83937702..d65ef4380 100644 --- a/packages/shared/utils/index.ts +++ b/packages/shared/utils/index.ts @@ -22,3 +22,7 @@ export function downloadUsingAnchor(link: string, name: string) { URL.revokeObjectURL(link); a.remove(); } + +export function isPromise(obj: T | Promise): obj is Promise { + return obj && typeof (obj as any).then === 'function'; +}