isolate autosuggestion state (#273)

fixes #261
This commit is contained in:
Nolan Lawson 2018-05-06 16:25:17 -07:00 committed by GitHub
parent 67e41e4fb0
commit 07fb5e867c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 271 additions and 183 deletions

View File

@ -0,0 +1,62 @@
import { store } from '../_store/store'
export async function insertUsername (realm, username, startIndex, endIndex) {
let { currentInstance } = store.get()
let oldText = store.getComposeData(realm, 'text')
let pre = oldText.substring(0, startIndex)
let post = oldText.substring(endIndex)
let newText = `${pre}@${username} ${post}`
store.setComposeData(realm, {text: newText})
store.setForAutosuggest(currentInstance, realm, {autosuggestSearchResults: []})
}
export async function clickSelectedAutosuggestionUsername (realm) {
let {
composeSelectionStart,
autosuggestSearchText,
autosuggestSelected,
autosuggestSearchResults
} = store.get()
let account = autosuggestSearchResults[autosuggestSelected]
let startIndex = composeSelectionStart - autosuggestSearchText.length
let endIndex = composeSelectionStart
await insertUsername(realm, account.acct, startIndex, endIndex)
}
export function insertEmojiAtPosition (realm, emoji, startIndex, endIndex) {
let { currentInstance } = store.get()
let oldText = store.getComposeData(realm, 'text') || ''
let pre = oldText.substring(0, startIndex)
let post = oldText.substring(endIndex)
let newText = `${pre}:${emoji.shortcode}: ${post}`
store.setComposeData(realm, {text: newText})
store.setForAutosuggest(currentInstance, realm, {autosuggestSearchResults: []})
}
export async function clickSelectedAutosuggestionEmoji (realm) {
let {
composeSelectionStart,
autosuggestSearchText,
autosuggestSelected,
autosuggestSearchResults
} = store.get()
let emoji = autosuggestSearchResults[autosuggestSelected]
let startIndex = composeSelectionStart - autosuggestSearchText.length
let endIndex = composeSelectionStart
await insertEmojiAtPosition(realm, emoji, startIndex, endIndex)
}
export function selectAutosuggestItem (item) {
let {
currentComposeRealm,
composeSelectionStart,
autosuggestSearchText
} = store.get()
let startIndex = composeSelectionStart - autosuggestSearchText.length
let endIndex = composeSelectionStart
if (item.acct) {
/* no await */ insertUsername(currentComposeRealm, item.acct, startIndex, endIndex)
} else {
/* no await */ insertEmojiAtPosition(currentComposeRealm, item, startIndex, endIndex)
}
}

View File

@ -50,28 +50,6 @@ export async function postStatus (realm, text, inReplyToId, mediaIds,
}
}
export async function insertUsername (realm, username, startIndex, endIndex) {
let oldText = store.getComposeData(realm, 'text')
let pre = oldText.substring(0, startIndex)
let post = oldText.substring(endIndex)
let newText = `${pre}@${username} ${post}`
store.setComposeData(realm, {text: newText})
}
export async function clickSelectedAutosuggestionUsername (realm) {
let {
composeSelectionStart,
composeAutosuggestionSearchText,
composeAutosuggestionSelected,
composeAutosuggestionSearchResults
} = store.get()
composeAutosuggestionSelected = composeAutosuggestionSelected || 0
let account = composeAutosuggestionSearchResults[composeAutosuggestionSelected]
let startIndex = composeSelectionStart - composeAutosuggestionSearchText.length
let endIndex = composeSelectionStart
await insertUsername(realm, account.acct, startIndex, endIndex)
}
export function setReplySpoiler (realm, spoiler) {
let contentWarning = store.getComposeData(realm, 'contentWarning')
let contentWarningShown = store.getComposeData(realm, 'contentWarningShown')

View File

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

View File

@ -1,10 +1,10 @@
<div class="compose-autosuggest {shown ? 'shown' : ''} {realm === 'dialog' ? 'is-dialog' : ''}"
aria-hidden="true" >
<ComposeAutosuggestionList
items={searchResults}
items={autosuggestSearchResults}
on:click="onClick(event)"
{type}
{selected}
type={autosuggestType}
selected={autosuggestSelected}
/>
</div>
<style>
@ -39,134 +39,65 @@
</style>
<script>
import { store } from '../../_store/store'
import { insertUsername } from '../../_actions/compose'
import { insertEmojiAtPosition } from '../../_actions/emoji'
import { scheduleIdleTask } from '../../_utils/scheduleIdleTask'
import { once } from '../../_utils/once'
import ComposeAutosuggestionList from './ComposeAutosuggestionList.html'
import {
searchAccountsByUsername as searchAccountsByUsernameInDatabase
} from '../../_database/accountsAndRelationships'
import get from 'lodash-es/get'
import { selectAutosuggestItem } from '../../_actions/autosuggest'
import { observe } from 'svelte-extras'
const SEARCH_RESULTS_LIMIT = 4
const DATABASE_SEARCH_RESULTS_LIMIT = 30
const MIN_PREFIX_LENGTH = 1
const ACCOUNT_SEARCH_REGEX = new RegExp(`(?:\\s|^)(@\\S{${MIN_PREFIX_LENGTH},})$`)
const EMOJI_SEARCH_REGEX = new RegExp(`(?:\\s|^)(:[^:]{${MIN_PREFIX_LENGTH},})$`)
import { once } from '../../_utils/once'
export default {
oncreate () {
// perf improves for input responsiveness
this.observe('composeSelectionStart', () => {
scheduleIdleTask(() => {
let { composeSelectionStart } = this.get()
this.set({composeSelectionStartDeferred: composeSelectionStart})
})
})
this.observe('composeFocused', (composeFocused) => {
let updateFocusedState = () => {
scheduleIdleTask(() => {
let { composeFocused } = this.get()
this.set({composeFocusedDeferred: composeFocused})
})
}
this._promiseChain = Promise.resolve()
this.observe('shouldBeShown', (shouldBeShown) => {
// TODO: hack so that when the user clicks the button, and the textarea blurs,
// we don't immediately hide the dropdown which would cause the click to get lost
if (composeFocused) {
updateFocusedState()
} else {
Promise.race([
new Promise(resolve => setTimeout(resolve, 200)),
new Promise(resolve => this.once('autosuggestItemSelected', resolve))
]).then(updateFocusedState)
}
})
this.observe('searchText', async searchText => {
let { thisComposeFocused } = this.get()
if (!thisComposeFocused || !searchText) {
return
}
let type = searchText.startsWith('@') ? 'account' : 'emoji'
let results = (type === 'account')
? await this.searchAccounts(searchText)
: await this.searchEmoji(searchText)
this.store.set({
composeAutosuggestionSelected: 0,
composeAutosuggestionSearchText: searchText,
composeAutosuggestionSearchResults: results,
composeAutosuggestionType: type
this._promiseChain = this._promiseChain.then(() => {
if (!shouldBeShown) {
return Promise.race([
new Promise(resolve => setTimeout(resolve, 200)),
new Promise(resolve => this.once('autosuggestItemSelected', resolve))
])
}
}).then(() => {
this.set({shown: shouldBeShown})
})
})
this.observe('shown', shown => {
let { thisComposeFocused } = this.get()
if (!thisComposeFocused) {
return
}
this.store.set({composeAutosuggestionShown: shown})
})
},
methods: {
observe,
once,
onClick (item) {
this.fire('autosuggestItemSelected')
let { realm } = this.get()
let { composeSelectionStart, composeAutosuggestionSearchText } = this.store.get()
let startIndex = composeSelectionStart - composeAutosuggestionSearchText.length
let endIndex = composeSelectionStart
if (item.acct) {
/* no await */ insertUsername(realm, item.acct, startIndex, endIndex)
} else {
/* no await */ insertEmojiAtPosition(realm, item, startIndex, endIndex)
}
},
async searchAccounts (searchText) {
searchText = searchText.substring(1)
let { currentInstance } = this.store.get()
let results = await searchAccountsByUsernameInDatabase(
currentInstance, searchText, DATABASE_SEARCH_RESULTS_LIMIT)
return results.slice(0, SEARCH_RESULTS_LIMIT)
},
searchEmoji (searchText) {
searchText = searchText.toLowerCase().substring(1)
let { currentCustomEmoji } = this.store.get()
let results = currentCustomEmoji.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
selectAutosuggestItem(item)
}
},
computed: {
composeSelectionStart: ({ $composeSelectionStart }) => $composeSelectionStart,
composeFocused: ({ $composeFocused }) => $composeFocused,
thisComposeFocused: ({ composeFocusedDeferred, realm }) => composeFocusedDeferred === realm,
searchResults: ({ $composeAutosuggestionSearchResults }) => $composeAutosuggestionSearchResults || [],
type: ({ $composeAutosuggestionType }) => $composeAutosuggestionType || 'account',
selected: ({ $composeAutosuggestionSelected }) => $composeAutosuggestionSelected || 0,
searchText: ({ text, composeSelectionStartDeferred, thisComposeFocused }) => {
if (!thisComposeFocused) {
return
}
let selectionStart = composeSelectionStartDeferred
if (!text || selectionStart < MIN_PREFIX_LENGTH) {
return
}
let textUpToCursor = text.substring(0, selectionStart)
let match = textUpToCursor.match(ACCOUNT_SEARCH_REGEX) || textUpToCursor.match(EMOJI_SEARCH_REGEX)
return match && match[1]
},
shown: ({ thisComposeFocused, searchText, searchResults }) => {
return !!(thisComposeFocused &&
searchText &&
searchResults.length)
}
/* eslint-disable camelcase */
composeSelectionStart: ({ $autosuggestData_composeSelectionStart, $currentInstance, realm }) => (
get($autosuggestData_composeSelectionStart, [$currentInstance, realm], 0)
),
composeFocused: ({ $autosuggestData_composeFocused, $currentInstance, realm }) => (
get($autosuggestData_composeFocused, [$currentInstance, realm], false)
),
autosuggestSearchResults: ({ $autosuggestData_autosuggestSearchResults, $currentInstance, realm }) => (
get($autosuggestData_autosuggestSearchResults, [$currentInstance, realm], [])
),
autosuggestType: ({ $autosuggestData_autosuggestType, $currentInstance, realm }) => (
get($autosuggestData_autosuggestType, [$currentInstance, realm])
),
autosuggestSelected: ({ $autosuggestData_autosuggestSelected, $currentInstance, realm }) => (
get($autosuggestData_autosuggestSelected, [$currentInstance, realm], 0)
),
autosuggestSearchText: ({ $autosuggestData_autosuggestSelected, $currentInstance, realm }) => (
get($autosuggestData_autosuggestSelected, [$currentInstance, realm])
),
/* eslint-enable camelcase */
shouldBeShown: ({ realm, $autosuggestShown, composeFocused }) => (
!!($autosuggestShown && composeFocused)
)
},
data: () => ({
composeFocusedDeferred: void 0,
composeSelectionStartDeferred: 0
shown: false
}),
store: () => store,
components: {

View File

@ -3,7 +3,7 @@
<li class="compose-autosuggest-list-item">
<button class="compose-autosuggest-list-button {i === selected ? 'selected' : ''}"
tabindex="0"
on:click="fire('click', item)">
on:click="onClick(event, item)">
<div class="compose-autosuggest-list-grid">
{#if type === 'account'}
<Avatar
@ -102,6 +102,13 @@
export default {
store: () => store,
methods: {
onClick (event, item) {
event.preventDefault()
event.stopPropagation()
this.fire('click', item)
}
},
components: {
Avatar
}

View File

@ -33,8 +33,10 @@
import debounce from 'lodash-es/debounce'
import { mark, stop } from '../../_utils/marks'
import { selectionChange } from '../../_utils/events'
import { clickSelectedAutosuggestionUsername } from '../../_actions/compose'
import { clickSelectedAutosuggestionEmoji } from '../../_actions/emoji'
import {
clickSelectedAutosuggestionUsername,
clickSelectedAutosuggestionEmoji
} from '../../_actions/autosuggest'
import { observe } from 'svelte-extras'
export default {
@ -95,14 +97,21 @@
stop('autosize.destroy()')
},
onBlur () {
this.store.set({composeFocused: null})
scheduleIdleTask(() => {
this.store.setForCurrentAutosuggest({composeFocused: false})
})
},
onFocus () {
let { realm } = this.get()
this.store.set({composeFocused: realm})
scheduleIdleTask(() => {
let {realm} = this.get()
this.store.set({currentComposeRealm: realm})
this.store.setForCurrentAutosuggest({composeFocused: true})
})
},
onSelectionChange (selectionStart) {
this.store.set({composeSelectionStart: selectionStart})
scheduleIdleTask(() => {
this.store.setForCurrentAutosuggest({composeSelectionStart: selectionStart})
})
},
onKeydown (e) {
let { keyCode } = e
@ -132,14 +141,14 @@
},
clickSelectedAutosuggestion (event) {
let {
composeAutosuggestionShown,
composeAutosuggestionType
autosuggestShown,
autosuggestType
} = this.store.get()
if (!composeAutosuggestionShown) {
if (!autosuggestShown) {
return false
}
let { realm } = this.get()
if (composeAutosuggestionType === 'account') {
if (autosuggestType === 'account') {
/* no await */ clickSelectedAutosuggestionUsername(realm)
} else { // emoji
/* no await */ clickSelectedAutosuggestionEmoji(realm)
@ -150,33 +159,31 @@
},
incrementAutosuggestSelected (increment, event) {
let {
composeAutosuggestionShown,
composeAutosuggestionSelected,
composeAutosuggestionSearchResults
autosuggestShown,
autosuggestSelected,
autosuggestSearchResults
} = this.store.get()
if (!composeAutosuggestionShown) {
if (!autosuggestShown) {
return
}
let selected = composeAutosuggestionSelected || 0
let searchResults = composeAutosuggestionSearchResults || []
selected += increment
if (selected >= 0) {
selected = selected % searchResults.length
autosuggestSelected += increment
if (autosuggestSelected >= 0) {
autosuggestSelected = autosuggestSelected % autosuggestSearchResults.length
} else {
selected = searchResults.length + selected
autosuggestSelected = autosuggestSearchResults.length + autosuggestSelected
}
this.store.set({composeAutosuggestionSelected: selected})
this.store.setForCurrentAutosuggest({autosuggestSelected})
event.preventDefault()
event.stopPropagation()
},
clearAutosuggestions (event) {
let { composeAutosuggestionShown } = this.store.get()
if (!composeAutosuggestionShown) {
let { autosuggestShown } = this.store.get()
if (!autosuggestShown) {
return
}
this.store.set({
composeAutosuggestionSearchResults: [],
composeAutosuggestionSelected: 0
this.store.setForCurrentAutosuggest({
autosuggestSearchResults: [],
autosuggestSelected: 0
})
event.preventDefault()
event.stopPropagation()

View File

@ -0,0 +1,56 @@
const MIN_PREFIX_LENGTH = 1
const ACCOUNT_SEARCH_REGEX = new RegExp(`(?:\\s|^)(@\\S{${MIN_PREFIX_LENGTH},})$`)
const EMOJI_SEARCH_REGEX = new RegExp(`(?:\\s|^)(:[^:]{${MIN_PREFIX_LENGTH},})$`)
function computeForAutosuggest (store, key, defaultValue) {
store.compute(key,
['currentInstance', 'currentComposeRealm', `autosuggestData_${key}`],
(currentInstance, currentComposeRealm, root) => {
let instanceData = root && root[currentInstance]
return (currentComposeRealm && instanceData && currentComposeRealm in instanceData) ? instanceData[currentComposeRealm] : defaultValue
})
}
export function autosuggestComputations (store) {
computeForAutosuggest(store, 'composeFocused', false)
computeForAutosuggest(store, 'composeSelectionStart', 0)
computeForAutosuggest(store, 'autosuggestSelected', 0)
computeForAutosuggest(store, 'autosuggestSearchResults', [])
computeForAutosuggest(store, 'autosuggestType', null)
store.compute(
'currentComposeText',
['currentComposeData', 'currentComposeRealm'],
(currentComposeData, currentComposeRealm) => (
currentComposeData[currentComposeRealm] && currentComposeData[currentComposeRealm].text) || ''
)
store.compute(
'autosuggestSearchText',
['currentComposeText', 'composeSelectionStart'],
(currentComposeText, composeSelectionStart) => {
let selectionStart = composeSelectionStart
if (!currentComposeText || selectionStart < MIN_PREFIX_LENGTH) {
return ''
}
let textUpToCursor = currentComposeText.substring(0, selectionStart)
let match = textUpToCursor.match(ACCOUNT_SEARCH_REGEX) || textUpToCursor.match(EMOJI_SEARCH_REGEX)
return (match && match[1]) || ''
}
)
store.compute(
'autosuggestNumSearchResults',
['autosuggestSearchResults'],
(autosuggestSearchResults) => autosuggestSearchResults.length
)
store.compute(
'autosuggestShown',
['composeFocused', 'autosuggestSearchText', 'autosuggestNumSearchResults'],
(composeFocused, autosuggestSearchText, autosuggestNumSearchResults) => (
!!(composeFocused && autosuggestSearchText && autosuggestNumSearchResults)
)
)
}

View File

@ -1,9 +1,11 @@
import { instanceComputations } from './instanceComputations'
import { timelineComputations } from './timelineComputations'
import { navComputations } from './navComputations'
import { autosuggestComputations } from './autosuggestComputations'
export function computations (store) {
instanceComputations(store)
timelineComputations(store)
navComputations(store)
autosuggestComputations(store)
}

View File

@ -0,0 +1,19 @@
export function autosuggestMixins (Store) {
Store.prototype.setForAutosuggest = function (instanceName, realm, obj) {
let valuesToSet = {}
for (let key of Object.keys(obj)) {
let rootKey = `autosuggestData_${key}`
let root = this.get()[rootKey] || {}
let instanceData = root[instanceName] = root[instanceName] || {}
instanceData[realm] = obj[key]
valuesToSet[rootKey] = root
}
this.set(valuesToSet)
}
Store.prototype.setForCurrentAutosuggest = function (obj) {
let { currentInstance, currentComposeRealm } = this.get()
this.setForAutosuggest(currentInstance, currentComposeRealm, obj)
}
}

View File

@ -1,9 +1,11 @@
import { timelineMixins } from './timelineMixins'
import { instanceMixins } from './instanceMixins'
import { statusMixins } from './statusMixins'
import { autosuggestMixins } from './autosuggestMixins'
export function mixins (Store) {
instanceMixins(Store)
timelineMixins(Store)
statusMixins(Store)
autosuggestMixins(Store)
}

View File

@ -0,0 +1,42 @@
import {
searchAccountsByUsername as searchAccountsByUsernameInDatabase
} from '../../_database/accountsAndRelationships'
const SEARCH_RESULTS_LIMIT = 4
const DATABASE_SEARCH_RESULTS_LIMIT = 30
async function searchAccounts (store, searchText) {
searchText = searchText.substring(1)
let { currentInstance } = store.get()
let results = await searchAccountsByUsernameInDatabase(
currentInstance, searchText, DATABASE_SEARCH_RESULTS_LIMIT)
return results.slice(0, SEARCH_RESULTS_LIMIT)
}
function searchEmoji (store, searchText) {
searchText = searchText.toLowerCase().substring(1)
let { currentCustomEmoji } = store.get()
let results = currentCustomEmoji.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
}
export function autosuggestObservers (store) {
store.observe('autosuggestSearchText', async autosuggestSearchText => {
let { composeFocused } = store.get()
if (!composeFocused || !autosuggestSearchText) {
return
}
let type = autosuggestSearchText.startsWith('@') ? 'account' : 'emoji'
let results = (type === 'account')
? await searchAccounts(store, autosuggestSearchText)
: await searchEmoji(store, autosuggestSearchText)
store.setForCurrentAutosuggest({
autosuggestSelected: 0,
autosuggestSearchText: autosuggestSearchText,
autosuggestSearchResults: results,
autosuggestType: type
})
})
}

View File

@ -3,6 +3,7 @@ import { timelineObservers } from './timelineObservers'
import { notificationObservers } from './notificationObservers'
import { onlineObservers } from './onlineObservers'
import { navObservers } from './navObservers'
import { autosuggestObservers } from './autosuggestObservers'
export function observers (store) {
instanceObservers(store)
@ -10,4 +11,5 @@ export function observers (store) {
notificationObservers(store)
onlineObservers(store)
navObservers(store)
autosuggestObservers(store)
}

View File

@ -76,7 +76,9 @@ module.exports = {
}
},
plugins: [
new LodashModuleReplacementPlugin()
new LodashModuleReplacementPlugin({
paths: true
})
].concat(isDev ? [
new webpack.HotModuleReplacementPlugin({
requestTimeout: 120000