add emoji autosuggest

This commit is contained in:
Nolan Lawson 2018-03-25 12:24:38 -07:00
parent 143d80c94e
commit 7ce9a39119
9 changed files with 163 additions and 45 deletions

View file

@ -25,3 +25,21 @@ export function insertEmoji (realm, emoji) {
let newText = `${pre}:${emoji.shortcode}: ${post}` let newText = `${pre}:${emoji.shortcode}: ${post}`
store.setComposeData(realm, {text: newText}) store.setComposeData(realm, {text: newText})
} }
export function insertEmojiAtPosition (realm, emoji, startIndex, endIndex) {
let oldText = store.getComposeData(realm, 'text')
let pre = oldText ? substring(oldText, 0, startIndex) : ''
let post = oldText ? substring(oldText, endIndex) : ''
let newText = `${pre}:${emoji.shortcode}: ${post}`
store.setComposeData(realm, {text: newText})
}
export async function clickSelectedAutosuggestionEmoji (realm) {
let selectionStart = store.get('composeSelectionStart')
let searchText = store.get('composeAutosuggestionSearchText')
let selection = store.get('composeAutosuggestionSelected') || 0
let emoji = store.get('composeAutosuggestionSearchResults')[selection]
let startIndex = selectionStart - searchText.length
let endIndex = selectionStart
await insertEmojiAtPosition(realm, emoji, startIndex, endIndex)
}

View file

