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(), []);
+ });
+});