logo

pleroma-fe

My custom branche(s) on git.pleroma.social/pleroma/pleroma-fe
commit: 69eff65130170c0cd8fffda45b952d3bec49c218
parent: c0c012ccf9114fb5740dbaf41baa09c0d0c41ebc
Author: HJ <30-hj@users.noreply.git.pleroma.social>
Date:   Tue, 18 Jun 2019 19:17:37 +0000

Merge branch 'refactor-emoji-input' into 'develop'

EmojiInput refactoring

Closes #565

See merge request pleroma/pleroma-fe!824

Diffstat:

Msrc/App.scss51---------------------------------------------------
Msrc/boot/after_store.js13+++++++++++--
Msrc/components/emoji-input/emoji-input.js208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Msrc/components/emoji-input/emoji-input.vue141+++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Asrc/components/emoji-input/suggestor.js79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/post_status_form/post_status_form.js136+++++++++++++++++--------------------------------------------------------------
Msrc/components/post_status_form/post_status_form.vue74++++++++++++++++++++++++++++++++++----------------------------------------
Msrc/components/timeline/timeline.js2++
Msrc/components/user_settings/user_settings.js16++++++++++++++++
Msrc/components/user_settings/user_settings.vue24+++++++++++++-----------
10 files changed, 445 insertions(+), 299 deletions(-)