@ -2,7 +2,8 @@
aria-hidden="true" > aria-hidden="true" >
<ComposeAutosuggestionList <ComposeAutosuggestionList
items="{{searchResults}}" items="{{searchResults}}"
on:click="onUserSelected(event)" on:click="onClick(event)"
:type
:selected :selected
/> />
</div> </div>
@ -32,6 +33,7 @@
import { store } from '../../_store/store' import { store } from '../../_store/store'
import { database } from '../../_database/database' import { database } from '../../_database/database'
import { insertUsername } from '../../_actions/compose' import { insertUsername } from '../../_actions/compose'
import { insertEmojiAtPosition } from '../../_actions/emoji'
import { scheduleIdleTask } from '../../_utils/scheduleIdleTask' import { scheduleIdleTask } from '../../_utils/scheduleIdleTask'
import { once } from '../../_utils/once' import { once } from '../../_utils/once'
import ComposeAutosuggestionList from './ComposeAutosuggestionList.html' import ComposeAutosuggestionList from './ComposeAutosuggestionList.html'
@ -39,7 +41,8 @@
const SEARCH_RESULTS_LIMIT = 4 const SEARCH_RESULTS_LIMIT = 4
const DATABASE_SEARCH_RESULTS_LIMIT = 30 const DATABASE_SEARCH_RESULTS_LIMIT = 30
const MIN_PREFIX_LENGTH = 1 const MIN_PREFIX_LENGTH = 1
const SEARCH_REGEX = new RegExp(`(?:\\s|^)(@\\S{${MIN_PREFIX_LENGTH},})$`) const ACCOUNT_SEARCH_REGEX = new RegExp(`(?:\\s|^)(@\\S{${MIN_PREFIX_LENGTH},})$`)
const EMOJI_SEARCH_REGEX = new RegExp(`(?:\\s|^)(:[^:]{${MIN_PREFIX_LENGTH},})$`)
export default { export default {
oncreate() { oncreate() {
@ -63,7 +66,7 @@
} else { } else {
Promise.race([ Promise.race([
new Promise(resolve => setTimeout(resolve, 200)), new Promise(resolve => setTimeout(resolve, 200)),
new Promise(resolve => this.once('userSelected', resolve)) new Promise(resolve => this.once('autosuggestItemSelected', resolve))
]).then(updateFocusedState) ]).then(updateFocusedState)
} }
}) })
@ -71,11 +74,15 @@
if (!searchText) { if (!searchText) {
return return
} }
let results = await this.search(searchText) let type = searchText.startsWith('@') ? 'account' : 'emoji'
let results = (type === 'account')
? await this.searchAccounts(searchText)
: await this.searchEmoji(searchText)
this.store.set({ this.store.set({
composeAutosuggestionSelected: 0, composeAutosuggestionSelected: 0,
composeAutosuggestionSearchText: searchText, composeAutosuggestionSearchText: searchText,
composeAutosuggestionSearchResults: results composeAutosuggestionSearchResults: results,
composeAutosuggestionType: type,
}) })
}) })
this.observe('shown', shown => { this.observe('shown', shown => {
@ -84,26 +91,41 @@
}, },
methods: { methods: {
once: once, once: once,
onUserSelected(account) { onClick(item) {
this.fire('userSelected') this.fire('autosuggestItemSelected')
let realm = this.get('realm') let realm = this.get('realm')
let selectionStart = this.store.get('composeSelectionStart') let selectionStart = this.store.get('composeSelectionStart')
let searchText = this.store.get('composeAutosuggestionSearchText') let searchText = this.store.get('composeAutosuggestionSearchText')
let startIndex = selectionStart - searchText.length let startIndex = selectionStart - searchText.length
let endIndex = selectionStart let endIndex = selectionStart
/* no await */ insertUsername(realm, account.acct, startIndex, endIndex) if (item.acct) {
/* no await */ insertUsername(realm, item.acct, startIndex, endIndex)
} else {
/* no await */ insertEmojiAtPosition(realm, item, startIndex, endIndex)
}
}, },
async search(searchText) { async searchAccounts(searchText) {
searchText = searchText.substring(1)
let currentInstance = this.store.get('currentInstance') let currentInstance = this.store.get('currentInstance')
let results = await database.searchAccountsByUsername( let results = await database.searchAccountsByUsername(
currentInstance, searchText.substring(1), DATABASE_SEARCH_RESULTS_LIMIT) currentInstance, searchText, DATABASE_SEARCH_RESULTS_LIMIT)
return results.slice(0, SEARCH_RESULTS_LIMIT) return results.slice(0, SEARCH_RESULTS_LIMIT)
},
searchEmoji(searchText) {
searchText = searchText.toLowerCase().substring(1)
let customEmoji = this.store.get('currentCustomEmoji')
let results = customEmoji.filter(emoji => emoji.shortcode.toLowerCase().startsWith(searchText))
.sort((a, b) => a.shortcode.toLowerCase() < b.shortcode.toLowerCase() ? -1 : 1)
.slice(0, SEARCH_RESULTS_LIMIT)
return results
} }
}, },
computed: { computed: {
composeSelectionStart: ($composeSelectionStart) => $composeSelectionStart, composeSelectionStart: ($composeSelectionStart) => $composeSelectionStart,
composeFocused: ($composeFocused) => $composeFocused, composeFocused: ($composeFocused) => $composeFocused,
searchResults: ($composeAutosuggestionSearchResults) => $composeAutosuggestionSearchResults || [], searchResults: ($composeAutosuggestionSearchResults) => $composeAutosuggestionSearchResults || [],
type: ($composeAutosuggestionType) => $composeAutosuggestionType || 'account',
selected: ($composeAutosuggestionSelected) => $composeAutosuggestionSelected || 0, selected: ($composeAutosuggestionSelected) => $composeAutosuggestionSelected || 0,
searchText: (text, composeSelectionStartDeferred) => { searchText: (text, composeSelectionStartDeferred) => {
let selectionStart = composeSelectionStartDeferred || 0 let selectionStart = composeSelectionStartDeferred || 0
@ -111,7 +133,8 @@
return return
} }
let match = text.substring(0, selectionStart).match(SEARCH_REGEX) let textUpToCursor = text.substring(0, selectionStart)
let match = textUpToCursor.match(ACCOUNT_SEARCH_REGEX) || textUpToCursor.match(EMOJI_SEARCH_REGEX)
return match && match[1] return match && match[1]
}, },
shown: (composeFocusedDeferred, searchText, searchResults) => { shown: (composeFocusedDeferred, searchText, searchResults) => {

View file

@ -1,61 +1,76 @@
<ul class="generic-user-list"> <ul class="compose-autosuggest-list">
{{#each items as account, i @id}} {{#each items as item, i}}
<li class="generic-user-list-item"> <li class="compose-autosuggest-list-item">
<button class="generic-user-list-button {{i === selected ? 'selected' : ''}}" <button class="compose-autosuggest-list-button {{i === selected ? 'selected' : ''}}"
tabindex="0" tabindex="0"
on:click="fire('click', account)"> on:click="fire('click', item)">
<div class="generic-user-list-grid"> <div class="compose-autosuggest-list-grid">
{{#if type === 'account'}}
<Avatar <Avatar
className="generic-user-list-item-avatar" className="compose-autosuggest-list-item-avatar"
size="small" size="small"
:account account="{{item}}"
/> />
<span class="generic-user-list-display-name"> <span class="compose-autosuggest-list-display-name">
{{account.display_name || account.acct}} {{item.display_name || item.acct}}
</span> </span>
<span class="generic-user-list-username"> <span class="compose-autosuggest-list-username">
{{'@' + account.acct}} {{'@' + item.acct}}
</span> </span>
{{else}}
<img src="{{$autoplayGifs ? item.url : item.static_url}}"
class="compose-autosuggest-list-item-icon"
alt="{{':' + item.shortcode + ':'}}"
/>
<span class="compose-autosuggest-list-display-name">
{{':' + item.shortcode + ':'}}
</span>
{{/if}}
</div> </div>
</button> </button>
</li> </li>
{{/each}} {{/each}}
</ul> </ul>
<style> <style>
.generic-user-list { .compose-autosuggest-list {
list-style: none; list-style: none;
width: 100%; width: 100%;
border-radius: 2px; border-radius: 2px;
box-sizing: border-box; box-sizing: border-box;
border: 1px solid var(--compose-autosuggest-outline); border: 1px solid var(--compose-autosuggest-outline);
} }
.generic-user-list-item { .compose-autosuggest-list-item {
border-bottom: 1px solid var(--compose-autosuggest-outline); border-bottom: 1px solid var(--compose-autosuggest-outline);
display: flex; display: flex;
} }
.generic-user-list-item:last-child { .compose-autosuggest-list-item:last-child {
border-bottom: none; border-bottom: none;
} }
.generic-user-list-button { .compose-autosuggest-list-button {
padding: 10px; padding: 10px;
background: var(--settings-list-item-bg); background: var(--settings-list-item-bg);
border: none; border: none;
margin: 0; margin: 0;
flex: 1; flex: 1;
} }
.generic-user-list-grid { .compose-autosuggest-list-grid {
display: grid; display: grid;
width: 100%; width: 100%;
grid-template-areas: "avatar display-name" grid-template-areas: "icon display-name"
"avatar username"; "icon username";
grid-template-columns: min-content 1fr; grid-template-columns: min-content 1fr;
grid-column-gap: 10px; grid-column-gap: 10px;
grid-row-gap: 5px; grid-row-gap: 5px;
} }
:global(.generic-user-list-item-avatar) { :global(.compose-autosuggest-list-item-avatar) {
grid-area: avatar; grid-area: icon;
} }
.generic-user-list-display-name { .compose-autosuggest-list-item-icon {
grid-area: icon;
width: 48px;
height: 48px;
}
.compose-autosuggest-list-display-name {
grid-area: display-name; grid-area: display-name;
font-size: 1.1em; font-size: 1.1em;
white-space: nowrap; white-space: nowrap;
@ -64,7 +79,7 @@
min-width: 0; min-width: 0;
text-align: left; text-align: left;
} }
.generic-user-list-username { .compose-autosuggest-list-username {
grid-area: username; grid-area: username;
font-size: 1em; font-size: 1em;
color: var(--deemphasized-text-color); color: var(--deemphasized-text-color);
@ -73,16 +88,19 @@
text-overflow: ellipsis; text-overflow: ellipsis;
text-align: left; text-align: left;
} }
.generic-user-list-button:hover, .generic-user-list-button.selected { .compose-autosuggest-list-button:hover, .compose-autosuggest-list-button.selected {
background: var(--compose-autosuggest-item-hover); background: var(--compose-autosuggest-item-hover);
} }
.generic-user-list-button:active { .compose-autosuggest-list-button:active {
background: var(--compose-autosuggest-item-active); background: var(--compose-autosuggest-item-active);
} }
</style> </style>
<script> <script>
import Avatar from '../Avatar.html' import Avatar from '../Avatar.html'
import { store } from '../../_store/store'
export default { export default {
store: () => store,
components: { components: {
Avatar Avatar
} }

View file

@ -34,6 +34,7 @@
import { mark, stop } from '../../_utils/marks' import { mark, stop } from '../../_utils/marks'
import { selectionChange } from '../../_utils/events' import { selectionChange } from '../../_utils/events'
import { clickSelectedAutosuggestionUsername } from '../../_actions/compose' import { clickSelectedAutosuggestionUsername } from '../../_actions/compose'
import { clickSelectedAutosuggestionEmoji } from '../../_actions/emoji'
export default { export default {
oncreate() { oncreate() {
@ -49,16 +50,19 @@
let textarea = this.refs.textarea let textarea = this.refs.textarea
let firstTime = true let firstTime = true
this.observe('text', text => { this.observe('text', text => {
this.set({rawText: text}) if (this.get('rawText') !== text) {
this.set({rawText: text})
// this next autosize is required to resize after
// the user clicks the "toot" button
mark('autosize.update()')
autosize.update(textarea)
stop('autosize.update()')
}
if (firstTime) { if (firstTime) {
firstTime = false firstTime = false
if (this.get('autoFocus')) { if (this.get('autoFocus')) {
textarea.focus() textarea.focus()
} }
} else {
mark('autosize.update()')
autosize.update(textarea)
stop('autosize.update()')
} }
}) })
}, },
@ -116,7 +120,12 @@
if (!autosuggestionShown) { if (!autosuggestionShown) {
return return
} }
clickSelectedAutosuggestionUsername(this.get('realm')) let type = this.store.get('composeAutosuggestionType')
if (type === 'account') {
/* no await */ clickSelectedAutosuggestionUsername(this.get('realm'))
} else { // emoji
/* no await */ clickSelectedAutosuggestionEmoji(this.get('realm'))
}
event.preventDefault() event.preventDefault()
}, },
incrementAutosuggestSelected(increment, event) { incrementAutosuggestSelected(increment, event) {

View file

@ -54,7 +54,6 @@
<script> <script>
import IconButton from '../IconButton.html' import IconButton from '../IconButton.html'
import { store } from '../../_store/store' import { store } from '../../_store/store'
import { updateCustomEmojiForInstance } from '../../_actions/emoji'
import { importDialogs } from '../../_utils/asyncModules' import { importDialogs } from '../../_utils/asyncModules'
import { doMediaUpload } from '../../_actions/media' import { doMediaUpload } from '../../_actions/media'
import { toggleContentWarningShown } from '../../_actions/contentWarnings' import { toggleContentWarningShown } from '../../_actions/contentWarnings'
@ -79,7 +78,6 @@
store: () => store, store: () => store,
methods: { methods: {
async onEmojiClick() { async onEmojiClick() {
/* no await */ updateCustomEmojiForInstance(this.store.get('currentInstance'))
let dialogs = await importDialogs() let dialogs = await importDialogs()
dialogs.showEmojiDialog(this.get('realm')) dialogs.showEmojiDialog(this.get('realm'))
}, },

View file

@ -1,6 +1,7 @@
import { updateInstanceInfo, updateVerifyCredentialsForInstance } from '../../_actions/instances' import { updateInstanceInfo, updateVerifyCredentialsForInstance } from '../../_actions/instances'
import { updateLists } from '../../_actions/lists' import { updateLists } from '../../_actions/lists'
import { createStream } from '../../_actions/streaming' import { createStream } from '../../_actions/streaming'
import { updateCustomEmojiForInstance } from '../../_actions/emoji'
export function instanceObservers (store) { export function instanceObservers (store) {
// stream to watch for home timeline updates and notifications // stream to watch for home timeline updates and notifications
@ -22,6 +23,7 @@ export function instanceObservers (store) {
} }
updateVerifyCredentialsForInstance(currentInstance) updateVerifyCredentialsForInstance(currentInstance)
updateInstanceInfo(currentInstance) updateInstanceInfo(currentInstance)
updateCustomEmojiForInstance(currentInstance)
updateLists() updateLists()
await updateInstanceInfo(currentInstance) await updateInstanceInfo(currentInstance)

View file

@ -10,7 +10,7 @@ function login (t, username, password) {
.pressKey('enter') .pressKey('enter')
.expect(getUrl()).contains('/oauth/authorize') .expect(getUrl()).contains('/oauth/authorize')
.click(authorizeInput) .click(authorizeInput)
.expect(getUrl()).eql('http://localhost:4002/') .expect(getUrl()).eql('http://localhost:4002/', {timeout: 20000})
} }
export const foobarRole = Role('http://localhost:4002/settings/instances/add', async t => { export const foobarRole = Role('http://localhost:4002/settings/instances/add', async t => {

View file

@ -0,0 +1,46 @@
import {
composeInput, getNthAutosuggestionResult
} from '../utils'
import { foobarRole } from '../roles'
fixture`018-compose-autosuggest.js`
.page`http://localhost:4002`
test('autosuggests user handles', async t => {
await t.useRole(foobarRole)
.hover(composeInput)
.typeText(composeInput, 'hey @qu')
.click(getNthAutosuggestionResult(1))
.expect(composeInput.value).eql('hey @quux ')
.typeText(composeInput, 'and also @adm')
.click(getNthAutosuggestionResult(1))
.expect(composeInput.value).eql('hey @quux and also @admin ')
.typeText(composeInput, 'and also @AdM')
.expect(getNthAutosuggestionResult(1).innerText).contains('@admin')
.pressKey('tab')
.expect(composeInput.value).eql('hey @quux and also @admin and also @admin ')
.typeText(composeInput, 'and @QU')
.expect(getNthAutosuggestionResult(1).innerText).contains('@quux')
.pressKey('enter')
.expect(composeInput.value).eql('hey @quux and also @admin and also @admin and @quux ')
})
test('autosuggests custom emoji', async t => {
await t.useRole(foobarRole)
.hover(composeInput)
.typeText(composeInput, ':blob')
.click(getNthAutosuggestionResult(1))
.expect(composeInput.value).eql(':blobnom: ')
.typeText(composeInput, 'and :blob')
.expect(getNthAutosuggestionResult(1).innerText).contains(':blobnom:')
.expect(getNthAutosuggestionResult(2).innerText).contains(':blobpats:')
.expect(getNthAutosuggestionResult(3).innerText).contains(':blobpeek:')
.pressKey('down')
.pressKey('down')
.pressKey('enter')
.expect(composeInput.value).eql(':blobnom: and :blobpeek: ')
.typeText(composeInput, 'and also :blobpa')
.expect(getNthAutosuggestionResult(1).innerText).contains(':blobpats:')
.pressKey('tab')
.expect(composeInput.value).eql(':blobnom: and :blobpeek: and also :blobpats: ')
})

View file

@ -95,6 +95,10 @@ export const uploadKittenImage = i => (exec(() => {
} }
})) }))
export function getNthAutosuggestionResult (n) {
return $(`.compose-autosuggest-list-item:nth-child(${n}) button`)
}
export function getNthSearchResult (n) { export function getNthSearchResult (n) {
return $(`.search-result:nth-child(${n}) a`) return $(`.search-result:nth-child(${n}) a`)
} }