diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..ff4c2fd1
--- /dev/null
+++ b/CHANGELOG.md
@@ -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
diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
index cb95a244..18b6c560 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -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:
diff --git a/docs/example_emoji.png b/docs/example_emoji.png
new file mode 100644
index 00000000..0a22a256
Binary files /dev/null and b/docs/example_emoji.png differ
diff --git a/index.html b/index.html
index a8681c8b..fd4e795e 100644
--- a/index.html
+++ b/index.html
@@ -9,7 +9,7 @@
-
+
+
+
+
+
diff --git a/src/boot/after_store.js b/src/boot/after_store.js
index 5cb2acba..490ac4d0 100644
--- a/src/boot/after_store.js
+++ b/src/boot/after_store.js
@@ -184,7 +184,7 @@ const getStaticEmoji = async ({ store }) => {
imageUrl: false,
replacement: values[key]
}
- })
+ }).sort((a, b) => a.displayText - b.displayText)
store.dispatch('setInstanceOption', { name: 'emoji', value: emoji })
} else {
throw (res)
@@ -203,14 +203,16 @@ const getCustomEmoji = async ({ store }) => {
if (res.ok) {
const result = await res.json()
const values = Array.isArray(result) ? Object.assign({}, ...result) : result
- const emoji = Object.keys(values).map((key) => {
- const imageUrl = values[key].image_url
+ const emoji = Object.entries(values).map(([key, value]) => {
+ const imageUrl = value.image_url
return {
displayText: key,
- imageUrl: imageUrl ? store.state.instance.server + imageUrl : values[key],
+ imageUrl: imageUrl ? store.state.instance.server + imageUrl : value,
+ tags: imageUrl ? value.tags.sort((a, b) => a > b ? 1 : 0) : ['utf'],
replacement: `:${key}: `
}
- })
+ // Technically could use tags but those are kinda useless right now, should have been "pack" field, that would be more useful
+ }).sort((a, b) => a.displayText.toLowerCase() > b.displayText.toLowerCase() ? 1 : 0)
store.dispatch('setInstanceOption', { name: 'customEmoji', value: emoji })
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: true })
} else {
diff --git a/src/components/emoji-input/emoji-input.js b/src/components/emoji_input/emoji_input.js
similarity index 51%
rename from src/components/emoji-input/emoji-input.js
rename to src/components/emoji_input/emoji_input.js
index fab64a69..a586b819 100644
--- a/src/components/emoji-input/emoji-input.js
+++ b/src/components/emoji_input/emoji_input.js
@@ -1,5 +1,7 @@
import Completion from '../../services/completion/completion.js'
+import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import { take } from 'lodash'
+import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
/**
* EmojiInput - augmented inputs for emoji and autocomplete support in inputs
@@ -52,6 +54,31 @@ const EmojiInput = {
*/
required: true,
type: String
+ },
+ enableEmojiPicker: {
+ /**
+ * Enables emoji picker support, this implies that custom emoji are supported
+ */
+ required: false,
+ type: Boolean,
+ default: false
+ },
+ hideEmojiButton: {
+ /**
+ * intended to use with external picker trigger, i.e. you have a button outside
+ * input that will open up the picker, see triggerShowPicker()
+ */
+ required: false,
+ type: Boolean,
+ default: false
+ },
+ enableStickerPicker: {
+ /**
+ * Enables sticker picker support, only makes sense when enableEmojiPicker=true
+ */
+ required: false,
+ type: Boolean,
+ default: false
}
},
data () {
@@ -60,10 +87,20 @@ const EmojiInput = {
highlighted: 0,
caret: 0,
focused: false,
- blurTimeout: null
+ blurTimeout: null,
+ showPicker: false,
+ temporarilyHideSuggestions: false,
+ keepOpen: false,
+ disableClickOutside: false
}
},
+ components: {
+ EmojiPicker
+ },
computed: {
+ padEmoji () {
+ return this.$store.state.config.padEmoji
+ },
suggestions () {
const firstchar = this.textAtCaret.charAt(0)
if (this.textAtCaret === firstchar) { return [] }
@@ -79,8 +116,12 @@ const EmojiInput = {
highlighted: index === this.highlighted
}))
},
- showPopup () {
- return this.focused && this.suggestions && this.suggestions.length > 0
+ showSuggestions () {
+ return this.focused &&
+ this.suggestions &&
+ this.suggestions.length > 0 &&
+ !this.showPicker &&
+ !this.temporarilyHideSuggestions
},
textAtCaret () {
return (this.wordAtCaret || {}).word || ''
@@ -104,6 +145,7 @@ const EmojiInput = {
input.elm.addEventListener('paste', this.onPaste)
input.elm.addEventListener('keyup', this.onKeyUp)
input.elm.addEventListener('keydown', this.onKeyDown)
+ input.elm.addEventListener('click', this.onClickInput)
input.elm.addEventListener('transitionend', this.onTransition)
input.elm.addEventListener('compositionupdate', this.onCompositionUpdate)
},
@@ -115,16 +157,80 @@ const EmojiInput = {
input.elm.removeEventListener('paste', this.onPaste)
input.elm.removeEventListener('keyup', this.onKeyUp)
input.elm.removeEventListener('keydown', this.onKeyDown)
+ input.elm.removeEventListener('click', this.onClickInput)
input.elm.removeEventListener('transitionend', this.onTransition)
input.elm.removeEventListener('compositionupdate', this.onCompositionUpdate)
}
},
methods: {
+ triggerShowPicker () {
+ this.showPicker = true
+ this.$nextTick(() => {
+ this.scrollIntoView()
+ })
+ // This temporarily disables "click outside" handler
+ // since external trigger also means click originates
+ // from outside, thus preventing picker from opening
+ this.disableClickOutside = true
+ setTimeout(() => {
+ this.disableClickOutside = false
+ }, 0)
+ },
+ togglePicker () {
+ this.input.elm.focus()
+ this.showPicker = !this.showPicker
+ if (this.showPicker) {
+ this.scrollIntoView()
+ }
+ },
replace (replacement) {
const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
this.$emit('input', newValue)
this.caret = 0
},
+ insert ({ insertion, keepOpen }) {
+ const before = this.value.substring(0, this.caret) || ''
+ const after = this.value.substring(this.caret) || ''
+
+ /* Using a bit more smart approach to padding emojis with spaces:
+ * - put a space before cursor if there isn't one already, unless we
+ * are at the beginning of post or in spam mode
+ * - put a space after emoji if there isn't one already unless we are
+ * in spam mode
+ *
+ * The idea is that when you put a cursor somewhere in between sentence
+ * inserting just ' :emoji: ' will add more spaces to post which might
+ * break the flow/spacing, as well as the case where user ends sentence
+ * with a space before adding emoji.
+ *
+ * Spam mode is intended for creating multi-part emojis and overall spamming
+ * 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 && this.padEmoji > 0 ? ' ' : ''
+ const spaceAfter = !isSpaceRegex.exec(after[0]) && this.padEmoji ? ' ' : ''
+
+ const newValue = [
+ before,
+ spaceBefore,
+ insertion,
+ spaceAfter,
+ after
+ ].join('')
+ 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
+ // Set selection right after the replacement instead of the very end
+ this.input.elm.setSelectionRange(position, position)
+ this.caret = position
+ })
+ },
replaceText (e, suggestion) {
const len = this.suggestions.length || 0
if (this.textAtCaret.length === 1) { return }
@@ -148,7 +254,7 @@ const EmojiInput = {
},
cycleBackward (e) {
const len = this.suggestions.length || 0
- if (len > 0) {
+ if (len > 1) {
this.highlighted -= 1
if (this.highlighted < 0) {
this.highlighted = this.suggestions.length - 1
@@ -160,7 +266,7 @@ const EmojiInput = {
},
cycleForward (e) {
const len = this.suggestions.length || 0
- if (len > 0) {
+ if (len > 1) {
this.highlighted += 1
if (this.highlighted >= len) {
this.highlighted = 0
@@ -170,6 +276,37 @@ const EmojiInput = {
this.highlighted = 0
}
},
+ scrollIntoView () {
+ const rootRef = this.$refs['picker'].$el
+ /* Scroller is either `window` (replies in TL), sidebar (main post form,
+ * replies in notifs) or mobile post form. Note that getting and setting
+ * scroll is different for `Window` and `Element`s
+ */
+ const scrollerRef = this.$el.closest('.sidebar-scroller') ||
+ this.$el.closest('.post-form-modal-view') ||
+ window
+ const currentScroll = scrollerRef === window
+ ? scrollerRef.scrollY
+ : scrollerRef.scrollTop
+ const scrollerHeight = scrollerRef === window
+ ? scrollerRef.innerHeight
+ : scrollerRef.offsetHeight
+
+ const scrollerBottomBorder = currentScroll + scrollerHeight
+ // We check where the bottom border of root element is, this uses findOffset
+ // to find offset relative to scrollable container (scroller)
+ const rootBottomBorder = rootRef.offsetHeight + findOffset(rootRef, scrollerRef).top
+
+ const bottomDelta = Math.max(0, rootBottomBorder - scrollerBottomBorder)
+ // could also check top delta but there's no case for it
+ const targetScroll = currentScroll + bottomDelta
+
+ if (scrollerRef === window) {
+ scrollerRef.scroll(0, targetScroll)
+ } else {
+ scrollerRef.scrollTop = targetScroll
+ }
+ },
onTransition (e) {
this.resize()
},
@@ -191,50 +328,93 @@ const EmojiInput = {
this.blurTimeout = null
}
+ if (!this.keepOpen) {
+ this.showPicker = false
+ }
this.focused = true
this.setCaret(e)
this.resize()
+ this.temporarilyHideSuggestions = false
},
onKeyUp (e) {
+ const { key } = e
this.setCaret(e)
this.resize()
+
+ // Setting hider in keyUp to prevent suggestions from blinking
+ // when moving away from suggested spot
+ if (key === 'Escape') {
+ this.temporarilyHideSuggestions = true
+ } else {
+ this.temporarilyHideSuggestions = false
+ }
},
onPaste (e) {
this.setCaret(e)
this.resize()
},
onKeyDown (e) {
- this.setCaret(e)
- this.resize()
-
const { ctrlKey, shiftKey, key } = e
- if (key === 'Tab') {
- if (shiftKey) {
+ // Disable suggestions hotkeys if suggestions are hidden
+ if (!this.temporarilyHideSuggestions) {
+ if (key === 'Tab') {
+ if (shiftKey) {
+ this.cycleBackward(e)
+ } else {
+ this.cycleForward(e)
+ }
+ }
+ if (key === 'ArrowUp') {
this.cycleBackward(e)
- } else {
+ } else if (key === 'ArrowDown') {
this.cycleForward(e)
}
- }
- if (key === 'ArrowUp') {
- this.cycleBackward(e)
- } else if (key === 'ArrowDown') {
- this.cycleForward(e)
- }
- if (key === 'Enter') {
- if (!ctrlKey) {
- this.replaceText(e)
+ if (key === 'Enter') {
+ if (!ctrlKey) {
+ this.replaceText(e)
+ }
}
}
+ // Probably add optional keyboard controls for emoji picker?
+
+ // Escape hides suggestions, if suggestions are hidden it
+ // de-focuses the element (i.e. default browser behavior)
+ if (key === 'Escape') {
+ if (!this.temporarilyHideSuggestions) {
+ this.input.elm.focus()
+ }
+ }
+
+ this.showPicker = false
+ this.resize()
},
onInput (e) {
+ this.showPicker = false
this.setCaret(e)
+ this.resize()
this.$emit('input', e.target.value)
},
onCompositionUpdate (e) {
+ this.showPicker = false
this.setCaret(e)
this.resize()
this.$emit('input', e.target.value)
},
+ onClickInput (e) {
+ this.showPicker = false
+ },
+ onClickOutside (e) {
+ if (this.disableClickOutside) return
+ this.showPicker = false
+ },
+ onStickerUploaded (e) {
+ this.showPicker = false
+ this.$emit('sticker-uploaded', e)
+ },
+ onStickerUploadFailed (e) {
+ this.showPicker = false
+ this.$emit('sticker-upload-Failed', e)
+ },
setCaret ({ target: { selectionStart } }) {
this.caret = selectionStart
},
@@ -243,6 +423,7 @@ const EmojiInput = {
if (!panel) return
const { offsetHeight, offsetTop } = this.input.elm
this.$refs.panel.style.top = (offsetTop + offsetHeight) + 'px'
+ this.$refs.picker.$el.style.top = (offsetTop + offsetHeight) + 'px'
}
}
}
diff --git a/src/components/emoji-input/emoji-input.vue b/src/components/emoji_input/emoji_input.vue
similarity index 68%
rename from src/components/emoji-input/emoji-input.vue
rename to src/components/emoji_input/emoji_input.vue
index 48739ec8..13530e8b 100644
--- a/src/components/emoji-input/emoji-input.vue
+++ b/src/components/emoji_input/emoji_input.vue
@@ -1,10 +1,32 @@
-