diff --git a/src/constants.js b/src/constants.js index c21ace4448a8c..53d4612bdc65d 100644 --- a/src/constants.js +++ b/src/constants.js @@ -26,10 +26,12 @@ const IpcChannels = { DB_HISTORY: 'db-history', DB_PROFILES: 'db-profiles', DB_PLAYLISTS: 'db-playlists', + DB_SEARCH_HISTORY: 'db-search-history', DB_SUBSCRIPTION_CACHE: 'db-subscription-cache', SYNC_SETTINGS: 'sync-settings', SYNC_HISTORY: 'sync-history', + SYNC_SEARCH_HISTORY: 'sync-search-history', SYNC_PROFILES: 'sync-profiles', SYNC_PLAYLISTS: 'sync-playlists', SYNC_SUBSCRIPTION_CACHE: 'sync-subscription-cache', @@ -191,6 +193,12 @@ const PLAYLIST_HEIGHT_FORCE_LIST_THRESHOLD = 500 // YouTube search character limit is 100 characters const SEARCH_CHAR_LIMIT = 100 +// max # of results we show for search suggestions +const SEARCH_RESULTS_DISPLAY_LIMIT = 14 + +// max # of search history results we show when mixed with YT search suggestions +const MIXED_SEARCH_HISTORY_ENTRIES_DISPLAY_LIMIT = 4 + // Displayed on the about page and used in the main.js file to only allow bitcoin URLs with this wallet address to be opened const ABOUT_BITCOIN_ADDRESS = '1Lih7Ho5gnxb1CwPD4o59ss78pwo2T91eS' @@ -203,5 +211,7 @@ export { MOBILE_WIDTH_THRESHOLD, PLAYLIST_HEIGHT_FORCE_LIST_THRESHOLD, SEARCH_CHAR_LIMIT, + SEARCH_RESULTS_DISPLAY_LIMIT, + MIXED_SEARCH_HISTORY_ENTRIES_DISPLAY_LIMIT, ABOUT_BITCOIN_ADDRESS, } diff --git a/src/datastores/handlers/base.js b/src/datastores/handlers/base.js index a0f620925b6a9..d25963376f641 100644 --- a/src/datastores/handlers/base.js +++ b/src/datastores/handlers/base.js @@ -226,6 +226,24 @@ class Playlists { } } +class SearchHistory { + static find() { + return db.searchHistory.findAsync({}).sort({ lastUpdatedAt: -1 }) + } + + static upsert(searchHistoryEntry) { + return db.searchHistory.updateAsync({ _id: searchHistoryEntry._id }, searchHistoryEntry, { upsert: true }) + } + + static delete(_id) { + return db.searchHistory.removeAsync({ _id: _id }) + } + + static deleteAll() { + return db.searchHistory.removeAsync({}, { multi: true }) + } +} + class SubscriptionCache { static find() { return db.subscriptionCache.findAsync({}) @@ -311,6 +329,7 @@ function compactAllDatastores() { db.history.compactDatafileAsync(), db.profiles.compactDatafileAsync(), db.playlists.compactDatafileAsync(), + db.searchHistory.compactDatafileAsync(), db.subscriptionCache.compactDatafileAsync(), ]) } @@ -320,6 +339,7 @@ export { History as history, Profiles as profiles, Playlists as playlists, + SearchHistory as searchHistory, SubscriptionCache as subscriptionCache, compactAllDatastores, diff --git a/src/datastores/handlers/electron.js b/src/datastores/handlers/electron.js index b18706ec8ee5d..7a178174f0db0 100644 --- a/src/datastores/handlers/electron.js +++ b/src/datastores/handlers/electron.js @@ -218,6 +218,36 @@ class Playlists { } } +class SearchHistory { + static find() { + return ipcRenderer.invoke( + IpcChannels.DB_SEARCH_HISTORY, + { action: DBActions.GENERAL.FIND } + ) + } + + static upsert(searchHistoryEntry) { + return ipcRenderer.invoke( + IpcChannels.DB_SEARCH_HISTORY, + { action: DBActions.GENERAL.UPSERT, data: searchHistoryEntry } + ) + } + + static delete(_id) { + return ipcRenderer.invoke( + IpcChannels.DB_SEARCH_HISTORY, + { action: DBActions.GENERAL.DELETE, data: _id } + ) + } + + static deleteAll() { + return ipcRenderer.invoke( + IpcChannels.DB_SEARCH_HISTORY, + { action: DBActions.GENERAL.DELETE_ALL } + ) + } +} + class SubscriptionCache { static find() { return ipcRenderer.invoke( @@ -296,5 +326,6 @@ export { History as history, Profiles as profiles, Playlists as playlists, + SearchHistory as searchHistory, SubscriptionCache as subscriptionCache, } diff --git a/src/datastores/handlers/index.js b/src/datastores/handlers/index.js index 6d9b8ab729d43..1409b1cd3a450 100644 --- a/src/datastores/handlers/index.js +++ b/src/datastores/handlers/index.js @@ -3,5 +3,6 @@ export { history as DBHistoryHandlers, profiles as DBProfileHandlers, playlists as DBPlaylistHandlers, + searchHistory as DBSearchHistoryHandlers, subscriptionCache as DBSubscriptionCacheHandlers, } from 'DB_HANDLERS_ELECTRON_RENDERER_OR_WEB' diff --git a/src/datastores/handlers/web.js b/src/datastores/handlers/web.js index dfddc6a0c31ba..68e7e9e75e8a5 100644 --- a/src/datastores/handlers/web.js +++ b/src/datastores/handlers/web.js @@ -24,4 +24,4 @@ class Settings { export { Settings as settings } // These classes don't require any changes from the base classes, so can be exported as-is. -export { history, profiles, playlists, subscriptionCache } from './base' +export { history, profiles, playlists, searchHistory, subscriptionCache } from './base' diff --git a/src/datastores/index.js b/src/datastores/index.js index 7a3da53356f00..37a27b12cff89 100644 --- a/src/datastores/index.js +++ b/src/datastores/index.js @@ -26,4 +26,5 @@ export const settings = new Datastore({ filename: dbPath('settings'), autoload: export const profiles = new Datastore({ filename: dbPath('profiles'), autoload: true }) export const playlists = new Datastore({ filename: dbPath('playlists'), autoload: true }) export const history = new Datastore({ filename: dbPath('history'), autoload: true }) +export const searchHistory = new Datastore({ filename: dbPath('search-history'), autoload: true }) export const subscriptionCache = new Datastore({ filename: dbPath('subscription-cache'), autoload: true }) diff --git a/src/main/index.js b/src/main/index.js index d361bf19dfa98..1a7544a6ea7b3 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -1351,6 +1351,51 @@ function runApp() { // *********** // + // ************** // + // Search History + ipcMain.handle(IpcChannels.DB_SEARCH_HISTORY, async (event, { action, data }) => { + try { + switch (action) { + case DBActions.GENERAL.FIND: + return await baseHandlers.searchHistory.find() + + case DBActions.GENERAL.UPSERT: + await baseHandlers.searchHistory.upsert(data) + syncOtherWindows( + IpcChannels.SYNC_SEARCH_HISTORY, + event, + { event: SyncEvents.GENERAL.UPSERT, data } + ) + return null + + case DBActions.GENERAL.DELETE: + await baseHandlers.searchHistory.delete(data) + syncOtherWindows( + IpcChannels.SYNC_SEARCH_HISTORY, + event, + { event: SyncEvents.GENERAL.DELETE, data } + ) + return null + + case DBActions.GENERAL.DELETE_ALL: + await baseHandlers.searchHistory.deleteAll() + syncOtherWindows( + IpcChannels.SYNC_SEARCH_HISTORY, + event, + { event: SyncEvents.GENERAL.DELETE_ALL } + ) + return null + + default: + // eslint-disable-next-line no-throw-literal + throw 'invalid search history db action' + } + } catch (err) { + if (typeof err === 'string') throw err + else throw err.toString() + } + }) + // *********** // // Profiles ipcMain.handle(IpcChannels.DB_SUBSCRIPTION_CACHE, async (event, { action, data }) => { diff --git a/src/renderer/App.js b/src/renderer/App.js index e7ebffa07ca9b..c56a7394f2625 100644 --- a/src/renderer/App.js +++ b/src/renderer/App.js @@ -194,6 +194,7 @@ export default defineComponent({ this.grabHistory() this.grabAllPlaylists() this.grabAllSubscriptions() + this.grabSearchHistoryEntries() if (process.env.IS_ELECTRON) { ipcRenderer = require('electron').ipcRenderer @@ -579,6 +580,7 @@ export default defineComponent({ 'grabHistory', 'grabAllPlaylists', 'grabAllSubscriptions', + 'grabSearchHistoryEntries', 'getYoutubeUrlInfo', 'getExternalPlayerCmdArgumentsData', 'fetchInvidiousInstances', diff --git a/src/renderer/components/ft-input/ft-input.css b/src/renderer/components/ft-input/ft-input.css index 89b7450e588f6..9ad55ce7f9c1f 100644 --- a/src/renderer/components/ft-input/ft-input.css +++ b/src/renderer/components/ft-input/ft-input.css @@ -31,12 +31,15 @@ body[dir='rtl'] .ft-input-component.search.showClearTextButton:focus-within .inp padding-inline-start: 46px; } -.ft-input-component:focus-within .clearInputTextButton { +.ft-input-component:focus-within .clearInputTextButton, +.ft-input-component.showOptions .clearInputTextButton { opacity: 0.5; } -.clearTextButtonVisible .clearInputTextButton.visible, -.ft-input-component:focus-within .clearInputTextButton.visible { + +.ft-input-component.inputDataPresent .clearInputTextButton.visible, +.clearTextButtonVisible:not(.showOptions) .clearInputTextButton.visible, +.ft-input-component:focus-within:not(.showOptions) .clearInputTextButton.visible { cursor: pointer; opacity: 1; } @@ -200,10 +203,30 @@ body[dir='rtl'] .ft-input-component.search.showClearTextButton:focus-within .inp } .list li { - display: block; + display: flex; + justify-content: space-between; padding-block: 0; - padding-inline: 15px; line-height: 2rem; + padding-inline: 15px; +} + +.searchResultIcon { + opacity: 0.6; + padding-inline-end: 10px; + inline-size: 16px; + block-size: 16px; +} + +.removeButton { + text-decoration: none; + float: var(--float-right-ltr-rtl-value); + font-size: 13px; +} + +.removeButton:hover, +.removeButtonSelected { + text-decoration: underline; + font-weight: bold; } .hover { diff --git a/src/renderer/components/ft-input/ft-input.js b/src/renderer/components/ft-input/ft-input.js index 7a3aa07150fe7..1c0d5a132d463 100644 --- a/src/renderer/components/ft-input/ft-input.js +++ b/src/renderer/components/ft-input/ft-input.js @@ -63,12 +63,24 @@ export default defineComponent({ type: Array, default: () => { return [] } }, + dataListProperties: { + type: Array, + default: () => { return [] } + }, + searchResultIconNames: { + type: Array, + default: null + }, + showDataWhenEmpty: { + type: Boolean, + default: false + }, tooltip: { type: String, default: '' } }, - emits: ['clear', 'click', 'input'], + emits: ['clear', 'click', 'input', 'remove'], data: function () { let actionIcon = ['fas', 'search'] if (this.forceActionButtonIconName !== null) { @@ -88,10 +100,16 @@ export default defineComponent({ // As the text input box should be empty clearTextButtonExisting: false, clearTextButtonVisible: false, + removeButtonSelectedIndex: -1, + removalMade: false, actionButtonIconName: actionIcon } }, computed: { + showOptions: function () { + return (this.inputData !== '' || this.showDataWhenEmpty) && this.visibleDataList.length > 0 && this.searchState.showOptions + }, + barColor: function () { return this.$store.getters.getBarColor }, @@ -101,8 +119,9 @@ export default defineComponent({ }, inputDataPresent: function () { - return this.inputData.length > 0 + return this.inputDataDisplayed.length > 0 }, + inputDataDisplayed() { if (!this.isSearch) { return this.inputData } @@ -116,7 +135,6 @@ export default defineComponent({ searchStateKeyboardSelectedOptionValue() { if (this.searchState.keyboardSelectedOptionIndex === -1) { return null } - return this.visibleDataList[this.searchState.keyboardSelectedOptionIndex] }, }, @@ -152,6 +170,7 @@ export default defineComponent({ this.searchState.showOptions = false this.searchState.selectedOption = -1 this.searchState.keyboardSelectedOptionIndex = -1 + this.removeButtonSelectedIndex = -1 this.$emit('input', this.inputData) this.$emit('click', this.inputData, { event: e }) }, @@ -173,6 +192,7 @@ export default defineComponent({ this.inputData = '' this.handleActionIconChange() this.updateVisibleDataList() + this.searchState.isPointerInList = false this.$refs.input.value = '' @@ -233,56 +253,83 @@ export default defineComponent({ }, handleOptionClick: function (index) { + if (this.removeButtonSelectedIndex !== -1) { + this.handleRemoveClick(index) + return + } this.searchState.showOptions = false this.inputData = this.visibleDataList[index] this.$emit('input', this.inputData) this.handleClick() }, + handleRemoveClick: function (index) { + if (!this.dataListProperties[index]?.isRemoveable) { return } + + // keep input in focus even when the to-be-removed "Remove" button was clicked + this.$refs.input.focus() + this.removalMade = true + this.$emit('remove', this.visibleDataList[index]) + }, + /** * @param {KeyboardEvent} event */ handleKeyDown: function (event) { + // Update Input box value if enter key was pressed and option selected if (event.key === 'Enter') { - // Update Input box value if enter key was pressed and option selected - if (this.searchState.selectedOption !== -1) { + if (this.removeButtonSelectedIndex !== -1) { + this.handleRemoveClick(this.removeButtonSelectedIndex) + } else if (this.searchState.selectedOption !== -1) { this.searchState.showOptions = false event.preventDefault() this.inputData = this.visibleDataList[this.searchState.selectedOption] + this.handleOptionClick(this.searchState.selectedOption) + } else { + this.handleClick(event) } - this.handleClick(event) - // Early return + return } if (this.visibleDataList.length === 0) { return } this.searchState.showOptions = true - const isArrow = event.key === 'ArrowDown' || event.key === 'ArrowUp' - if (!isArrow) { + + // "select" the Remove button through right arrow navigation, and unselect it with the left arrow + if (event.key === 'ArrowRight') { + this.removeButtonSelectedIndex = this.searchState.selectedOption + } else if (event.key === 'ArrowLeft') { + this.removeButtonSelectedIndex = -1 + } else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + event.preventDefault() + const newIndex = this.searchState.selectedOption + (event.key === 'ArrowDown' ? 1 : -1) + this.updateSelectedOptionIndex(newIndex) + } else { const selectedOptionValue = this.searchStateKeyboardSelectedOptionValue // Keyboard selected & is char if (!isNullOrEmpty(selectedOptionValue) && isKeyboardEventKeyPrintableChar(event.key)) { // Update input based on KB selected suggestion value instead of current input value event.preventDefault() this.handleInput(`${selectedOptionValue}${event.key}`) - return } - return } + }, + + // Updates the selected dropdown option index and handles the under/over-flow behavior + updateSelectedOptionIndex: function (index) { + this.searchState.selectedOption = index + + // unset selection of "Remove" button + this.removeButtonSelectedIndex = -1 - event.preventDefault() - if (event.key === 'ArrowDown') { - this.searchState.selectedOption++ - } else if (event.key === 'ArrowUp') { - this.searchState.selectedOption-- - } // Allow deselecting suggestion if (this.searchState.selectedOption < -1) { this.searchState.selectedOption = this.visibleDataList.length - 1 } else if (this.searchState.selectedOption > this.visibleDataList.length - 1) { this.searchState.selectedOption = -1 } + // Update displayed value this.searchState.keyboardSelectedOptionIndex = this.searchState.selectedOption }, @@ -291,16 +338,22 @@ export default defineComponent({ if (!this.searchState.isPointerInList) { this.searchState.showOptions = false } }, - handleFocus: function(e) { + handleFocus: function () { this.searchState.showOptions = true }, updateVisibleDataList: function () { - if (this.dataList.length === 0) { return } // Reset selected option before it's updated - this.searchState.selectedOption = -1 - this.searchState.keyboardSelectedOptionIndex = -1 - if (this.inputData === '') { + // Block resetting if it was just the "Remove" button that was pressed + if (!this.removalMade || this.searchState.selectedOption >= this.dataList.length) { + this.searchState.selectedOption = -1 + this.searchState.keyboardSelectedOptionIndex = -1 + this.removeButtonSelectedIndex = -1 + } + + this.removalMade = false + + if (this.inputData.trim() === '') { this.visibleDataList = this.dataList return } diff --git a/src/renderer/components/ft-input/ft-input.vue b/src/renderer/components/ft-input/ft-input.vue index ea5f5c80d56a0..0177c818e1309 100644 --- a/src/renderer/components/ft-input/ft-input.vue +++ b/src/renderer/components/ft-input/ft-input.vue @@ -7,7 +7,9 @@ forceTextColor: forceTextColor, showActionButton: showActionButton, showClearTextButton: showClearTextButton, - clearTextButtonVisible: inputDataPresent, + clearTextButtonVisible: inputDataPresent || showOptions, + inputDataPresent: inputDataPresent, + showOptions: showOptions, disabled: disabled }" > @@ -29,7 +31,7 @@ :icon="['fas', 'times-circle']" class="clearInputTextButton" :class="{ - visible: inputDataPresent + visible: inputDataPresent || showOptions }" tabindex="0" role="button" @@ -68,21 +70,39 @@
+