diff --git a/special-pages/pages/new-tab/app/components/App.module.css b/special-pages/pages/new-tab/app/components/App.module.css index d3c15f21c7..9d678319ad 100644 --- a/special-pages/pages/new-tab/app/components/App.module.css +++ b/special-pages/pages/new-tab/app/components/App.module.css @@ -29,7 +29,7 @@ body[data-animate-background="true"] { :global(.layout-centered) { margin-inline: auto; width: 100%; - max-width: calc(504 * var(--px-in-rem)); + max-width: calc(var(--default-ntp-tube-width) * var(--px-in-rem)); } :global(.vertical-space) { diff --git a/special-pages/pages/new-tab/app/entry-points/search.js b/special-pages/pages/new-tab/app/entry-points/search.js new file mode 100644 index 0000000000..e001fa2b0f --- /dev/null +++ b/special-pages/pages/new-tab/app/entry-points/search.js @@ -0,0 +1,11 @@ +import { h } from 'preact'; +import { Centered } from '../components/Layout.js'; +import { Search } from '../search/components/Search.js'; + +export function factory() { + return ( + + + + ); +} diff --git a/special-pages/pages/new-tab/app/favorites/components/Favorites.js b/special-pages/pages/new-tab/app/favorites/components/Favorites.js index 0861b5a4e2..b241918757 100644 --- a/special-pages/pages/new-tab/app/favorites/components/Favorites.js +++ b/special-pages/pages/new-tab/app/favorites/components/Favorites.js @@ -20,7 +20,7 @@ import { useDocumentVisibility } from '../../../../../shared/components/Document * @typedef {import('../../../types/new-tab.js').FavoritesOpenAction['target']} OpenTarget */ export const FavoritesMemo = memo(Favorites); -export const ROW_CAPACITY = 6; +export const ROW_CAPACITY = 8; /** * Note: These values MUST match exactly what's defined in the CSS. */ @@ -65,7 +65,7 @@ export function Favorites({ favorites, expansion, toggle, openContextMenu, openF return ( -
+
+
{ + if (suggestions.value.length > 0) return 'showing-suggestions'; + return 'none'; + }); + + function setMode(next) { + viewTransition(() => { + mode.value = next; + suggestions.value = []; + window.dispatchEvent(new Event('reset-back-to-last-typed-value')); + }); + } + + useEffect(() => { + const listener = () => { + suggestions.value = []; + console.log('did wipe'); + selected.value = null; + }; + window.addEventListener('clear-suggestions', listener); + return () => { + window.removeEventListener('clear-suggestions', listener); + }; + }, []); + + useEffect(() => { + const listener = (e) => { + if (e.key === 'Escape') { + window.dispatchEvent(new Event('clear-suggestions')); + } + }; + window.addEventListener('keydown', listener); + return () => { + window.removeEventListener('keydown', listener); + }; + }, []); + + useEffect(() => { + const listener = (/** @type {CustomEvent} */ evt) => { + if (!formRef.current) return console.warn('formRef.current is null'); + const { detail } = evt; + const data = new FormData(formRef.current); + const term = data.get('term')?.toString().trim() || ''; + + if (term) { + accept(term, 'ai', detail.target, null); + } + }; + window.addEventListener('accept-ai', listener); + return () => { + window.removeEventListener('accept-ai', listener); + }; + }, []); + + /** + * @param {string} term + * @param {"search" | "ai"} mode + * @param {import('../../../types/new-tab').OpenTarget} target + * @param {string|null} selected + * @returns {*} + */ + function accept(term, mode, target, selected) { + if (mode === 'ai') { + if (term) { + ntp.messaging.notify('search_submitChat', { chat: String(term), target }); + } + } else if (mode === 'search') { + if (term && selected) { + const suggestion = suggestions.value[Number(selected)]; + if (suggestion) { + ntp.messaging.notify('search_openSuggestion', { suggestion, target }); + } else { + console.warn('not found'); + } + } else if (term) { + console.log({ term, selected, v: selected }); + ntp.messaging.notify('search_submit', { term: String(term), target }); + } + } + + window.dispatchEvent(new Event('clear-all')); + window.dispatchEvent(new Event('clear-suggestions')); + } + + function onSubmit(e) { + e.preventDefault(); + + if (!(e.target instanceof HTMLFormElement)) return; + + const data = new FormData(e.target); + const term = data.get('term')?.toString().trim() || ''; + const selectedForm = data.get('selected')?.toString() || null; + + const mode = /** @type {"search"|"ai"} */ (data.get('mode')?.toString() || 'search'); + const target = eventToTarget(e, platformName); + accept(term, mode, target, selectedForm); + } + + return ( +
+
+ Search + Search +
+
+
+ + +
+
+
+
+ + + + {showing.value === 'showing-suggestions' && } + +
+
+ ); +} + +function SelectedInput({ selected }) { + if (selected.value === null) return null; + return ; +} + +/** + * @param {object} props + * @param {import("@preact/signals").Signal} props.suggestions + * @param {Signal} props.selected + */ +function SuggestionList({ suggestions, selected }) { + const ref = useRef(/** @type {HTMLDivElement|null} */ (null)); + const list = useComputed(() => { + const index = selected.value; + return suggestions.value.map((x, i) => { + return { item: x, selected: i === index }; + }); + }); + useEffect(() => { + const listener = () => { + if (!ref.current?.contains(document.activeElement)) { + window.dispatchEvent(new Event('clear-suggestions')); + } + }; + window.addEventListener('focusin', listener); + return () => { + window.removeEventListener('focusin', listener); + }; + }, []); + useEffect(() => { + const listener = (e) => { + if (!ref.current?.contains(e.target)) { + // todo: re-instate the click-outside + // setTimeout(() => { + // window.dispatchEvent(new Event('clear-suggestions')); + // }, 0); + } + }; + document.addEventListener('click', listener); + return () => { + document.removeEventListener('click', listener); + }; + }, []); + useEffect(() => { + const listener = (e) => { + if (e.key === 'ArrowDown') { + if (selected.value === null) { + selected.value = 0; + } else { + const next = Math.min(selected.value + 1, list.value.length - 1); + selected.value = next; + } + } + if (e.key === 'ArrowUp') { + if (selected.value === null) return; + if (selected.value === 0) { + selected.value = null; + window.dispatchEvent(new Event('focus-input')); + } else { + const next = Math.max(selected.value - 1, 0); + selected.value = next; + } + } + }; + window.addEventListener('keydown', listener); + return () => { + window.removeEventListener('keydown', listener); + }; + }, [selected]); + + useEffect(() => { + function mouseEnter(e) { + const button = e.target.closest('button[value]'); + if (button && button instanceof HTMLButtonElement) { + selected.value = Number(button.value); + } + } + ref.current?.addEventListener('mouseenter', mouseEnter, true); + return () => { + ref.current?.removeEventListener('mouseenter', mouseEnter, true); + }; + }, [selected]); + + return ( +
{ + window.dispatchEvent(new Event('reset-back-to-last-typed-value')); + }} + > + {list.value.map((x, index) => { + const icon = iconFor(x.item); + return ( + + ); + })} +
+ ); +} + +/** + * + * @param {import('../../../types/new-tab').Suggestions[number]} suggestion + */ +function iconFor(suggestion) { + switch (suggestion.kind) { + case 'phrase': + return ; + case 'website': + return ; + case 'historyEntry': + return ; + case 'bookmark': + if (suggestion.isFavorite) { + return ; + } + return ; + case 'openTab': + case 'internalPage': + console.warn('icon not implemented for ', suggestion.kind); + return ; + } +} + +export function SearchIcon() { + return ( + + + + ); +} + +export function BookmarkIcon() { + return ( + + + + ); +} + +export function FavoriteIcon() { + return ( + + + + + ); +} + +export function GlobeIcon() { + return ( + + + + ); +} + +export function BrowserIcon() { + return ( + + + + ); +} + +export function HistoryIcon() { + return ( + + + + + ); +} + +export function DuckAiIcon() { + return ( + + + + + + + + + + + + ); +} diff --git a/special-pages/pages/new-tab/app/search/components/Search.module.css b/special-pages/pages/new-tab/app/search/components/Search.module.css new file mode 100644 index 0000000000..9f4b88f6ea --- /dev/null +++ b/special-pages/pages/new-tab/app/search/components/Search.module.css @@ -0,0 +1,126 @@ +.root { + margin-bottom: var(--ntp-gap); + position: relative; +} +.icons { + display: flex; + flex-direction: column; + align-items: center; +} +.iconSearch { + width: 84px; +} +.iconText { + width: 144px; + margin-top: 12px; +} +.wrap { + display: flex; + justify-content: center; + margin-top: 24px; +} +.formWrap { + height: 48px; + margin-top: 16px; + position: relative; + z-index: 1; + [data-mode="ai"] & { + height: 96px; + } +} +.form { + border-radius: 16px; + background-color: #fff; + box-shadow: var(--ntp-surface-shadow); +} +.pillSwitcher { + display: flex; + background: var(--color-black-at-6); + padding: 4px; + border-radius: 999px; + gap: 4px; + width: auto; +} +.pillOption { + padding: 8px 16px; + border: none; + background: transparent; + cursor: pointer; + border-radius: 99px; + white-space: nowrap; + display: flex; + align-items: center; + gap: 6px; + font-weight: 600; + transition: background .2s; + + svg { + width: 16px; + height: 16px; + } + + &:hover { + background: var(--color-black-at-9); + } +} + +.pillOption.active { + background: var(--color-white); + box-shadow: 0px 0px 3px 0px rgba(0, 0, 0, 0.08), 0px 2px 4px 0px rgba(0, 0, 0, 0.10); + svg[data-name="search"] { + color: blue; + } + svg[data-name="duckai"] { + color: var(--duckai-purple); + } +} +.list { + top: 100%; + width: 100%; + background: white; + z-index: 1; + border-bottom-right-radius: 16px; + border-bottom-left-radius: 16px; + border-top: 1px solid var(--color-black-at-6); + display: flex; + flex-direction: column; + gap: 4px; +} +.list:not(:empty) { + padding: 8px; +} +.item { + display: flex; + align-items: center; + text-decoration: none; + font-size: var(--title-3-em-font-size); + padding: 4px 8px; + color: var(--ntp-text-normal); + background-color: transparent; + border: 0; + text-align: left; + cursor: pointer; + gap: 8px; + + svg { + width: 16px; + height: 16px; + display: block; + } + + svg path { + fill-opacity: 1!important; + } + + &:hover { + background: var(--ddg-color-primary); + color: white; + border-radius: 4px; + } + + &[data-selected="true"] { + background: var(--ddg-color-primary); + color: white; + border-radius: 4px; + } +} \ No newline at end of file diff --git a/special-pages/pages/new-tab/app/search/components/SearchInput.js b/special-pages/pages/new-tab/app/search/components/SearchInput.js new file mode 100644 index 0000000000..e8e222112c --- /dev/null +++ b/special-pages/pages/new-tab/app/search/components/SearchInput.js @@ -0,0 +1,476 @@ +import cn from 'classnames'; +import { h } from 'preact'; +import styles from './SearchInput.module.css'; +import { DuckAiIcon, SearchIcon } from './Search.js'; +import { useCallback, useEffect, useRef } from 'preact/hooks'; +import { useMessaging } from '../../types.js'; +import { useSignal, useSignalEffect } from '@preact/signals'; +import { eventToTarget } from '../../utils.js'; +import { usePlatformName } from '../../settings.provider.js'; + +const url = new URL(window.location.href); + +/** + * + * @import { Signal } from '@preact/signals'; + * + * Renders a search input component with optional mode-based controls and tab switching functionality. + * + * @param {Object} props - The props for the SearchInput component. + * @param {import('@preact/signals').Signal<'ai' | 'search'>} props.mode - The mode in which the component operates. Determines the presence of additional controls. + * @param {import('@preact/signals').Signal} props.suggestions - The mode in which the component operates. Determines the presence of additional controls. + * @param {import('@preact/signals').Signal} props.selected + */ +export function SearchInput({ mode, suggestions, selected }) { + const platformName = usePlatformName(); + return ( +
+
+ + {mode.value === 'search' && ( +
+ +
+ +
+ )} + {mode.value === 'ai' && ( +
+ +
+ )} +
+ {mode.value === 'ai' && ( +
+ + +
+ )} +
+ ); +} + +/** + * Renders a controlled input field with specific attributes and styling. + * + * @param {Object} props - The properties passed to the Input component. + * @param {import('@preact/signals').Signal<'ai' | 'search'>} props.mode - The mode in which the component operates. Determines the presence of additional controls. + * @param {Signal} props.suggestions - The mode in which the component operates. Determines the presence of additional controls. + * @param {import('@preact/signals').Signal} props.selected + */ +function InputFieldWithSuggestions({ mode, suggestions, selected }) { + const { onInput, onKeydown, ref } = useSuggestions(suggestions, mode, selected); + useEffect(() => { + return mode.subscribe(() => { + ref.current?.focus(); + }); + }, [mode]); + return ( + + ); +} + +/** + * @param {Signal} suggestions + * @param {import('@preact/signals').Signal<'ai' | 'search'>} mode + * @param {import('@preact/signals').Signal} selected + * @returns {{ + * onInput: ((function(*): void)|*), + * onKeydown: ((function(*): void)|*), + * ref: import('preact/hooks').MutableRef + * }} + */ +function useSuggestions(suggestions, mode, selected) { + const ref = useRef(/** @type {HTMLInputElement|null} */ (null)); + const last = useRef(/** @type {string} */ ('')); + const strings = useSignal(/** @type {string[]} */ ([])); + const ntp = useMessaging(); + + /** + * @param {string} reason + * @param {string} value + * @param {number} start + * @param {number} end + */ + function setValueAndRange(reason, value, start, end) { + if (url.searchParams.getAll('debug').includes('reason')) console.log('reason:', reason); + const input = ref.current; + if (!input || typeof input.selectionStart !== 'number') return console.warn('no'); + input.value = value; + input.setSelectionRange(start, end); + } + + /** + * @param {string} reason + */ + function reset(reason) { + if (url.searchParams.getAll('debug').includes('reason')) console.log('[reset] reason:', reason); + const input = ref.current; + if (!input) return console.warn('missing input'); + input.value = ''; + last.current = ''; + } + + useEffect(() => { + const listener = () => { + const input = ref.current; + if (!input || typeof input.selectionStart !== 'number') return console.warn('no'); + setValueAndRange('reset-back-to-last-typed-value', last.current, input.value.length, input.value.length); + }; + window.addEventListener('reset-back-to-last-typed-value', listener); + return () => { + window.removeEventListener('reset-back-to-last-typed-value', listener); + }; + }, []); + + useEffect(() => { + const listener = () => { + reset('window event "clear-all"'); + }; + window.addEventListener('clear-all', listener); + return () => { + window.removeEventListener('clear-all', listener); + }; + }, []); + + useEffect(() => { + return suggestions.subscribe((values) => { + // const flat = values + const input = ref.current; + if (!input || typeof input.selectionStart !== 'number') return console.warn('no'); + const result = next(last.current, input.value, input.selectionStart, values); + last.current = result.lastTypedValue; + switch (result.kind) { + case 'autocomplete': { + const { value, range } = result; + const { start, end } = range; + setValueAndRange('suggestions changed', value, start, end); + } + } + }); + }, [strings, ref]); + + useSignalEffect(() => { + const sub = selected.value; + if (sub !== null) { + const input = ref.current; + if (!input || typeof input.selectionStart !== 'number') return console.warn('no'); + const suggestion = suggestions.peek()[sub]; + if (!suggestion) console.warn('missing suggestion', sub); + const result = pick(last.current, input.value, last.current.length, suggestion); + switch (result.kind) { + case 'autocomplete': { + const { value, range } = result; + const { start, end } = range; + setValueAndRange('selected.value useSignalEffect', value, start, end); + } + } + } + }); + + const onInput = useCallback( + (e) => { + if (!(e && e.target instanceof HTMLInputElement)) { + return; + } + if (mode.peek() === 'ai') return; + if (url.searchParams.getAll('debug').includes('api')) { + console.log(`✉️ search_getSuggestions('${e.target.value}')`); + } + ntp.messaging + .request('search_getSuggestions', { term: e.target.value }) + // eslint-disable-next-line promise/prefer-await-to-then + .then((/** @type {import('../../../types/new-tab').SuggestionsData} */ data) => { + if (url.searchParams.getAll('debug').includes('api')) { + console.group(`✅ search_getSuggestions`); + console.log(data); + console.groupEnd(); + } + const flat = [ + ...data.suggestions.topHits, + ...data.suggestions.duckduckgoSuggestions, + ...data.suggestions.localSuggestions, + ]; + const asStrings = flat.map(toDisplay); + strings.value = asStrings; + suggestions.value = flat; + }); + }, + [ref, last, suggestions, mode, ntp], + ); + const onKeydown = useCallback( + (e) => { + if (!(e && e.target instanceof HTMLInputElement)) { + return; + } + if (mode.peek() === 'ai') return; + const result = keynext(e.target, e, last.current); + last.current = result.lastTypedValue; + switch (result.kind) { + case 'autocomplete': { + const { value, range } = result; + setValueAndRange('other keydown', value, range.start, range.end); + } + } + }, + [ref, mode, last], + ); + return { + onInput, + onKeydown, + ref, + }; +} + +/** + * @param {string} lastTypedValue + * @param {string} currentValue + * @param {number} cursorPos + * @param {import('../../../types/new-tab').Suggestions} suggestions + * @return {{kind: "none"; lastTypedValue: string} + * | {kind: "delete"; lastTypedValue: string} + * | {kind: "empty"; lastTypedValue: string} + * | {kind: "autocomplete"; lastTypedValue: string, value: string, range: {start: number; end: number}, others: string[]} + * } + */ +function next(lastTypedValue, currentValue, cursorPos, suggestions) { + let inner = lastTypedValue; + // If we're deleting (current value is shorter than what user actually typed) + if (currentValue.length < lastTypedValue.length) { + return { + kind: 'delete', + lastTypedValue: currentValue, + }; + } + + // Get the actual typed portion (everything up to cursor) + const typedValue = currentValue.substring(0, cursorPos).toLowerCase(); + inner = typedValue; + + if (typedValue.length === 0) { + return { kind: 'empty', lastTypedValue: inner }; + } + + // Find first matching suggestion + const matches = suggestions + .filter((suggestion) => { + return suggestion.kind === 'website'; + }) + .filter((suggestion) => { + const comparer = toDisplay(suggestion); + return comparer.toLowerCase().startsWith(typedValue); + }) + .map(toDisplay); + + const [first, ...others] = matches; + + if (first && first.toLowerCase() !== typedValue) { + return { + kind: 'autocomplete', + value: first, + range: { start: typedValue.length, end: first.length }, + others, + lastTypedValue: inner, + }; + } + + return { kind: 'none', lastTypedValue: inner }; +} + +/** + * @param {string} lastTypedValue + * @param {string} currentValue + * @param {number} cursorPos + * @param {import('../../../types/new-tab').Suggestions[number]} suggestion + * @return {{kind: "none"; lastTypedValue: string} + * | {kind: "delete"; lastTypedValue: string} + * | {kind: "empty"; lastTypedValue: string} + * | {kind: "autocomplete"; lastTypedValue: string, value: string, range: {start: number; end: number}, others: string[]} + * } + */ +function pick(lastTypedValue, currentValue, cursorPos, suggestion) { + let inner = lastTypedValue; + + // Get the actual typed portion (everything up to cursor) + const typedValue = currentValue.substring(0, cursorPos).toLowerCase(); + inner = typedValue; + + if (typedValue.length === 0) { + return { kind: 'empty', lastTypedValue: inner }; + } + + const first = toDisplay(suggestion); + + if (first && first.toLowerCase() !== typedValue) { + return { + kind: 'autocomplete', + value: first, + range: { start: typedValue.length, end: first.length }, + lastTypedValue: inner, + others: [], + }; + } + + return { kind: 'none', lastTypedValue: inner }; +} + +export function toDisplay(suggestion) { + switch (suggestion.kind) { + case 'bookmark': + return suggestion.title; + case 'historyEntry': + return suggestion.title; + case 'phrase': + return suggestion.phrase; + case 'openTab': + return suggestion.title; + case 'website': { + const url = new URL(suggestion.url); + return url.host + url.pathname + url.search + url.hash; + } + case 'internalPage': + return suggestion.title; + default: + console.log(suggestion); + throw new Error('unreachable?'); + } +} + +/** + * @param {HTMLInputElement} input + * @param {KeyboardEvent} e + * @param {string} lastTypedValue + * @param {string} input + * @return {{kind: "none"; lastTypedValue: string} + * | {kind: "autocomplete"; lastTypedValue: string, value: string, range: {start: number; end: number}, others: string[]} + * } + */ +function keynext(input, e, lastTypedValue) { + if (e.key === 'Tab' || e.key === 'ArrowRight') { + // Accept the suggestion by moving cursor to end + if (input.selectionStart !== input.selectionEnd) { + e.preventDefault(); + return { + kind: 'autocomplete', + lastTypedValue: input.value, + range: { start: input.value.length, end: input.value.length }, + others: [], + value: input.value, + }; + } + } + + if (e.key === 'Escape' && typeof input.selectionStart === 'number') { + return { + kind: 'none', + lastTypedValue, + }; + } + + if (e.key === 'Backspace' || e.key === 'Delete') { + // If there's a selection, we're about to delete the autocompleted part + if (input.selectionStart !== input.selectionEnd && typeof input.selectionStart === 'number') { + const typedPortion = input.value.substring(0, input.selectionStart); + if (e.key === 'Backspace') { + // Backspace: remove one character from typed portion + const newTyped = typedPortion.slice(0, -1); + e.preventDefault(); + return { + kind: 'autocomplete', + lastTypedValue: newTyped, + range: { start: newTyped.length, end: newTyped.length }, + others: [], + value: newTyped, + }; + } else { + // Delete: just remove the selection + e.preventDefault(); + return { + kind: 'autocomplete', + lastTypedValue: typedPortion, + range: { start: typedPortion.length, end: typedPortion.length }, + others: [], + value: typedPortion, + }; + } + } + } + + return { kind: 'none', lastTypedValue }; +} + +function Arrow() { + return ( + + + + ); +} + +function Plus() { + return ( + + + + ); +} + +function Opts() { + return ( + + + + + ); +} diff --git a/special-pages/pages/new-tab/app/search/components/SearchInput.module.css b/special-pages/pages/new-tab/app/search/components/SearchInput.module.css new file mode 100644 index 0000000000..38ddaddf1d --- /dev/null +++ b/special-pages/pages/new-tab/app/search/components/SearchInput.module.css @@ -0,0 +1,121 @@ +.root { + position: relative; + width: 100%; + margin: 0 auto; + padding: 8px 8px; + display: flex; + flex-direction: column; + gap: 8px; +} + +::view-transition-new(search-input-transition) { + animation: 0.25s grow-y; +} + +.searchContainer { + position: relative; + display: flex; + align-items: center; + height: 32px; +} + +.searchInput { + flex: 1; + height: 32px; + padding: 0; + padding: 0 8px; + border: none; + font-size: 16px; + outline: none; + color: #202124; + background-color: transparent; +} + +.searchInput::placeholder { + color: #9aa0a6; +} + +.searchActions { + display: flex; + align-items: center; +} + +.searchTypeButton { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 32px; + border: none; + background-color: transparent; + cursor: pointer; + transition: background-color 0.2s; + padding: 0; + + svg { + width: 16px; + height: 16px; + } + + svg[data-name="duckai"] { + color: var(--color-black-at-84); + } +} + +.searchTypeButton:hover { + background-color: rgba(60, 64, 67, 0.08); +} + +.searchTypeButton.active { + color: #1a73e8; +} + +.separator { + height: 24px; + width: 1px; + background-color: #dadce0; + margin: 0 8px; +} + +.secondaryControls { + height: 40px; + display: flex; + gap: 8px; +} + +.squareButton { + width: 40px; + height: 40px; + border: none; + background-color: transparent; + cursor: pointer; + transition: background-color 0.2s; + padding: 0; + border-radius: 8px; + position: relative; + svg { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + } +} + +.submit { + background-color: var(--duckai-purple); + color: #fff; + position: relative; + top: 4px; + &:hover { + background-color: var(--duckai-purple-hover); + } + &:active { + background-color: var(--duckai-purple-active); + } +} + + +.buttonSecondary { + background-color: rgba(0, 0, 0, 0.06); + color: var(--color-black-at-84); +} \ No newline at end of file diff --git a/special-pages/pages/new-tab/app/search/mocks/getSuggestions.json b/special-pages/pages/new-tab/app/search/mocks/getSuggestions.json new file mode 100644 index 0000000000..0a88a9d13c --- /dev/null +++ b/special-pages/pages/new-tab/app/search/mocks/getSuggestions.json @@ -0,0 +1,87 @@ +{ + "suggestions": { + "topHits": [ + { + "kind": "phrase", + "phrase": "pizza near me", + "score": 99 + }, + { + "kind": "phrase", + "phrase": "pizza delivery", + "score": 99 + }, + { + "kind": "phrase", + "phrase": "pizza hut", + "score": 99 + } + ], + "duckduckgoSuggestions": [ + { + "kind": "phrase", + "phrase": "pizza recipes" + }, + { + "kind": "phrase", + "phrase": "pizza express" + }, + { + "kind": "phrase", + "phrase": "pizza dough recipe" + }, + { + "kind": "phrase", + "phrase": "pizza marinara" + }, + { + "kind": "phrase", + "phrase": "pizza margherita" + }, + { + "kind": "website", + "url": "https://pizzahut.com" + }, + { + "kind": "website", + "url": "https://papajohns.com" + }, + { + "kind": "website", + "url": "https://pizzaexpress.com" + } + ], + "localSuggestions": [ + { + "kind": "historyEntry", + "title": "Best Pizza Places in New York", + "url": "https://example.com/search?q=Best%20Pizza%20Places%20in%20New%20York", + "score": 87 + }, + { + "kind": "historyEntry", + "title": "Pizza Making Tips and Tricks", + "url": "https://example.com/search?q=Pizza%20Making%20Tips%20and%20Tricks", + "score": 85 + }, + { + "kind": "historyEntry", + "title": "Italian Pizza History", + "url": "https://example.com/search?q=Italian%20Pizza%20History", + "score": 85 + }, + { + "kind": "historyEntry", + "title": "Homemade Pizza Guide", + "url": "https://example.com/search?q=Homemade%20Pizza%20Guide", + "score": 85 + }, + { + "kind": "historyEntry", + "title": "Pizza Dough Calculator", + "url": "https://example.com/search?q=Pizza%20Dough%20Calculator", + "score": 87 + } + ] + } +} \ No newline at end of file diff --git a/special-pages/pages/new-tab/app/search/mocks/search.mock-transport.js b/special-pages/pages/new-tab/app/search/mocks/search.mock-transport.js new file mode 100644 index 0000000000..cba59a723f --- /dev/null +++ b/special-pages/pages/new-tab/app/search/mocks/search.mock-transport.js @@ -0,0 +1,37 @@ +import { TestTransportConfig } from '@duckduckgo/messaging'; +import { getMockSuggestions } from './search.mocks.js'; + +export function searchMockTransport() { + return new TestTransportConfig({ + notify(_msg) { + /** @type {import('../../../types/new-tab.ts').NewTabMessages['notifications']} */ + const msg = /** @type {any} */ (_msg); + switch (msg.method) { + default: { + console.group('unhandled notification', msg); + console.warn(JSON.stringify(msg)); + console.groupEnd(); + } + } + }, + subscribe(_msg, _cb) { + /** @type {import('../../../types/new-tab.ts').NewTabMessages['subscriptions']['subscriptionEvent']} */ + const sub = /** @type {any} */ (_msg.subscriptionName); + console.warn('unhandled sub', sub); + return () => {}; + }, + // eslint-ignore-next-line require-await + request(_msg) { + /** @type {import('../../../types/new-tab.ts').NewTabMessages['requests']} */ + const msg = /** @type {any} */ (_msg); + switch (msg.method) { + case 'search_getSuggestions': { + return Promise.resolve(getMockSuggestions(msg.params.term)); + } + default: { + return Promise.reject(new Error('unhandled request' + msg)); + } + } + }, + }); +} diff --git a/special-pages/pages/new-tab/app/search/mocks/search.mocks.js b/special-pages/pages/new-tab/app/search/mocks/search.mocks.js new file mode 100644 index 0000000000..22689a7f48 --- /dev/null +++ b/special-pages/pages/new-tab/app/search/mocks/search.mocks.js @@ -0,0 +1,138 @@ +export const searchMocks = { + basic: { + topHits: [ + { + kind: 'bookmark', + title: 'DuckDuckGo Search Engine', + url: 'https://duckduckgo.com', + isFavorite: true, + score: 98, + }, + { + kind: 'openTab', + title: 'GitHub - Version Control', + url: 'https://github.com', + tabId: 'tab-456-def', + score: 95, + }, + ], + duckduckgoSuggestions: [ + { + kind: 'phrase', + phrase: 'best pizza recipe', + }, + { + kind: 'website', + url: 'https://www.example.com', + }, + ], + localSuggestions: [ + { + kind: 'historyEntry', + title: 'Stack Overflow - Programming Q&A', + url: 'https://stackoverflow.com', + score: 87, + }, + { + kind: 'internalPage', + title: 'Settings', + url: 'duckduckgo://settings', + score: 80, + }, + ], + }, + manySuggestions: { + topHits: [ + { + kind: 'phrase', + phrase: 'best pizza recipe', + }, + ], + localSuggestions: [], + }, + generateSuggestions: getMockSuggestions, +}; + +const pizzaRelatedData = { + phrases: [ + 'pizza near me', + 'pizza delivery', + 'pizza hut', + 'pizza recipes', + 'pizza express', + 'pizza dough recipe', + 'pizza marinara', + 'pizza margherita', + 'pizzeria italiano', + 'pizza places open now', + 'pizza express menu', + 'pizza toppings', + 'pizza sauce recipe', + 'pizza napoletana', + 'pizza pasta', + ], + websites: ['pizzahut.com', 'dominos.com', 'papajohns.com', 'littlecaesars.com', 'pizzaexpress.com'], + historyEntries: [ + 'Best Pizza Places in New York', + 'Pizza Making Tips and Tricks', + 'Italian Pizza History', + 'Homemade Pizza Guide', + 'Pizza Dough Calculator', + ], +}; + +/** + * @param {string} searchTerm + * @return {import("../../../types/new-tab").SuggestionsData} + */ +export function getMockSuggestions(searchTerm) { + const term = searchTerm.toLowerCase(); + return { + suggestions: { + topHits: pizzaRelatedData.phrases + .filter((phrase) => phrase.toLowerCase().includes(term)) + .slice(0, 3) + .map((phrase) => ({ + kind: /** @type {const} */ ('phrase'), + phrase, + score: 95 + Math.floor(Math.random() * 5), + })), + duckduckgoSuggestions: [ + ...pizzaRelatedData.websites + .filter((phrase) => phrase.toLowerCase().includes(term)) + .slice(0, 2) + .map((website, index) => ({ + kind: /** @type {const} */ ('bookmark'), + title: website, + url: website, + isFavorite: index === 0, + score: 95 + Math.floor(Math.random() * 5), + })), + ...pizzaRelatedData.phrases + .filter((phrase) => phrase.toLowerCase().includes(term)) + .slice(3, 8) + .map((phrase) => ({ + kind: /** @type {const} */ ('phrase'), + phrase, + })), + ...pizzaRelatedData.websites + .filter((site) => site.toLowerCase().includes(term)) + .map((url) => ({ + kind: /** @type {const} */ ('website'), + url: `https://${url}`, + })), + ], + localSuggestions: pizzaRelatedData.historyEntries + .filter((title) => title.toLowerCase().includes(term)) + .map((title) => ({ + kind: /** @type {const} */ ('historyEntry'), + title, + url: `https://example.com/search?q=${encodeURIComponent(title)}`, + score: 80 + Math.floor(Math.random() * 10), + })), + }, + }; +} + +// console.log(getMockSuggestions('p')); +// console.log(getMockSuggestions('pizza d')); diff --git a/special-pages/pages/new-tab/app/search/search.md b/special-pages/pages/new-tab/app/search/search.md new file mode 100644 index 0000000000..fad653c670 --- /dev/null +++ b/special-pages/pages/new-tab/app/search/search.md @@ -0,0 +1,51 @@ +--- +title: Search +--- + +## Setup + +- Widget ID: `"search"` +- Add it to the `widgets` + `widgetConfigs` fields on [initialSetup](../new-tab.md) +- Example: + +```json +{ + "...": "...", + "widgets": [ + {"...": "..."}, + {"id": "search"} + ], + "widgetConfigs": [ + {"...": "..."}, + {"id": "search", "visibility": "visible" } + ] +} +``` + +## Requests: +### `search_getSuggestions` +- {@link "NewTab Messages".SearchGetSuggestionsRequest} +- returns {@link "NewTab Messages".SuggestionsData} + +{@includeCode ./mocks/getSuggestions.json} + +## Notifications: +### `search_openSuggestion` +- {@link "NewTab Messages".SearchOpenSuggestionNotification} +- Sends {@link "NewTab Messages".SearchOpenSuggestion} + +## Notifications: +### `search_submit` +- {@link "NewTab Messages".SearchSubmitNotification} +- Sends {@link "NewTab Messages".SearchSubmitParams} + +## Notifications: +### `search_submitChat` +- {@link "NewTab Messages".SearchSubmitChatNotification} +- Sends {@link "NewTab Messages".SearchSubmitChatParams} +```json +{ + "chat": "Give me 5 pub-quiz style facts about Austria", + "target": "same-tab" +} +``` \ No newline at end of file diff --git a/special-pages/pages/new-tab/app/styles/ntp-theme.css b/special-pages/pages/new-tab/app/styles/ntp-theme.css index 795b90eae4..5796f12b60 100644 --- a/special-pages/pages/new-tab/app/styles/ntp-theme.css +++ b/special-pages/pages/new-tab/app/styles/ntp-theme.css @@ -3,6 +3,7 @@ --ntp-drawer-width: calc(224 * var(--px-in-rem)); --ntp-drawer-scroll-width: 12px; --ntp-combined-width: calc(var(--ntp-drawer-width) + var(--ntp-drawer-scroll-width)); + --ntp-surface-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.13); /* Mac/System/Callout */ --callout-font-size: 12px; @@ -35,13 +36,23 @@ --border-radius-xs: 4px; --focus-ring: 0px 0px 0px 1px var(--color-white), 0px 0px 0px 3px var(--ntp-focus-outline-color); --focus-ring-thin: 0px 0px 0px 1px var(--ntp-focus-outline-color), 0px 0px 0px 1px var(--color-white); + + --duckai-purple: rgba(107, 78, 186, 1); + --duckai-purple-hover: rgb(128, 101, 208); + --duckai-purple-active: rgb(85, 64, 160); } body { --default-light-background-color: var(--color-gray-0); --default-dark-background-color: var(--color-gray-85); + --default-ntp-tube-width: 504; + --default-favorites-count: 6; } +body:has([data-entry-point="search"]) { + --default-ntp-tube-width: 680; + --default-favorites-count: 8; +} [data-theme=light] { --ntp-surface-background-color: var(--color-white-at-30); diff --git a/special-pages/pages/new-tab/messages/search_getSuggestions.request.json b/special-pages/pages/new-tab/messages/search_getSuggestions.request.json new file mode 100644 index 0000000000..875525d918 --- /dev/null +++ b/special-pages/pages/new-tab/messages/search_getSuggestions.request.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["term"], + "properties": { + "term": { + "type": "string" + } + } +} diff --git a/special-pages/pages/new-tab/messages/search_getSuggestions.response.json b/special-pages/pages/new-tab/messages/search_getSuggestions.response.json new file mode 100644 index 0000000000..c9c8e5fba4 --- /dev/null +++ b/special-pages/pages/new-tab/messages/search_getSuggestions.response.json @@ -0,0 +1,169 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Suggestions Data", + "description": "", + "type": "object", + "required": [ + "suggestions" + ], + "properties": { + "suggestions": { + "type": "object", + "required": ["topHits", "duckduckgoSuggestions", "localSuggestions"], + "properties": { + "topHits": { + "$ref": "#/definitions/Suggestions" + }, + "duckduckgoSuggestions": { + "$ref": "#/definitions/Suggestions" + }, + "localSuggestions": { + "$ref": "#/definitions/Suggestions" + } + } + } + }, + "definitions": { + "Suggestions": { + "type": "array", + "items": {"$ref": "#/definitions/Suggestion"} + }, + "Suggestion": { + "oneOf": [ + { + "type": "object", + "title": "Bookmark Suggestion", + "properties": { + "kind": { + "const": "bookmark" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + }, + "isFavorite": { + "type": "boolean" + }, + "score": { + "type": "number" + } + }, + "required": [ + "kind", + "title", + "url", + "isFavorite", + "score" + ] + }, + { + "type": "object", + "title": "OpenTab Suggestion", + "properties": { + "kind": { + "const": "openTab" + }, + "title": { + "type": "string" + }, + "tabId": { + "type": "string" + }, + "score": { + "type": "number" + } + }, + "required": [ + "kind", + "title", + "tabId", + "score" + ] + }, + { + "type": "object", + "title": "Phrase Suggestion", + "properties": { + "kind": { + "const": "phrase" + }, + "phrase": { + "type": "string" + } + }, + "required": [ + "kind", + "phrase" + ] + }, + { + "type": "object", + "title": "Website Suggestion", + "properties": { + "kind": { + "const": "website" + }, + "url": { + "type": "string" + } + }, + "required": [ + "kind", + "url" + ] + }, + { + "type": "object", + "title": "HistoryEntry Suggestion", + "properties": { + "kind": { + "const": "historyEntry" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + }, + "score": { + "type": "number" + } + }, + "required": [ + "kind", + "title", + "url", + "score" + ] + }, + { + "type": "object", + "title": "InternalPage Suggestion", + "properties": { + "kind": { + "const": "internalPage" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + }, + "score": { + "type": "number" + } + }, + "required": [ + "kind", + "title", + "url", + "score" + ] + } + ] + } + } +} + diff --git a/special-pages/pages/new-tab/messages/search_openSuggestion.notify.json b/special-pages/pages/new-tab/messages/search_openSuggestion.notify.json new file mode 100644 index 0000000000..ad83fd1352 --- /dev/null +++ b/special-pages/pages/new-tab/messages/search_openSuggestion.notify.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Search Open Suggestion", + "type": "object", + "required": [ + "target", + "suggestion" + ], + "properties": { + "suggestion": { + "$ref": "./search_getSuggestions.response.json#/definitions/Suggestion" + }, + "target": { + "$ref": "./types/open-target.json" + } + } +} diff --git a/special-pages/pages/new-tab/messages/search_submit.notify.json b/special-pages/pages/new-tab/messages/search_submit.notify.json new file mode 100644 index 0000000000..0988b710e6 --- /dev/null +++ b/special-pages/pages/new-tab/messages/search_submit.notify.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Search Submit Params", + "type": "object", + "required": [ + "term", + "target" + ], + "properties": { + "term": { + "type": "string" + }, + "target": { + "$ref": "./types/open-target.json" + } + } +} diff --git a/special-pages/pages/new-tab/messages/search_submitChat.notify.json b/special-pages/pages/new-tab/messages/search_submitChat.notify.json new file mode 100644 index 0000000000..6f3126d2fc --- /dev/null +++ b/special-pages/pages/new-tab/messages/search_submitChat.notify.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Search Submit Chat Params", + "type": "object", + "required": [ + "chat", + "target" + ], + "properties": { + "chat": { + "type": "string" + }, + "target": { + "$ref": "./types/open-target.json" + } + } +} diff --git a/special-pages/pages/new-tab/messages/types/suggestions-data.json b/special-pages/pages/new-tab/messages/types/suggestions-data.json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/special-pages/pages/new-tab/public/icons/search/Logo.svg b/special-pages/pages/new-tab/public/icons/search/Logo.svg new file mode 100644 index 0000000000..8583f54763 --- /dev/null +++ b/special-pages/pages/new-tab/public/icons/search/Logo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/special-pages/pages/new-tab/public/icons/search/Logotype.svg b/special-pages/pages/new-tab/public/icons/search/Logotype.svg new file mode 100644 index 0000000000..0ff8348480 --- /dev/null +++ b/special-pages/pages/new-tab/public/icons/search/Logotype.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/special-pages/pages/new-tab/types/new-tab.ts b/special-pages/pages/new-tab/types/new-tab.ts index a76fcf5024..3917aa95b8 100644 --- a/special-pages/pages/new-tab/types/new-tab.ts +++ b/special-pages/pages/new-tab/types/new-tab.ts @@ -85,6 +85,14 @@ export type NextStepsCards = { }[]; export type RMFMessage = SmallMessage | MediumMessage | BigSingleActionMessage | BigTwoActionMessage; export type RMFIcon = "Announce" | "DDGAnnounce" | "CriticalUpdate" | "AppUpdate" | "PrivacyPro"; +export type Suggestions = ( + | BookmarkSuggestion + | OpenTabSuggestion + | PhraseSuggestion + | WebsiteSuggestion + | HistoryEntrySuggestion + | InternalPageSuggestion +)[]; /** * Requests, Notifications and Subscriptions from the NewTab feature @@ -118,6 +126,9 @@ export interface NewTabMessages { | RmfDismissNotification | RmfPrimaryActionNotification | RmfSecondaryActionNotification + | SearchOpenSuggestionNotification + | SearchSubmitNotification + | SearchSubmitChatNotification | StatsShowLessNotification | StatsShowMoreNotification | TelemetryEventNotification @@ -137,6 +148,7 @@ export interface NewTabMessages { | ProtectionsGetConfigRequest | ProtectionsGetDataRequest | RmfGetDataRequest + | SearchGetSuggestionsRequest | StatsGetDataRequest; subscriptions: | ActivityOnBurnCompleteSubscription @@ -507,6 +519,78 @@ export interface RmfSecondaryActionNotification { export interface RMFSecondaryAction { id: string; } +/** + * Generated from @see "../messages/search_openSuggestion.notify.json" + */ +export interface SearchOpenSuggestionNotification { + method: "search_openSuggestion"; + params: SearchOpenSuggestion; +} +export interface SearchOpenSuggestion { + suggestion: + | BookmarkSuggestion + | OpenTabSuggestion + | PhraseSuggestion + | WebsiteSuggestion + | HistoryEntrySuggestion + | InternalPageSuggestion; + target: OpenTarget; +} +export interface BookmarkSuggestion { + kind: "bookmark"; + title: string; + url: string; + isFavorite: boolean; + score: number; +} +export interface OpenTabSuggestion { + kind: "openTab"; + title: string; + tabId: string; + score: number; +} +export interface PhraseSuggestion { + kind: "phrase"; + phrase: string; +} +export interface WebsiteSuggestion { + kind: "website"; + url: string; +} +export interface HistoryEntrySuggestion { + kind: "historyEntry"; + title: string; + url: string; + score: number; +} +export interface InternalPageSuggestion { + kind: "internalPage"; + title: string; + url: string; + score: number; +} +/** + * Generated from @see "../messages/search_submit.notify.json" + */ +export interface SearchSubmitNotification { + method: "search_submit"; + params: SearchSubmitParams; +} +export interface SearchSubmitParams { + term: string; + target: OpenTarget; +} +/** + * Generated from @see "../messages/search_submitChat.notify.json" + */ +export interface SearchSubmitChatNotification { + method: "search_submitChat"; + params: SearchSubmitChatParams; +} +export interface SearchSubmitChatParams { + chat: string; + target: OpenTarget; +} /** * Generated from @see "../messages/stats_showLess.notify.json" */ @@ -838,6 +922,24 @@ export interface BigTwoActionMessage { primaryActionText: string; secondaryActionText: string; } +/** + * Generated from @see "../messages/search_getSuggestions.request.json" + */ +export interface SearchGetSuggestionsRequest { + method: "search_getSuggestions"; + params: SearchGetSuggestionsRequest1; + result: SuggestionsData; +} +export interface SearchGetSuggestionsRequest1 { + term: string; +} +export interface SuggestionsData { + suggestions: { + topHits: Suggestions; + duckduckgoSuggestions: Suggestions; + localSuggestions: Suggestions; + }; +} /** * Generated from @see "../messages/stats_getData.request.json" */