pinafore/routes/_components/compose/ComposeAutosuggest.html

176 lines
6.3 KiB
HTML

<div class="compose-autosuggest {shown ? 'shown' : ''} {realm === 'dialog' ? 'is-dialog' : ''}"
aria-hidden="true" >
<ComposeAutosuggestionList
items={searchResults}
on:click="onClick(event)"
{type}
{selected}
/>
</div>
<style>
.compose-autosuggest {
position: absolute;
left: 5px;
top: 0;
pointer-events: none;
opacity: 0;
transition: opacity 0.1s linear;
min-width: 400px;
max-width: calc(100vw - 20px);
z-index: 7000;
}
.compose-autosuggest.is-dialog {
z-index: 11000;
}
.compose-autosuggest.shown {
pointer-events: auto;
opacity: 1;
}
@media (max-width: 479px) {
.compose-autosuggest {
/* hack: move this over to the left on mobile so it's easier to see */
transform: translateX(-58px); /* avatar size 48px + 10px padding */
min-width: 0;
width: calc(100vw - 20px);
}
}
</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 { 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},})$`)
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})
})
}
// 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.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
}
},
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)
}
},
data: () => ({
composeFocusedDeferred: void 0,
composeSelectionStartDeferred: 0
}),
store: () => store,
components: {
ComposeAutosuggestionList
}
}
</script>