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)}
{#if type === 'account'}
-
+
+
+
+
+ {'@' + item.acct}
+
+ {:elseif type === 'hashtag'}
+
-
-
-
-
-
- {'@' + 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 ')
+})