From 8b3842f15a66d4beda99a2e1ce5ac895204d52b6 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sat, 12 Oct 2019 18:06:04 -0700 Subject: [PATCH] feat: add hashtag autocomplete (#1567) * feat: add hashtag autocomplete fixes #1209 * add test and tweak aria label and styles * do not prefer lowercase * Change text --- bin/svgs.js | 3 +- src/routes/_actions/autosuggest.js | 71 ++++++++++--------- .../_actions/autosuggestHashtagSearch.js | 51 +++++++++++++ .../compose/ComposeAutosuggestionList.html | 53 +++++++++----- .../_components/compose/ComposeInput.html | 4 +- .../computations/autosuggestComputations.js | 8 +-- .../_store/observers/autosuggestObservers.js | 9 +-- .../createAutosuggestAccessibleLabel.js | 2 + tests/spec/018-compose-autosuggest.js | 20 +++++- tests/spec/131-compose-autosuggest.js | 34 +++++++++ 10 files changed, 196 insertions(+), 59 deletions(-) create mode 100644 src/routes/_actions/autosuggestHashtagSearch.js create mode 100644 tests/spec/131-compose-autosuggest.js diff --git a/bin/svgs.js b/bin/svgs.js index 880be748..65c488cf 100644 --- a/bin/svgs.js +++ b/bin/svgs.js @@ -54,5 +54,6 @@ module.exports = [ { id: 'fa-plus', src: 'src/thirdparty/font-awesome-svg-png/white/svg/plus.svg' }, { id: 'fa-info-circle', src: 'src/thirdparty/font-awesome-svg-png/white/svg/info-circle.svg' }, { id: 'fa-crosshairs', src: 'src/thirdparty/font-awesome-svg-png/white/svg/crosshairs.svg' }, - { id: 'fa-magic', src: 'src/thirdparty/font-awesome-svg-png/white/svg/magic.svg' } + { id: 'fa-magic', src: 'src/thirdparty/font-awesome-svg-png/white/svg/magic.svg' }, + { id: 'fa-hashtag', src: 'src/thirdparty/font-awesome-svg-png/white/svg/hashtag.svg' } ] diff --git a/src/routes/_actions/autosuggest.js b/src/routes/_actions/autosuggest.js index f3f51fba..0a612d57 100644 --- a/src/routes/_actions/autosuggest.js +++ b/src/routes/_actions/autosuggest.js @@ -1,49 +1,54 @@ import { store } from '../_store/store' -export async function insertUsername (realm, username, startIndex, endIndex) { +const emojiMapper = emoji => `:${emoji.shortcode}:` +const hashtagMapper = hashtag => `#${hashtag.name}` +const accountMapper = account => `@${account.acct}` + +async function insertTextAtPosition (realm, text, startIndex, endIndex) { const { currentInstance } = store.get() const oldText = store.getComposeData(realm, 'text') const pre = oldText.substring(0, startIndex) const post = oldText.substring(endIndex) - const newText = `${pre}@${username} ${post}` + const newText = `${pre}${text} ${post}` store.setComposeData(realm, { text: newText }) store.setForAutosuggest(currentInstance, realm, { autosuggestSearchResults: [] }) } +export async function insertUsername (realm, account, startIndex, endIndex) { + await insertTextAtPosition(realm, accountMapper(account), startIndex, endIndex) +} + +export async function insertHashtag (realm, hashtag, startIndex, endIndex) { + await insertTextAtPosition(realm, hashtagMapper(hashtag), startIndex, endIndex) +} + +export async function insertEmojiAtPosition (realm, emoji, startIndex, endIndex) { + await insertTextAtPosition(realm, emojiMapper(emoji), startIndex, endIndex) +} + +async function clickSelectedItem (realm, resultMapper) { + const { + composeSelectionStart, + autosuggestSearchText, + autosuggestSelected, + autosuggestSearchResults + } = store.get() + const result = autosuggestSearchResults[autosuggestSelected] + const startIndex = composeSelectionStart - autosuggestSearchText.length + const endIndex = composeSelectionStart + await insertTextAtPosition(realm, resultMapper(result), startIndex, endIndex) +} + export async function clickSelectedAutosuggestionUsername (realm) { - const { - composeSelectionStart, - autosuggestSearchText, - autosuggestSelected, - autosuggestSearchResults - } = store.get() - const account = autosuggestSearchResults[autosuggestSelected] - const startIndex = composeSelectionStart - autosuggestSearchText.length - const endIndex = composeSelectionStart - await insertUsername(realm, account.acct, startIndex, endIndex) + return clickSelectedItem(realm, accountMapper) } -export function insertEmojiAtPosition (realm, emoji, startIndex, endIndex) { - const { currentInstance } = store.get() - const oldText = store.getComposeData(realm, 'text') || '' - const pre = oldText.substring(0, startIndex) - const post = oldText.substring(endIndex) - const newText = `${pre}:${emoji.shortcode}: ${post}` - store.setComposeData(realm, { text: newText }) - store.setForAutosuggest(currentInstance, realm, { autosuggestSearchResults: [] }) +export async function clickSelectedAutosuggestionHashtag (realm) { + return clickSelectedItem(realm, hashtagMapper) } export async function clickSelectedAutosuggestionEmoji (realm) { - const { - composeSelectionStart, - autosuggestSearchText, - autosuggestSelected, - autosuggestSearchResults - } = store.get() - const emoji = autosuggestSearchResults[autosuggestSelected] - const startIndex = composeSelectionStart - autosuggestSearchText.length - const endIndex = composeSelectionStart - await insertEmojiAtPosition(realm, emoji, startIndex, endIndex) + return clickSelectedItem(realm, emojiMapper) } export function selectAutosuggestItem (item) { @@ -55,8 +60,10 @@ export function selectAutosuggestItem (item) { const startIndex = composeSelectionStart - autosuggestSearchText.length const endIndex = composeSelectionStart if (item.acct) { - /* no await */ insertUsername(currentComposeRealm, item.acct, startIndex, endIndex) - } else { + /* no await */ insertUsername(currentComposeRealm, item, startIndex, endIndex) + } else if (item.shortcode) { /* no await */ insertEmojiAtPosition(currentComposeRealm, item, startIndex, endIndex) + } else { // hashtag + /* no await */ insertHashtag(currentComposeRealm, item, startIndex, endIndex) } } diff --git a/src/routes/_actions/autosuggestHashtagSearch.js b/src/routes/_actions/autosuggestHashtagSearch.js new file mode 100644 index 00000000..8d0b0bb3 --- /dev/null +++ b/src/routes/_actions/autosuggestHashtagSearch.js @@ -0,0 +1,51 @@ +import { search } from '../_api/search' +import { store } from '../_store/store' +import { scheduleIdleTask } from '../_utils/scheduleIdleTask' +import { SEARCH_RESULTS_LIMIT } from '../_static/autosuggest' +import { PromiseThrottler } from '../_utils/PromiseThrottler' + +const promiseThrottler = new PromiseThrottler(200) // Mastodon FE also uses 200ms + +export function doHashtagSearch (searchText) { + let canceled = false + const { currentInstance, accessToken } = store.get() + let controller = typeof AbortController === 'function' && new AbortController() + + function abortFetch () { + if (controller) { + controller.abort() + controller = null + } + } + + async function searchHashtagsRemotely (searchText) { + // Throttle our XHRs to be a good citizen and not spam the server with one XHR per keystroke + await promiseThrottler.next() + if (canceled) { + return + } + const searchPromise = search( + currentInstance, accessToken, searchText, false, SEARCH_RESULTS_LIMIT, controller && controller.signal + ) + const results = (await searchPromise).hashtags + store.setForCurrentAutosuggest({ + autosuggestType: 'hashtag', + autosuggestSelected: 0, + autosuggestSearchResults: results + }) + } + + scheduleIdleTask(() => { + if (canceled) { + return + } + /* no await */ searchHashtagsRemotely(searchText) + }) + + return { + cancel: () => { + canceled = true + abortFetch() + } + } +} diff --git a/src/routes/_components/compose/ComposeAutosuggestionList.html b/src/routes/_components/compose/ComposeAutosuggestionList.html index 3748ed3d..5243dae8 100644 --- a/src/routes/_components/compose/ComposeAutosuggestionList.html +++ b/src/routes/_components/compose/ComposeAutosuggestionList.html @@ -3,7 +3,7 @@ class="compose-autosuggest-list" role="listbox" > - {#each items as item, i (item.shortcode ? `emoji-${item.shortcode}` : `account-${item.id}`)} + {#each items as item, i (item.shortcode || item.id || item.name)}
  • - - - - - {'@' + item.acct} - + + {item.name.toLowerCase()} + {:else} - + {':' + item.shortcode + ':'} {/if} @@ -70,11 +80,12 @@ :global(.compose-autosuggest-list-item-avatar) { grid-area: icon; } - .compose-autosuggest-list-item-icon { + :global(.compose-autosuggest-list-item-icon) { grid-area: icon; width: 48px; height: 48px; object-fit: contain; + fill: var(--deemphasized-text-color); } .compose-autosuggest-list-display-name { grid-area: display-name; @@ -85,6 +96,10 @@ min-width: 0; text-align: left; } + .compose-autosuggest-list-display-name-single { + grid-row: 1 / 3; + align-self: center; + } .compose-autosuggest-list-username { grid-area: username; font-size: 1em; @@ -108,6 +123,10 @@ .compose-autosuggest-list-grid { grid-column-gap: 5px; } + :global(.compose-autosuggest-list-item-icon) { + width: 24px; + height: 24px; + } } diff --git a/src/routes/_components/compose/ComposeInput.html b/src/routes/_components/compose/ComposeInput.html index ae089232..be0f01c2 100644 --- a/src/routes/_components/compose/ComposeInput.html +++ b/src/routes/_components/compose/ComposeInput.html @@ -63,7 +63,7 @@ import { selectionChange } from '../../_utils/events' import { clickSelectedAutosuggestionUsername, - clickSelectedAutosuggestionEmoji + clickSelectedAutosuggestionEmoji, clickSelectedAutosuggestionHashtag } from '../../_actions/autosuggest' import { observe } from 'svelte-extras' import { get } from '../../_utils/lodash-lite' @@ -209,6 +209,8 @@ this.store.setForCurrentAutosuggest({ autosuggestSelecting: true }) if (autosuggestType === 'account') { await clickSelectedAutosuggestionUsername(realm) + } else if (autosuggestType === 'hashtag') { + await clickSelectedAutosuggestionHashtag(realm) } else { // emoji await clickSelectedAutosuggestionEmoji(realm) } diff --git a/src/routes/_store/computations/autosuggestComputations.js b/src/routes/_store/computations/autosuggestComputations.js index 9a9328eb..31c64ba9 100644 --- a/src/routes/_store/computations/autosuggestComputations.js +++ b/src/routes/_store/computations/autosuggestComputations.js @@ -4,9 +4,9 @@ import { mark, stop } from '../../_utils/marks' const MIN_PREFIX_LENGTH = 2 // Technically mastodon accounts allow dots, but it would be weird to do an autosuggest search if it ends with a dot. // Also this is rare. https://github.com/tootsuite/mastodon/pull/6844 -const VALID_ACCOUNT_AND_EMOJI_CHAR = '\\w' -const ACCOUNT_SEARCH_REGEX = new RegExp(`(?:\\s|^)(@${VALID_ACCOUNT_AND_EMOJI_CHAR}{${MIN_PREFIX_LENGTH},})$`) -const EMOJI_SEARCH_REGEX = new RegExp(`(?:\\s|^)(:${VALID_ACCOUNT_AND_EMOJI_CHAR}{${MIN_PREFIX_LENGTH},})$`) +const VALID_CHARS = '\\w' +const PREFIXES = '(?:@|:|#)' +const REGEX = new RegExp(`(?:\\s|^)(${PREFIXES}${VALID_CHARS}{${MIN_PREFIX_LENGTH},})$`) function computeForAutosuggest (store, key, defaultValue) { store.compute(key, @@ -43,7 +43,7 @@ export function autosuggestComputations (store) { } const textUpToCursor = currentComposeText.substring(0, selectionStart) - const match = textUpToCursor.match(ACCOUNT_SEARCH_REGEX) || textUpToCursor.match(EMOJI_SEARCH_REGEX) + const match = textUpToCursor.match(REGEX) return (match && match[1]) || '' } ) diff --git a/src/routes/_store/observers/autosuggestObservers.js b/src/routes/_store/observers/autosuggestObservers.js index 3415810b..d61fe9d6 100644 --- a/src/routes/_store/observers/autosuggestObservers.js +++ b/src/routes/_store/observers/autosuggestObservers.js @@ -1,6 +1,7 @@ import { store } from '../store' import { doEmojiSearch } from '../../_actions/autosuggestEmojiSearch' import { doAccountSearch } from '../../_actions/autosuggestAccountSearch' +import { doHashtagSearch } from '../../_actions/autosuggestHashtagSearch' export function autosuggestObservers () { let lastSearch @@ -19,11 +20,11 @@ export function autosuggestObservers () { return } - const autosuggestType = autosuggestSearchText.startsWith('@') ? 'account' : 'emoji' - - if (autosuggestType === 'emoji') { + if (autosuggestSearchText.startsWith(':')) { // emoji lastSearch = doEmojiSearch(autosuggestSearchText) - } else { + } else if (autosuggestSearchText.startsWith('#')) { // hashtag + lastSearch = doHashtagSearch(autosuggestSearchText) + } else { // account lastSearch = doAccountSearch(autosuggestSearchText) } }) diff --git a/src/routes/_utils/createAutosuggestAccessibleLabel.js b/src/routes/_utils/createAutosuggestAccessibleLabel.js index 49af8515..76686c86 100644 --- a/src/routes/_utils/createAutosuggestAccessibleLabel.js +++ b/src/routes/_utils/createAutosuggestAccessibleLabel.js @@ -7,6 +7,8 @@ export function createAutosuggestAccessibleLabel ( let label if (autosuggestType === 'emoji') { label = `${selected.shortcode}` + } else if (autosuggestType === 'hashtag') { + label = `#${selected.name}` } else { // account let displayName = selected.display_name || selected.username const emojis = selected.emojis || [] diff --git a/tests/spec/018-compose-autosuggest.js b/tests/spec/018-compose-autosuggest.js index 3366aa11..a5abfc55 100644 --- a/tests/spec/018-compose-autosuggest.js +++ b/tests/spec/018-compose-autosuggest.js @@ -1,5 +1,11 @@ import { - composeInput, getNthAutosuggestionResult, getNthComposeReplyInput, getNthReplyButton, getNthStatus, sleep + composeInput, + composeLengthIndicator, + getNthAutosuggestionResult, + getNthComposeReplyInput, + getNthReplyButton, + getNthStatus, + sleep } from '../utils' import { Selector as $ } from 'testcafe' import { loginAsFoobar } from '../roles' @@ -16,6 +22,7 @@ test('autosuggests user handles', async t => { await sleep(1000) await t .typeText(composeInput, 'hey @qu') + .expect(getNthAutosuggestionResult(1).getAttribute('aria-label')).contains('@quux') .click(getNthAutosuggestionResult(1), { timeout }) .expect(composeInput.value).eql('hey @quux ') .typeText(composeInput, 'and also @adm') @@ -39,6 +46,7 @@ test('autosuggests custom emoji', async t => { .click(getNthAutosuggestionResult(1)) .expect(composeInput.value).eql(':blobnom: ') .typeText(composeInput, 'and :blob') + .expect(getNthAutosuggestionResult(1).getAttribute('aria-label')).contains('blobnom') .expect(getNthAutosuggestionResult(1).innerText).contains(':blobnom:', { timeout }) .expect(getNthAutosuggestionResult(2).innerText).contains(':blobpats:') .expect(getNthAutosuggestionResult(3).innerText).contains(':blobpeek:') @@ -121,3 +129,13 @@ test('autosuggest only shows for one input part 2', async t => { await t.pressKey('backspace') .expect($('.compose-autosuggest.shown').exists).notOk() }) + +test('autocomplete disappears on blur', async t => { + await loginAsFoobar(t) + await t + .hover(composeInput) + .typeText(composeInput, '@adm') + .expect($('.compose-autosuggest.shown').exists).ok({ timeout }) + .click(composeLengthIndicator) + .expect($('.compose-autosuggest.shown').exists).notOk() +}) diff --git a/tests/spec/131-compose-autosuggest.js b/tests/spec/131-compose-autosuggest.js new file mode 100644 index 00000000..7cdc3a6f --- /dev/null +++ b/tests/spec/131-compose-autosuggest.js @@ -0,0 +1,34 @@ +import { + composeInput, getNthAutosuggestionResult, sleep +} from '../utils' +import { loginAsFoobar } from '../roles' +import { postAs } from '../serverActions' + +fixture`131-compose-autosuggest.js` + .page`http://localhost:4002` + +const timeout = 30000 + +test('autosuggests hashtags', async t => { + await postAs('admin', 'hello #blank and hello #blanka') + await sleep(1000) + await loginAsFoobar(t) + await t + .hover(composeInput) + await sleep(1000) + await t + .typeText(composeInput, 'hey #bl') + .expect(getNthAutosuggestionResult(1).innerText).contains('blank', { timeout }) + .expect(getNthAutosuggestionResult(2).innerText).contains('blanka', { timeout }) + .expect(getNthAutosuggestionResult(1).getAttribute('aria-label')).contains('#blank', { timeout }) + .expect(getNthAutosuggestionResult(2).getAttribute('aria-label')).contains('#blanka', { timeout }) + .click(getNthAutosuggestionResult(1), { timeout }) + .expect(composeInput.value).eql('hey #blank ') + .typeText(composeInput, 'and also #BL') + .click(getNthAutosuggestionResult(1), { timeout }) + .expect(composeInput.value).eql('hey #blank and also #blank ') + .typeText(composeInput, 'and also #blanka') + .expect(getNthAutosuggestionResult(1).innerText).contains('blanka', { timeout }) + .pressKey('enter') + .expect(composeInput.value).eql('hey #blank and also #blank and also #blanka ') +})