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
This commit is contained in:
Nolan Lawson 2019-10-12 18:06:04 -07:00 committed by GitHub
parent 138fe83082
commit 8b3842f15a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 196 additions and 59 deletions

View file

@ -54,5 +54,6 @@ module.exports = [
{ id: 'fa-plus', src: 'src/thirdparty/font-awesome-svg-png/white/svg/plus.svg' }, { 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-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-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' }
] ]

View file

@ -1,49 +1,54 @@
import { store } from '../_store/store' 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 { currentInstance } = store.get()
const oldText = store.getComposeData(realm, 'text') const oldText = store.getComposeData(realm, 'text')
const pre = oldText.substring(0, startIndex) const pre = oldText.substring(0, startIndex)
const post = oldText.substring(endIndex) const post = oldText.substring(endIndex)
const newText = `${pre}@${username} ${post}` const newText = `${pre}${text} ${post}`
store.setComposeData(realm, { text: newText }) store.setComposeData(realm, { text: newText })
store.setForAutosuggest(currentInstance, realm, { autosuggestSearchResults: [] }) 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) { export async function clickSelectedAutosuggestionUsername (realm) {
const { return clickSelectedItem(realm, accountMapper)
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)
} }
export function insertEmojiAtPosition (realm, emoji, startIndex, endIndex) { export async function clickSelectedAutosuggestionHashtag (realm) {
const { currentInstance } = store.get() return clickSelectedItem(realm, hashtagMapper)
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 clickSelectedAutosuggestionEmoji (realm) { export async function clickSelectedAutosuggestionEmoji (realm) {
const { return clickSelectedItem(realm, emojiMapper)
composeSelectionStart,
autosuggestSearchText,
autosuggestSelected,
autosuggestSearchResults
} = store.get()
const emoji = autosuggestSearchResults[autosuggestSelected]
const startIndex = composeSelectionStart - autosuggestSearchText.length
const endIndex = composeSelectionStart
await insertEmojiAtPosition(realm, emoji, startIndex, endIndex)
} }
export function selectAutosuggestItem (item) { export function selectAutosuggestItem (item) {
@ -55,8 +60,10 @@ export function selectAutosuggestItem (item) {
const startIndex = composeSelectionStart - autosuggestSearchText.length const startIndex = composeSelectionStart - autosuggestSearchText.length
const endIndex = composeSelectionStart const endIndex = composeSelectionStart
if (item.acct) { if (item.acct) {
/* no await */ insertUsername(currentComposeRealm, item.acct, startIndex, endIndex) /* no await */ insertUsername(currentComposeRealm, item, startIndex, endIndex)
} else { } else if (item.shortcode) {
/* no await */ insertEmojiAtPosition(currentComposeRealm, item, startIndex, endIndex) /* no await */ insertEmojiAtPosition(currentComposeRealm, item, startIndex, endIndex)
} else { // hashtag
/* no await */ insertHashtag(currentComposeRealm, item, startIndex, endIndex)
} }
} }

View file

@ -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()
}
}
}

View file

