Merge branch 'emoji-selector-update' into shigusegubu

* emoji-selector-update:
  autoscroll post form on typing + some minor improvements
  split spam mode into two separate options (one in settings page)
  Apply suggestion to src/components/emoji_input/emoji_input.js
  changelog
  Update docs/USER_GUIDE.md
  docs update
  unit test for emoji input, for now covering only insertion mechanism
This commit is contained in:
Henry Jameson 2019-09-23 22:13:39 +03:00
commit 3364c8fe01
14 changed files with 204 additions and 25 deletions

13
CHANGELOG.md Normal file
View file

@ -0,0 +1,13 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased]
### Added
- Emoji picker
- Started changelog anew
### Changed
- changed the way fading effects for user profile/long statuses works, now uses css-mask instead of gradient background hacks which weren't exactly compatible with semi-transparent themes
### Fixed
- improved hotkey behavior on autocomplete popup

View file

@ -23,6 +23,15 @@ Posts will contain the text you are posting, but some content will be modified:
**Depending on your instance some of the options might not be available or have different defaults**
Let's clear up some basic stuff. When you post something it's called a **post** or it could be called a **status** or even a **toot** or a **prööt** depending on whom you ask. Post has body/content but it also has some other stuff in it - from attachments, visibility scope, subject line.
* **Emoji** are small images embedded in text, there are two major types of emoji: [unicode emoji](https://en.wikipedia.org/wiki/Emoji) and custom emoji. While unicode emoji are universal and standardized, they can appear differently depending on where you are using them or may not appear at all on older systems. Custom emoji are more *fun* kind - instance administrator can define many images as *custom emoji* for their users. This works very simple - custom emoji is defined by its *shortcode* and an image, so that any shortcode enclosed in colons get replaced with image if such shortcode exist.
Let's say there's `:pleroma:` emoji defined on instance. That means
> First time using :pleroma: pleroma!
will become
> First time using ![pleroma](./example_emoji.png) pleroma!
Note that you can only use emoji defined on your instance, you cannot "copy" someone else's emoji, and will have to ask your administrator to copy emoji from other instance to yours.
Lastly, there's two convenience options for emoji: an emoji picker (smiley face to the right of "submit" button) and autocomplete suggestions - when you start typing :shortcode: it will automatically try to suggest you emoj and complete the shortcode for you if you select one. **Note** that if emoji doesn't show up in suggestions nor in emoji picker it means there's no such emoji on your instance, if shortcode doesn't match any defined emoji it will appear as text.
* **Attachments** are fairly simple - you can attach any file to a post as long as the file is within maximum size limits. If you're uploading explicit material you can mark all of your attachments as sensitive (or add `#nsfw` tag) - it will hide the images and videos behind a warning so that it won't be displayed instantly.
* **Subject line** also known as **CW** (Content Warning) could be used as a header to the post and/or to warn others about contents of the post having something that might upset somebody or something among those lines. Several applications allow to hide post content leaving only subject line visible. As a side-effect using subject line will also mark your images as sensitive (see above).
* **Visiblity scope** controls who will be able to see your posts. There are four scopes available:

BIN
docs/example_emoji.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 B

View file

@ -64,7 +64,6 @@ const EmojiInput = {
},
hideEmojiButton: {
/**
enableStickerPicker: {
* intended to use with external picker trigger, i.e. you have a button outside
* input that will open up the picker, see triggerShowPicker()
*/
@ -90,7 +89,7 @@ const EmojiInput = {
blurTimeout: null,
showPicker: false,
temporarilyHideSuggestions: false,
spamMode: false,
keepOpen: false,
disableClickOutside: false
}
},
@ -98,6 +97,9 @@ const EmojiInput = {
EmojiPicker
},
computed: {
padEmoji () {
return this.$store.state.config.padEmoji
},
suggestions () {
const firstchar = this.textAtCaret.charAt(0)
if (this.textAtCaret === firstchar) { return [] }
@ -177,7 +179,7 @@ const EmojiInput = {
this.$emit('input', newValue)
this.caret = 0
},
insert ({ insertion, spamMode }) {
insert ({ insertion, keepOpen }) {
const before = this.value.substring(0, this.caret) || ''
const after = this.value.substring(this.caret) || ''
@ -196,8 +198,8 @@ const EmojiInput = {
* them, masto seem to be rendering :emoji::emoji: correctly now so why not
*/
const isSpaceRegex = /\s/
const spaceBefore = !isSpaceRegex.exec(before.slice(-1)) && before.length && !spamMode > 0 ? ' ' : ''
const spaceAfter = !isSpaceRegex.exec(after[0]) && !spamMode ? ' ' : ''
const spaceBefore = !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0 ? ' ' : ''
const spaceAfter = !isSpaceRegex.exec(after[0]) && this.padEmoji ? ' ' : ''
const newValue = [
before,
@ -206,13 +208,15 @@ const EmojiInput = {
spaceAfter,
after
].join('')
this.spamMode = spamMode
this.keepOpen = keepOpen
this.$emit('input', newValue)
const position = this.caret + (insertion + spaceAfter + spaceBefore).length
if (!keepOpen) {
this.input.elm.focus()
}
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
@ -284,7 +288,7 @@ const EmojiInput = {
this.blurTimeout = null
}
if (!this.spamMode) {
if (!this.keepOpen) {
this.showPicker = false
}
this.focused = true

View file

@ -18,7 +18,7 @@ const EmojiPicker = {
activeGroup: 'custom',
showingStickers: false,
groupsScrolledClass: 'scrolled-top',
spamMode: false
keepOpen: false
}
},
components: {
@ -27,7 +27,7 @@ const EmojiPicker = {
methods: {
onEmoji (emoji) {
const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement
this.$emit('emoji', { insertion: value, spamMode: this.spamMode })
this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen })
},
highlight (key) {
const ref = this.$refs['group-' + key]

View file

@ -10,11 +10,11 @@
margin: 0 !important;
z-index: 1;
.spam-mode {
.keep-open {
padding: 7px;
line-height: normal;
}
.spam-mode-label {
.keep-open-label {
padding: 0 7px;
display: flex;
}

View file

@ -76,16 +76,16 @@
</div>
</div>
<div
class="spam-mode"
class="keep-open"
>
<input
:id="labelKey + 'spam-mode'"
v-model="spamMode"
:id="labelKey + 'keep-open'"
v-model="keepOpen"
type="checkbox"
>
<label class="spam-mode-label" :for="labelKey + 'spam-mode'">
<div class="spam-mode-label-text">
{{ $t('emoji.spam') }}
<label class="keep-open-label" :for="labelKey + 'keep-open'">
<div class="keep-open-label-text">
{{ $t('emoji.keep_open') }}
</div>
</label>
</div>

View file

@ -249,6 +249,7 @@ const PostStatusForm = {
return fileTypeService.fileType(fileInfo.mimetype)
},
paste (e) {
this.resize()
if (e.clipboardData.files.length > 0) {
// prevent pasting of file as text
e.preventDefault()
@ -267,6 +268,11 @@ const PostStatusForm = {
fileDrag (e) {
e.dataTransfer.dropEffect = 'copy'
},
onEmojiInputInput (e) {
this.$nextTick(() => {
this.resize(this.$refs['textarea'])
})
},
resize (e) {
const target = e.target || e
if (!(target instanceof window.Element)) { return }
@ -275,12 +281,25 @@ const PostStatusForm = {
// Remove "px" at the end of the values
const vertPadding = Number(topPaddingStr.substr(0, topPaddingStr.length - 2)) +
Number(bottomPaddingStr.substr(0, bottomPaddingStr.length - 2))
const oldValue = Number((/([0-9.]+)px/.exec(target.style.height || '') || [])[1])
// Auto is needed to make textbox shrink when removing lines
target.style.height = 'auto'
target.style.height = `${target.scrollHeight - vertPadding}px`
const newValue = target.scrollHeight - vertPadding
target.style.height = `${newValue}px`
const scroller = this.$el.closest('.sidebar-scroller') ||
this.$el.closest('.post-form-modal-view') ||
window
const delta = newValue - oldValue || 0
if (target.value === '') {
target.style.height = null
} else {
/* For some reason this doens't _exactly_ work on mobile post form when typing
* but it works when adding emojis. Supposedly, removing the "height = auto"
* line helps with that but it obviously breaks the autoheight.
*/
scroller.scrollBy(0, delta)
}
this.$refs['emoji-input'].resize()
},
showEmojiPicker () {
this.$refs['textarea'].focus()

View file

@ -81,6 +81,7 @@
enable-emoji-picker
hide-emoji-button
enable-sticker-picker
@input="onEmojiInputInput"
@sticker-uploaded="addMediaFile"
@sticker-upload-failed="uploadFailed"
>
@ -95,7 +96,8 @@
@keyup.ctrl.enter="postStatus(newStatus)"
@drop="fileDrop"
@dragover.prevent="fileDrag"
@input="resize"
@keydown.exact="resize"
@compositionupdate="resize"
@paste="paste"
/>
<p
@ -489,10 +491,6 @@
box-sizing: content-box;
}
.form-post-body:focus {
min-height: 48px;
}
.main-input {
position: relative;
}

View file

@ -16,6 +16,7 @@ const settings = {
return {
hideAttachmentsLocal: user.hideAttachments,
padEmojiLocal: user.padEmoji,
hideAttachmentsInConvLocal: user.hideAttachmentsInConv,
maxThumbnails: user.maxThumbnails,
hideNsfwLocal: user.hideNsfw,
@ -127,6 +128,9 @@ const settings = {
hideAttachmentsLocal (value) {
this.$store.dispatch('setOption', { name: 'hideAttachments', value })
},
padEmojiLocal (value) {
this.$store.dispatch('setOption', { name: 'padEmoji', value })
},
hideAttachmentsInConvLocal (value) {
this.$store.dispatch('setOption', { name: 'hideAttachmentsInConv', value })
},

View file

@ -198,6 +198,14 @@
>
<label for="autohideFloatingPostButton">{{ $t('settings.autohide_floating_post_button') }}</label>
</li>
<li>
<input
id="padEmoji"
v-model="padEmojiLocal"
type="checkbox"
>
<label for="padEmoji">{{ $t('settings.pad_emoji') }}</label>
</li>
</ul>
</div>

View file

@ -109,7 +109,7 @@
"emoji": {
"stickers": "Stickers",
"emoji": "Emoji",
"spam": "Keep picker open, don't separate emoji with spaces",
"keep_open": "Keep picker open",
"search_emoji": "Search for an emoji",
"add_emoji": "Insert emoji",
"custom": "Custom emoji",
@ -232,6 +232,7 @@
"delete_account_error": "There was an issue deleting your account. If this persists please contact your instance administrator.",
"delete_account_instructions": "Type your password in the input below to confirm account deletion.",
"avatar_size_instruction": "The recommended minimum size for avatar images is 150x150 pixels.",
"pad_emoji": "Pad emoji with spaces when adding from picker",
"export_theme": "Save preset",
"filtering": "Filtering",
"filtering_explanation": "All statuses containing these words will be muted, one per line",

View file

@ -7,6 +7,7 @@ const defaultState = {
colors: {},
hideMutedPosts: undefined, // instance default
collapseMessageWithSubject: undefined, // instance default
padEmoji: true,
hideAttachments: false,
hideAttachmentsInConv: false,
maxThumbnails: 16,

View file

@ -0,0 +1,122 @@
import { shallowMount, createLocalVue } from '@vue/test-utils'
import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
const generateInput = (value) => {
const localVue = createLocalVue()
localVue.directive('click-outside', () => {})
const wrapper = shallowMount(EmojiInput, {
propsData: {
suggest: () => [],
enableEmojiPicker: true,
value
},
slots: {
default: '<input />'
},
localVue
})
return [wrapper, localVue]
}
describe('EmojiInput', () => {
describe('insertion mechanism', () => {
it('inserts string at the end with trailing space', () => {
const initialString = 'Testing'
const [wrapper] = generateInput(initialString)
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: initialString.length })
wrapper.vm.insert({ insertion: '(test)', keepOpen: false })
expect(wrapper.emitted().input[0][0]).to.eql('Testing (test) ')
})
it('inserts string at the end with trailing space (source has a trailing space)', () => {
const initialString = 'Testing '
const [wrapper] = generateInput(initialString)
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: initialString.length })
wrapper.vm.insert({ insertion: '(test)', keepOpen: false })
expect(wrapper.emitted().input[0][0]).to.eql('Testing (test) ')
})
it('inserts string at the begginning without leading space', () => {
const initialString = 'Testing'
const [wrapper] = generateInput(initialString)
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: 0 })
wrapper.vm.insert({ insertion: '(test)', keepOpen: false })
expect(wrapper.emitted().input[0][0]).to.eql('(test) Testing')
})
it('inserts string between words without creating extra spaces', () => {
const initialString = 'Spurdo Sparde'
const [wrapper] = generateInput(initialString)
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: 6 })
wrapper.vm.insert({ insertion: ':ebin:', keepOpen: false })
expect(wrapper.emitted().input[0][0]).to.eql('Spurdo :ebin: Sparde')
})
it('inserts string between words without creating extra spaces (other caret)', () => {
const initialString = 'Spurdo Sparde'
const [wrapper] = generateInput(initialString)
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: 7 })
wrapper.vm.insert({ insertion: ':ebin:', keepOpen: false })
expect(wrapper.emitted().input[0][0]).to.eql('Spurdo :ebin: Sparde')
})
it('inserts string without any padding in spam mode', () => {
const initialString = 'Eat some spam!'
const [wrapper] = generateInput(initialString)
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: initialString.length })
wrapper.vm.insert({ insertion: ':spam:', keepOpen: true })
expect(wrapper.emitted().input[0][0]).to.eql('Eat some spam!:spam:')
})
it('correctly sets caret after insertion at beginning', (done) => {
const initialString = '1234'
const [wrapper, vue] = generateInput(initialString)
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: 0 })
wrapper.vm.insert({ insertion: '1234', keepOpen: false })
vue.nextTick(() => {
expect(wrapper.vm.caret).to.eql(5)
done()
})
})
it('correctly sets caret after insertion at end', (done) => {
const initialString = '1234'
const [wrapper, vue] = generateInput(initialString)
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: initialString.length })
wrapper.vm.insert({ insertion: '1234', keepOpen: false })
vue.nextTick(() => {
expect(wrapper.vm.caret).to.eql(10)
done()
})
})
it('correctly sets caret after insertion in spam mode', (done) => {
const initialString = '1234'
const [wrapper, vue] = generateInput(initialString)
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: initialString.length })
wrapper.vm.insert({ insertion: '1234', keepOpen: true })
vue.nextTick(() => {
expect(wrapper.vm.caret).to.eql(8)
done()
})
})
})
})