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:
parent
138fe83082
commit
8b3842f15a
|
@ -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' }
|
||||
]
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
51
src/routes/_actions/autosuggestHashtagSearch.js
Normal file
51
src/routes/_actions/autosuggestHashtagSearch.js
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
@ -13,24 +13,34 @@
|
|||
>
|
||||
<div class="compose-autosuggest-list-grid" aria-hidden="true">
|
||||
{#if type === 'account'}
|
||||
<div class="compose-autosuggest-list-item-avatar">
|
||||
<Avatar
|
||||
size="{$isVeryTinyMobileSize ? 'extra-small' : 'small'}"
|
||||
account={item}
|
||||
<div class="compose-autosuggest-list-item-avatar">
|
||||
<Avatar
|
||||
size="{$isVeryTinyMobileSize ? 'extra-small' : 'small'}"
|
||||
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">
|
||||
<AccountDisplayName account={item} />
|
||||
</span>
|
||||
<span class="compose-autosuggest-list-username">
|
||||
{'@' + item.acct}
|
||||
</span>
|
||||
<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>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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]) || ''
|
||||
}
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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 || []
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
|
|
34
tests/spec/131-compose-autosuggest.js
Normal file
34
tests/spec/131-compose-autosuggest.js
Normal 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 ')
|
||||
})
|
Loading…
Reference in a new issue