@ -3,7 +3,7 @@
class="compose-autosuggest-list" class="compose-autosuggest-list"
role="listbox" 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)}
<li id="{i === selected ? `compose-autosuggest-active-item-${realm}` : ''}" <li id="{i === selected ? `compose-autosuggest-active-item-${realm}` : ''}"
class="compose-autosuggest-list-item {i === selected ? 'selected' : ''}" class="compose-autosuggest-list-item {i === selected ? 'selected' : ''}"
role="option" role="option"
@ -13,24 +13,34 @@
> >
<div class="compose-autosuggest-list-grid" aria-hidden="true"> <div class="compose-autosuggest-list-grid" aria-hidden="true">
{#if type === 'account'} {#if type === 'account'}
<div class="compose-autosuggest-list-item-avatar"> <div class="compose-autosuggest-list-item-avatar">
<Avatar <Avatar
size="{$isVeryTinyMobileSize ? 'extra-small' : 'small'}" size="{$isVeryTinyMobileSize ? 'extra-small' : 'small'}"
account={item} account={item}
/>
</div>
<span class="compose-autosuggest-list-display-name">
<AccountDisplayName account={item} />
</span>
<span class="compose-autosuggest-list-username">
{'@' + item.acct}
</span>
{:elseif type === 'hashtag'}
<SvgIcon
href="#fa-hashtag"
ariaHidden={true}
className="compose-autosuggest-list-item-icon"
/> />
</div> <span class="compose-autosuggest-list-display-name compose-autosuggest-list-display-name-single">
<span class="compose-autosuggest-list-display-name"> {item.name.toLowerCase()}
<AccountDisplayName account={item} /> </span>
</span>
<span class="compose-autosuggest-list-username">
{'@' + item.acct}
</span>
{:else} {:else}
<img src={$autoplayGifs ? item.url : item.static_url} <img src={$autoplayGifs ? item.url : item.static_url}
class="compose-autosuggest-list-item-icon" class="compose-autosuggest-list-item-icon"
alt="{':' + item.shortcode + ':'}" alt=""
aria-hidden="true"
/> />
<span class="compose-autosuggest-list-display-name"> <span class="compose-autosuggest-list-display-name compose-autosuggest-list-display-name-single">
{':' + item.shortcode + ':'} {':' + item.shortcode + ':'}
</span> </span>
{/if} {/if}
@ -70,11 +80,12 @@
:global(.compose-autosuggest-list-item-avatar) { :global(.compose-autosuggest-list-item-avatar) {
grid-area: icon; grid-area: icon;
} }
.compose-autosuggest-list-item-icon { :global(.compose-autosuggest-list-item-icon) {
grid-area: icon; grid-area: icon;
width: 48px; width: 48px;
height: 48px; height: 48px;
object-fit: contain; object-fit: contain;
fill: var(--deemphasized-text-color);
} }
.compose-autosuggest-list-display-name { .compose-autosuggest-list-display-name {
grid-area: display-name; grid-area: display-name;
@ -85,6 +96,10 @@
min-width: 0; min-width: 0;
text-align: left; text-align: left;
} }
.compose-autosuggest-list-display-name-single {
grid-row: 1 / 3;
align-self: center;
}
.compose-autosuggest-list-username { .compose-autosuggest-list-username {
grid-area: username; grid-area: username;
font-size: 1em; font-size: 1em;
@ -108,6 +123,10 @@
.compose-autosuggest-list-grid { .compose-autosuggest-list-grid {
grid-column-gap: 5px; grid-column-gap: 5px;
} }
:global(.compose-autosuggest-list-item-icon) {
width: 24px;
height: 24px;
}
} }
</style> </style>
<script> <script>
@ -115,6 +134,7 @@
import { store } from '../../_store/store' import { store } from '../../_store/store'
import AccountDisplayName from '../profile/AccountDisplayName.html' import AccountDisplayName from '../profile/AccountDisplayName.html'
import { createAutosuggestAccessibleLabel } from '../../_utils/createAutosuggestAccessibleLabel' import { createAutosuggestAccessibleLabel } from '../../_utils/createAutosuggestAccessibleLabel'
import SvgIcon from '../SvgIcon.html'
export default { export default {
store: () => store, store: () => store,
@ -134,7 +154,8 @@
}, },
components: { components: {
Avatar, Avatar,
AccountDisplayName AccountDisplayName,
SvgIcon
} }
} }
</script> </script>

View file

@ -63,7 +63,7 @@
import { selectionChange } from '../../_utils/events' import { selectionChange } from '../../_utils/events'
import { import {
clickSelectedAutosuggestionUsername, clickSelectedAutosuggestionUsername,
clickSelectedAutosuggestionEmoji clickSelectedAutosuggestionEmoji, clickSelectedAutosuggestionHashtag
} from '../../_actions/autosuggest' } from '../../_actions/autosuggest'
import { observe } from 'svelte-extras' import { observe } from 'svelte-extras'
import { get } from '../../_utils/lodash-lite' import { get } from '../../_utils/lodash-lite'
@ -209,6 +209,8 @@
this.store.setForCurrentAutosuggest({ autosuggestSelecting: true }) this.store.setForCurrentAutosuggest({ autosuggestSelecting: true })
if (autosuggestType === 'account') { if (autosuggestType === 'account') {
await clickSelectedAutosuggestionUsername(realm) await clickSelectedAutosuggestionUsername(realm)
} else if (autosuggestType === 'hashtag') {
await clickSelectedAutosuggestionHashtag(realm)
} else { // emoji } else { // emoji
await clickSelectedAutosuggestionEmoji(realm) await clickSelectedAutosuggestionEmoji(realm)
} }

View file

@ -4,9 +4,9 @@ import { mark, stop } from '../../_utils/marks'
const MIN_PREFIX_LENGTH = 2 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. // 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 // Also this is rare. https://github.com/tootsuite/mastodon/pull/6844
const VALID_ACCOUNT_AND_EMOJI_CHAR = '\\w' const VALID_CHARS = '\\w'
const ACCOUNT_SEARCH_REGEX = new RegExp(`(?:\\s|^)(@${VALID_ACCOUNT_AND_EMOJI_CHAR}{${MIN_PREFIX_LENGTH},})$`) const PREFIXES = '(?:@|:|#)'
const EMOJI_SEARCH_REGEX = new RegExp(`(?:\\s|^)(:${VALID_ACCOUNT_AND_EMOJI_CHAR}{${MIN_PREFIX_LENGTH},})$`) const REGEX = new RegExp(`(?:\\s|^)(${PREFIXES}${VALID_CHARS}{${MIN_PREFIX_LENGTH},})$`)
function computeForAutosuggest (store, key, defaultValue) { function computeForAutosuggest (store, key, defaultValue) {
store.compute(key, store.compute(key,
@ -43,7 +43,7 @@ export function autosuggestComputations (store) {
} }
const textUpToCursor = currentComposeText.substring(0, selectionStart) 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]) || '' return (match && match[1]) || ''
} }
) )

