diff --git a/package.json b/package.json index 5301b8bd..43333e0e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.113", + "version": "0.18.114", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/css/style.css b/src/css/style.css index 7db0e13e..880708c5 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -5686,6 +5686,116 @@ button.profile-list-item-button.is-disabled { flex-direction: column; } +.search-recent-heading { + padding: 8px 16px; + font-size: 15px; + font-weight: 600; + color: var(--text-color); +} + +.search-recent-row { + position: relative; + display: flex; + align-items: center; +} + +.search-recent-row .search-typeahead-row { + border: none; +} + +.search-recent-row .search-typeahead-icon { + display: none; +} + +.search-recent-row-button { + flex: 1; + min-width: 0; + width: 100%; + padding-right: 48px; +} + +.search-recent-remove-button { + position: absolute; + right: 12px; + width: 32px; + height: 32px; + color: var(--text-color-muted); +} + +.search-recent-remove-button .icon, +.search-recent-remove-button .icon svg { + width: 20px; + height: 20px; +} + +.search-recent-profiles { + display: flex; + flex-wrap: nowrap; + gap: 20px; + padding-inline: 16px; + padding-top: 8px; + padding-bottom: 12px; + overflow-x: auto; + scrollbar-width: none; +} + +.search-recent-profiles::-webkit-scrollbar { + display: none; +} + +.search-recent-profile { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + width: 80px; + flex-shrink: 0; +} + +.search-recent-profile .avatar { + height: 72px; + line-height: 0; +} + +.search-recent-profile .avatar-image-frame { + width: 72px; + height: 72px; +} + +.search-recent-profile-name { + max-width: 100%; + font-size: 11px; + color: var(--text-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.search-recent-profile-remove { + top: 0; + right: 4px; + width: 24px; + height: 24px; +} + +.search-recent-profile-skeleton-avatar { + width: 72px; + height: 72px; +} + +.search-recent-profile-skeleton-name-line { + display: inline-block; + width: 48px; + height: 10px; +} + +.search-recent-profile-remove .close-icon, +.search-recent-profile-remove .close-icon svg { + width: 16px; + height: 16px; +} + .search-placeholder { padding: 48px 24px; color: var(--text-color-muted); diff --git a/src/js/components/plugin-slot.js b/src/js/components/plugin-slot.js index a693d06f..e7d9733d 100644 --- a/src/js/components/plugin-slot.js +++ b/src/js/components/plugin-slot.js @@ -27,7 +27,7 @@ class PluginSlot extends Component { this._disposeEffect = effect(() => { this.pluginService.$slots.get(slotName); this._reconcile(); - }, `plugin-slot[${slotName}]`); + }); } disconnectedCallback() { diff --git a/src/js/dataLayer/declarative.js b/src/js/dataLayer/declarative.js index 45bc806f..8d359abb 100644 --- a/src/js/dataLayer/declarative.js +++ b/src/js/dataLayer/declarative.js @@ -68,6 +68,18 @@ export class Declarative { return profileFollows; } + async ensureProfiles(profileDids) { + const getProfile = (did) => + this.derived.$hydratedDetailedProfiles.get(did) ?? + this.derived.$hydratedProfiles.get(did) ?? + null; + const missing = profileDids.filter((did) => !getProfile(did)); + if (missing.length > 0) { + await this.requests.loadDetailedProfiles(missing); + } + return profileDids.map((did) => getProfile(did)); + } + async ensureDetailedProfiles(profileDids) { const getProfile = (did) => this.derived.$hydratedDetailedProfiles.get(did); const missing = profileDids.filter((did) => !getProfile(did)); diff --git a/src/js/dataLayer/derived.js b/src/js/dataLayer/derived.js index 8eb29f45..507e0f92 100644 --- a/src/js/dataLayer/derived.js +++ b/src/js/dataLayer/derived.js @@ -283,6 +283,22 @@ export class Derived extends ReactiveStore { if (!data) return null; return data.actors.map((actor) => this.$hydratedProfiles.get(actor.did)); }); + this.$recentSearchTerms = new Signal.Computed(() => { + const preferences = this.$preferences.get(); + if (!preferences) return []; + return preferences.getRecentSearches(); + }); + this.$recentSearchProfiles = new Signal.Computed(() => { + const preferences = this.$preferences.get(); + if (!preferences) return null; + return preferences.getRecentSearchProfiles().map((did) => ({ + did, + profile: + this.$hydratedDetailedProfiles.get(did) ?? + this.$hydratedProfiles.get(did) ?? + null, + })); + }); this.$feedSearchResults = new Signal.Computed(() => { const data = this.dataStore.$feedSearchResults.get(); if (!data) return null; diff --git a/src/js/dataLayer/mutations.js b/src/js/dataLayer/mutations.js index 086236c3..9072a6a9 100644 --- a/src/js/dataLayer/mutations.js +++ b/src/js/dataLayer/mutations.js @@ -482,6 +482,58 @@ export class Mutations { } } + async addRecentSearch(q) { + const preferences = this.preferencesProvider.requirePreferences(); + const newPreferences = preferences.addRecentSearch(q); + await this.preferencesProvider.updatePreferences(newPreferences); + } + + async removeRecentSearch(q) { + const patchId = this.patchStore.addPreferencePatch({ + type: "removeRecentSearch", + q, + }); + const preferences = this.preferencesProvider.requirePreferences(); + const newPreferences = preferences.removeRecentSearch(q); + try { + await this.preferencesProvider.updatePreferences(newPreferences); + } catch (error) { + console.error(error); + throw error; + } finally { + this.patchStore.removePreferencePatch(patchId); + } + } + + async addRecentSearchProfile(did) { + const preferences = this.preferencesProvider.requirePreferences(); + const newPreferences = preferences.addRecentSearchProfile(did); + await this.preferencesProvider.updatePreferences(newPreferences); + } + + async removeRecentSearchProfile(did) { + const patchId = this.patchStore.addPreferencePatch({ + type: "removeRecentSearchProfile", + did, + }); + const preferences = this.preferencesProvider.requirePreferences(); + const newPreferences = preferences.removeRecentSearchProfile(did); + try { + await this.preferencesProvider.updatePreferences(newPreferences); + } catch (error) { + console.error(error); + throw error; + } finally { + this.patchStore.removePreferencePatch(patchId); + } + } + + async removeRecentSearchProfiles(dids) { + const preferences = this.preferencesProvider.requirePreferences(); + const newPreferences = preferences.removeRecentSearchProfiles(dids); + await this.preferencesProvider.updatePreferences(newPreferences); + } + async addMutedWord({ value, targets, actorTarget, expiresAt }) { const preferences = this.preferencesProvider.requirePreferences(); const newPreferences = preferences.addMutedWord({ diff --git a/src/js/dataLayer/patchStore.js b/src/js/dataLayer/patchStore.js index 280248ae..f3f1d41f 100644 --- a/src/js/dataLayer/patchStore.js +++ b/src/js/dataLayer/patchStore.js @@ -395,6 +395,10 @@ export class PatchStore extends ReactiveStore { visibility: patchBody.visibility, labelerDid: patchBody.labelerDid, }); + case "removeRecentSearch": + return preferences.removeRecentSearch(patchBody.q); + case "removeRecentSearchProfile": + return preferences.removeRecentSearchProfile(patchBody.did); default: throw new Error("Unknown patch type", patchBody.type); } diff --git a/src/js/identityPrecaching.js b/src/js/identityPrecaching.js index a71670cb..e1c3fa18 100644 --- a/src/js/identityPrecaching.js +++ b/src/js/identityPrecaching.js @@ -60,6 +60,14 @@ export function setUpIdentityPrecaching(dataLayer, identityResolver) { } }); + effect(() => { + const typeaheadResults = dataLayer.dataStore.$searchTypeaheadResults.get(); + if (!typeaheadResults) return; + for (const searchResult of typeaheadResults.actors) { + setDid(searchResult); + } + }); + effect(() => { const preferences = dataLayer.preferencesProvider.$preferences.get(); if (!preferences) return; diff --git a/src/js/preferences.js b/src/js/preferences.js index 83456171..4ceb30da 100644 --- a/src/js/preferences.js +++ b/src/js/preferences.js @@ -41,6 +41,10 @@ function getContentTextFromEmbed(embed) { return texts; } +const MAX_RECENT_SEARCHES = 10; +const MAX_RECENT_SEARCH_PROFILES = 10; +const MAX_RECENT_SEARCH_QUERY_LENGTH = 300; + const WORD_BOUNDARY_REGEX = /[\s\n\t\r\f\v]+/g; const LEADING_TRAILING_PUNCTUATION_REGEX = /(?:^\p{P}+|\p{P}+$)/gu; const INTERNAL_PUNCTUATION_REGEX = /\p{P}+/gu; @@ -172,6 +176,91 @@ export class Preferences { return clone; } + getRecentSearches() { + const pref = Preferences.getSearchHistoryPreference(this.obj); + if (!pref || !Array.isArray(pref.searches)) { + return []; + } + return pref.searches + .filter( + (entry) => typeof entry?.q === "string" && entry.q.trim().length > 0, + ) + .map((entry) => ({ ...entry, ts: Number(entry.ts) || 0 })) + .slice(0, MAX_RECENT_SEARCHES); + } + + addRecentSearch(q) { + const clone = this.clone(); + const query = (q ?? "").trim().slice(0, MAX_RECENT_SEARCH_QUERY_LENGTH); + if (!query) { + return clone; + } + const pref = Preferences.ensureSearchHistoryPreference(clone.obj); + if (!Array.isArray(pref.searches)) { + pref.searches = []; + } + pref.searches = pref.searches.filter((entry) => entry?.q !== query); + pref.searches.unshift({ q: query, ts: Date.now() }); + pref.searches = pref.searches.slice(0, MAX_RECENT_SEARCHES); + return clone; + } + + removeRecentSearch(q) { + const clone = this.clone(); + const pref = Preferences.getSearchHistoryPreference(clone.obj); + if (!pref || !Array.isArray(pref.searches)) { + return clone; + } + pref.searches = pref.searches.filter((entry) => entry?.q !== q); + return clone; + } + + getRecentSearchProfiles() { + const pref = Preferences.getSearchHistoryPreference(this.obj); + if (!pref || !Array.isArray(pref.profiles)) { + return []; + } + return pref.profiles + .filter((did) => typeof did === "string" && did.length > 0) + .slice(0, MAX_RECENT_SEARCH_PROFILES); + } + + addRecentSearchProfile(did) { + const clone = this.clone(); + if (typeof did !== "string" || did.length === 0) { + return clone; + } + const pref = Preferences.ensureSearchHistoryPreference(clone.obj); + if (!Array.isArray(pref.profiles)) { + pref.profiles = []; + } + pref.profiles = pref.profiles.filter((existing) => existing !== did); + pref.profiles.unshift(did); + pref.profiles = pref.profiles.slice(0, MAX_RECENT_SEARCH_PROFILES); + return clone; + } + + removeRecentSearchProfile(did) { + const clone = this.clone(); + const pref = Preferences.getSearchHistoryPreference(clone.obj); + if (!pref || !Array.isArray(pref.profiles)) { + return clone; + } + pref.profiles = pref.profiles.filter((existing) => existing !== did); + return clone; + } + + removeRecentSearchProfiles(dids) { + const clone = this.clone(); + const pref = Preferences.getSearchHistoryPreference(clone.obj); + if (!pref || !Array.isArray(pref.profiles)) { + return clone; + } + const removedSet = new Set(dids); + pref.profiles = pref.profiles.filter((did) => !removedSet.has(did)); + return clone; + } + getPinnedFeeds() { const savedFeedsPreference = Preferences.getSavedFeedsPreference(this.obj); if (!savedFeedsPreference) { @@ -599,6 +688,26 @@ export class Preferences { ); } + static getSearchHistoryPreference(obj) { + // Custom preference type, following the improHiddenPostsPref precedent. + return Preferences.getPreferenceByType( + obj, + "app.bsky.actor.defs#improSearchHistoryPref", + ); + } + + static ensureSearchHistoryPreference(obj) { + let pref = Preferences.getSearchHistoryPreference(obj); + if (!pref) { + pref = { + $type: "app.bsky.actor.defs#improSearchHistoryPref", + searches: [], + }; + obj.push(pref); + } + return pref; + } + static getMutedWordsPreference(obj) { return Preferences.getPreferenceByType( obj, diff --git a/src/js/views/search.view.js b/src/js/views/search.view.js index a12014ce..79a4e613 100644 --- a/src/js/views/search.view.js +++ b/src/js/views/search.view.js @@ -1,13 +1,17 @@ -import { html, render } from "/js/lib/lit-html.js"; +import { html, keyed, render } from "/js/lib/lit-html.js"; import { View } from "/js/views/view.js"; import { searchIconTemplate } from "/js/templates/icons/searchIcon.template.js"; import { closeIconTemplate } from "/js/templates/icons/closeIcon.template.js"; import { headerTemplate } from "/js/templates/header.template.js"; import { avatarTemplate } from "/js/templates/avatar.template.js"; import { classnames } from "/js/utils.js"; -import { getDisplayName } from "/js/dataHelpers.js"; +import { getDisplayName, MISSING_HANDLE } from "/js/dataHelpers.js"; import { Signal, ReactiveStore } from "/js/signals.js"; -import { linkToFeed, linkToProfile } from "/js/navigation.js"; +import { + linkToFeed, + linkToProfile, + linkToProfileByDid, +} from "/js/navigation.js"; import { smallPostTemplate } from "/js/templates/smallPost.template.js"; import { pageEffect, bindPageTitle } from "/js/router.js"; import { pinIconTemplate } from "/js/templates/icons/pinIcon.template.js"; @@ -33,6 +37,7 @@ class SearchView extends View { state.$inputValue = new Signal.State(initialQuery); state.$committedQuery = new Signal.State(initialQuery); state.$showTypeahead = new Signal.State(false); + state.$recentProfilesLoading = new Signal.State(true); const tabScrollState = new Map(); const loadedTabs = new Set(); @@ -117,6 +122,9 @@ class SearchView extends View { function commitSearch() { const query = state.$inputValue.get().trim(); if (!query) return; + if (isAuthenticated) { + dataLayer.mutations.addRecentSearch(query).catch(console.warn); + } state.$showTypeahead.set(false); const queryChanged = query !== state.$committedQuery.get(); state.$committedQuery.set(query); @@ -136,6 +144,66 @@ class SearchView extends View { root.querySelector(".search-input")?.focus(); } + function handleRecentSearchSelect(q) { + state.$inputValue.set(q); + state.$showTypeahead.set(false); + commitSearch(); + } + + function handleRecentSearchRemove(q) { + dataLayer.mutations.removeRecentSearch(q).catch(console.warn); + } + + function handleRecentProfileRecord(did) { + if (!isAuthenticated) return; + dataLayer.mutations.addRecentSearchProfile(did).catch(console.warn); + } + + function handleRecentProfileRemove(did) { + dataLayer.mutations.removeRecentSearchProfile(did).catch(console.warn); + } + + function isRecentProfileVisible(profile) { + if (!profile) return false; + if (profile.viewer?.blocking || profile.viewer?.blockedBy) return false; + if (profile.handle === MISSING_HANDLE) return false; + if ((profile.labels ?? []).some((label) => label.val === "!takendown")) { + return false; + } + return true; + } + + async function hydrateAndPruneRecentProfiles() { + try { + if (!isAuthenticated) return; + const entries = dataLayer.derived.$recentSearchProfiles.get() ?? []; + if (entries.length === 0) return; + const dids = entries.map((entry) => entry.did); + try { + await dataLayer.declarative.ensureProfiles(dids); + } catch (error) { + console.warn("Failed to load recent search profiles", error); + return; + } + const hydrated = dataLayer.derived.$recentSearchProfiles.get() ?? []; + const fetchedDids = new Set(dids); + const prunedDids = hydrated + .filter( + (entry) => + fetchedDids.has(entry.did) && + !isRecentProfileVisible(entry.profile), + ) + .map((entry) => entry.did); + if (prunedDids.length > 0) { + dataLayer.mutations + .removeRecentSearchProfiles(prunedDids) + .catch(console.warn); + } + } finally { + state.$recentProfilesLoading.set(false); + } + } + function handleTabChange(tab) { if (tab === state.$activeTab.get()) { if (window.scrollY > 0) { @@ -216,6 +284,7 @@ class SearchView extends View { class="search-typeahead-row clickable" data-testid="search-typeahead-result" href=${linkToProfile(profile)} + @click=${() => handleRecentProfileRecord(profile.did)} > ${avatarTemplate({ author: profile, clickAction: "none" })}
@@ -232,6 +301,95 @@ class SearchView extends View {
`; } + function recentSearchRowTemplate(q) { + return html`
+ + +
`; + } + + function recentProfileTileTemplate(profile) { + return html` handleRecentProfileRecord(profile.did)} + > + ${avatarTemplate({ author: profile, clickAction: "none" })} +
${getDisplayName(profile)}
+ +
`; + } + + function recentProfileSkeletonTemplate() { + return html`
+
+
+ ​ +
+
`; + } + + function recentSearchesTemplate({ terms, profileItems }) { + return html`
+
Recent searches
+ ${profileItems.length > 0 + ? html`
+ ${profileItems.map((item) => + keyed( + item.did, + item.profile + ? recentProfileTileTemplate(item.profile) + : recentProfileSkeletonTemplate(), + ), + )} +
` + : ""} + ${terms.map((entry) => recentSearchRowTemplate(entry.q))} +
`; + } + function postSearchResultsTemplate({ status, postSearchResults, @@ -481,38 +639,59 @@ class SearchView extends View { bindPageTitle(root, () => "Search"); - pageEffect( - root, - () => { - const currentUser = dataLayer.derived.$currentUser.get(); - const inputValue = state.$inputValue.get(); - const showTypeahead = state.$showTypeahead.get(); - const committedQuery = state.$committedQuery.get(); - const activeTab = state.$activeTab.get(); - const trimmedInput = inputValue.trim(); - const mode = !trimmedInput - ? "placeholder" - : showTypeahead - ? "typeahead" - : "results"; - - let bodyTemplate; - if (mode === "typeahead") { - bodyTemplate = typeaheadTemplate({ - query: trimmedInput, - profiles: dataLayer.derived.$searchTypeaheadResults.get(), - onCommit: commitSearch, + pageEffect(root, () => { + const currentUser = dataLayer.derived.$currentUser.get(); + const inputValue = state.$inputValue.get(); + const showTypeahead = state.$showTypeahead.get(); + const committedQuery = state.$committedQuery.get(); + const activeTab = state.$activeTab.get(); + const trimmedInput = inputValue.trim(); + const mode = !trimmedInput + ? "placeholder" + : showTypeahead + ? "typeahead" + : "results"; + + let bodyTemplate; + if (mode === "typeahead") { + bodyTemplate = typeaheadTemplate({ + query: trimmedInput, + profiles: dataLayer.derived.$searchTypeaheadResults.get(), + onCommit: commitSearch, + }); + } else if (mode === "results") { + bodyTemplate = html`
+
+ ${getActivePanelTemplate( + isAuthenticated ? activeTab : "profiles", + committedQuery, + currentUser, + )} +
+
`; + } else { + const recentTerms = isAuthenticated + ? dataLayer.derived.$recentSearchTerms.get() + : []; + const recentProfilesLoading = state.$recentProfilesLoading.get(); + const recentProfileItems = ( + (isAuthenticated + ? dataLayer.derived.$recentSearchProfiles.get() + : null) ?? [] + ) + .map((entry) => ({ + did: entry.did, + profile: isRecentProfileVisible(entry.profile) + ? entry.profile + : null, + pending: !entry.profile && recentProfilesLoading, + })) + .filter((item) => item.profile || item.pending); + if (recentTerms.length > 0 || recentProfileItems.length > 0) { + bodyTemplate = recentSearchesTemplate({ + terms: recentTerms, + profileItems: recentProfileItems, }); - } else if (mode === "results") { - bodyTemplate = html`
-
- ${getActivePanelTemplate( - isAuthenticated ? activeTab : "profiles", - committedQuery, - currentUser, - )} -
-
`; } else { bodyTemplate = html`
${searchIconTemplate()}
@@ -524,80 +703,79 @@ class SearchView extends View {
`; } + } - render( - html`
- ${headerTemplate({ - title: "Search", - leftButton: "menu", - onClickMenuButton: () => layout.openSidebar(), - bottomItemTemplate: () => html` -
- ${searchIconTemplate()} - { - // Prevent events from being picked up by password manager extensions - event.stopPropagation(); - handleInput(event.target.value); - }} - @keydown=${(event) => { - event.stopPropagation(); - if (event.key === "Enter") { - event.preventDefault(); - commitSearch(); - } - }} - /> - ${inputValue.length > 0 - ? html` - - ` - : ""} - ${mode === "results" && isAuthenticated - ? html` - handleTabChange(event.detail)} - > - ` - : ""} -
- `, - })} -
-
${bodyTemplate}
-
-
`, - root, - ); - }, - { debugName: "searchView" }, - ); + render( + html`
+ ${headerTemplate({ + title: "Search", + leftButton: "menu", + onClickMenuButton: () => layout.openSidebar(), + bottomItemTemplate: () => html` +
+ ${searchIconTemplate()} + { + // Prevent events from being picked up by password manager extensions + event.stopPropagation(); + handleInput(event.target.value); + }} + @keydown=${(event) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + commitSearch(); + } + }} + /> + ${inputValue.length > 0 + ? html` + + ` + : ""} + ${mode === "results" && isAuthenticated + ? html` + handleTabChange(event.detail)} + > + ` + : ""} +
+ `, + })} +
+
${bodyTemplate}
+
+
`, + root, + ); + }); root.addEventListener("page-enter", () => { const query = new URLSearchParams(window.location.search); @@ -614,6 +792,7 @@ class SearchView extends View { tabScrollState.clear(); loadTabIfNeeded(state.$activeTab.get()); } + hydrateAndPruneRecentProfiles().catch(console.warn); }); root.addEventListener("page-restore", (event) => { diff --git a/tests/e2e/mockServer.js b/tests/e2e/mockServer.js index edf403fc..16cf4669 100644 --- a/tests/e2e/mockServer.js +++ b/tests/e2e/mockServer.js @@ -83,6 +83,11 @@ export class MockServer { this.timelineDelayMs = 0; this.pluginSettings = new Map(); this.installedPlugins = []; + // Seeded/captured improSearchHistoryPref state; null means the preference + // is absent from getPreferences and no putPreferences has written it. + this.searchHistory = null; + this.getProfilesDelayMs = 0; + this.putPreferencesDelayMs = 0; // Override the source served for the local test plugin's main.js; defaults // to the standard fixture when null. this.localPluginSource = null; @@ -100,6 +105,10 @@ export class MockServer { this.tokenRefreshShouldFail = true; } + setSearchHistory({ searches = [], profiles = [] } = {}) { + this.searchHistory = { searches, profiles }; + } + addAuthorFeedPosts(did, filter, posts) { this.authorFeeds.set(`${did}-${filter}`, posts); } @@ -739,6 +748,14 @@ export class MockServer { }, ] : []), + ...(this.searchHistory + ? [ + { + $type: "app.bsky.actor.defs#improSearchHistoryPref", + ...this.searchHistory, + }, + ] + : []), ...(this.labelerSubscriptions.length > 0 ? [ { @@ -1390,7 +1407,12 @@ export class MockServer { }); }); - await page.route("**/xrpc/app.bsky.actor.getProfiles*", (route) => { + await page.route("**/xrpc/app.bsky.actor.getProfiles*", async (route) => { + if (this.getProfilesDelayMs > 0) { + await new Promise((resolve) => + setTimeout(resolve, this.getProfilesDelayMs), + ); + } const url = new URL(route.request().url()); const actors = url.searchParams.getAll("actors"); const profiles = actors @@ -2326,60 +2348,77 @@ export class MockServer { }); }); - await page.route("**/xrpc/app.bsky.actor.putPreferences*", (route) => { - const body = route.request().postDataJSON(); - const savedFeedsPref = body?.preferences?.find( - (p) => p.$type === "app.bsky.actor.defs#savedFeedsPrefV2", - ); - if (savedFeedsPref) { - this.pinnedFeedUris = savedFeedsPref.items - .filter((item) => item.type === "feed" && item.pinned) - .map((item) => item.value); - this.savedFeedUris = savedFeedsPref.items - .filter((item) => item.type === "feed" && !item.pinned) - .map((item) => item.value); - } - const hiddenPostsPref = body?.preferences?.find( - (p) => p.$type === "app.bsky.actor.defs#improHiddenPostsPref", - ); - if (hiddenPostsPref) { - this.hiddenPostUris = hiddenPostsPref.items || []; - } - const labelersPref = body?.preferences?.find( - (p) => p.$type === "app.bsky.actor.defs#labelersPref", - ); - if (labelersPref) { - this.labelerSubscriptions = labelersPref.labelers.map((l) => l.did); - } else { - this.labelerSubscriptions = []; - } - this.contentLabelPrefs = (body?.preferences || []).filter( - (p) => p.$type === "app.bsky.actor.defs#contentLabelPref", - ); - const mutedWordsPref = body?.preferences?.find( - (p) => p.$type === "app.bsky.actor.defs#mutedWordsPref", - ); - if (mutedWordsPref) { - this.mutedWords = mutedWordsPref.items || []; - } - const installedPluginsPref = body?.preferences?.find( - (p) => p.$type === "app.bsky.actor.defs#improInstalledPluginsPref", - ); - if (installedPluginsPref) { - this.installedPlugins = installedPluginsPref.plugins || []; - } - const pluginSettingsPrefs = (body?.preferences || []).filter( - (p) => p.$type === "app.bsky.actor.defs#improPluginSettingsPref", - ); - this.pluginSettings = new Map( - pluginSettingsPrefs.map((p) => [p.pluginId, p.data]), - ); - return route.fulfill({ - status: 200, - contentType: "application/json", - body: "{}", - }); - }); + await page.route( + "**/xrpc/app.bsky.actor.putPreferences*", + async (route) => { + if (this.putPreferencesDelayMs > 0) { + await new Promise((resolve) => + setTimeout(resolve, this.putPreferencesDelayMs), + ); + } + const body = route.request().postDataJSON(); + const savedFeedsPref = body?.preferences?.find( + (p) => p.$type === "app.bsky.actor.defs#savedFeedsPrefV2", + ); + if (savedFeedsPref) { + this.pinnedFeedUris = savedFeedsPref.items + .filter((item) => item.type === "feed" && item.pinned) + .map((item) => item.value); + this.savedFeedUris = savedFeedsPref.items + .filter((item) => item.type === "feed" && !item.pinned) + .map((item) => item.value); + } + const hiddenPostsPref = body?.preferences?.find( + (p) => p.$type === "app.bsky.actor.defs#improHiddenPostsPref", + ); + if (hiddenPostsPref) { + this.hiddenPostUris = hiddenPostsPref.items || []; + } + const searchHistoryPref = body?.preferences?.find( + (p) => p.$type === "app.bsky.actor.defs#improSearchHistoryPref", + ); + if (searchHistoryPref) { + this.searchHistory = { + searches: searchHistoryPref.searches || [], + profiles: searchHistoryPref.profiles || [], + }; + } + const labelersPref = body?.preferences?.find( + (p) => p.$type === "app.bsky.actor.defs#labelersPref", + ); + if (labelersPref) { + this.labelerSubscriptions = labelersPref.labelers.map((l) => l.did); + } else { + this.labelerSubscriptions = []; + } + this.contentLabelPrefs = (body?.preferences || []).filter( + (p) => p.$type === "app.bsky.actor.defs#contentLabelPref", + ); + const mutedWordsPref = body?.preferences?.find( + (p) => p.$type === "app.bsky.actor.defs#mutedWordsPref", + ); + if (mutedWordsPref) { + this.mutedWords = mutedWordsPref.items || []; + } + const installedPluginsPref = body?.preferences?.find( + (p) => p.$type === "app.bsky.actor.defs#improInstalledPluginsPref", + ); + if (installedPluginsPref) { + this.installedPlugins = installedPluginsPref.plugins || []; + } + const pluginSettingsPrefs = (body?.preferences || []).filter( + (p) => p.$type === "app.bsky.actor.defs#improPluginSettingsPref", + ); + this.pluginSettings = new Map( + pluginSettingsPrefs.map((p) => [p.pluginId, p.data]), + ); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: "{}", + }); + }, + ); await page.route( "**/xrpc/com.atproto.moderation.createReport*", diff --git a/tests/e2e/specs/views/search.view.test.js b/tests/e2e/specs/views/search.view.test.js index eb0c60e2..ac1dee24 100644 --- a/tests/e2e/specs/views/search.view.test.js +++ b/tests/e2e/specs/views/search.view.test.js @@ -1259,4 +1259,372 @@ test.describe("Search view", () => { await expect(view.locator("tab-bar")).toBeHidden(); }); }); + + test.describe("Recent searches", () => { + test("shows recent searches instead of the placeholder when history exists", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.setSearchHistory({ + searches: [ + { q: "dogs", ts: 2 }, + { q: "cats", ts: 1 }, + ], + }); + await mockServer.setup(page); + + await login(page); + await page.goto("/search"); + + const view = page.locator("#search-view"); + await expect(view.locator('[data-testid="search-recent"]')).toBeVisible({ + timeout: 10000, + }); + await expect(view.locator(".search-placeholder")).not.toBeVisible(); + const rows = view.locator('[data-testid="search-recent-row"]'); + await expect(rows).toHaveCount(2); + await expect(rows.nth(0)).toContainText("dogs"); + await expect(rows.nth(1)).toContainText("cats"); + // Rendering recents must not fire any search requests + expect(mockServer.searchRequestCounts.profiles).toBe(0); + expect(mockServer.searchRequestCounts.top).toBe(0); + expect(mockServer.searchRequestCounts.latest).toBe(0); + expect(mockServer.searchRequestCounts.feeds).toBe(0); + expect(mockServer.searchRequestCounts.typeahead).toBe(0); + }); + + test("records a committed search and shows it after clearing the input", async ({ + page, + }) => { + const mockServer = new MockServer(); + await mockServer.setup(page); + + await login(page); + await page.goto("/search"); + + const view = page.locator("#search-view"); + const input = view.locator(".search-input"); + await expect(view.locator(".search-placeholder")).toBeVisible({ + timeout: 10000, + }); + await input.fill("kittens"); + await input.press("Enter"); + + await expect + .poll(() => mockServer.searchHistory?.searches?.[0]?.q, { + timeout: 10000, + }) + .toBe("kittens"); + + await view.locator(".search-clear-button").click(); + const rows = view.locator('[data-testid="search-recent-row"]'); + await expect(rows).toHaveCount(1, { timeout: 10000 }); + await expect(rows.nth(0)).toContainText("kittens"); + }); + + test("re-running an existing search moves it to the front without duplicating", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.setSearchHistory({ + searches: [ + { q: "cats", ts: 2 }, + { q: "dogs", ts: 1 }, + ], + }); + await mockServer.setup(page); + + await login(page); + await page.goto("/search"); + + const view = page.locator("#search-view"); + const input = view.locator(".search-input"); + await expect(view.locator('[data-testid="search-recent"]')).toBeVisible({ + timeout: 10000, + }); + await input.fill("dogs"); + await input.press("Enter"); + + await expect + .poll( + () => mockServer.searchHistory?.searches?.map((entry) => entry.q), + { timeout: 10000 }, + ) + .toEqual(["dogs", "cats"]); + }); + + test("clicking a recent row fills the input and runs the search", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.setSearchHistory({ searches: [{ q: "hello", ts: 1 }] }); + mockServer.addSearchPosts([ + createPost({ + uri: "at://did:plc:author1/app.bsky.feed.post/post1", + text: "Hello world from search", + authorHandle: "author1.bsky.social", + authorDisplayName: "Author One", + }), + ]); + await mockServer.setup(page); + + await login(page); + await page.goto("/search"); + + const view = page.locator("#search-view"); + await view + .locator('[data-testid="search-recent-row-button"]') + .first() + .click(); + + await expect(page).toHaveURL(/[?&]q=hello/); + await expect(view.locator(".search-input")).toHaveValue("hello"); + await expect( + view.locator(".search-post-results-top [data-post-uri]"), + ).toHaveCount(1, { timeout: 10000 }); + }); + + test("removing a middle entry keeps the others and does not navigate", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.setSearchHistory({ + searches: [ + { q: "alpha", ts: 3 }, + { q: "beta", ts: 2 }, + { q: "gamma", ts: 1 }, + ], + }); + await mockServer.setup(page); + + await login(page); + await page.goto("/search"); + + const view = page.locator("#search-view"); + const rows = view.locator('[data-testid="search-recent-row"]'); + await expect(rows).toHaveCount(3, { timeout: 10000 }); + + await rows + .nth(1) + .locator('[data-testid="search-recent-remove-button"]') + .click(); + + await expect(rows).toHaveCount(2, { timeout: 10000 }); + await expect(rows.nth(0)).toContainText("alpha"); + await expect(rows.nth(1)).toContainText("gamma"); + await expect(page).toHaveURL(/\/search$/); + await expect + .poll( + () => mockServer.searchHistory?.searches?.map((entry) => entry.q), + { timeout: 10000 }, + ) + .toEqual(["alpha", "gamma"]); + }); + + test("removes a recent search optimistically before the write settles", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.setSearchHistory({ + searches: [ + { q: "cats", ts: 2 }, + { q: "dogs", ts: 1 }, + ], + }); + mockServer.putPreferencesDelayMs = 2000; + await mockServer.setup(page); + + await login(page); + await page.goto("/search"); + + const view = page.locator("#search-view"); + const rows = view.locator('[data-testid="search-recent-row"]'); + await expect(rows).toHaveCount(2, { timeout: 10000 }); + + await rows + .nth(0) + .locator('[data-testid="search-recent-remove-button"]') + .click(); + + // The row disappears immediately, long before the delayed + // putPreferences settles + await expect(rows).toHaveCount(1, { timeout: 500 }); + await expect(rows.nth(0)).toContainText("dogs"); + expect(mockServer.searchHistory.searches.length).toBe(2); + + await expect + .poll( + () => mockServer.searchHistory?.searches?.map((entry) => entry.q), + { timeout: 10000 }, + ) + .toEqual(["dogs"]); + await expect(rows).toHaveCount(1); + }); + + test("shows the placeholder again after removing the last entry", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.setSearchHistory({ searches: [{ q: "cats", ts: 1 }] }); + await mockServer.setup(page); + + await login(page); + await page.goto("/search"); + + const view = page.locator("#search-view"); + await view + .locator('[data-testid="search-recent-remove-button"]') + .first() + .click(); + + await expect(view.locator(".search-placeholder")).toBeVisible({ + timeout: 10000, + }); + await expect( + view.locator('[data-testid="search-recent"]'), + ).not.toBeVisible(); + }); + + test("logged out shows the placeholder and never writes history", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.addSearchProfiles([ + createProfile({ + did: "did:plc:profile1", + handle: "alice.bsky.social", + displayName: "Alice", + }), + ]); + await mockServer.setup(page); + + await page.goto("/search"); + + const view = page.locator("#search-view"); + await expect(view.locator(".search-placeholder")).toBeVisible({ + timeout: 10000, + }); + + const input = view.locator(".search-input"); + await input.fill("alice"); + await input.press("Enter"); + await expect(view.locator(".profile-list-item")).toHaveCount(1, { + timeout: 10000, + }); + expect(mockServer.searchHistory).toBe(null); + }); + + test("renders recent profiles in stored order and navigates on tap", async ({ + page, + }) => { + const mockServer = new MockServer(); + const profile1 = createProfile({ + did: "did:plc:recent1", + handle: "alice.bsky.social", + displayName: "Alice", + }); + const profile2 = createProfile({ + did: "did:plc:recent2", + handle: "bob.bsky.social", + displayName: "Bob", + }); + mockServer.addProfile(profile1); + mockServer.addProfile(profile2); + mockServer.setSearchHistory({ + profiles: [profile2.did, profile1.did], + }); + await mockServer.setup(page); + + await login(page); + await page.goto("/search"); + + const view = page.locator("#search-view"); + const tiles = view.locator('[data-testid="search-recent-profile"]'); + await expect(tiles).toHaveCount(2, { timeout: 10000 }); + await expect(tiles.nth(0)).toContainText("Bob"); + await expect(tiles.nth(1)).toContainText("Alice"); + + await tiles.nth(0).click(); + await expect(page).toHaveURL(/\/profile\//, { timeout: 10000 }); + }); + + test("shows skeleton tiles while recent profiles load, matching loaded height", async ({ + page, + }) => { + const mockServer = new MockServer(); + const profile1 = createProfile({ + did: "did:plc:recent1", + handle: "alice.bsky.social", + displayName: "Alice", + }); + const profile2 = createProfile({ + did: "did:plc:recent2", + handle: "bob.bsky.social", + displayName: "Bob", + }); + mockServer.addProfile(profile1); + mockServer.addProfile(profile2); + mockServer.setSearchHistory({ + profiles: [profile2.did, profile1.did], + }); + mockServer.getProfilesDelayMs = 1500; + await mockServer.setup(page); + + await login(page); + await page.goto("/search"); + + const view = page.locator("#search-view"); + const skeletons = view.locator( + '[data-testid="search-recent-profile-skeleton"]', + ); + await expect(skeletons).toHaveCount(2, { timeout: 10000 }); + const skeletonBox = await skeletons.first().boundingBox(); + + const tiles = view.locator('[data-testid="search-recent-profile"]'); + await expect(tiles).toHaveCount(2, { timeout: 10000 }); + await expect(skeletons).toHaveCount(0); + const tileBox = await tiles.first().boundingBox(); + expect(tileBox.height).toBe(skeletonBox.height); + expect(tileBox.width).toBe(skeletonBox.width); + }); + + test("removing a recent profile does not navigate", async ({ page }) => { + const mockServer = new MockServer(); + const profile1 = createProfile({ + did: "did:plc:recent1", + handle: "alice.bsky.social", + displayName: "Alice", + }); + const profile2 = createProfile({ + did: "did:plc:recent2", + handle: "bob.bsky.social", + displayName: "Bob", + }); + mockServer.addProfile(profile1); + mockServer.addProfile(profile2); + mockServer.setSearchHistory({ + profiles: [profile2.did, profile1.did], + }); + await mockServer.setup(page); + + await login(page); + await page.goto("/search"); + + const view = page.locator("#search-view"); + const tiles = view.locator('[data-testid="search-recent-profile"]'); + await expect(tiles).toHaveCount(2, { timeout: 10000 }); + + await tiles + .nth(0) + .locator('[data-testid="search-recent-profile-remove"]') + .click(); + + await expect(tiles).toHaveCount(1, { timeout: 10000 }); + await expect(tiles.nth(0)).toContainText("Alice"); + await expect(page).toHaveURL(/\/search$/); + await expect + .poll(() => mockServer.searchHistory?.profiles, { timeout: 10000 }) + .toEqual(["did:plc:recent1"]); + }); + }); }); diff --git a/tests/unit/specs/identityPrecaching.test.js b/tests/unit/specs/identityPrecaching.test.js index 1faac61b..2b163938 100644 --- a/tests/unit/specs/identityPrecaching.test.js +++ b/tests/unit/specs/identityPrecaching.test.js @@ -51,6 +51,20 @@ describe("notifications precaching", () => { }); }); +describe("search typeahead precaching", () => { + it("should cache identities from typeahead search results", async () => { + const { dataStore, dataLayer, identityResolver, resolvedHandles } = setup(); + setUpIdentityPrecaching(dataLayer, identityResolver); + + dataStore.$searchTypeaheadResults.set({ + actors: [{ handle: "dave.test", did: "did:plc:dave" }], + }); + await flushEffects(); + + assert.deepEqual(resolvedHandles.get("dave.test"), "did:plc:dave"); + }); +}); + describe("post precaching", () => { it("should cache identities for posts normalized from nested quotes", async () => { const { dataStore, dataLayer, identityResolver, resolvedHandles } = setup(); diff --git a/tests/unit/specs/preferences.test.js b/tests/unit/specs/preferences.test.js index d8f9bd44..665868e4 100644 --- a/tests/unit/specs/preferences.test.js +++ b/tests/unit/specs/preferences.test.js @@ -3069,3 +3069,233 @@ describe("Preferences installed plugins", () => { assert.deepEqual(records.length, 1); }); }); + +describe("Preferences recent searches", () => { + const buildObj = (searches) => [ + { + $type: "app.bsky.actor.defs#improSearchHistoryPref", + searches, + }, + ]; + + it("returns empty array when no search history preference exists", () => { + const preferences = new Preferences([], []); + assert.deepEqual(preferences.getRecentSearches(), []); + }); + + it("adds a search and reads it back newest first", () => { + const preferences = new Preferences([], []) + .addRecentSearch("cats") + .addRecentSearch("dogs"); + const searches = preferences.getRecentSearches(); + assert.deepEqual( + searches.map((entry) => entry.q), + ["dogs", "cats"], + ); + assert(typeof searches[0].ts === "number"); + assert(searches[0].ts > 0); + }); + + it("creates the preference on first add", () => { + const preferences = new Preferences([], []); + const updated = preferences.addRecentSearch("cats"); + const pref = Preferences.getSearchHistoryPreference(updated.obj); + assert.deepEqual(pref.$type, "app.bsky.actor.defs#improSearchHistoryPref"); + assert.deepEqual(pref.searches.length, 1); + // Original unchanged + assert.deepEqual(preferences.getRecentSearches(), []); + }); + + it("trims the query and ignores empty/whitespace queries", () => { + const preferences = new Preferences([], []) + .addRecentSearch(" cats ") + .addRecentSearch("") + .addRecentSearch(" ") + .addRecentSearch(null); + const searches = preferences.getRecentSearches(); + assert.deepEqual( + searches.map((entry) => entry.q), + ["cats"], + ); + }); + + it("clamps overlong queries", () => { + const preferences = new Preferences([], []).addRecentSearch( + "a".repeat(500), + ); + assert.deepEqual(preferences.getRecentSearches()[0].q, "a".repeat(300)); + }); + + it("dedupes by moving an existing query to the front", () => { + const preferences = new Preferences([], []) + .addRecentSearch("cats") + .addRecentSearch("dogs") + .addRecentSearch("cats"); + assert.deepEqual( + preferences.getRecentSearches().map((entry) => entry.q), + ["cats", "dogs"], + ); + }); + + it("caps stored searches at 10, dropping the oldest", () => { + let preferences = new Preferences([], []); + for (let i = 1; i <= 12; i++) { + preferences = preferences.addRecentSearch(`query ${i}`); + } + const searches = preferences.getRecentSearches(); + assert.deepEqual(searches.length, 10); + assert.deepEqual(searches[0].q, "query 12"); + assert.deepEqual(searches[9].q, "query 3"); + }); + + it("removes a search by query", () => { + const preferences = new Preferences([], []) + .addRecentSearch("cats") + .addRecentSearch("dogs"); + const updated = preferences.removeRecentSearch("cats"); + assert.deepEqual( + updated.getRecentSearches().map((entry) => entry.q), + ["dogs"], + ); + // Original unchanged + assert.deepEqual(preferences.getRecentSearches().length, 2); + }); + + it("handles removing an absent query gracefully", () => { + const preferences = new Preferences([], []).addRecentSearch("cats"); + const updated = preferences.removeRecentSearch("dogs"); + assert.deepEqual(updated.getRecentSearches().length, 1); + const noPref = new Preferences([], []).removeRecentSearch("cats"); + assert.deepEqual(noPref.getRecentSearches(), []); + }); + + it("drops malformed entries on read", () => { + const preferences = new Preferences( + buildObj([ + { q: "valid", ts: 123 }, + { q: "", ts: 1 }, + { q: " ", ts: 1 }, + { q: 42, ts: 1 }, + { ts: 1 }, + null, + "bare string", + ]), + [], + ); + const searches = preferences.getRecentSearches(); + assert.deepEqual(searches.length, 1); + assert.deepEqual(searches[0].q, "valid"); + }); + + it("returns empty array when searches is not an array", () => { + const preferences = new Preferences(buildObj("not an array"), []); + assert.deepEqual(preferences.getRecentSearches(), []); + }); + + it("coerces non-numeric ts to 0 on read", () => { + const preferences = new Preferences( + buildObj([{ q: "cats", ts: "soon" }, { q: "dogs" }]), + [], + ); + const searches = preferences.getRecentSearches(); + assert.deepEqual(searches[0].ts, 0); + assert.deepEqual(searches[1].ts, 0); + }); + + it("preserves unknown entry keys through unrelated mutations", () => { + const preferences = new Preferences( + buildObj([{ q: "cats", ts: 123, filters: { lang: "en" } }]), + [], + ); + const updated = preferences.addRecentSearch("dogs"); + const pref = Preferences.getSearchHistoryPreference(updated.obj); + assert.deepEqual(pref.searches[1], { + q: "cats", + ts: 123, + filters: { lang: "en" }, + }); + }); + + it("leaves other preference types untouched", () => { + const obj = [{ $type: "app.bsky.actor.defs#savedFeedsPrefV2", items: [] }]; + const preferences = new Preferences(obj, []); + const updated = preferences.addRecentSearch("cats"); + assert.deepEqual( + Preferences.getSavedFeedsPreference(updated.obj).items, + [], + ); + assert.deepEqual(updated.obj.length, 2); + }); +}); + +describe("Preferences recent search profiles", () => { + it("returns empty array when no search history preference exists", () => { + const preferences = new Preferences([], []); + assert.deepEqual(preferences.getRecentSearchProfiles(), []); + }); + + it("adds profiles newest first and dedupes by DID", () => { + const preferences = new Preferences([], []) + .addRecentSearchProfile("did:plc:aaa") + .addRecentSearchProfile("did:plc:bbb") + .addRecentSearchProfile("did:plc:aaa"); + assert.deepEqual(preferences.getRecentSearchProfiles(), [ + "did:plc:aaa", + "did:plc:bbb", + ]); + }); + + it("caps stored profiles at 10", () => { + let preferences = new Preferences([], []); + for (let i = 1; i <= 12; i++) { + preferences = preferences.addRecentSearchProfile(`did:plc:profile${i}`); + } + const profiles = preferences.getRecentSearchProfiles(); + assert.deepEqual(profiles.length, 10); + assert.deepEqual(profiles[0], "did:plc:profile12"); + assert.deepEqual(profiles[9], "did:plc:profile3"); + }); + + it("removes a profile by DID without mutating the original", () => { + const preferences = new Preferences([], []) + .addRecentSearchProfile("did:plc:aaa") + .addRecentSearchProfile("did:plc:bbb"); + const updated = preferences.removeRecentSearchProfile("did:plc:bbb"); + assert.deepEqual(updated.getRecentSearchProfiles(), ["did:plc:aaa"]); + assert.deepEqual(preferences.getRecentSearchProfiles().length, 2); + }); + + it("removes multiple profiles preserving stored order", () => { + const preferences = new Preferences([], []) + .addRecentSearchProfile("did:plc:aaa") + .addRecentSearchProfile("did:plc:bbb") + .addRecentSearchProfile("did:plc:ccc"); + const updated = preferences.removeRecentSearchProfiles([ + "did:plc:bbb", + "did:plc:absent", + ]); + assert.deepEqual(updated.getRecentSearchProfiles(), [ + "did:plc:ccc", + "did:plc:aaa", + ]); + }); + + it("shares the preference record with recent searches", () => { + const preferences = new Preferences([], []) + .addRecentSearch("cats") + .addRecentSearchProfile("did:plc:aaa"); + const records = preferences.obj.filter( + (pref) => pref.$type === "app.bsky.actor.defs#improSearchHistoryPref", + ); + assert.deepEqual(records.length, 1); + assert.deepEqual(preferences.getRecentSearches().length, 1); + assert.deepEqual(preferences.getRecentSearchProfiles().length, 1); + }); + + it("ignores invalid DID values", () => { + const preferences = new Preferences([], []) + .addRecentSearchProfile("") + .addRecentSearchProfile(null); + assert.deepEqual(preferences.getRecentSearchProfiles(), []); + }); +});