diff --git a/lib/app.tsx b/lib/app.tsx index 8c760dfad..a97df0d8b 100644 --- a/lib/app.tsx +++ b/lib/app.tsx @@ -100,6 +100,8 @@ const mapDispatchToProps: S.MapDispatch< dispatch ), closeNote: () => dispatch(closeNote()), + remoteNoteUpdate: (noteId, data) => + dispatch(actions.simperium.remoteNoteUpdate(noteId, data)), loadTags: () => dispatch(loadTags()), setSortType: thenReloadNotes(settingsActions.setSortType), toggleSortOrder: thenReloadNotes(settingsActions.toggleSortOrder), @@ -332,6 +334,9 @@ export const App = connect( selectNote, ui: { note }, } = this.props; + + this.props.remoteNoteUpdate(noteId, data); + if (note && noteId === note.id) { noteBucket.get(noteId, (e: unknown, storedNote: T.NoteEntity) => { if (e) { diff --git a/lib/search/index.ts b/lib/search/index.ts new file mode 100644 index 000000000..452e1fd36 --- /dev/null +++ b/lib/search/index.ts @@ -0,0 +1,109 @@ +import SearchWorker from 'worker-loader!./worker'; + +import actions from '../state/actions'; + +import * as A from '../state/action-types'; +import * as S from '../state'; +import * as T from '../types'; + +const emptyList = [] as T.NoteEntity[]; + +export const middleware: S.Middleware = store => { + const searchWorker = new SearchWorker(); + const { + port1: searchProcessor, + port2: _searchProcessor, + } = new MessageChannel(); + + searchProcessor.onmessage = event => { + switch (event.data.action) { + case 'filterNotes': + store.dispatch( + actions.ui.filterNotes( + store + .getState() + .appState.notes?.filter(({ id }) => event.data.noteIds.has(id)) || + emptyList + ) + ); + break; + } + }; + + searchWorker.postMessage('boot', [_searchProcessor]); + let hasInitialized = false; + + return next => (action: A.ActionType) => { + const result = next(action); + + switch (action.type) { + case 'App.notesLoaded': + if (!hasInitialized) { + const { + appState: { notes }, + } = store.getState(); + if (notes) { + notes.forEach(note => + searchProcessor.postMessage({ + action: 'updateNote', + noteId: note.id, + data: note.data, + }) + ); + } + + hasInitialized = true; + } + searchProcessor.postMessage({ action: 'filterNotes' }); + break; + + case 'REMOTE_NOTE_UPDATE': + searchProcessor.postMessage({ + action: 'updateNote', + noteId: action.noteId, + data: action.data, + }); + break; + + case 'App.selectTag': + searchProcessor.postMessage({ + action: 'filterNotes', + openedTag: action.tag.data.name, + }); + break; + + case 'App.selectTrash': + searchProcessor.postMessage({ + action: 'filterNotes', + openedTag: null, + showTrash: true, + }); + break; + + case 'App.showAllNotes': + searchProcessor.postMessage({ + action: 'filterNotes', + openedTag: null, + showTrash: false, + }); + break; + + case 'SEARCH': + searchProcessor.postMessage({ + action: 'filterNotes', + searchQuery: action.searchQuery, + }); + break; + + case 'DELETE_NOTE_FOREVER': + case 'RESTORE_NOTE': + case 'TRASH_NOTE': + case 'App.authChanged': + case 'App.trashNote': + searchProcessor.postMessage({ action: 'filterNotes' }); + break; + } + + return result; + }; +}; diff --git a/lib/search/worker/index.ts b/lib/search/worker/index.ts new file mode 100644 index 000000000..3a4257646 --- /dev/null +++ b/lib/search/worker/index.ts @@ -0,0 +1,127 @@ +import { getTerms } from '../../utils/filter-notes'; + +import * as T from '../../types'; + +const notes: Map< + T.EntityId, + [T.EntityId, T.Note & { tags: Set }] +> = new Map(); + +self.onmessage = bootEvent => { + const mainApp: MessagePort | undefined = bootEvent.ports[0]; + + if (!mainApp) { + // bail if we don't get a custom port + return; + } + + let searchQuery = ''; + let searchTerms: string[] = []; + let filterTags = new Set(); + let showTrash = false; + + const tagsFromSearch = (query: string) => { + const tagPattern = /(?:\btag:)([^\s,]+)/g; + const searchTags = new Set(); + let match; + while ((match = tagPattern.exec(query)) !== null) { + searchTags.add(match[1].toLocaleLowerCase()); + } + return searchTags; + }; + + const updateFilter = (scope = 'quickSearch') => { + const tic = performance.now(); + const matches = new Set(); + + for (const [noteId, note] of notes.values()) { + // return a small set of the results quickly and then + // queue up another search. this improves the responsiveness + // of the search and it gives us another opportunity to + // receive inbound messages from the main thread + // in testing this was rare and may only happen in unexpected + // circumstances such as when performing a garbage-collection + const toc = performance.now(); + if (scope === 'quickSearch' && toc - tic > 10) { + mainApp.postMessage({ action: 'filterNotes', noteIds: matches }); + queueUpdateFilter(0, 'fullSearch'); + return; + } + + if (showTrash !== note.deleted) { + continue; + } + + let hasAllTags = true; + for (const tagName of filterTags.values()) { + if (!note.tags.has(tagName)) { + hasAllTags = false; + break; + } + } + if (!hasAllTags) { + continue; + } + + if ( + searchTerms.length > 0 && + !searchTerms.every(term => note.content.includes(term)) + ) { + continue; + } + + matches.add(noteId); + } + + mainApp.postMessage({ action: 'filterNotes', noteIds: matches }); + }; + + let updateHandle: ReturnType | null = null; + const queueUpdateFilter = (delay = 0, searchScope = 'quickSearch') => { + if (updateHandle) { + clearTimeout(updateHandle); + } + + updateHandle = setTimeout(() => { + updateHandle = null; + updateFilter(searchScope); + }, delay); + }; + + mainApp.onmessage = event => { + if (event.data.action === 'updateNote') { + const { noteId, data } = event.data; + + const noteTags = new Set(data.tags.map(tag => tag.toLocaleLowerCase())); + notes.set(noteId, [ + noteId, + { + ...data, + content: data.content.toLocaleLowerCase(), + tags: noteTags, + }, + ]); + + queueUpdateFilter(1000); + } else if (event.data.action === 'filterNotes') { + if ('string' === typeof event.data.searchQuery) { + searchQuery = event.data.searchQuery.trim().toLocaleLowerCase(); + searchTerms = getTerms(searchQuery); + filterTags = tagsFromSearch(searchQuery); + } + + if ('string' === typeof event.data.openedTag) { + filterTags = tagsFromSearch(searchQuery); + filterTags.add(event.data.openedTag.toLocaleLowerCase()); + } else if (null === event.data.openedTag) { + filterTags = tagsFromSearch(searchQuery); + } + + if ('boolean' === typeof event.data.showTrash) { + showTrash = event.data.showTrash; + } + + queueUpdateFilter(); + } + }; +}; diff --git a/lib/search/worker/tsconfig.json b/lib/search/worker/tsconfig.json new file mode 100644 index 000000000..57328e8c6 --- /dev/null +++ b/lib/search/worker/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "isolatedModules": false, + "lib": ["esnext", "webworker"] + } +} diff --git a/lib/state/action-types.ts b/lib/state/action-types.ts index eb2b953ca..62ab879af 100644 --- a/lib/state/action-types.ts +++ b/lib/state/action-types.ts @@ -62,12 +62,16 @@ export type FilterNotes = Action< { notes: T.NoteEntity[]; previousIndex: number } >; export type FocusSearchField = Action<'FOCUS_SEARCH_FIELD'>; +export type RemoteNoteUpdate = Action< + 'REMOTE_NOTE_UPDATE', + { noteId: T.EntityId; data: T.Note } +>; +export type RestoreNote = Action<'RESTORE_NOTE', { previousIndex: number }>; export type Search = Action<'SEARCH', { searchQuery: string }>; export type SelectRevision = Action< 'SELECT_REVISION', { revision: T.NoteEntity } >; -export type RestoreNote = Action<'RESTORE_NOTE', { previousIndex: number }>; export type SetAuth = Action<'AUTH_SET', { status: AuthState }>; export type SetUnsyncedNoteIds = Action< 'SET_UNSYNCED_NOTE_IDS', @@ -100,6 +104,7 @@ export type ActionType = | LegacyAction | FilterNotes | FocusSearchField + | RemoteNoteUpdate | RestoreNote | Search | SelectNote diff --git a/lib/state/actions.ts b/lib/state/actions.ts index 8eb2b0370..aa5ff4aee 100644 --- a/lib/state/actions.ts +++ b/lib/state/actions.ts @@ -1,9 +1,11 @@ import * as auth from './auth/actions'; import * as settings from './settings/actions'; +import * as simperium from './simperium/actions'; import * as ui from './ui/actions'; export default { auth, + simperium, settings, ui, }; diff --git a/lib/state/index.ts b/lib/state/index.ts index 9c574101e..dbb9c9745 100644 --- a/lib/state/index.ts +++ b/lib/state/index.ts @@ -20,7 +20,7 @@ import { omit } from 'lodash'; import appState from '../flux/app-state'; -import uiMiddleware from './ui/middleware'; +import { middleware as searchMiddleware } from '../search'; import searchFieldMiddleware from './ui/search-field-middleware'; import simperiumMiddleware from './simperium/middleware'; @@ -71,7 +71,7 @@ export const store = createStore( }), applyMiddleware( thunk, - uiMiddleware, + searchMiddleware, searchFieldMiddleware, simperiumMiddleware ) diff --git a/lib/state/simperium/actions.ts b/lib/state/simperium/actions.ts new file mode 100644 index 000000000..d16afc7a4 --- /dev/null +++ b/lib/state/simperium/actions.ts @@ -0,0 +1,11 @@ +import * as A from '../action-types'; +import * as T from '../../types'; + +export const remoteNoteUpdate: A.ActionCreator = ( + noteId: T.EntityId, + data: T.Note +) => ({ + type: 'REMOTE_NOTE_UPDATE', + noteId, + data, +}); diff --git a/lib/state/ui/middleware.ts b/lib/state/ui/middleware.ts deleted file mode 100644 index 293f8356c..000000000 --- a/lib/state/ui/middleware.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { filterNotes as filterAction } from './actions'; -import filterNotes from '../../utils/filter-notes'; - -import * as A from '../action-types'; -import * as S from '../'; - -let searchTimeout: NodeJS.Timeout; - -export const middleware: S.Middleware = store => { - const updateNotes = () => - store.dispatch( - filterAction( - filterNotes(store.getState()), - store.getState().ui.previousIndex - ) - ); - - return next => (action: A.ActionType) => { - const result = next(action); - - switch (action.type) { - // on clicks re-filter immediately - case 'DELETE_NOTE_FOREVER': - case 'RESTORE_NOTE': - case 'TRASH_NOTE': - clearTimeout(searchTimeout); - updateNotes(); - break; - - // on events re-filter "immediately" - case 'App.authChanged': - case 'App.notesLoaded': - case 'App.selectTag': - case 'App.selectTrash': - case 'App.showAllNotes': - case 'App.tagsLoaded': - case 'App.trashNote': - clearTimeout(searchTimeout); - searchTimeout = setTimeout(updateNotes, 50); - break; - - // on updating the search field we should delay the update - // so we don't waste our CPU time and lose responsiveness - case 'SEARCH': - clearTimeout(searchTimeout); - if (!action.searchQuery) { - // if we just cleared out the search bar then immediately update - updateNotes(); - } else { - searchTimeout = setTimeout(updateNotes, 500); - } - break; - } - - return result; - }; -}; - -export default middleware; diff --git a/lib/utils/filter-notes.ts b/lib/utils/filter-notes.ts index 8afcd0261..c086bbea0 100644 --- a/lib/utils/filter-notes.ts +++ b/lib/utils/filter-notes.ts @@ -12,7 +12,7 @@ export const withoutTags = (s: string) => s.replace(tagPattern(), '').trim(); export const filterHasText = (searchQuery: string) => !!withoutTags(searchQuery); -const getTerms = (filterText: string) => { +export const getTerms = (filterText: string): string[] => { if (!filterText) { return []; } diff --git a/package-lock.json b/package-lock.json index 14f9e4eab..288925163 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19309,6 +19309,28 @@ "errno": "~0.1.7" } }, + "worker-loader": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/worker-loader/-/worker-loader-2.0.0.tgz", + "integrity": "sha512-tnvNp4K3KQOpfRnD20m8xltE3eWh89Ye+5oj7wXEEHKac1P4oZ6p9oTj8/8ExqoSBnk9nu5Pr4nKfQ1hn2APJw==", + "dev": true, + "requires": { + "loader-utils": "^1.0.0", + "schema-utils": "^0.4.0" + }, + "dependencies": { + "schema-utils": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-0.4.7.tgz", + "integrity": "sha512-v/iwU6wvwGK8HbU9yi3/nhGzP0yGSuhQMzL6ySiec1FSrZZDkhm4noOSWzrNFo/jEc+SJY6jRTwuwbSXJPDUnQ==", + "dev": true, + "requires": { + "ajv": "^6.1.0", + "ajv-keywords": "^3.1.0" + } + } + } + }, "wrap-ansi": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", diff --git a/package.json b/package.json index 50f31687a..1d9213bdb 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,8 @@ "wait-on": "4.0.0", "webpack": "4.41.5", "webpack-cli": "3.3.10", - "webpack-dev-server": "3.10.1" + "webpack-dev-server": "3.10.1", + "worker-loader": "2.0.0" }, "dependencies": { "@automattic/color-studio": "2.2.0",