diff --git a/src/App.scss b/src/App.scss @@ -808,54 +808,3 @@ nav { .btn.btn-default { min-height: 28px; } - -.autocomplete { - &-panel { - position: relative; - - &-body { - margin: 0 0.5em 0 0.5em; - border-radius: $fallback--tooltipRadius; - border-radius: var(--tooltipRadius, $fallback--tooltipRadius); - position: absolute; - z-index: 1; - box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5); - // this doesn't match original but i don't care, making it uniform. - box-shadow: var(--popupShadow); - min-width: 75%; - background: $fallback--bg; - background: var(--bg, $fallback--bg); - color: $fallback--lightText; - color: var(--lightText, $fallback--lightText); - } - } - - &-item { - cursor: pointer; - padding: 0.2em 0.4em 0.2em 0.4em; - border-bottom: 1px solid rgba(0, 0, 0, 0.4); - display: flex; - - img { - width: 24px; - height: 24px; - object-fit: contain; - } - - span { - line-height: 24px; - margin: 0 0.1em 0 0.2em; - } - - small { - margin-left: .5em; - color: $fallback--faint; - color: var(--faint, $fallback--faint); - } - - &.highlighted { - background-color: $fallback--fg; - background-color: var(--lightBg, $fallback--fg); - } - } -} diff --git a/src/boot/after_store.js b/src/boot/after_store.js @@ -154,7 +154,11 @@ const getStaticEmoji = async ({ store }) => { if (res.ok) { const values = await res.json() const emoji = Object.keys(values).map((key) => { - return { shortcode: key, image_url: false, 'utf': values[key] } + return { + displayText: key, + imageUrl: false, + replacement: values[key] + } }) store.dispatch('setInstanceOption', { name: 'emoji', value: emoji }) } else { @@ -175,7 +179,12 @@ const getCustomEmoji = async ({ store }) => { const result = await res.json() const values = Array.isArray(result) ? Object.assign({}, ...result) : result const emoji = Object.keys(values).map((key) => { - return { shortcode: key, image_url: values[key].image_url || values[key] } + const imageUrl = values[key].image_url + return { + displayText: key, + imageUrl: imageUrl ? store.state.instance.server + imageUrl : values[key], + replacement: `:${key}: ` + } }) store.dispatch('setInstanceOption', { name: 'customEmoji', value: emoji }) store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: true }) diff --git a/src/components/emoji-input/emoji-input.js b/src/components/emoji-input/emoji-input.js @@ -1,51 +1,119 @@ import Completion from '../../services/completion/completion.js' -import { take, filter, map } from 'lodash' +import { take } from 'lodash' + +/** + * EmojiInput - augmented inputs for emoji and autocomplete support in inputs + * without having to give up the comfort of <input/> and <textarea/> elements + * + * Intended usage is: + * <EmojiInput v-model="something"> + * <input v-model="something"/> + * </EmojiInput> + * + * Works only with <input> and <textarea>. Intended to use with only one nested + * input. It will find first input or textarea and work with that, multiple + * nested children not tested. You HAVE TO duplicate v-model for both + * <emoji-input> and <input>/<textarea> otherwise it will not work. + * + * Be prepared for CSS troubles though because it still wraps component in a div + * while TRYING to make it look like nothing happened, but it could break stuff. + */ const EmojiInput = { - props: [ - 'value', - 'placeholder', - 'type', - 'classname' - ], + props: { + suggest: { + /** + * suggest: function (input: String) => Suggestion[] + * + * Function that takes input string which takes string (textAtCaret) + * and returns an array of Suggestions + * + * Suggestion is an object containing following properties: + * displayText: string. Main display text, what actual suggestion + * represents (user's screen name/emoji shortcode) + * replacement: string. Text that should replace the textAtCaret + * detailText: string, optional. Subtitle text, providing additional info + * if present (user's nickname) + * imageUrl: string, optional. Image to display alongside with suggestion, + * currently if no image is provided, replacement will be used (for + * unicode emojis) + * + * TODO: make it asynchronous when adding proper server-provided user + * suggestions + * + * For commonly used suggestors (emoji, users, both) use suggestor.js + */ + required: true, + type: Function + }, + value: { + /** + * Used for v-model + */ + required: true, + type: String + } + }, data () { return { + input: undefined, highlighted: 0, - caret: 0 + caret: 0, + focused: false } }, computed: { suggestions () { const firstchar = this.textAtCaret.charAt(0) - if (firstchar === ':') { - if (this.textAtCaret === ':') { return } - const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1))) - if (matchedEmoji.length <= 0) { - return false - } - return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({ - shortcode: `:${shortcode}:`, - utf: utf || '', + if (this.textAtCaret === firstchar) { return [] } + const matchedSuggestions = this.suggest(this.textAtCaret) + if (matchedSuggestions.length <= 0) { + return [] + } + return take(matchedSuggestions, 5) + .map(({ imageUrl, ...rest }, index) => ({ + ...rest, // eslint-disable-next-line camelcase - img: utf ? '' : this.$store.state.instance.server + image_url, + img: imageUrl || '', highlighted: index === this.highlighted })) - } else { - return false - } + }, + showPopup () { + return this.focused && this.suggestions && this.suggestions.length > 0 }, textAtCaret () { return (this.wordAtCaret || {}).word || '' }, wordAtCaret () { - const word = Completion.wordAtPosition(this.value, this.caret - 1) || {} - return word - }, - emoji () { - return this.$store.state.instance.emoji || [] - }, - customEmoji () { - return this.$store.state.instance.customEmoji || [] + if (this.value && this.caret) { + const word = Completion.wordAtPosition(this.value, this.caret - 1) || {} + return word + } + } + }, + mounted () { + const slots = this.$slots.default + if (!slots || slots.length === 0) return + const input = slots.find(slot => ['input', 'textarea'].includes(slot.tag)) + if (!input) return + this.input = input + this.resize() + input.elm.addEventListener('blur', this.onBlur) + input.elm.addEventListener('focus', this.onFocus) + input.elm.addEventListener('paste', this.onPaste) + input.elm.addEventListener('keyup', this.onKeyUp) + input.elm.addEventListener('keydown', this.onKeyDown) + input.elm.addEventListener('transitionend', this.onTransition) + }, + unmounted () { + const { input } = this + if (input) { + input.elm.removeEventListener('blur', this.onBlur) + input.elm.removeEventListener('focus', this.onFocus) + input.elm.removeEventListener('paste', this.onPaste) + input.elm.removeEventListener('keyup', this.onKeyUp) + input.elm.removeEventListener('keydown', this.onKeyDown) + input.elm.removeEventListener('transitionend', this.onTransition) } }, methods: { @@ -54,27 +122,35 @@ const EmojiInput = { this.$emit('input', newValue) this.caret = 0 }, - replaceEmoji (e) { + replaceText (e) { const len = this.suggestions.length || 0 - if (this.textAtCaret === ':' || e.ctrlKey) { return } + if (this.textAtCaret.length === 1) { return } if (len > 0) { - e.preventDefault() - const emoji = this.suggestions[this.highlighted] - const replacement = emoji.utf || (emoji.shortcode + ' ') + const suggestion = this.suggestions[this.highlighted] + const replacement = suggestion.replacement const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement) this.$emit('input', newValue) - this.caret = 0 this.highlighted = 0 + const position = this.wordAtCaret.start + replacement.length + + this.$nextTick(function () { + // Re-focus inputbox after clicking suggestion + this.input.elm.focus() + // Set selection right after the replacement instead of the very end + this.input.elm.setSelectionRange(position, position) + this.caret = position + }) + e.preventDefault() } }, cycleBackward (e) { const len = this.suggestions.length || 0 if (len > 0) { - e.preventDefault() this.highlighted -= 1 if (this.highlighted < 0) { this.highlighted = this.suggestions.length - 1 } + e.preventDefault() } else { this.highlighted = 0 } @@ -82,24 +158,74 @@ const EmojiInput = { cycleForward (e) { const len = this.suggestions.length || 0 if (len > 0) { - if (e.shiftKey) { return } - e.preventDefault() this.highlighted += 1 if (this.highlighted >= len) { this.highlighted = 0 } + e.preventDefault() } else { this.highlighted = 0 } }, - onKeydown (e) { - e.stopPropagation() + onTransition (e) { + this.resize() + }, + onBlur (e) { + // Clicking on any suggestion removes focus from autocomplete, + // preventing click handler ever executing. + setTimeout(() => { + this.focused = false + this.setCaret(e) + this.resize() + }, 200) + }, + onFocus (e) { + this.focused = true + this.setCaret(e) + this.resize() + }, + onKeyUp (e) { + this.setCaret(e) + this.resize() + }, + onPaste (e) { + this.setCaret(e) + this.resize() + }, + onKeyDown (e) { + this.setCaret(e) + this.resize() + + const { ctrlKey, shiftKey, key } = e + if (key === 'Tab') { + if (shiftKey) { + this.cycleBackward(e) + } else { + this.cycleForward(e) + } + } + if (key === 'ArrowUp') { + this.cycleBackward(e) + } else if (key === 'ArrowDown') { + this.cycleForward(e) + } + if (key === 'Enter') { + if (!ctrlKey) { + this.replaceText(e) + } + } }, onInput (e) { this.$emit('input', e.target.value) }, - setCaret ({target: {selectionStart}}) { + setCaret ({ target: { selectionStart } }) { this.caret = selectionStart + }, + resize () { + const { panel } = this.$refs + if (!panel) return + const { offsetHeight, offsetTop } = this.input.elm + this.$refs.panel.style.top = (offsetTop + offsetHeight) + 'px' } } } diff --git a/src/components/emoji-input/emoji-input.vue b/src/components/emoji-input/emoji-input.vue @@ -1,54 +1,27 @@ <template> - <div class="emoji-input"> - <input - v-if="type !== 'textarea'" - :class="classname" - :type="type" - :value="value" - :placeholder="placeholder" - @input="onInput" - @click="setCaret" - @keyup="setCaret" - @keydown="onKeydown" - @keydown.down="cycleForward" - @keydown.up="cycleBackward" - @keydown.shift.tab="cycleBackward" - @keydown.tab="cycleForward" - @keydown.enter="replaceEmoji" - /> - <textarea - v-else - :class="classname" - :value="value" - :placeholder="placeholder" - @input="onInput" - @click="setCaret" - @keyup="setCaret" - @keydown="onKeydown" - @keydown.down="cycleForward" - @keydown.up="cycleBackward" - @keydown.shift.tab="cycleBackward" - @keydown.tab="cycleForward" - @keydown.enter="replaceEmoji" - ></textarea> - <div class="autocomplete-panel" v-if="suggestions"> - <div class="autocomplete-panel-body"> - <div - v-for="(emoji, index) in suggestions" - :key="index" - @click="replace(emoji.utf || (emoji.shortcode + ' '))" - class="autocomplete-item" - :class="{ highlighted: emoji.highlighted }" +<div class="emoji-input"> + <slot></slot> + <div ref="panel" class="autocomplete-panel" :class="{ hide: !showPopup }"> + <div class="autocomplete-panel-body"> + <div + v-for="(suggestion, index) in suggestions" + :key="index" + @click.stop.prevent="replaceText" + class="autocomplete-item" + :class="{ highlighted: suggestion.highlighted }" > - <span v-if="emoji.img"> - <img :src="emoji.img" /> - </span> - <span v-else>{{emoji.utf}}</span> - <span>{{emoji.shortcode}}</span> + <span class="image"> + <img v-if="suggestion.img":src="suggestion.img" /> + <span v-else>{{suggestion.replacement}}</span> + </span> + <div class="label"> + <span class="displayText">{{suggestion.displayText}}</span> + <span class="detailText">{{suggestion.detailText}}</span> </div> </div> </div> </div> +</div> </template> <script src="./emoji-input.js"></script> @@ -57,8 +30,82 @@ @import '../../_variables.scss'; .emoji-input { - .form-control { - width: 100%; + display: flex; + flex-direction: column; + + .autocomplete { + &-panel { + position: absolute; + z-index: 9; + margin-top: 2px; + + &.hide { + display: none + } + + &-body { + margin: 0 0.5em 0 0.5em; + border-radius: $fallback--tooltipRadius; + border-radius: var(--tooltipRadius, $fallback--tooltipRadius); + box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5); + box-shadow: var(--popupShadow); + min-width: 75%; + background: $fallback--bg; + background: var(--bg, $fallback--bg); + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); + } + } + + &-item { + display: flex; + cursor: pointer; + padding: 0.2em 0.4em; + border-bottom: 1px solid rgba(0, 0, 0, 0.4); + height: 32px; + + .image { + width: 32px; + height: 32px; + line-height: 32px; + text-align: center; + font-size: 32px; + + margin-right: 4px; + + img { + width: 32px; + height: 32px; + object-fit: contain; + } + } + + .label { + display: flex; + flex-direction: column; + justify-content: center; + margin: 0 0.1em 0 0.2em; + + .displayText { + line-height: 1.5; + } + + .detailText { + font-size: 9px; + line-height: 9px; + } + } + + &.highlighted { + background-color: $fallback--fg; + background-color: var(--lightBg, $fallback--fg); + } + } + } + + + input, textarea { + flex: 1 0 auto; } } </style> diff --git a/src/components/emoji-input/suggestor.js b/src/components/emoji-input/suggestor.js @@ -0,0 +1,79 @@ +/** + * suggest - generates a suggestor function to be used by emoji-input + * data: object providing source information for specific types of suggestions: + * data.emoji - optional, an array of all emoji available i.e. + * (state.instance.emoji + state.instance.customEmoji) + * data.users - optional, an array of all known users + * + * Depending on data present one or both (or none) can be present, so if field + * doesn't support user linking you can just provide only emoji. + */ +export default data => input => { + const firstChar = input[0] + if (firstChar === ':' && data.emoji) { + return suggestEmoji(data.emoji)(input) + } + if (firstChar === '@' && data.users) { + return suggestUsers(data.users)(input) + } + return [] +} + +export const suggestEmoji = emojis => input => { + const noPrefix = input.toLowerCase().substr(1) + return emojis + .filter(({ displayText }) => displayText.toLowerCase().startsWith(noPrefix)) + .sort((a, b) => { + let aScore = 0 + let bScore = 0 + + // Make custom emojis a priority + aScore += a.imageUrl ? 10 : 0 + bScore += b.imageUrl ? 10 : 0 + + // Sort alphabetically + const alphabetically = a.displayText > b.displayText ? 1 : -1 + + return bScore - aScore + alphabetically + }) +} + +export const suggestUsers = users => input => { + const noPrefix = input.toLowerCase().substr(1) + return users.filter( + user => + user.screen_name.toLowerCase().startsWith(noPrefix) || + user.name.toLowerCase().startsWith(noPrefix) + + /* taking only 20 results so that sorting is a bit cheaper, we display + * only 5 anyway. could be inaccurate, but we ideally we should query + * backend anyway + */ + ).slice(0, 20).sort((a, b) => { + let aScore = 0 + let bScore = 0 + + // Matches on screen name (i.e. user@instance) makes a priority + aScore += a.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0 + bScore += b.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0 + + // Matches on name takes second priority + aScore += a.name.toLowerCase().startsWith(noPrefix) ? 1 : 0 + bScore += b.name.toLowerCase().startsWith(noPrefix) ? 1 : 0 + + const diff = (bScore - aScore) * 10 + + // Then sort alphabetically + const nameAlphabetically = a.name > b.name ? 1 : -1 + const screenNameAlphabetically = a.screen_name > b.screen_name ? 1 : -1 + + return diff + nameAlphabetically + screenNameAlphabetically + /* eslint-disable camelcase */ + }).map(({ screen_name, name, profile_image_url_original }) => ({ + displayText: screen_name, + detailText: name, + imageUrl: profile_image_url_original, + replacement: '@' + screen_name + ' ' + })) + /* eslint-enable camelcase */ +} diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js @@ -3,16 +3,16 @@ import MediaUpload from '../media_upload/media_upload.vue' import ScopeSelector from '../scope_selector/scope_selector.vue' import EmojiInput from '../emoji-input/emoji-input.vue' import fileTypeService from '../../services/file_type/file_type.service.js' -import Completion from '../../services/completion/completion.js' -import { take, filter, reject, map, uniqBy } from 'lodash' +import { reject, map, uniqBy } from 'lodash' +import suggestor from '../emoji-input/suggestor.js' -const buildMentionsString = ({user, attentions}, currentUser) => { +const buildMentionsString = ({ user, attentions }, currentUser) => { let allAttentions = [...attentions] allAttentions.unshift(user) allAttentions = uniqBy(allAttentions, 'id') - allAttentions = reject(allAttentions, {id: currentUser.id}) + allAttentions = reject(allAttentions, { id: currentUser.id }) let mentions = map(allAttentions, (attention) => { return `@${attention.screen_name}` @@ -48,17 +48,17 @@ const PostStatusForm = { let statusText = preset || '' const scopeCopy = typeof this.$store.state.config.scopeCopy === 'undefined' - ? this.$store.state.instance.scopeCopy - : this.$store.state.config.scopeCopy + ? this.$store.state.instance.scopeCopy + : this.$store.state.config.scopeCopy if (this.replyTo) { const currentUser = this.$store.state.users.currentUser statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser) } - const scope = (this.copyMessageScope && scopeCopy || this.copyMessageScope === 'direct') - ? this.copyMessageScope - : this.$store.state.users.currentUser.default_scope + const scope = ((this.copyMessageScope && scopeCopy) || this.copyMessageScope === 'direct') + ? this.copyMessageScope + : this.$store.state.users.currentUser.default_scope const contentType = typeof this.$store.state.config.postContentType === 'undefined' ? this.$store.state.instance.postContentType @@ -82,50 +82,6 @@ const PostStatusForm = { } }, computed: { - candidates () { - const firstchar = this.textAtCaret.charAt(0) - if (firstchar === '@') { - const query = this.textAtCaret.slice(1).toUpperCase() - const matchedUsers = filter(this.users, (user) => { - return user.screen_name.toUpperCase().startsWith(query) || - user.name && user.name.toUpperCase().startsWith(query) - }) - if (matchedUsers.length <= 0) { - return false - } - // eslint-disable-next-line camelcase - return map(take(matchedUsers, 5), ({screen_name, name, profile_image_url_original}, index) => ({ - // eslint-disable-next-line camelcase - screen_name: `@${screen_name}`, - name: name, - img: profile_image_url_original, - highlighted: index === this.highlighted - })) - } else if (firstchar === ':') { - if (this.textAtCaret === ':') { return } - const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1))) - if (matchedEmoji.length <= 0) { - return false - } - return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({ - screen_name: `:${shortcode}:`, - name: '', - utf: utf || '', - // eslint-disable-next-line camelcase - img: utf ? '' : this.$store.state.instance.server + image_url, - highlighted: index === this.highlighted - })) - } else { - return false - } - }, - textAtCaret () { - return (this.wordAtCaret || {}).word || '' - }, - wordAtCaret () { - const word = Completion.wordAtPosition(this.newStatus.status, this.caret - 1) || {} - return word - }, users () { return this.$store.state.users.users }, @@ -134,10 +90,27 @@ const PostStatusForm = { }, showAllScopes () { const minimalScopesMode = typeof this.$store.state.config.minimalScopesMode === 'undefined' - ? this.$store.state.instance.minimalScopesMode - : this.$store.state.config.minimalScopesMode + ? this.$store.state.instance.minimalScopesMode + : this.$store.state.config.minimalScopesMode return !minimalScopesMode }, + emojiUserSuggestor () { + return suggestor({ + emoji: [ + ...this.$store.state.instance.emoji, + ...this.$store.state.instance.customEmoji + ], + users: this.$store.state.users.users + }) + }, + emojiSuggestor () { + return suggestor({ + emoji: [ + ...this.$store.state.instance.emoji, + ...this.$store.state.instance.customEmoji + ] + }) + }, emoji () { return this.$store.state.instance.emoji || [] }, @@ -185,57 +158,6 @@ const PostStatusForm = { } }, methods: { - replace (replacement) { - this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement) - const el = this.$el.querySelector('textarea') - el.focus() - this.caret = 0 - }, - replaceCandidate (e) { - const len = this.candidates.length || 0 - if (this.textAtCaret === ':' || e.ctrlKey) { return } - if (len > 0) { - e.preventDefault() - const candidate = this.candidates[this.highlighted] - const replacement = candidate.utf || (candidate.screen_name + ' ') - this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement) - const el = this.$el.querySelector('textarea') - el.focus() - this.caret = 0 - this.highlighted = 0 - } - }, - cycleBackward (e) { - const len = this.candidates.length || 0 - if (len > 0) { - e.preventDefault() - this.highlighted -= 1 - if (this.highlighted < 0) { - this.highlighted = this.candidates.length - 1 - } - } else { - this.highlighted = 0 - } - }, - cycleForward (e) { - const len = this.candidates.length || 0 - if (len > 0) { - if (e.shiftKey) { return } - e.preventDefault() - this.highlighted += 1 - if (this.highlighted >= len) { - this.highlighted = 0 - } - } else { - this.highlighted = 0 - } - }, - onKeydown (e) { - e.stopPropagation() - }, - setCaret ({target: {selectionStart}}) { - this.caret = selectionStart - }, postStatus (newStatus) { if (this.posting) { return } if (this.submitDisabled) { return } @@ -314,7 +236,7 @@ const PostStatusForm = { }, fileDrop (e) { if (e.dataTransfer.files.length > 0) { - e.preventDefault() // allow dropping text like before + e.preventDefault() // allow dropping text like before this.dropFiles = e.dataTransfer.files } }, diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue @@ -33,30 +33,39 @@ </p> <EmojiInput v-if="newStatus.spoilerText || alwaysShowSubject" - type="text" - :placeholder="$t('post_status.content_warning')" + :suggest="emojiSuggestor" v-model="newStatus.spoilerText" - classname="form-control" - /> - <textarea - ref="textarea" - @click="setCaret" - @keyup="setCaret" v-model="newStatus.status" :placeholder="$t('post_status.default')" rows="1" class="form-control" - @keydown="onKeydown" - @keydown.down="cycleForward" - @keydown.up="cycleBackward" - @keydown.shift.tab="cycleBackward" - @keydown.tab="cycleForward" - @keydown.enter="replaceCandidate" - @keydown.meta.enter="postStatus(newStatus)" - @keyup.ctrl.enter="postStatus(newStatus)" - @drop="fileDrop" - @dragover.prevent="fileDrag" - @input="resize" - @paste="paste" - :disabled="posting" - > - </textarea> + class="form-control" + > + <input + + type="text" + :placeholder="$t('post_status.content_warning')" + v-model="newStatus.spoilerText" + class="form-post-subject" + /> + </EmojiInput> + <EmojiInput + :suggest="emojiUserSuggestor" + v-model="newStatus.status" + class="form-control" + > + <textarea + ref="textarea" + v-model="newStatus.status" + :placeholder="$t('post_status.default')" + rows="1" + @keydown.meta.enter="postStatus(newStatus)" + @keyup.ctrl.enter="postStatus(newStatus)" + @drop="fileDrop" + @dragover.prevent="fileDrag" + @input="resize" + @paste="paste" + :disabled="posting" + class="form-post-body" + > + </textarea> + </EmojiInput> <div class="visibility-tray"> <div class="text-format" v-if="postFormats.length > 1"> <label for="post-content-type" class="select"> @@ -82,21 +91,6 @@ :onScopeChange="changeVis"/> </div> </div> - <div class="autocomplete-panel" v-if="candidates"> - <div class="autocomplete-panel-body"> - <div - v-for="(candidate, index) in candidates" - :key="index" - @click="replace(candidate.utf || (candidate.screen_name + ' '))" - class="autocomplete-item" - :class="{ highlighted: candidate.highlighted }" - > - <span v-if="candidate.img"><img :src="candidate.img" /></span> - <span v-else>{{candidate.utf}}</span> - <span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span> - </div> - </div> - </div> <div class='form-bottom'> <media-upload ref="mediaUpload" @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload> @@ -276,7 +270,7 @@ min-height: 1px; } - form textarea.form-control { + .form-post-body { line-height:16px; resize: none; overflow: hidden; @@ -285,7 +279,7 @@ box-sizing: content-box; } - form textarea.form-control:focus { + .form-post-body:focus { min-height: 48px; } diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js @@ -78,6 +78,8 @@ const Timeline = { }, methods: { handleShortKey (e) { + // Ignore when input fields are focused + if (['textarea', 'input'].includes(e.target.tagName.toLowerCase())) return if (e.key === '.') this.showNewStatuses() }, showNewStatuses () { diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js @@ -12,6 +12,7 @@ import MuteCard from '../mute_card/mute_card.vue' import SelectableList from '../selectable_list/selectable_list.vue' import ProgressButton from '../progress_button/progress_button.vue' import EmojiInput from '../emoji-input/emoji-input.vue' +import suggestor from '../emoji-input/suggestor.js' import Autosuggest from '../autosuggest/autosuggest.vue' import Importer from '../importer/importer.vue' import Exporter from '../exporter/exporter.vue' @@ -85,6 +86,21 @@ const UserSettings = { user () { return this.$store.state.users.currentUser }, + emojiUserSuggestor () { + return suggestor({ + emoji: [ + ...this.$store.state.instance.emoji, + ...this.$store.state.instance.customEmoji + ], + users: this.$store.state.users.users + }) + }, + emojiSuggestor () { + return suggestor({ emoji: [ + ...this.$store.state.instance.emoji, + ...this.$store.state.instance.customEmoji + ]}) + }, pleromaBackend () { return this.$store.state.instance.pleromaBackend }, diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue @@ -22,18 +22,20 @@ <div class="setting-item" > <h2>{{$t('settings.name_bio')}}</h2> <p>{{$t('settings.name')}}</p> - <EmojiInput - type="text" - v-model="newName" - id="username" - classname="name-changer" - /> + <EmojiInput :suggest="emojiSuggestor" v-model="newName"> + <input + v-model="newName" + id="username" + classname="name-changer" + /> + </EmojiInput> <p>{{$t('settings.bio')}}</p> - <EmojiInput - type="textarea" - v-model="newBio" - classname="bio" - /> + <EmojiInput :suggest="emojiUserSuggestor" v-model="newBio"> + <textarea + v-model="newBio" + classname="bio" + /> + </EmojiInput> <p> <input type="checkbox" v-model="newLocked" id="account-locked"> <label for="account-locked">{{$t('settings.lock_account_description')}}</label>