diff --git a/apps/browser-extension/src/entrypoints/background.ts b/apps/browser-extension/src/entrypoints/background.ts index 14cbdef72..42ecdbf19 100644 --- a/apps/browser-extension/src/entrypoints/background.ts +++ b/apps/browser-extension/src/entrypoints/background.ts @@ -11,7 +11,7 @@ import { handleGetWebAuthnSettings, handleWebAuthnCreate, handleWebAuthnGet, han import { handleOpenPopup, handlePopupWithItem, handleOpenPopupCreateCredential, handleToggleContextMenu } from '@/entrypoints/background/PopupMessageHandler'; import { handleStoreSavePromptState, handleGetSavePromptState, handleClearSavePromptState, handleStoreLastAutofilled, handleGetLastAutofilled, handleClearLastAutofilled } from '@/entrypoints/background/SavePromptStateHandler'; import { handleStoreTwoFactorState, handleGetTwoFactorState, handleClearTwoFactorState } from '@/entrypoints/background/TwoFactorStateHandler'; -import { handleCheckAuthStatus, handleClearPersistedFormValues, handleClearSession, handleClearVaultData, handleLockVault, handleCreateItem, handleGetFilteredItems, handleGetSearchItems, handleGetDefaultEmailDomain, handleGetDefaultIdentitySettings, handleGetEncryptionKey, handleGetEncryptionKeyDerivationParams, handleGetPasswordSettings, handleGetPersistedFormValues, handleGetVault, handlePersistFormValues, handleStoreEncryptionKey, handleStoreEncryptionKeyDerivationParams, handleStoreVaultMetadata, handleSyncVault, handleUploadVault, handleGetEncryptedVault, handleStoreEncryptedVault, handleGetSyncState, handleMarkVaultClean, handleGetServerRevision, handleCheckSyncStatus, handleFullVaultSync, handleCheckLoginDuplicate, handleSaveLoginCredential, handleAddUrlToCredential, handleGetLoginSaveSettings, handleSetLoginSaveEnabled, handleGetItemsWithTotp, handleSearchItemsWithTotp, handleGetTotpSecrets, handleGenerateTotpCode } from '@/entrypoints/background/VaultMessageHandler'; +import { handleCheckAuthStatus, handleClearPersistedFormValues, handleClearSession, handleClearVaultData, handleLockVault, handleCreateItem, handleGetFilteredItems, handleGetSearchItems, handleGetDefaultEmailDomain, handleGetDefaultIdentitySettings, handleGetEncryptionKey, handleGetEncryptionKeyDerivationParams, handleGetPasswordSettings, handleGetPersistedFormValues, handleGetVault, handlePersistFormValues, handleStoreEncryptionKey, handleStoreEncryptionKeyDerivationParams, handleStoreVaultMetadata, handleSyncVault, handleUploadVault, handleGetEncryptedVault, handleStoreEncryptedVault, handleGetSyncState, handleMarkVaultClean, handleGetServerRevision, handleCheckSyncStatus, handleFullVaultSync, handleCheckLoginDuplicate, handleSaveLoginCredential, handleAddUrlToCredential, handleGetLoginSaveSettings, handleSetLoginSaveEnabled, handleGetItemsWithTotp, handleSearchItemsWithTotp, handleGetTotpSecrets, handleGenerateTotpCode, handleSetRecentlySelected, handleGetRecentlySelected } from '@/entrypoints/background/VaultMessageHandler'; import { EncryptionKeyDerivationParams } from "@/utils/dist/core/models/metadata"; import type { LoginResponse } from "@/utils/dist/core/models/webapi"; @@ -31,7 +31,7 @@ export default defineBackground({ onMessage('GET_ENCRYPTION_KEY', () => handleGetEncryptionKey()); onMessage('GET_ENCRYPTION_KEY_DERIVATION_PARAMS', () => handleGetEncryptionKeyDerivationParams()); onMessage('GET_VAULT', () => handleGetVault()); - onMessage('GET_FILTERED_ITEMS', ({ data }) => handleGetFilteredItems(data as { currentUrl: string, pageTitle: string, matchingMode?: string })); + onMessage('GET_FILTERED_ITEMS', ({ data }) => handleGetFilteredItems(data as { currentUrl: string, pageTitle: string, matchingMode?: string, includeRecentlySelected?: boolean })); onMessage('GET_SEARCH_ITEMS', ({ data }) => handleGetSearchItems(data as { searchTerm: string })); onMessage('GET_DEFAULT_EMAIL_DOMAIN', () => handleGetDefaultEmailDomain()); @@ -79,6 +79,10 @@ export default defineBackground({ onMessage('GET_TOTP_SECRETS', ({ data }) => handleGetTotpSecrets(data as { itemIds: string[] })); onMessage('GENERATE_TOTP_CODE', ({ data }) => handleGenerateTotpCode(data as { itemId: string })); + // Track recently selected items for autofill prioritization + onMessage('SET_RECENTLY_SELECTED', ({ data }) => handleSetRecentlySelected(data as { itemId: string; domain: string })); + onMessage('GET_RECENTLY_SELECTED', ({ data }) => handleGetRecentlySelected(data as { domain: string })); + // Remember login save state (for surviving page navigation) onMessage('STORE_SAVE_PROMPT_STATE', ({ data, sender }) => handleStoreSavePromptState({ tabId: sender.tabId!, state: data as SavePromptPersistedState })); onMessage('GET_SAVE_PROMPT_STATE', ({ sender }) => handleGetSavePromptState({ tabId: sender.tabId! })); diff --git a/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts b/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts index fa5583f96..0509418dc 100644 --- a/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts +++ b/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts @@ -8,6 +8,7 @@ import type { Vault, VaultResponse, VaultPostResponse } from '@/utils/dist/core/ import { EncryptionUtility } from '@/utils/EncryptionUtility'; import { filterItems, AutofillMatchingMode } from '@/utils/itemMatcher/ItemMatcher'; import { LocalPreferencesService } from '@/utils/LocalPreferencesService'; +import { RecentlySelectedItemService } from '@/utils/RecentlySelectedItemService'; import { SqliteClient } from '@/utils/SqliteClient'; import { getItemWithFallback } from '@/utils/StorageUtility'; import { ApiAuthError } from '@/utils/types/errors/ApiAuthError'; @@ -359,8 +360,68 @@ function filterItemsByUrl(items: Item[], currentUrl: string, pageTitle: string, return filterItems(items, currentUrl, pageTitle, matchingMode); } +/** + * Prioritize recently selected item in the filtered items list. + * If a recently selected item exists and is valid, ensure it's at the front of the array. + * If the item is not in the filtered results, fetch it from the vault and add it. + * + * @param items - The filtered items array + * @param domain - The current domain for recently selected item validation + * @param allItems - All items from the vault (to fetch recently selected if not in filtered) + * @returns The items array with recently selected item prioritized + */ +async function prioritizeRecentlySelectedItem(items: Item[], domain: string, allItems: Item[]): Promise { + const recentlySelectedId = await RecentlySelectedItemService.getRecentlySelected(domain); + + if (!recentlySelectedId) { + return items; + } + + // Find the recently selected item in the filtered results + const recentlySelectedIndex = items.findIndex(item => item.Id === recentlySelectedId); + + if (recentlySelectedIndex !== -1) { + // Item is already in filtered results - move it to the front + const recentlySelectedItem = items[recentlySelectedIndex]; + const reorderedItems = [ + recentlySelectedItem, + ...items.slice(0, recentlySelectedIndex), + ...items.slice(recentlySelectedIndex + 1) + ]; + return reorderedItems; + } + + // Item is not in filtered results - fetch it from all items and prepend it + const recentlySelectedItem = allItems.find(item => item.Id === recentlySelectedId); + + if (!recentlySelectedItem) { + // Item not found in vault (might have been deleted) + return items; + } + + // Prepend the recently selected item to the filtered results + return [recentlySelectedItem, ...items]; +} + +/** + * Extract domain from URL for recently selected item scoping. + * @param url - The full URL + * @returns The domain or the original URL if parsing fails + */ +function extractDomain(url: string): string { + try { + const urlObj = new URL(url); + return urlObj.hostname; + } catch { + // If URL parsing fails, return the original URL + return url; + } +} + /** * Filter items by search term. + * Splits search into words and matches items where ALL words appear in searchable fields. + * Word order doesn't matter - matching behavior consistent with popup search. * * @param items - The items to filter * @param searchTerm - The search term to use @@ -371,7 +432,10 @@ function filterItemsBySearchTerm(items: Item[], searchTerm: string): Item[] { return []; } - const normalizedTerm = searchTerm.toLowerCase().trim(); + const searchLower = searchTerm.toLowerCase().trim(); + + // Split search query into individual words (same as popup search) + const searchWords = searchLower.split(/\s+/).filter(word => word.length > 0); const searchableFieldKeys = [ FieldKey.LoginUsername, @@ -382,19 +446,24 @@ function filterItemsBySearchTerm(items: Item[], searchTerm: string): Item[] { ]; return items.filter((item: Item) => { - // Search in item name - if (item.Name?.toLowerCase().includes(normalizedTerm)) { - return true; - } + // Build searchable fields array + const searchableFields: string[] = [ + item.Name?.toLowerCase() || '' + ]; - // Search in field values - return item.Fields?.some((field: { FieldKey: string; Value: string | string[] }) => { + // Add field values to searchable fields + item.Fields?.forEach((field: { FieldKey: string; Value: string | string[]; Label: string }) => { if ((searchableFieldKeys as string[]).includes(field.FieldKey)) { const value = Array.isArray(field.Value) ? field.Value.join(' ') : field.Value; - return value?.toLowerCase().includes(normalizedTerm); + searchableFields.push(value?.toLowerCase() || ''); + searchableFields.push(field.Label.toLowerCase()); } - return false; }); + + // Every word must appear in at least one searchable field (order doesn't matter) + return searchWords.every(word => + searchableFields.some(field => field.includes(word)) + ); }).sort((a: Item, b: Item) => (a.Name ?? '').localeCompare(b.Name ?? '')); } @@ -402,10 +471,10 @@ function filterItemsBySearchTerm(items: Item[], searchTerm: string): Item[] { * Get items filtered by URL matching (for autofill). * Filters items in the background script before sending to reduce message payload size. * - * @param message - Filtering parameters: currentUrl, pageTitle, matchingMode + * @param message - Filtering parameters: currentUrl, pageTitle, matchingMode, skipRecentlySelected */ export async function handleGetFilteredItems( - message: { currentUrl: string, pageTitle: string, matchingMode?: string } + message: { currentUrl: string, pageTitle: string, matchingMode?: string, includeRecentlySelected?: boolean } ) : Promise { const encryptionKey = await handleGetEncryptionKey(); @@ -419,7 +488,14 @@ export async function handleGetFilteredItems( const allItems = sqliteClient.items.getAll(); const filteredItems = await filterItemsByUrl(allItems, message.currentUrl, message.pageTitle, message.matchingMode); - return { success: true, items: filteredItems }; + // Prioritize recently selected item for multi-step login flows (opt-in only) + let prioritizedItems = filteredItems; + if (message.includeRecentlySelected) { + const domain = extractDomain(message.currentUrl); + prioritizedItems = await prioritizeRecentlySelectedItem(filteredItems, domain, allItems); + } + + return { success: true, items: prioritizedItems }; } catch (error) { console.error('Error getting filtered items:', error); // E-304: Item read failed @@ -1672,7 +1748,11 @@ export async function handleGetItemsWithTotp( // Then filter by URL matching using shared logic const filteredItems = await filterItemsByUrl(itemsWithTotp, message.currentUrl, message.pageTitle, message.matchingMode); - return { success: true, items: filteredItems }; + // Prioritize recently selected item for multi-step login flows + const domain = extractDomain(message.currentUrl); + const prioritizedItems = await prioritizeRecentlySelectedItem(filteredItems, domain, itemsWithTotp); + + return { success: true, items: prioritizedItems }; } catch (error) { console.error('Error getting items with TOTP:', error); return { success: false, error: formatErrorWithCode(await t('common.errors.unknownError'), AppErrorCode.ITEM_READ_FAILED) }; @@ -1780,3 +1860,33 @@ export async function handleGenerateTotpCode( return { success: false, error: formatErrorWithCode(await t('common.errors.unknownError'), AppErrorCode.ITEM_READ_FAILED) }; } } + +/** + * Set recently selected item for smart autofill. + */ +export async function handleSetRecentlySelected( + message: { itemId: string; domain: string } +): Promise<{ success: boolean }> { + try { + await RecentlySelectedItemService.setRecentlySelected(message.itemId, message.domain); + return { success: true }; + } catch (error) { + console.error('Error setting recently selected item:', error); + return { success: false }; + } +} + +/** + * Get recently selected item for smart autofill. + */ +export async function handleGetRecentlySelected( + message: { domain: string } +): Promise<{ success: boolean; itemId?: string | null }> { + try { + const itemId = await RecentlySelectedItemService.getRecentlySelected(message.domain); + return { success: true, itemId }; + } catch (error) { + console.error('Error getting recently selected item:', error); + return { success: false, itemId: null }; + } +} diff --git a/apps/browser-extension/src/entrypoints/contentScript/Form.ts b/apps/browser-extension/src/entrypoints/contentScript/Form.ts index bee3345a8..fbf60179d 100644 --- a/apps/browser-extension/src/entrypoints/contentScript/Form.ts +++ b/apps/browser-extension/src/entrypoints/contentScript/Form.ts @@ -133,6 +133,11 @@ export async function fillItem(item: Item, input: HTMLInputElement): Promise { // Ignore errors as background script might not be ready }); + + // Store recently selected item for smart autofill prioritization + sendMessage('SET_RECENTLY_SELECTED', { itemId: item.Id, domain: window.location.hostname }, 'background').catch(() => { + // Ignore errors as background script might not be ready + }); } /** @@ -324,6 +329,11 @@ export async function fillTotpCode(itemId: string, input: HTMLInputElement): Pro // Trigger input events for form validation triggerInputEvents(input); + + // Store recently selected item for smart autofill prioritization + sendMessage('SET_RECENTLY_SELECTED', { itemId, domain: window.location.hostname }, 'background').catch(() => { + // Ignore errors as background script might not be ready + }); } /** diff --git a/apps/browser-extension/src/entrypoints/contentScript/Popup.ts b/apps/browser-extension/src/entrypoints/contentScript/Popup.ts index f3a3b65ae..67624394a 100644 --- a/apps/browser-extension/src/entrypoints/contentScript/Popup.ts +++ b/apps/browser-extension/src/entrypoints/contentScript/Popup.ts @@ -133,7 +133,8 @@ export function openAutofillPopup(input: HTMLInputElement, container: HTMLElemen const response = await sendMessage('GET_FILTERED_ITEMS', { currentUrl: window.location.href, pageTitle: document.title, - matchingMode: matchingMode + matchingMode: matchingMode, + includeRecentlySelected: true // Enable for multi-step login autofill }, 'background') as ItemsResponse; if (response.success) { @@ -655,7 +656,7 @@ export function createLoadingPopup(input: HTMLInputElement, message: string, roo * @param itemList - The item list element. * @param input - The input element that triggered the popup. Required when filling items to know which form to fill. */ -export function updatePopupContent(items: Item[], itemList: HTMLElement | null, input: HTMLInputElement, rootContainer: HTMLElement, noMatchesText?: string) : void { +export async function updatePopupContent(items: Item[], itemList: HTMLElement | null, input: HTMLInputElement, rootContainer: HTMLElement, noMatchesText?: string) : Promise { if (!itemList) { itemList = document.getElementById('aliasvault-credential-list') as HTMLElement; } @@ -721,7 +722,7 @@ export async function createAutofillPopup(input: HTMLInputElement, items: Item[] items = []; } - updatePopupContent(items, credentialList, input, rootContainer, noMatchesText); + await updatePopupContent(items, credentialList, input, rootContainer, noMatchesText); // Add divider const divider = document.createElement('div'); @@ -1118,7 +1119,7 @@ async function handleSearchInput(searchInput: HTMLInputElement, initialItems: It if (searchTerm === '') { // If search is empty, show the initially URL-filtered items - updatePopupContent(initialItems, itemList, input, rootContainer, noMatchesText); + await updatePopupContent(initialItems, itemList, input, rootContainer, noMatchesText); } else { // Search in full vault with search term const response = await sendMessage('GET_SEARCH_ITEMS', { @@ -1126,10 +1127,10 @@ async function handleSearchInput(searchInput: HTMLInputElement, initialItems: It }, 'background') as ItemsResponse; if (response.success && response.items) { - updatePopupContent(response.items, itemList, input, rootContainer, noMatchesText); + await updatePopupContent(response.items, itemList, input, rootContainer, noMatchesText); } else { // On error, fallback to showing initial filtered items - updatePopupContent(initialItems, itemList, input, rootContainer, noMatchesText); + await updatePopupContent(initialItems, itemList, input, rootContainer, noMatchesText); } } } @@ -1144,7 +1145,7 @@ function createItemList(items: Item[], input: HTMLInputElement, rootContainer: H const elements: HTMLElement[] = []; if (items.length > 0) { - items.forEach(item => { + items.forEach((item) => { const itemElement = document.createElement('div'); itemElement.className = 'av-credential-item'; diff --git a/apps/browser-extension/src/entrypoints/popup/hooks/useCurrentTabMatching.ts b/apps/browser-extension/src/entrypoints/popup/hooks/useCurrentTabMatching.ts index cf9eb25c8..ef9002ea7 100644 --- a/apps/browser-extension/src/entrypoints/popup/hooks/useCurrentTabMatching.ts +++ b/apps/browser-extension/src/entrypoints/popup/hooks/useCurrentTabMatching.ts @@ -21,9 +21,6 @@ export type CurrentTabMatchResult = { /** * Hook for matching vault items against the current browser tab. - * - * Uses the same credential matching logic as the content script autofill popup, - * respecting user's autofill matching mode settings. */ const useCurrentTabMatching = (): { matchCurrentTab: () => Promise; @@ -59,11 +56,12 @@ const useCurrentTabMatching = (): { // Get autofill matching mode from user settings const matchingMode = await LocalPreferencesService.getAutofillMatchingMode(); - // Use the same filtering logic as content script + // Use the same filtering logic as content script (without recently selected prioritization) const response = await sendMessage('GET_FILTERED_ITEMS', { currentUrl: activeTab.url, pageTitle: activeTab.title || '', matchingMode: matchingMode + // includeRecentlySelected defaults to false (recently selected is for autofill only) }, 'background') as ItemsResponse; if (!response.success || !response.items) { diff --git a/apps/browser-extension/src/entrypoints/popup/pages/Reinitialize.tsx b/apps/browser-extension/src/entrypoints/popup/pages/Reinitialize.tsx index 762d7b349..192eb4a2e 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/Reinitialize.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/Reinitialize.tsx @@ -14,6 +14,7 @@ import { storage } from '#imports'; const LAST_VISITED_PAGE_KEY = 'session:lastVisitedPage'; const LAST_VISITED_TIME_KEY = 'session:lastVisitedTime'; const NAVIGATION_HISTORY_KEY = 'session:navigationHistory'; +const LAST_TAB_URL_KEY = 'session:lastTabUrl'; const PAGE_MEMORY_DURATION = 120 * 1000; // 2 minutes in milliseconds type NavigationHistoryEntry = { @@ -67,10 +68,10 @@ const Reinitialize: React.FC = () => { navigate('/items', { replace: true }); navigate(`/items/${matchResult.items[0].Id}`, { replace: false }); } else if (matchResult && matchResult.items.length > 1) { - // Multiple matches - navigate to items list with domain search + // Multiple matches - navigate to items list with domain search to help user find the right one navigate(`/items?search=${encodeURIComponent(matchResult.domain)}`, { replace: true }); } else { - // No matches or matching failed - navigate to items page as default + // No matches or matching failed - navigate to items page without search (don't prefill search when there are no matches) navigate('/items', { replace: true }); } }, [navigate]); @@ -84,18 +85,24 @@ const Reinitialize: React.FC = () => { const matchResult = await matchCurrentTab(); const matchedPath = getMatchedPath(matchResult); - const [lastPage, lastVisitTime, savedHistory] = await Promise.all([ + const [lastPage, lastVisitTime, savedHistory, lastTabUrl] = await Promise.all([ storage.getItem(LAST_VISITED_PAGE_KEY) as Promise, storage.getItem(LAST_VISITED_TIME_KEY) as Promise, storage.getItem(NAVIGATION_HISTORY_KEY) as Promise, + storage.getItem(LAST_TAB_URL_KEY) as Promise, ]); + // Check if user switched to a different tab (different URL) + const currentTabUrl = matchResult?.currentUrl; + const hasTabChanged = currentTabUrl && lastTabUrl && currentTabUrl !== lastTabUrl; + if (lastPage && lastVisitTime) { const timeSinceLastVisit = Date.now() - lastVisitTime; if (timeSinceLastVisit <= PAGE_MEMORY_DURATION) { /* * Check if user navigated away from the auto-matched page to a specific different page. * Use fresh URL matching if: + * - Tab URL has changed (user switched tabs) * - lastPage matches what URL matching would show AND has no search query (user stayed on auto-matched page) * - lastPage is /items with no search query (default index page - treat as "home" state) * @@ -107,7 +114,7 @@ const Reinitialize: React.FC = () => { const hasSearchQuery = lastHistoryEntry?.search && lastHistoryEntry.search.length > 0; const isOnMatchedPage = lastPage === matchedPath && !hasSearchQuery; const isOnDefaultIndexPage = lastPage === '/items' && !hasSearchQuery; - const shouldUseFreshMatch = isOnMatchedPage || isOnDefaultIndexPage; + const shouldUseFreshMatch = hasTabChanged || isOnMatchedPage || isOnDefaultIndexPage; if (!shouldUseFreshMatch) { // Restore user's navigation since they navigated away from auto-matched page @@ -137,6 +144,11 @@ const Reinitialize: React.FC = () => { sendMessage('CLEAR_PERSISTED_FORM_VALUES', null, 'background'), ]); + // Save current tab URL for future tab-switch detection + if (currentTabUrl) { + await storage.setItem(LAST_TAB_URL_KEY, currentTabUrl); + } + // Navigate based on URL matching await navigateWithUrlMatching(matchResult); }, [navigate, matchCurrentTab, getMatchedPath, navigateWithUrlMatching]); diff --git a/apps/browser-extension/src/utils/RecentlySelectedItemService.ts b/apps/browser-extension/src/utils/RecentlySelectedItemService.ts new file mode 100644 index 000000000..9466545cc --- /dev/null +++ b/apps/browser-extension/src/utils/RecentlySelectedItemService.ts @@ -0,0 +1,106 @@ +import { storage } from '#imports'; + +/** + * Storage key for recently selected item. + * Uses session storage (memory-only, cleared on browser restart). + */ +const RECENTLY_SELECTED_KEY = 'session:aliasvault_recently_selected_item'; + +/** + * Time-to-live for recently selected items (60 seconds). + */ +const TTL_MS = 60 * 1000; + +/** + * Interface for the recently selected item data. + */ +export interface IRecentlySelectedItem { + itemId: string; + timestamp: number; + domain: string; +} + +/** + * Service for managing the recently selected autofill item. + * This enables "smart autofill" where the most recently selected credential + * is prioritized in subsequent autofill suggestions for multi-step login flows. + * + * The recently selected item is stored in session storage with a 60-second TTL + * and is scoped to the domain to prevent cross-site leakage. + */ +export const RecentlySelectedItemService = { + /** + * Store a recently selected item with the current timestamp. + * @param itemId - The ID of the item that was selected + * @param domain - The domain where the item was used (for scoping) + */ + async setRecentlySelected(itemId: string, domain: string): Promise { + const data: IRecentlySelectedItem = { + itemId, + timestamp: Date.now(), + domain, + }; + await storage.setItem(RECENTLY_SELECTED_KEY, data); + }, + + /** + * Get the recently selected item if it exists and is not expired. + * @param domain - The current domain to check against + * @returns The item ID if valid, or null if expired or not matching domain + */ + async getRecentlySelected(domain: string): Promise { + const data = await storage.getItem(RECENTLY_SELECTED_KEY) as IRecentlySelectedItem | null; + + if (!data) { + return null; + } + + // Check if expired + const age = Date.now() - data.timestamp; + if (age > TTL_MS) { + await this.clear(); + return null; + } + + // Check if domain matches + if (data.domain !== domain) { + return null; + } + + return data.itemId; + }, + + /** + * Check if a recently selected item exists and is valid for the current domain. + * @param domain - The current domain to check against + * @returns True if a valid recently selected item exists + */ + async hasRecentlySelected(domain: string): Promise { + const itemId = await this.getRecentlySelected(domain); + return itemId !== null; + }, + + /** + * Clear the recently selected item. + */ + async clear(): Promise { + await storage.removeItem(RECENTLY_SELECTED_KEY); + }, + + /** + * Get the remaining TTL in milliseconds for the currently selected item. + * @returns Remaining TTL in ms, or 0 if expired or not set + */ + async getRemainingTTL(): Promise { + const data = await storage.getItem(RECENTLY_SELECTED_KEY) as IRecentlySelectedItem | null; + + if (!data) { + return 0; + } + + const age = Date.now() - data.timestamp; + const remaining = TTL_MS - age; + + return remaining > 0 ? remaining : 0; + }, +};