fix: tweak autocomplete behavior (#1570)

tweak the hashtag sort algorithm
fix issue where wrong results shown when offline or on slow network
refactor RequestThrottler
This commit is contained in:
Nolan Lawson 2019-10-13 08:08:06 -07:00 committed by GitHub
parent 89265f709e
commit 3209d934e8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 66 additions and 36 deletions

View file

@ -25,22 +25,21 @@ export function doAccountSearch (searchText) {
let localResults let localResults
let remoteResults let remoteResults
const { currentInstance, accessToken } = store.get() const { currentInstance, accessToken } = store.get()
const requestThrottler = new RequestThrottler(searchAccountsRemotely, onNewRemoteResults) const requestThrottler = new RequestThrottler(doSearchAccountsRemotely)
async function searchAccountsLocally (searchText) { async function searchAccountsLocally () {
localResults = await database.searchAccountsByUsername( localResults = await database.searchAccountsByUsername(
currentInstance, searchText.substring(1), DATABASE_SEARCH_RESULTS_LIMIT) currentInstance, searchText.substring(1), DATABASE_SEARCH_RESULTS_LIMIT)
} }
async function searchAccountsRemotely (signal) { async function searchAccountsRemotely () {
return (await search( remoteResults = await requestThrottler.request()
currentInstance, accessToken, searchText, false, SEARCH_RESULTS_LIMIT, signal
)).accounts
} }
function onNewRemoteResults (results) { async function doSearchAccountsRemotely (signal) {
remoteResults = results return (await search(
onNewResults() currentInstance, accessToken, searchText, false, SEARCH_RESULTS_LIMIT, false, signal
)).accounts
} }
function mergeAndTruncateResults () { function mergeAndTruncateResults () {
@ -79,8 +78,8 @@ export function doAccountSearch (searchText) {
return return
} }
// run the two searches in parallel // run the two searches in parallel
searchAccountsLocally(searchText).then(onNewResults) searchAccountsLocally().then(onNewResults)
requestThrottler.request() searchAccountsRemotely().then(onNewResults)
}) })
return { return {

View file

@ -3,27 +3,42 @@ import { store } from '../_store/store'
import { scheduleIdleTask } from '../_utils/scheduleIdleTask' import { scheduleIdleTask } from '../_utils/scheduleIdleTask'
import { SEARCH_RESULTS_LIMIT } from '../_static/autosuggest' import { SEARCH_RESULTS_LIMIT } from '../_static/autosuggest'
import { RequestThrottler } from '../_utils/RequestThrottler' import { RequestThrottler } from '../_utils/RequestThrottler'
import { sum } from '../_utils/lodash-lite'
const HASHTAG_SEARCH_LIMIT = 10
function getUses (historyItem) {
return historyItem.uses
}
// Show the most common hashtags first, then sort by name
function byUsesThenName (a, b) {
if (a.history && b.history && a.history.length && b.history.length) {
const aCount = sum(a.history.map(getUses))
const bCount = sum(b.history.map(getUses))
return aCount > bCount ? -1 : aCount < bCount ? 1 : 0
}
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
}
export function doHashtagSearch (searchText) { export function doHashtagSearch (searchText) {
const { currentInstance, accessToken } = store.get() const { currentInstance, accessToken } = store.get()
const requestThrottler = new RequestThrottler(searchHashtagsRemotely, onNewResults) const requestThrottler = new RequestThrottler(searchHashtags)
async function searchHashtagsRemotely (signal) { async function searchHashtags (signal) {
return (await search( const results = await search(
currentInstance, accessToken, searchText, false, SEARCH_RESULTS_LIMIT, signal currentInstance, accessToken, searchText, false, HASHTAG_SEARCH_LIMIT, true, signal
)).hashtags )
return results.hashtags.sort(byUsesThenName).slice(0, SEARCH_RESULTS_LIMIT)
} }
function onNewResults (results) { scheduleIdleTask(async () => {
const results = await requestThrottler.request()
store.setForCurrentAutosuggest({ store.setForCurrentAutosuggest({
autosuggestType: 'hashtag', autosuggestType: 'hashtag',
autosuggestSelected: 0, autosuggestSelected: 0,
autosuggestSearchResults: results autosuggestSearchResults: results
}) })
}
scheduleIdleTask(() => {
requestThrottler.request()
}) })
return { return {

View file

@ -1,11 +1,12 @@
import { get, paramsString, DEFAULT_TIMEOUT } from '../_utils/ajax' import { get, paramsString, DEFAULT_TIMEOUT } from '../_utils/ajax'
import { auth, basename } from './utils' import { auth, basename } from './utils'
function doSearch (version, instanceName, accessToken, query, resolve, limit, signal) { function doSearch (version, instanceName, accessToken, query, resolve, limit, excludeUnreviewed, signal) {
const url = `${basename(instanceName)}/api/${version}/search?` + paramsString({ const url = `${basename(instanceName)}/api/${version}/search?` + paramsString({
q: query, q: query,
resolve, resolve,
limit limit,
exclude_unreviewed: !!excludeUnreviewed
}) })
return get(url, auth(accessToken), { return get(url, auth(accessToken), {
timeout: DEFAULT_TIMEOUT, timeout: DEFAULT_TIMEOUT,
@ -13,8 +14,8 @@ function doSearch (version, instanceName, accessToken, query, resolve, limit, si
}) })
} }
async function doSearchV1 (instanceName, accessToken, query, resolve, limit, signal) { async function doSearchV1 (instanceName, accessToken, query, resolve, limit, excludeUnreviewed, signal) {
const resp = await doSearch('v1', instanceName, accessToken, query, resolve, limit, signal) const resp = await doSearch('v1', instanceName, accessToken, query, resolve, limit, excludeUnreviewed, signal)
resp.hashtags = resp.hashtags && resp.hashtags.map(tag => ({ resp.hashtags = resp.hashtags && resp.hashtags.map(tag => ({
name: tag, name: tag,
url: `${basename(instanceName)}/tags/${tag.toLowerCase()}`, url: `${basename(instanceName)}/tags/${tag.toLowerCase()}`,
@ -23,16 +24,17 @@ async function doSearchV1 (instanceName, accessToken, query, resolve, limit, sig
return resp return resp
} }
async function doSearchV2 (instanceName, accessToken, query, resolve, limit, signal) { async function doSearchV2 (instanceName, accessToken, query, resolve, limit, excludeUnreviewed, signal) {
return doSearch('v2', instanceName, accessToken, query, resolve, limit, signal) return doSearch('v2', instanceName, accessToken, query, resolve, limit, excludeUnreviewed, signal)
} }
export async function search (instanceName, accessToken, query, resolve = true, limit = 5, signal = null) { export async function search (instanceName, accessToken, query, resolve = true, limit = 5,
excludeUnreviewed = false, signal = null) {
try { try {
return (await doSearchV2(instanceName, accessToken, query, resolve, limit, signal)) return (await doSearchV2(instanceName, accessToken, query, resolve, limit, excludeUnreviewed, signal))
} catch (err) { } catch (err) {
if (err && err.status === 404) { // fall back to old search API if (err && err.status === 404) { // fall back to old search API
return doSearchV1(instanceName, accessToken, query, resolve, limit, signal) return doSearchV1(instanceName, accessToken, query, resolve, limit, excludeUnreviewed, signal)
} else { } else {
throw err throw err
} }

View file

@ -3,6 +3,13 @@ import { doEmojiSearch } from '../../_actions/autosuggestEmojiSearch'
import { doAccountSearch } from '../../_actions/autosuggestAccountSearch' import { doAccountSearch } from '../../_actions/autosuggestAccountSearch'
import { doHashtagSearch } from '../../_actions/autosuggestHashtagSearch' import { doHashtagSearch } from '../../_actions/autosuggestHashtagSearch'
function resetAutosuggest () {
store.setForCurrentAutosuggest({
autosuggestSelected: 0,
autosuggestSearchResults: []
})
}
export function autosuggestObservers () { export function autosuggestObservers () {
let lastSearch let lastSearch
@ -20,6 +27,7 @@ export function autosuggestObservers () {
return return
} }
resetAutosuggest()
if (autosuggestSearchText.startsWith(':')) { // emoji if (autosuggestSearchText.startsWith(':')) { // emoji
lastSearch = doEmojiSearch(autosuggestSearchText) lastSearch = doEmojiSearch(autosuggestSearchText)
} else if (autosuggestSearchText.startsWith('#')) { // hashtag } else if (autosuggestSearchText.startsWith('#')) { // hashtag

View file

@ -4,24 +4,22 @@ import { PromiseThrottler } from './PromiseThrottler'
const promiseThrottler = new PromiseThrottler(200) // Mastodon FE also uses 200ms const promiseThrottler = new PromiseThrottler(200) // Mastodon FE also uses 200ms
export class RequestThrottler { export class RequestThrottler {
constructor (fetcher, onNewResults) { constructor (fetcher) {
this._canceled = false this._canceled = false
this._controller = typeof AbortController === 'function' && new AbortController() this._controller = typeof AbortController === 'function' && new AbortController()
this._fetcher = fetcher this._fetcher = fetcher
this._onNewResults = onNewResults
} }
async request () { async request () {
if (this._canceled) { if (this._canceled) {
return throw new Error('canceled')
} }
await promiseThrottler.next() await promiseThrottler.next()
if (this._canceled) { if (this._canceled) {
return throw new Error('canceled')
} }
const signal = this._controller && this._controller.signal const signal = this._controller && this._controller.signal
const results = await this._fetcher(signal) return this._fetcher(signal)
this._onNewResults(results)
} }
cancel () { cancel () {

View file

@ -28,3 +28,11 @@ export function padStart (string, length, chars) {
} }
return string return string
} }
export function sum (list) {
let total = 0
for (const item of list) {
total += item
}
return total
}