View file

@ -1,6 +1,7 @@
import { store } from '../store' import { store } from '../store'
import { doEmojiSearch } from '../../_actions/autosuggestEmojiSearch' import { doEmojiSearch } from '../../_actions/autosuggestEmojiSearch'
import { doAccountSearch } from '../../_actions/autosuggestAccountSearch' import { doAccountSearch } from '../../_actions/autosuggestAccountSearch'
import { doHashtagSearch } from '../../_actions/autosuggestHashtagSearch'
export function autosuggestObservers () { export function autosuggestObservers () {
let lastSearch let lastSearch
@ -19,11 +20,11 @@ export function autosuggestObservers () {
return return
} }
const autosuggestType = autosuggestSearchText.startsWith('@') ? 'account' : 'emoji' if (autosuggestSearchText.startsWith(':')) { // emoji
if (autosuggestType === 'emoji') {
lastSearch = doEmojiSearch(autosuggestSearchText) lastSearch = doEmojiSearch(autosuggestSearchText)
} else { } else if (autosuggestSearchText.startsWith('#')) { // hashtag
lastSearch = doHashtagSearch(autosuggestSearchText)
} else { // account
lastSearch = doAccountSearch(autosuggestSearchText) lastSearch = doAccountSearch(autosuggestSearchText)
} }
}) })

View file

@ -7,6 +7,8 @@ export function createAutosuggestAccessibleLabel (
let label let label
if (autosuggestType === 'emoji') { if (autosuggestType === 'emoji') {
label = `${selected.shortcode}` label = `${selected.shortcode}`
} else if (autosuggestType === 'hashtag') {
label = `#${selected.name}`
} else { // account } else { // account
let displayName = selected.display_name || selected.username let displayName = selected.display_name || selected.username
const emojis = selected.emojis || [] const emojis = selected.emojis || []

View file

@ -1,5 +1,11 @@
import { import {
composeInput, getNthAutosuggestionResult, getNthComposeReplyInput, getNthReplyButton, getNthStatus, sleep composeInput,
composeLengthIndicator,
getNthAutosuggestionResult,
getNthComposeReplyInput,
getNthReplyButton,
getNthStatus,
sleep
} from '../utils' } from '../utils'
import { Selector as $ } from 'testcafe' import { Selector as $ } from 'testcafe'
import { loginAsFoobar } from '../roles' import { loginAsFoobar } from '../roles'
@ -16,6 +22,7 @@ test('autosuggests user handles', async t => {
await sleep(1000) await sleep(1000)
await t await t
.typeText(composeInput, 'hey @qu') .typeText(composeInput, 'hey @qu')
.expect(getNthAutosuggestionResult(1).getAttribute('aria-label')).contains('@quux')
.click(getNthAutosuggestionResult(1), { timeout }) .click(getNthAutosuggestionResult(1), { timeout })
.expect(composeInput.value).eql('hey @quux ') .expect(composeInput.value).eql('hey @quux ')
.typeText(composeInput, 'and also @adm') .typeText(composeInput, 'and also @adm')
@ -39,6 +46,7 @@ test('autosuggests custom emoji', async t => {
.click(getNthAutosuggestionResult(1)) .click(getNthAutosuggestionResult(1))
.expect(composeInput.value).eql(':blobnom: ') .expect(composeInput.value).eql(':blobnom: ')
.typeText(composeInput, 'and :blob') .typeText(composeInput, 'and :blob')
.expect(getNthAutosuggestionResult(1).getAttribute('aria-label')).contains('blobnom')
.expect(getNthAutosuggestionResult(1).innerText).contains(':blobnom:', { timeout }) .expect(getNthAutosuggestionResult(1).innerText).contains(':blobnom:', { timeout })
.expect(getNthAutosuggestionResult(2).innerText).contains(':blobpats:') .expect(getNthAutosuggestionResult(2).innerText).contains(':blobpats:')
.expect(getNthAutosuggestionResult(3).innerText).contains(':blobpeek:') .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') await t.pressKey('backspace')
.expect($('.compose-autosuggest.shown').exists).notOk() .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()
})

View file

@ -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 ')
})