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-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' }
]

View file

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

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"
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}` : ''}"
class="compose-autosuggest-list-item {i === selected ? 'selected' : ''}"
role="option"
@ -25,12 +25,22 @@
<span class="compose-autosuggest-list-username">
{'@' + item.acct}
</span>
{:elseif type === 'hashtag'}
<SvgIcon
href="#fa-hashtag"
ariaHidden={true}
className="compose-autosuggest-list-item-icon"
/>
<span class="compose-autosuggest-list-display-name compose-autosuggest-list-display-name-single">
{item.name.toLowerCase()}
</span>
{:else}
<img src={$autoplayGifs ? item.url : item.static_url}
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 + ':'}
</span>
{/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;
}
}
</style>
<script>
@ -115,6 +134,7 @@
import { store } from '../../_store/store'
import AccountDisplayName from '../profile/AccountDisplayName.html'
import { createAutosuggestAccessibleLabel } from '../../_utils/createAutosuggestAccessibleLabel'
import SvgIcon from '../SvgIcon.html'
export default {
store: () => store,
@ -134,7 +154,8 @@
},
components: {
Avatar,
AccountDisplayName
AccountDisplayName,
SvgIcon
}
}
</script>

View file

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

View file

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

View file

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

View file

@ -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 || []

View file

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

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