aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.js8
-rw-r--r--src/App.scss5
-rw-r--r--src/App.vue28
-rw-r--r--src/boot/after_store.js12
-rw-r--r--src/components/emoji_input/emoji_input.js (renamed from src/components/emoji-input/emoji-input.js)219
-rw-r--r--src/components/emoji_input/emoji_input.vue (renamed from src/components/emoji-input/emoji-input.vue)55
-rw-r--r--src/components/emoji_input/suggestor.js (renamed from src/components/emoji-input/suggestor.js)0
-rw-r--r--src/components/emoji_picker/emoji_picker.js115
-rw-r--r--src/components/emoji_picker/emoji_picker.scss165
-rw-r--r--src/components/emoji_picker/emoji_picker.vue110
-rw-r--r--src/components/extra_buttons/extra_buttons.vue4
-rw-r--r--src/components/media_modal/media_modal.vue5
-rw-r--r--src/components/media_upload/media_upload.vue16
-rw-r--r--src/components/mobile_post_status_button/mobile_post_status_button.js (renamed from src/components/mobile_post_status_modal/mobile_post_status_modal.js)25
-rw-r--r--src/components/mobile_post_status_button/mobile_post_status_button.vue (renamed from src/components/mobile_post_status_modal/mobile_post_status_modal.vue)38
-rw-r--r--src/components/notification/notification.js14
-rw-r--r--src/components/notification/notification.vue202
-rw-r--r--src/components/notifications/notifications.scss5
-rw-r--r--src/components/post_status_form/post_status_form.js119
-rw-r--r--src/components/post_status_form/post_status_form.vue80
-rw-r--r--src/components/post_status_modal/post_status_modal.js32
-rw-r--r--src/components/post_status_modal/post_status_modal.vue43
-rw-r--r--src/components/settings/settings.js4
-rw-r--r--src/components/settings/settings.vue8
-rw-r--r--src/components/status/status.vue18
-rw-r--r--src/components/sticker_picker/sticker_picker.js4
-rw-r--r--src/components/sticker_picker/sticker_picker.vue72
-rw-r--r--src/components/tab_switcher/tab_switcher.js26
-rw-r--r--src/components/tab_switcher/tab_switcher.scss11
-rw-r--r--src/components/user_card/user_card.js14
-rw-r--r--src/components/user_card/user_card.vue42
-rw-r--r--src/components/user_panel/user_panel.vue2
-rw-r--r--src/components/user_settings/user_settings.js4
-rw-r--r--src/components/user_settings/user_settings.vue2
-rw-r--r--src/directives/body_scroll_lock.js69
-rw-r--r--src/i18n/en.json12
-rw-r--r--src/i18n/es.json15
-rw-r--r--src/i18n/eu.json45
-rw-r--r--src/i18n/oc.json73
-rw-r--r--src/main.js6
-rw-r--r--src/modules/config.js1
-rw-r--r--src/modules/postStatus.js25
-rw-r--r--src/modules/statuses.js12
-rw-r--r--src/services/notifications_fetcher/notifications_fetcher.service.js5
-rw-r--r--src/services/offset_finder/offset_finder.service.js31
45 files changed, 1427 insertions, 374 deletions
diff --git a/src/App.js b/src/App.js
index e9cd5917..fe63b54c 100644
--- a/src/App.js
+++ b/src/App.js
@@ -8,9 +8,10 @@ import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_pan
import ChatPanel from './components/chat_panel/chat_panel.vue'
import MediaModal from './components/media_modal/media_modal.vue'
import SideDrawer from './components/side_drawer/side_drawer.vue'
-import MobilePostStatusModal from './components/mobile_post_status_modal/mobile_post_status_modal.vue'
+import MobilePostStatusButton from './components/mobile_post_status_button/mobile_post_status_button.vue'
import MobileNav from './components/mobile_nav/mobile_nav.vue'
import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue'
+import PostStatusModal from './components/post_status_modal/post_status_modal.vue'
import { windowWidth } from './services/window_utils/window_utils'
export default {
@@ -26,9 +27,10 @@ export default {
ChatPanel,
MediaModal,
SideDrawer,
- MobilePostStatusModal,
+ MobilePostStatusButton,
MobileNav,
- UserReportingModal
+ UserReportingModal,
+ PostStatusModal
},
data: () => ({
mobileActivePanel: 'timeline',
diff --git a/src/App.scss b/src/App.scss
index ea7b54e8..2190f91a 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -10,7 +10,8 @@
position: fixed;
z-index: -1;
height: 100%;
- width: 100%;
+ left: 0;
+ right: -20px;
background-size: cover;
background-repeat: no-repeat;
background-position: 0 50%;
@@ -347,6 +348,7 @@ i[class*=icon-] {
align-items: center;
position: fixed;
height: 50px;
+ box-sizing: border-box;
.logo {
display: flex;
@@ -386,6 +388,7 @@ i[class*=icon-] {
}
.inner-nav {
+ position: relative;
margin: auto;
box-sizing: border-box;
padding-left: 10px;
diff --git a/src/App.vue b/src/App.vue
index 719e00a4..8d7f6c79 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -4,6 +4,7 @@
:style="bgAppStyle"
>
<div
+ id="app_bg_wrapper"
class="app-bg-wrapper"
:style="bgStyle"
/>
@@ -14,20 +15,20 @@
class="nav-bar container"
@click="scrollToTop()"
>
- <div
- class="logo"
- :style="logoBgStyle"
- >
+ <div class="inner-nav">
<div
- class="mask"
- :style="logoMaskStyle"
- />
- <img
- :src="logo"
- :style="logoStyle"
+ class="logo"
+ :style="logoBgStyle"
>
- </div>
- <div class="inner-nav">
+ <div
+ class="mask"
+ :style="logoMaskStyle"
+ />
+ <img
+ :src="logo"
+ :style="logoStyle"
+ >
+ </div>
<div class="item">
<router-link
class="site-name"
@@ -107,8 +108,9 @@
:floating="true"
class="floating-chat mobile-hidden"
/>
- <MobilePostStatusModal />
+ <MobilePostStatusButton />
<UserReportingModal />
+ <PostStatusModal />
<portal-target name="modal" />
</div>
</template>
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
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 === 'Enter') {
+ if (!ctrlKey) {
+ this.replaceText(e)
+ }
+ }
}
- if (key === 'ArrowUp') {
- this.cycleBackward(e)
- } else if (key === 'ArrowDown') {
- this.cycleForward(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
index 48739ec8..13530e8b 100644
--- a/src/components/emoji-input/emoji-input.vue
+++ b/src/components/emoji_input/emoji_input.vue
@@ -1,10 +1,32 @@
<template>
- <div class="emoji-input">
+ <div
+ v-click-outside="onClickOutside"
+ class="emoji-input"
+ >
<slot />
+ <template v-if="enableEmojiPicker">
+ <div
+ v-if="!hideEmojiButton"
+ class="emoji-picker-icon"
+ @click.prevent="togglePicker"
+ >
+ <i class="icon-smile" />
+ </div>
+ <EmojiPicker
+ v-if="enableEmojiPicker"
+ ref="picker"
+ :class="{ hide: !showPicker }"
+ :enable-sticker-picker="enableStickerPicker"
+ class="emoji-picker-panel"
+ @emoji="insert"
+ @sticker-uploaded="onStickerUploaded"
+ @sticker-upload-failed="onStickerUploadFailed"
+ />
+ </template>
<div
ref="panel"
class="autocomplete-panel"
- :class="{ hide: !showPopup }"
+ :class="{ hide: !showSuggestions }"
>
<div class="autocomplete-panel-body">
<div
@@ -31,7 +53,7 @@
</div>
</template>
-<script src="./emoji-input.js"></script>
+<script src="./emoji_input.js"></script>
<style lang="scss">
@import '../../_variables.scss';
@@ -39,11 +61,36 @@
.emoji-input {
display: flex;
flex-direction: column;
+ position: relative;
+
+ .emoji-picker-icon {
+ position: absolute;
+ top: 0;
+ right: 0;
+ margin: .2em .25em;
+ font-size: 16px;
+ cursor: pointer;
+ line-height: 24px;
+
+ &:hover i {
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ }
+ }
+ .emoji-picker-panel {
+ position: absolute;
+ z-index: 20;
+ margin-top: 2px;
+
+ &.hide {
+ display: none
+ }
+ }
.autocomplete {
&-panel {
position: absolute;
- z-index: 9;
+ z-index: 20;
margin-top: 2px;
&.hide {
diff --git a/src/components/emoji-input/suggestor.js b/src/components/emoji_input/suggestor.js
index aec5c39d..aec5c39d 100644
--- a/src/components/emoji-input/suggestor.js
+++ b/src/components/emoji_input/suggestor.js
diff --git a/src/components/emoji_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js
new file mode 100644
index 00000000..824412dd
--- /dev/null
+++ b/src/components/emoji_picker/emoji_picker.js
@@ -0,0 +1,115 @@
+
+const filterByKeyword = (list, keyword = '') => {
+ return list.filter(x => x.displayText.includes(keyword))
+}
+
+const EmojiPicker = {
+ props: {
+ enableStickerPicker: {
+ required: false,
+ type: Boolean,
+ default: false
+ }
+ },
+ data () {
+ return {
+ labelKey: String(Math.random() * 100000),
+ keyword: '',
+ activeGroup: 'custom',
+ showingStickers: false,
+ groupsScrolledClass: 'scrolled-top',
+ keepOpen: false
+ }
+ },
+ components: {
+ StickerPicker: () => import('../sticker_picker/sticker_picker.vue')
+ },
+ methods: {
+ onEmoji (emoji) {
+ const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement
+ this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen })
+ },
+ highlight (key) {
+ const ref = this.$refs['group-' + key]
+ const top = ref[0].offsetTop
+ this.setShowStickers(false)
+ this.activeGroup = key
+ this.$nextTick(() => {
+ this.$refs['emoji-groups'].scrollTop = top + 1
+ })
+ },
+ scrolledGroup (e) {
+ const target = (e && e.target) || this.$refs['emoji-groups']
+ const top = target.scrollTop + 5
+ if (target.scrollTop <= 5) {
+ this.groupsScrolledClass = 'scrolled-top'
+ } else if (target.scrollTop >= target.scrollTopMax - 5) {
+ this.groupsScrolledClass = 'scrolled-bottom'
+ } else {
+ this.groupsScrolledClass = 'scrolled-middle'
+ }
+ this.$nextTick(() => {
+ this.emojisView.forEach(group => {
+ const ref = this.$refs['group-' + group.id]
+ if (ref[0].offsetTop <= top) {
+ this.activeGroup = group.id
+ }
+ })
+ })
+ },
+ toggleStickers () {
+ this.showingStickers = !this.showingStickers
+ },
+ setShowStickers (value) {
+ this.showingStickers = value
+ },
+ onStickerUploaded (e) {
+ this.$emit('sticker-uploaded', e)
+ },
+ onStickerUploadFailed (e) {
+ this.$emit('sticker-upload-failed', e)
+ }
+ },
+ watch: {
+ keyword () {
+ this.scrolledGroup()
+ }
+ },
+ computed: {
+ activeGroupView () {
+ return this.showingStickers ? '' : this.activeGroup
+ },
+ stickersAvailable () {
+ if (this.$store.state.instance.stickers) {
+ return this.$store.state.instance.stickers.length > 0
+ }
+ return 0
+ },
+ emojis () {
+ const standardEmojis = this.$store.state.instance.emoji || []
+ const customEmojis = this.$store.state.instance.customEmoji || []
+ return [
+ {
+ id: 'custom',
+ text: this.$t('emoji.custom'),
+ icon: 'icon-smile',
+ emojis: filterByKeyword(customEmojis, this.keyword)
+ },
+ {
+ id: 'standard',
+ text: this.$t('emoji.unicode'),
+ icon: 'icon-picture',
+ emojis: filterByKeyword(standardEmojis, this.keyword)
+ }
+ ]
+ },
+ emojisView () {
+ return this.emojis.filter(value => value.emojis.length > 0)
+ },
+ stickerPickerEnabled () {
+ return (this.$store.state.instance.stickers || []).length !== 0
+ }
+ }
+}
+
+export default EmojiPicker
diff --git a/src/components/emoji_picker/emoji_picker.scss b/src/components/emoji_picker/emoji_picker.scss
new file mode 100644
index 00000000..b0ed00e9
--- /dev/null
+++ b/src/components/emoji_picker/emoji_picker.scss
@@ -0,0 +1,165 @@
+@import '../../_variables.scss';
+
+.emoji-picker {
+ display: flex;
+ flex-direction: column;
+ position: absolute;
+ right: 0;
+ left: 0;
+ height: 320px;
+ margin: 0 !important;
+ z-index: 1;
+
+ .keep-open {
+ padding: 7px;
+ line-height: normal;
+ }
+ .keep-open-label {
+ padding: 0 7px;
+ display: flex;
+ }
+
+ .heading {
+ display: flex;
+ height: 32px;
+ padding: 10px 7px 5px;
+ }
+
+ .content {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 0;
+ min-height: 0px;
+ }
+
+ .emoji-tabs {
+ flex-grow: 1;
+ }
+
+ .additional-tabs {
+ border-left: 1px solid;
+ border-left-color: $fallback--icon;
+ border-left-color: var(--icon, $fallback--icon);
+ padding-left: 7px;
+ flex: 0 0 0;
+ }
+
+ .additional-tabs,
+ .emoji-tabs {
+ display: block;
+ min-width: 0;
+ flex-basis: auto;
+ flex-shrink: 1;
+
+ &-item {
+ padding: 0 7px;
+ cursor: pointer;
+ font-size: 24px;
+
+ &.disabled {
+ opacity: 0.5;
+ pointer-events: none;
+ }
+ &.active {
+ border-bottom: 4px solid;
+
+ i {
+ color: $fallback--lightText;
+ color: var(--lightText, $fallback--lightText);
+ }
+ }
+ }
+ }
+
+ .sticker-picker {
+ flex: 1 1 0
+ }
+
+ .stickers,
+ .emoji {
+ &-content {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 0;
+ min-height: 0;
+
+ &.hidden {
+ opacity: 0;
+ pointer-events: none;
+ position: absolute;
+ }
+ }
+ }
+
+ .emoji {
+ &-search {
+ padding: 5px;
+ flex: 0 0 0;
+
+ input {
+ width: 100%;
+ }
+ }
+
+ &-groups {
+ flex: 1 1 1px;
+ position: relative;
+ overflow: auto;
+ user-select: none;
+ mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat,
+ linear-gradient(to bottom, white 0, transparent 100%) top no-repeat,
+ linear-gradient(to top, white, white);
+ transition: mask-size 150ms;
+ mask-size: 100% 20px, 100% 20px, auto;
+ // Autoprefixed seem to ignore this one, and also syntax is different
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ &.scrolled {
+ &-top {
+ mask-size: 100% 20px, 100% 0, auto;
+ }
+ &-bottom {
+ mask-size: 100% 0, 100% 20px, auto;
+ }
+ }
+ }
+
+ &-group {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ padding-left: 5px;
+ justify-content: left;
+
+ &-title {
+ font-size: 12px;
+ width: 100%;
+ margin: 0;
+ &.disabled {
+ display: none;
+ }
+ }
+ }
+
+ &-item {
+ width: 32px;
+ height: 32px;
+ box-sizing: border-box;
+ display: flex;
+ font-size: 32px;
+ align-items: center;
+ justify-content: center;
+ margin: 4px;
+
+ cursor: pointer;
+
+ img {
+ object-fit: contain;
+ max-width: 100%;
+ max-height: 100%;
+ }
+ }
+
+ }
+
+}
diff --git a/src/components/emoji_picker/emoji_picker.vue b/src/components/emoji_picker/emoji_picker.vue
new file mode 100644
index 00000000..42f20130
--- /dev/null
+++ b/src/components/emoji_picker/emoji_picker.vue
@@ -0,0 +1,110 @@
+<template>
+ <div class="emoji-picker panel panel-default panel-body">
+ <div class="heading">
+ <span class="emoji-tabs">
+ <span
+ v-for="group in emojis"
+ :key="group.id"
+ class="emoji-tabs-item"
+ :class="{
+ active: activeGroupView === group.id,
+ disabled: group.emojis.length === 0
+ }"
+ :title="group.text"
+ @click.prevent="highlight(group.id)"
+ >
+ <i :class="group.icon" />
+ </span>
+ </span>
+ <span
+ v-if="stickerPickerEnabled"
+ class="additional-tabs"
+ >
+ <span
+ class="stickers-tab-icon additional-tabs-item"
+ :class="{active: showingStickers}"
+ :title="$t('emoji.stickers')"
+ @click.prevent="toggleStickers"
+ >
+ <i class="icon-star" />
+ </span>
+ </span>
+ </div>
+ <div class="content">
+ <div
+ class="emoji-content"
+ :class="{hidden: showingStickers}"
+ >
+ <div class="emoji-search">
+ <input
+ v-model="keyword"
+ type="text"
+ class="form-control"
+ :placeholder="$t('emoji.search_emoji')"
+ >
+ </div>
+ <div
+ ref="emoji-groups"
+ class="emoji-groups"
+ :class="groupsScrolledClass"
+ @scroll="scrolledGroup"
+ >
+ <div
+ v-for="group in emojisView"
+ :key="group.id"
+ class="emoji-group"
+ >
+ <h6
+ :ref="'group-' + group.id"
+ class="emoji-group-title"
+ >
+ {{ group.text }}
+ </h6>
+ <span
+ v-for="emoji in group.emojis"
+ :key="group.id + emoji.displayText"
+ :title="emoji.displayText"
+ class="emoji-item"
+ @click.stop.prevent="onEmoji(emoji)"
+ >
+ <span v-if="!emoji.imageUrl">{{ emoji.replacement }}</span>
+ <img
+ v-else
+ :src="emoji.imageUrl"
+ >
+ </span>
+ </div>
+ </div>
+ <div
+ class="keep-open"
+ >
+ <input
+ :id="labelKey + 'keep-open'"
+ v-model="keepOpen"
+ type="checkbox"
+ >
+ <label
+ class="keep-open-label"
+ :for="labelKey + 'keep-open'"
+ >
+ <div class="keep-open-label-text">
+ {{ $t('emoji.keep_open') }}
+ </div>
+ </label>
+ </div>
+ </div>
+ <div
+ v-if="showingStickers"
+ class="stickers-content"
+ >
+ <sticker-picker
+ @uploaded="onStickerUploaded"
+ @upload-failed="onStickerUploadFailed"
+ />
+ </div>
+ </div>
+ </div>
+</template>
+
+<script src="./emoji_picker.js"></script>
+<style lang="scss" src="./emoji_picker.scss"></style>
diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue
index ed0f3aa4..6781a4f8 100644
--- a/src/components/extra_buttons/extra_buttons.vue
+++ b/src/components/extra_buttons/extra_buttons.vue
@@ -10,14 +10,14 @@
<div slot="popover">
<div class="dropdown-menu">
<button
- v-if="canMute && !status.muted"
+ v-if="canMute && !status.thread_muted"
class="dropdown-item dropdown-item-icon"
@click.prevent="muteConversation"
>
<i class="icon-eye-off" /><span>{{ $t("status.mute_conversation") }}</span>
</button>
<button
- v-if="canMute && status.muted"
+ v-if="canMute && status.thread_muted"
class="dropdown-item dropdown-item-icon"
@click.prevent="unmuteConversation"
>
diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue
index ab5a36a5..06ced5a1 100644
--- a/src/components/media_modal/media_modal.vue
+++ b/src/components/media_modal/media_modal.vue
@@ -1,6 +1,7 @@
<template>
<div
v-if="showing"
+ v-body-scroll-lock="showing"
class="modal-view media-modal-view"
@click.prevent="hide"
>
@@ -43,6 +44,10 @@
.media-modal-view {
z-index: 1001;
+ body:not(.scroll-locked) & {
+ display: none;
+ }
+
&:hover {
.modal-view-button-arrow {
opacity: 0.75;
diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue
index ac32ae83..1dda7bc1 100644
--- a/src/components/media_upload/media_upload.vue
+++ b/src/components/media_upload/media_upload.vue
@@ -31,12 +31,14 @@
<script src="./media_upload.js" ></script>
<style>
- .media-upload {
- font-size: 26px;
- min-width: 50px;
- }
+.media-upload {
+ .icon-upload {
+ cursor: pointer;
+ }
- .icon-upload {
- cursor: pointer;
- }
+ label {
+ display: block;
+ width: 100%;
+ }
+}
</style>
diff --git a/src/components/mobile_post_status_modal/mobile_post_status_modal.js b/src/components/mobile_post_status_button/mobile_post_status_button.js
index 3cec23c6..3e77148a 100644
--- a/src/components/mobile_post_status_modal/mobile_post_status_modal.js
+++ b/src/components/mobile_post_status_button/mobile_post_status_button.js
@@ -1,14 +1,9 @@
-import PostStatusForm from '../post_status_form/post_status_form.vue'
import { debounce } from 'lodash'
-const MobilePostStatusModal = {
- components: {
- PostStatusForm
- },
+const MobilePostStatusButton = {
data () {
return {
hidden: false,
- postFormOpen: false,
scrollingDown: false,
inputActive: false,
oldScrollPos: 0,
@@ -28,8 +23,8 @@ const MobilePostStatusModal = {
window.removeEventListener('resize', this.handleOSK)
},
computed: {
- currentUser () {
- return this.$store.state.users.currentUser
+ isLoggedIn () {
+ return !!this.$store.state.users.currentUser
},
isHidden () {
return this.autohideFloatingPostButton && (this.hidden || this.inputActive)
@@ -57,17 +52,7 @@ const MobilePostStatusModal = {
window.removeEventListener('scroll', this.handleScrollEnd)
},
openPostForm () {
- this.postFormOpen = true
- this.hidden = true
-
- const el = this.$el.querySelector('textarea')
- this.$nextTick(function () {
- el.focus()
- })
- },
- closePostForm () {
- this.postFormOpen = false
- this.hidden = false
+ this.$store.dispatch('openPostStatusModal')
},
handleOSK () {
// This is a big hack: we're guessing from changed window sizes if the
@@ -105,4 +90,4 @@ const MobilePostStatusModal = {
}
}
-export default MobilePostStatusModal
+export default MobilePostStatusButton
diff --git a/src/components/mobile_post_status_modal/mobile_post_status_modal.vue b/src/components/mobile_post_status_button/mobile_post_status_button.vue
index b6d7d3ba..9cf45de3 100644
--- a/src/components/mobile_post_status_modal/mobile_post_status_modal.vue
+++ b/src/components/mobile_post_status_button/mobile_post_status_button.vue
@@ -1,23 +1,5 @@
<template>
- <div v-if="currentUser">
- <div
- v-show="postFormOpen"
- class="post-form-modal-view modal-view"
- @click="closePostForm"
- >
- <div
- class="post-form-modal-panel panel"
- @click.stop=""
- >
- <div class="panel-heading">
- {{ $t('post_status.new_status') }}
- </div>
- <PostStatusForm
- class="panel-body"
- @posted="closePostForm"
- />
- </div>
- </div>
+ <div v-if="isLoggedIn">
<button
class="new-status-button"
:class="{ 'hidden': isHidden }"
@@ -28,27 +10,11 @@
</div>
</template>
-<script src="./mobile_post_status_modal.js"></script>
+<script src="./mobile_post_status_button.js"></script>
<style lang="scss">
@import '../../_variables.scss';
-.post-form-modal-view {
- align-items: flex-start;
-}
-
-.post-form-modal-panel {
- flex-shrink: 0;
- margin-top: 25%;
- margin-bottom: 2em;
- width: 100%;
- max-width: 700px;
-
- @media (orientation: landscape) {
- margin-top: 8%;
- }
-}
-
.new-status-button {
width: 5em;
height: 5em;
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index 896c6d52..8e817f3b 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -9,7 +9,8 @@ const Notification = {
data () {
return {
userExpanded: false,
- betterShadow: this.$store.state.interface.browserSupport.cssFilter
+ betterShadow: this.$store.state.interface.browserSupport.cssFilter,
+ unmuted: false
}
},
props: [ 'notification' ],
@@ -23,11 +24,14 @@ const Notification = {
toggleUserExpanded () {
this.userExpanded = !this.userExpanded
},
- userProfileLink (user) {
+ generateUserProfileLink (user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
},
getUser (notification) {
return this.$store.state.users.usersObject[notification.from_profile.id]
+ },
+ toggleMute () {
+ this.unmuted = !this.unmuted
}
},
computed: {
@@ -47,6 +51,12 @@ const Notification = {
return this.userInStore
}
return this.notification.from_profile
+ },
+ userProfileLink () {
+ return this.generateUserProfileLink(this.user)
+ },
+ needMute () {
+ return this.user.muted
}
}
}
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index bafcd026..1f192c77 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -4,104 +4,126 @@
:compact="true"
:statusoid="notification.status"
/>
- <div
- v-else
- class="non-mention"
- :class="[userClass, { highlighted: userStyle }]"
- :style="[ userStyle ]"
- >
- <a
- class="avatar-container"
- :href="notification.from_profile.statusnet_profile_url"
- @click.stop.prevent.capture="toggleUserExpanded"
+ <div v-else>
+ <div
+ v-if="needMute && !unmuted"
+ class="container muted"
>
- <UserAvatar
- :compact="true"
- :better-shadow="betterShadow"
- :user="notification.from_profile"
- />
- </a>
- <div class="notification-right">
- <UserCard
- v-if="userExpanded"
- :user="getUser(notification)"
- :rounded="true"
- :bordered="true"
- />
- <span class="notification-details">
- <div class="name-and-action">
- <!-- eslint-disable vue/no-v-html -->
- <span
- v-if="!!notification.from_profile.name_html"
- class="username"
- :title="'@'+notification.from_profile.screen_name"
- v-html="notification.from_profile.name_html"
- />
- <!-- eslint-enable vue/no-v-html -->
- <span
- v-else
- class="username"
- :title="'@'+notification.from_profile.screen_name"
- >{{ notification.from_profile.name }}</span>
- <span v-if="notification.type === 'like'">
- <i class="fa icon-star lit" />
- <small>{{ $t('notifications.favorited_you') }}</small>
- </span>
- <span v-if="notification.type === 'repeat'">
- <i
- class="fa icon-retweet lit"
- :title="$t('tool_tip.repeat')"
+ <small>
+ <router-link :to="userProfileLink">
+ {{ notification.from_profile.screen_name }}
+ </router-link>
+ </small>
+ <a
+ href="#"
+ class="unmute"
+ @click.prevent="toggleMute"
+ ><i class="button-icon icon-eye-off" /></a>
+ </div>
+ <div
+ v-else
+ class="non-mention"
+ :class="[userClass, { highlighted: userStyle }]"
+ :style="[ userStyle ]"
+ >
+ <a
+ class="avatar-container"
+ :href="notification.from_profile.statusnet_profile_url"
+ @click.stop.prevent.capture="toggleUserExpanded"
+ >
+ <UserAvatar
+ :compact="true"
+ :better-shadow="betterShadow"
+ :user="notification.from_profile"
+ />
+ </a>
+ <div class="notification-right">
+ <UserCard
+ v-if="userExpanded"
+ :user="getUser(notification)"
+ :rounded="true"
+ :bordered="true"
+ />
+ <span class="notification-details">
+ <div class="name-and-action">
+ <!-- eslint-disable vue/no-v-html -->
+ <span
+ v-if="!!notification.from_profile.name_html"
+ class="username"
+ :title="'@'+notification.from_profile.screen_name"
+ v-html="notification.from_profile.name_html"
/>
- <small>{{ $t('notifications.repeated_you') }}</small>
- </span>
- <span v-if="notification.type === 'follow'">
- <i class="fa icon-user-plus lit" />
- <small>{{ $t('notifications.followed_you') }}</small>
- </span>
- </div>
+ <!-- eslint-enable vue/no-v-html -->
+ <span
+ v-else
+ class="username"
+ :title="'@'+notification.from_profile.screen_name"
+ >{{ notification.from_profile.name }}</span>
+ <span v-if="notification.type === 'like'">
+ <i class="fa icon-star lit" />
+ <small>{{ $t('notifications.favorited_you') }}</small>
+ </span>
+ <span v-if="notification.type === 'repeat'">
+ <i
+ class="fa icon-retweet lit"
+ :title="$t('tool_tip.repeat')"
+ />
+ <small>{{ $t('notifications.repeated_you') }}</small>
+ </span>
+ <span v-if="notification.type === 'follow'">
+ <i class="fa icon-user-plus lit" />
+ <small>{{ $t('notifications.followed_you') }}</small>
+ </span>
+ </div>
+ <div
+ v-if="notification.type === 'follow'"
+ class="timeago"
+ >
+ <span class="faint">
+ <Timeago
+ :time="notification.created_at"
+ :auto-update="240"
+ />
+ </span>
+ </div>
+ <div
+ v-else
+ class="timeago"
+ >
+ <router-link
+ v-if="notification.status"
+ :to="{ name: 'conversation', params: { id: notification.status.id } }"
+ class="faint-link"
+ >
+ <Timeago
+ :time="notification.created_at"
+ :auto-update="240"
+ />
+ </router-link>
+ </div>
+ <a
+ v-if="needMute"
+ href="#"
+ @click.prevent="toggleMute"
+ ><i class="button-icon icon-eye-off" /></a>
+ </span>
<div
v-if="notification.type === 'follow'"
- class="timeago"
- >
- <span class="faint">
- <Timeago
- :time="notification.created_at"
- :auto-update="240"
- />
- </span>
- </div>
- <div
- v-else
- class="timeago"
+ class="follow-text"
>
- <router-link
- v-if="notification.status"
- :to="{ name: 'conversation', params: { id: notification.status.id } }"
- class="faint-link"
- >
- <Timeago
- :time="notification.created_at"
- :auto-update="240"
- />
+ <router-link :to="userProfileLink">
+ @{{ notification.from_profile.screen_name }}
</router-link>
</div>
- </span>
- <div
- v-if="notification.type === 'follow'"
- class="follow-text"
- >
- <router-link :to="userProfileLink(notification.from_profile)">
- @{{ notification.from_profile.screen_name }}
- </router-link>
+ <template v-else>
+ <status
+ class="faint"
+ :compact="true"
+ :statusoid="notification.action"
+ :no-heading="true"
+ />
+ </template>
</div>
- <template v-else>
- <status
- class="faint"
- :compact="true"
- :statusoid="notification.action"
- :no-heading="true"
- />
- </template>
</div>
</div>
</template>
diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss
index 622d12f4..71876b14 100644
--- a/src/components/notifications/notifications.scss
+++ b/src/components/notifications/notifications.scss
@@ -33,7 +33,6 @@
.notification {
box-sizing: border-box;
- display: flex;
border-bottom: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
@@ -47,6 +46,10 @@
}
}
+ .muted {
+ padding: .25em .6em;
+ }
+
.non-mention {
display: flex;
flex: 1;
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index 40bbf6d4..9b2a9c90 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -1,14 +1,14 @@
import statusPoster from '../../services/status_poster/status_poster.service.js'
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 EmojiInput from '../emoji_input/emoji_input.vue'
import PollForm from '../poll/poll_form.vue'
-import StickerPicker from '../sticker_picker/sticker_picker.vue'
import fileTypeService from '../../services/file_type/file_type.service.js'
+import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import { reject, map, uniqBy } from 'lodash'
-import suggestor from '../emoji-input/suggestor.js'
+import suggestor from '../emoji_input/suggestor.js'
-const buildMentionsString = ({ user, attentions }, currentUser) => {
+const buildMentionsString = ({ user, attentions = [] }, currentUser) => {
let allAttentions = [...attentions]
allAttentions.unshift(user)
@@ -35,7 +35,6 @@ const PostStatusForm = {
MediaUpload,
EmojiInput,
PollForm,
- StickerPicker,
ScopeSelector
},
mounted () {
@@ -84,8 +83,7 @@ const PostStatusForm = {
contentType
},
caret: 0,
- pollFormVisible: false,
- stickerPickerVisible: false
+ pollFormVisible: false
}
},
computed: {
@@ -161,12 +159,6 @@ const PostStatusForm = {
safeDMEnabled () {
return this.$store.state.instance.safeDM
},
- stickersAvailable () {
- if (this.$store.state.instance.stickers) {
- return this.$store.state.instance.stickers.length > 0
- }
- return 0
- },
pollsAvailable () {
return this.$store.state.instance.pollsAvailable &&
this.$store.state.instance.pollLimits.max_options >= 2
@@ -222,7 +214,6 @@ const PostStatusForm = {
poll: {}
}
this.pollFormVisible = false
- this.stickerPickerVisible = false
this.$refs.mediaUpload.clearFile()
this.clearPollForm()
this.$emit('posted')
@@ -239,7 +230,6 @@ const PostStatusForm = {
addMediaFile (fileInfo) {
this.newStatus.files.push(fileInfo)
this.enableSubmit()
- this.stickerPickerVisible = false
},
removeMediaFile (fileInfo) {
let index = this.newStatus.files.indexOf(fileInfo)
@@ -260,6 +250,7 @@ const PostStatusForm = {
return fileTypeService.fileType(fileInfo.mimetype)
},
paste (e) {
+ this.resize(e)
if (e.clipboardData.files.length > 0) {
// prevent pasting of file as text
e.preventDefault()
@@ -278,20 +269,96 @@ 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 }
+
+ // Reset to default height for empty form, nothing else to do here.
+ if (target.value === '') {
+ target.style.height = null
+ this.$refs['emoji-input'].resize()
+ return
+ }
+
+ const rootRef = this.$refs['root']
+ /* 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
+
+ // Getting info about padding we have to account for, removing 'px' part
const topPaddingStr = window.getComputedStyle(target)['padding-top']
const bottomPaddingStr = window.getComputedStyle(target)['padding-bottom']
- // Remove "px" at the end of the values
- const vertPadding = Number(topPaddingStr.substr(0, topPaddingStr.length - 2)) +
- Number(bottomPaddingStr.substr(0, bottomPaddingStr.length - 2))
- // Auto is needed to make textbox shrink when removing lines
+ const topPadding = Number(topPaddingStr.substring(0, topPaddingStr.length - 2))
+ const bottomPadding = Number(bottomPaddingStr.substring(0, bottomPaddingStr.length - 2))
+ const vertPadding = topPadding + bottomPadding
+
+ const oldHeightStr = target.style.height || ''
+ const oldHeight = Number(oldHeightStr.substring(0, oldHeightStr.length - 2))
+
+ /* Explanation:
+ *
+ * https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight
+ * scrollHeight returns element's scrollable content height, i.e. visible
+ * element + overscrolled parts of it. We use it to determine when text
+ * inside the textarea exceeded its height, so we can set height to prevent
+ * overscroll, i.e. make textarea grow with the text. HOWEVER, since we
+ * explicitly set new height, scrollHeight won't go below that, so we can't
+ * SHRINK the textarea when there's extra space. To workaround that we set
+ * height to 'auto' which makes textarea tiny again, so that scrollHeight
+ * will match text height again. HOWEVER, shrinking textarea can screw with
+ * the scroll since there might be not enough padding around root to even
+ * warrant a scroll, so it will jump to 0 and refuse to move anywhere,
+ * so we check current scroll position before shrinking and then restore it
+ * with needed delta.
+ */
+
+ // this part has to be BEFORE the content size update
+ const currentScroll = scrollerRef === window
+ ? scrollerRef.scrollY
+ : scrollerRef.scrollTop
+ const scrollerHeight = scrollerRef === window
+ ? scrollerRef.innerHeight
+ : scrollerRef.offsetHeight
+ const scrollerBottomBorder = currentScroll + scrollerHeight
+
+ // BEGIN content size update
target.style.height = 'auto'
- target.style.height = `${target.scrollHeight - vertPadding}px`
- if (target.value === '') {
- target.style.height = null
+ const newHeight = target.scrollHeight - vertPadding
+ target.style.height = `${newHeight}px`
+ // END content size update
+
+ // 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 textareaSizeChangeDelta = newHeight - oldHeight || 0
+ const isBottomObstructed = scrollerBottomBorder < rootBottomBorder
+ const rootChangeDelta = rootBottomBorder - scrollerBottomBorder
+ const totalDelta = textareaSizeChangeDelta +
+ (isBottomObstructed ? rootChangeDelta : 0)
+
+ const targetScroll = currentScroll + totalDelta
+
+ if (scrollerRef === window) {
+ scrollerRef.scroll(0, targetScroll)
+ } else {
+ scrollerRef.scrollTop = targetScroll
}
+
+ this.$refs['emoji-input'].resize()
+ },
+ showEmojiPicker () {
+ this.$refs['textarea'].focus()
+ this.$refs['emoji-input'].triggerShowPicker()
},
clearError () {
this.error = null
@@ -299,14 +366,6 @@ const PostStatusForm = {
changeVis (visibility) {
this.newStatus.visibility = visibility
},
- toggleStickerPicker () {
- this.stickerPickerVisible = !this.stickerPickerVisible
- },
- clearStickerPicker () {
- if (this.$refs.stickerPicker) {
- this.$refs.stickerPicker.clear()
- }
- },
togglePollForm () {
this.pollFormVisible = !this.pollFormVisible
},
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index d29d47e4..4916d988 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -1,5 +1,8 @@
<template>
- <div class="post-status-form">
+ <div
+ ref="root"
+ class="post-status-form"
+ >
<form
autocomplete="off"
@submit.prevent="postStatus(newStatus)"
@@ -61,6 +64,7 @@
<EmojiInput
v-if="newStatus.spoilerText || alwaysShowSubject"
v-model="newStatus.spoilerText"
+ enable-emoji-picker
:suggest="emojiSuggestor"
class="form-control"
>
@@ -73,9 +77,16 @@
>
</EmojiInput>
<EmojiInput
+ ref="emoji-input"
v-model="newStatus.status"
:suggest="emojiUserSuggestor"
class="form-control main-input"
+ enable-emoji-picker
+ hide-emoji-button
+ enable-sticker-picker
+ @input="onEmojiInputInput"
+ @sticker-uploaded="addMediaFile"
+ @sticker-upload-failed="uploadFailed"
>
<textarea
ref="textarea"
@@ -89,6 +100,7 @@
@drop="fileDrop"
@dragover.prevent="fileDrag"
@input="resize"
+ @compositionupdate="resize"
@paste="paste"
/>
<p
@@ -152,30 +164,29 @@
<div class="form-bottom-left">
<media-upload
ref="mediaUpload"
+ class="media-upload-icon"
:drop-files="dropFiles"
@uploading="disableSubmit"
@uploaded="addMediaFile"
@upload-failed="uploadFailed"
/>
<div
- v-if="stickersAvailable"
- class="sticker-icon"
+ class="emoji-icon"
>
<i
- :title="$t('stickers.add_sticker')"
- class="icon-picture btn btn-default"
- :class="{ selected: stickerPickerVisible }"
- @click="toggleStickerPicker"
+ :title="$t('emoji.add_emoji')"
+ class="icon-smile btn btn-default"
+ @click="showEmojiPicker"
/>
</div>
<div
v-if="pollsAvailable"
class="poll-icon"
+ :class="{ selected: pollFormVisible }"
>
<i
:title="$t('polls.add_poll')"
class="icon-chart-bar btn btn-default"
- :class="pollFormVisible && 'selected'"
@click="togglePollForm"
/>
</div>
@@ -258,11 +269,6 @@
<label for="filesSensitive">{{ $t('post_status.attachments_sensitive') }}</label>
</div>
</form>
- <sticker-picker
- v-if="stickerPickerVisible"
- ref="stickerPicker"
- @uploaded="addMediaFile"
- />
</div>
</template>
@@ -299,6 +305,7 @@
.post-status-form {
.form-bottom {
display: flex;
+ justify-content: space-between;
padding: 0.5em;
height: 32px;
@@ -316,6 +323,9 @@
.form-bottom-left {
display: flex;
flex: 1;
+ padding-right: 7px;
+ margin-right: 7px;
+ max-width: 10em;
}
.text-format {
@@ -325,19 +335,38 @@
}
}
- .poll-icon, .sticker-icon {
+ .media-upload-icon, .poll-icon, .emoji-icon {
font-size: 26px;
flex: 1;
- .selected {
- color: $fallback--lightText;
- color: var(--lightText, $fallback--lightText);
+ i {
+ display: block;
+ width: 100%;
+ }
+
+ &.selected, &:hover {
+ // needs to be specific to override icon default color
+ i, label {
+ color: $fallback--lightText;
+ color: var(--lightText, $fallback--lightText);
+ }
}
}
- .sticker-icon {
- flex: 0;
- min-width: 50px;
+ // Order is not necessary but a good indicator
+ .media-upload-icon {
+ order: 1;
+ text-align: left;
+ }
+
+ .emoji-icon {
+ order: 2;
+ text-align: center;
+ }
+
+ .poll-icon {
+ order: 3;
+ text-align: right;
}
.icon-chart-bar {
@@ -369,6 +398,13 @@
}
}
+ .status-input-wrapper {
+ display: flex;
+ position: relative;
+ width: 100%;
+ flex-direction: column;
+ }
+
.attachments {
padding: 0 0.5em;
@@ -444,10 +480,6 @@
box-sizing: content-box;
}
- .form-post-body:focus {
- min-height: 48px;
- }
-
.main-input {
position: relative;
}
diff --git a/src/components/post_status_modal/post_status_modal.js b/src/components/post_status_modal/post_status_modal.js
new file mode 100644
index 00000000..1033ba11
--- /dev/null
+++ b/src/components/post_status_modal/post_status_modal.js
@@ -0,0 +1,32 @@
+import PostStatusForm from '../post_status_form/post_status_form.vue'
+
+const PostStatusModal = {
+ components: {
+ PostStatusForm
+ },
+ computed: {
+ isLoggedIn () {
+ return !!this.$store.state.users.currentUser
+ },
+ isOpen () {
+ return this.isLoggedIn && this.$store.state.postStatus.modalActivated
+ },
+ params () {
+ return this.$store.state.postStatus.params || {}
+ }
+ },
+ watch: {
+ isOpen (val) {
+ if (val) {
+ this.$nextTick(() => this.$el.querySelector('textarea').focus())
+ }
+ }
+ },
+ methods: {
+ closeModal () {
+ this.$store.dispatch('closePostStatusModal')
+ }
+ }
+}
+
+export default PostStatusModal
diff --git a/src/components/post_status_modal/post_status_modal.vue b/src/components/post_status_modal/post_status_modal.vue
new file mode 100644
index 00000000..3f8eec69
--- /dev/null
+++ b/src/components/post_status_modal/post_status_modal.vue
@@ -0,0 +1,43 @@
+<template>
+ <div
+ v-if="isOpen"
+ class="post-form-modal-view modal-view"
+ @click="closeModal"
+ >
+ <div
+ class="post-form-modal-panel panel"
+ @click.stop=""
+ >
+ <div class="panel-heading">
+ {{ $t('post_status.new_status') }}
+ </div>
+ <PostStatusForm
+ class="panel-body"
+ v-bind="params"
+ @posted="closeModal"
+ />
+ </div>
+ </div>
+</template>
+
+<script src="./post_status_modal.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.post-form-modal-view {
+ align-items: flex-start;
+}
+
+.post-form-modal-panel {
+ flex-shrink: 0;
+ margin-top: 25%;
+ margin-bottom: 2em;
+ width: 100%;
+ max-width: 700px;
+
+ @media (orientation: landscape) {
+ margin-top: 8%;
+ }
+}
+</style>
diff --git a/src/components/settings/settings.js b/src/components/settings/settings.js
index c4aa45b2..b6540d7e 100644
--- a/src/components/settings/settings.js
+++ b/src/components/settings/settings.js
@@ -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 })
},
diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue
index 744ec566..6d87a060 100644
--- a/src/components/settings/settings.vue
+++ b/src/components/settings/settings.vue
@@ -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>
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 64218f6e..93f37a49 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -413,7 +413,7 @@
v-if="replying"
class="container"
>
- <post-status-form
+ <PostStatusForm
class="reply-body"
:reply-to="status.id"
:attentions="status.attentions"
@@ -665,6 +665,15 @@ $status-margin: 0.75em;
height: 220px;
overflow-x: hidden;
overflow-y: hidden;
+ z-index: 1;
+ .status-content {
+ height: 100%;
+ mask: linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat,
+ linear-gradient(to top, white, white);
+ // Autoprefixed seem to ignore this one, and also syntax is different
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ }
}
.tall-status-hider {
@@ -676,12 +685,7 @@ $status-margin: 0.75em;
width: 100%;
text-align: center;
line-height: 110px;
- background: linear-gradient(to bottom, rgba(0, 0, 0, 0), $fallback--bg 80%);
- background: linear-gradient(to bottom, rgba(0, 0, 0, 0), var(--bg, $fallback--bg) 80%);
- &_focused {
- background: linear-gradient(to bottom, rgba(0, 0, 0, 0), $fallback--lightBg 80%);
- background: linear-gradient(to bottom, rgba(0, 0, 0, 0), var(--lightBg, $fallback--lightBg) 80%);
- }
+ z-index: 2;
}
.status-unhider, .cw-status-hider {
diff --git a/src/components/sticker_picker/sticker_picker.js b/src/components/sticker_picker/sticker_picker.js
index a6dcded3..8daf3f07 100644
--- a/src/components/sticker_picker/sticker_picker.js
+++ b/src/components/sticker_picker/sticker_picker.js
@@ -3,9 +3,9 @@ import statusPosterService from '../../services/status_poster/status_poster.serv
import TabSwitcher from '../tab_switcher/tab_switcher.js'
const StickerPicker = {
- components: [
+ components: {
TabSwitcher
- ],
+ },
data () {
return {
meta: {
diff --git a/src/components/sticker_picker/sticker_picker.vue b/src/components/sticker_picker/sticker_picker.vue
index 938204c8..323855b9 100644
--- a/src/components/sticker_picker/sticker_picker.vue
+++ b/src/components/sticker_picker/sticker_picker.vue
@@ -2,32 +2,30 @@
<div
class="sticker-picker"
>
- <div
- class="sticker-picker-panel"
+ <tab-switcher
+ class="tab-switcher"
+ :render-only-focused="true"
+ scrollable-tabs
>
- <tab-switcher
- :render-only-focused="true"
+ <div
+ v-for="stickerpack in pack"
+ :key="stickerpack.path"
+ :image-tooltip="stickerpack.meta.title"
+ :image="stickerpack.path + stickerpack.meta.tabIcon"
+ class="sticker-picker-content"
>
<div
- v-for="stickerpack in pack"
- :key="stickerpack.path"
- :image-tooltip="stickerpack.meta.title"
- :image="stickerpack.path + stickerpack.meta.tabIcon"
- class="sticker-picker-content"
+ v-for="sticker in stickerpack.meta.stickers"
+ :key="sticker"
+ class="sticker"
+ @click.stop.prevent="pick(stickerpack.path + sticker, stickerpack.meta.title)"
>
- <div
- v-for="sticker in stickerpack.meta.stickers"
- :key="sticker"
- class="sticker"
- @click="pick(stickerpack.path + sticker, stickerpack.meta.title)"
+ <img
+ :src="stickerpack.path + sticker"
>
- <img
- :src="stickerpack.path + sticker"
- >
- </div>
</div>
- </tab-switcher>
- </div>
+ </div>
+ </tab-switcher>
</div>
</template>
@@ -37,22 +35,24 @@
@import '../../_variables.scss';
.sticker-picker {
- .sticker-picker-panel {
- display: inline-block;
- width: 100%;
- .sticker-picker-content {
- max-height: 300px;
- overflow-y: scroll;
- overflow-x: auto;
- .sticker {
- display: inline-block;
- width: 20%;
- height: 20%;
- img {
- width: 100%;
- &:hover {
- filter: drop-shadow(0 0 5px var(--link, $fallback--link));
- }
+ width: 100%;
+ position: relative;
+ .tab-switcher {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ }
+ .sticker-picker-content {
+ .sticker {
+ display: inline-block;
+ width: 20%;
+ height: 20%;
+ img {
+ width: 100%;
+ &:hover {
+ filter: drop-shadow(0 0 5px var(--link, $fallback--link));
}
}
}
diff --git a/src/components/tab_switcher/tab_switcher.js b/src/components/tab_switcher/tab_switcher.js
index 08d5d08f..3ca316b9 100644
--- a/src/components/tab_switcher/tab_switcher.js
+++ b/src/components/tab_switcher/tab_switcher.js
@@ -4,7 +4,26 @@ import './tab_switcher.scss'
export default Vue.component('tab-switcher', {
name: 'TabSwitcher',
- props: ['renderOnlyFocused', 'onSwitch', 'activeTab'],
+ props: {
+ renderOnlyFocused: {
+ required: false,
+ type: Boolean,
+ default: false
+ },
+ onSwitch: {
+ required: false,
+ type: Function
+ },
+ activeTab: {
+ required: false,
+ type: String
+ },
+ scrollableTabs: {
+ required: false,
+ type: Boolean,
+ default: false
+ }
+ },
data () {
return {
active: this.$slots.default.findIndex(_ => _.tag)
@@ -28,7 +47,8 @@ export default Vue.component('tab-switcher', {
},
methods: {
activateTab (index) {
- return () => {
+ return (e) => {
+ e.preventDefault()
if (typeof this.onSwitch === 'function') {
this.onSwitch.call(null, this.$slots.default[index].key)
}
@@ -87,7 +107,7 @@ export default Vue.component('tab-switcher', {
<div class="tabs">
{tabs}
</div>
- <div class="contents">
+ <div class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')}>
{contents}
</div>
</div>
diff --git a/src/components/tab_switcher/tab_switcher.scss b/src/components/tab_switcher/tab_switcher.scss
index 4eeb42e0..3e5eacd5 100644
--- a/src/components/tab_switcher/tab_switcher.scss
+++ b/src/components/tab_switcher/tab_switcher.scss
@@ -1,10 +1,21 @@
@import '../../_variables.scss';
.tab-switcher {
+ display: flex;
+ flex-direction: column;
+
.contents {
+ flex: 1 0 auto;
+ min-height: 0px;
+
.hidden {
display: none;
}
+
+ &.scrollable-tabs {
+ flex-basis: 0;
+ overflow-y: auto;
+ }
}
.tabs {
display: flex;
diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js
index e3bd7697..9c931c01 100644
--- a/src/components/user_card/user_card.js
+++ b/src/components/user_card/user_card.js
@@ -37,19 +37,10 @@ export default {
const rgb = (typeof color === 'string') ? hex2rgb(color) : color
const tintColor = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .5)`
- const gradient = [
- [tintColor, this.hideBio ? '60%' : ''],
- this.hideBio ? [
- color, '100%'
- ] : [
- tintColor, ''
- ]
- ].map(_ => _.join(' ')).join(', ')
-
return {
backgroundColor: `rgb(${Math.floor(rgb.r * 0.53)}, ${Math.floor(rgb.g * 0.56)}, ${Math.floor(rgb.b * 0.59)})`,
backgroundImage: [
- `linear-gradient(to bottom, ${gradient})`,
+ `linear-gradient(to bottom, ${tintColor}, ${tintColor})`,
`url(${this.user.cover_photo})`
].join(', ')
}
@@ -168,6 +159,9 @@ export default {
}
this.$store.dispatch('setMedia', [attachment])
this.$store.dispatch('setCurrent', attachment)
+ },
+ mentionUser () {
+ this.$store.dispatch('openPostStatusModal', { replyTo: true, repliedUser: this.user })
}
}
}
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index 0b83cf16..5b6f66e7 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -2,8 +2,12 @@
<div
class="user-card"
:class="classes"
- :style="style"
>
+ <div
+ :class="{ 'hide-bio': hideBio }"
+ :style="style"
+ class="background-image"
+ />
<div class="panel-heading">
<div class="user-info">
<div class="container">
@@ -190,6 +194,15 @@
<div>
<button
+ class="btn btn-default btn-block"
+ @click="mentionUser"
+ >
+ {{ $t('user_card.mention') }}
+ </button>
+ </div>
+
+ <div>
+ <button
v-if="user.muted"
class="btn btn-default btn-block pressed"
@click="unmuteUser"
@@ -298,7 +311,7 @@
@import '../../_variables.scss';
.user-card {
- background-size: cover;
+ position: relative;
.panel-heading {
padding: .5em 0;
@@ -307,14 +320,35 @@
background: transparent;
flex-direction: column;
align-items: stretch;
+ // create new stacking context
+ position: relative;
}
.panel-body {
word-wrap: break-word;
- background: linear-gradient(to bottom, rgba(0, 0, 0, 0), $fallback--bg 80%);
- background: linear-gradient(to bottom, rgba(0, 0, 0, 0), var(--bg, $fallback--bg) 80%);
border-bottom-right-radius: inherit;
border-bottom-left-radius: inherit;
+ // create new stacking context
+ position: relative;
+ }
+
+ .background-image {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ mask: linear-gradient(to top, white, transparent) bottom no-repeat,
+ linear-gradient(to top, white, white);
+ // Autoprefixed seem to ignore this one, and also syntax is different
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ background-size: cover;
+ mask-size: 100% 60%;
+
+ &.hide-bio {
+ mask-size: 100% 40px;
+ }
}
p {
diff --git a/src/components/user_panel/user_panel.vue b/src/components/user_panel/user_panel.vue
index c92630e3..e9f08015 100644
--- a/src/components/user_panel/user_panel.vue
+++ b/src/components/user_panel/user_panel.vue
@@ -11,7 +11,7 @@
rounded="top"
/>
<div class="panel-footer">
- <post-status-form v-if="user" />
+ <PostStatusForm />
</div>
</div>
<auth-form
diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js
index 108c82d2..21b01476 100644
--- a/src/components/user_settings/user_settings.js
+++ b/src/components/user_settings/user_settings.js
@@ -11,8 +11,8 @@ import BlockCard from '../block_card/block_card.vue'
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 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'
diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue
index e78f6e7c..a84e6f5c 100644
--- a/src/components/user_settings/user_settings.vue
+++ b/src/components/user_settings/user_settings.vue
@@ -32,6 +32,7 @@
<p>{{ $t('settings.name') }}</p>
<EmojiInput
v-model="newName"
+ enable-emoji-picker
:suggest="emojiSuggestor"
>
<input
@@ -43,6 +44,7 @@
<p>{{ $t('settings.bio') }}</p>
<EmojiInput
v-model="newBio"
+ enable-emoji-picker
:suggest="emojiUserSuggestor"
>
<textarea
diff --git a/src/directives/body_scroll_lock.js b/src/directives/body_scroll_lock.js
new file mode 100644
index 00000000..6ab20c3f
--- /dev/null
+++ b/src/directives/body_scroll_lock.js
@@ -0,0 +1,69 @@
+import * as bodyScrollLock from 'body-scroll-lock'
+
+let previousNavPaddingRight
+let previousAppBgWrapperRight
+
+const disableBodyScroll = (el) => {
+ const scrollBarGap = window.innerWidth - document.documentElement.clientWidth
+ bodyScrollLock.disableBodyScroll(el, {
+ reserveScrollBarGap: true
+ })
+ setTimeout(() => {
+ // If previousNavPaddingRight is already set, don't set it again.
+ if (previousNavPaddingRight === undefined) {
+ const navEl = document.getElementById('nav')
+ previousNavPaddingRight = window.getComputedStyle(navEl).getPropertyValue('padding-right')
+ navEl.style.paddingRight = previousNavPaddingRight ? `calc(${previousNavPaddingRight} + ${scrollBarGap}px)` : `${scrollBarGap}px`
+ }
+ // If previousAppBgWrapeprRight is already set, don't set it again.
+ if (previousAppBgWrapperRight === undefined) {
+ const appBgWrapperEl = document.getElementById('app_bg_wrapper')
+ previousAppBgWrapperRight = window.getComputedStyle(appBgWrapperEl).getPropertyValue('right')
+ appBgWrapperEl.style.right = previousAppBgWrapperRight ? `calc(${previousAppBgWrapperRight} + ${scrollBarGap}px)` : `${scrollBarGap}px`
+ }
+ document.body.classList.add('scroll-locked')
+ })
+}
+
+const enableBodyScroll = (el) => {
+ setTimeout(() => {
+ if (previousNavPaddingRight !== undefined) {
+ document.getElementById('nav').style.paddingRight = previousNavPaddingRight
+ // Restore previousNavPaddingRight to undefined so disableBodyScroll knows it can be set again.
+ previousNavPaddingRight = undefined
+ }
+ if (previousAppBgWrapperRight !== undefined) {
+ document.getElementById('app_bg_wrapper').style.right = previousAppBgWrapperRight
+ // Restore previousAppBgWrapperRight to undefined so disableBodyScroll knows it can be set again.
+ previousAppBgWrapperRight = undefined
+ }
+ document.body.classList.remove('scroll-locked')
+ })
+ bodyScrollLock.enableBodyScroll(el)
+}
+
+const directive = {
+ inserted: (el, binding) => {
+ if (binding.value) {
+ disableBodyScroll(el)
+ }
+ },
+ componentUpdated: (el, binding) => {
+ if (binding.oldValue === binding.value) {
+ return
+ }
+
+ if (binding.value) {
+ disableBodyScroll(el)
+ } else {
+ enableBodyScroll(el)
+ }
+ },
+ unbind: (el) => {
+ enableBodyScroll(el)
+ }
+}
+
+export default (Vue) => {
+ Vue.directive('body-scroll-lock', directive)
+}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index a63ee21d..25579e76 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -106,8 +106,14 @@
"expired": "Poll ended {0} ago",
"not_enough_options": "Too few unique options in poll"
},
- "stickers": {
- "add_sticker": "Add Sticker"
+ "emoji": {
+ "stickers": "Stickers",
+ "emoji": "Emoji",
+ "keep_open": "Keep picker open",
+ "search_emoji": "Search for an emoji",
+ "add_emoji": "Insert emoji",
+ "custom": "Custom emoji",
+ "unicode": "Unicode emoji"
},
"interactions": {
"favs_repeats": "Repeats and Favorites",
@@ -226,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",
@@ -531,6 +538,7 @@
"follows_you": "Follows you!",
"its_you": "It's you!",
"media": "Media",
+ "mention": "Mention",
"mute": "Mute",
"muted": "Muted",
"per_day": "per day",
diff --git a/src/i18n/es.json b/src/i18n/es.json
index 009599f5..91c7f383 100644
--- a/src/i18n/es.json
+++ b/src/i18n/es.json
@@ -508,7 +508,9 @@
"pinned": "Fijado",
"delete_confirm": "¿Realmente quieres borrar la publicación?",
"reply_to": "Respondiendo a",
- "replies_list": "Respuestas:"
+ "replies_list": "Respuestas:",
+ "mute_conversation": "Silenciar la conversación",
+ "unmute_conversation": "Mostrar la conversación"
},
"user_card": {
"approve": "Aprobar",
@@ -606,5 +608,16 @@
"person_talking": "{count} personas hablando",
"people_talking": "{count} gente hablando",
"no_results": "Sin resultados"
+ },
+ "password_reset": {
+ "forgot_password": "¿Contraseña olvidada?",
+ "password_reset": "Restablecer la contraseña",
+ "instruction": "Ingrese su dirección de correo electrónico o nombre de usuario. Le enviaremos un enlace para restablecer su contraseña.",
+ "placeholder": "Su correo electrónico o nombre de usuario",
+ "check_email": "Revise su correo electrónico para obtener un enlace para restablecer su contraseña.",
+ "return_home": "Volver a la página de inicio",
+ "not_found": "No pudimos encontrar ese correo electrónico o nombre de usuario.",
+ "too_many_requests": "Has alcanzado el límite de intentos, vuelve a intentarlo más tarde.",
+ "password_reset_disabled": "El restablecimiento de contraseñas está deshabilitado. Póngase en contacto con el administrador de su instancia."
}
} \ No newline at end of file
diff --git a/src/i18n/eu.json b/src/i18n/eu.json
index 1efaa310..ad8f4c05 100644
--- a/src/i18n/eu.json
+++ b/src/i18n/eu.json
@@ -88,7 +88,7 @@
"followed_you": "Zu jarraitzen zaitu",
"load_older": "Kargatu jakinarazpen zaharragoak",
"notifications": "Jakinarazpenak",
- "read": "Irakurri!",
+ "read": "Irakurrita!",
"repeated_you": "zure mezua errepikatu du",
"no_more_notifications": "Ez dago jakinarazpen gehiago"
},
@@ -116,7 +116,7 @@
},
"post_status": {
"new_status": "Mezu berri bat idatzi",
- "account_not_locked_warning": "Zure kontua ez dago {0}. Edozeinek jarraitzen hastearekin, zure mezuak irakur dezake.",
+ "account_not_locked_warning": "Zure kontua ez dago {0}. Edozeinek jarraitzen hastearekin, zure mezuak irakur ditzake.",
"account_not_locked_warning_link": "Blokeatuta",
"attachments_sensitive": "Nabarmendu eranskinak hunkigarri gisa ",
"content_type": {
@@ -136,10 +136,10 @@
"unlisted": "Mezu hau ez da argitaratuko Denbora-lerro Publikoan ezta Ezagutzen den Sarean"
},
"scope": {
- "direct": "Zuzena - Bidali aipatutako erabiltzaileei besterik ez",
- "private": "Jarraitzaileentzako bakarrik- Bidali jarraitzaileentzat bakarrik",
- "public": "Publickoa - Bistaratu denbora-lerro publikoetan",
- "unlisted": "Zerrendatu gabea - ez bidali denbora-lerro publikoetan"
+ "direct": "Zuzena: Bidali aipatutako erabiltzaileei besterik ez",
+ "private": "Jarraitzaileentzako bakarrik: Bidali jarraitzaileentzat bakarrik",
+ "public": "Publikoa: Bistaratu denbora-lerro publikoetan",
+ "unlisted": "Zerrendatu gabea: ez bidali denbora-lerro publikoetara"
}
},
"registration": {
@@ -228,7 +228,7 @@
"avatar_size_instruction": "Avatar irudien gomendatutako gutxieneko tamaina 150x150 pixel dira.",
"export_theme": "Gorde aurre-ezarpena",
"filtering": "Iragazten",
- "filtering_explanation": "Hitz hauek dituzten muzu guztiak isilduak izango dira. Lerro bakoitzeko bat",
+ "filtering_explanation": "Hitz hauek dituzten mezu guztiak isilduak izango dira. Lerro bakoitzeko bat",
"follow_export": "Jarraitzen dituzunak esportatu",
"follow_export_button": "Esportatu zure jarraitzaileak csv fitxategi batean",
"follow_import": "Jarraitzen dituzunak inportatu",
@@ -276,7 +276,7 @@
"no_blocks": "Ez daude erabiltzaile blokeatutak",
"no_mutes": "Ez daude erabiltzaile mututuak",
"hide_follows_description": "Ez erakutsi nor jarraitzen ari naizen",
- "hide_followers_description": "Ez erakutsi nor ari de ni jarraitzen",
+ "hide_followers_description": "Ez erakutsi nor ari den ni jarraitzen",
"show_admin_badge": "Erakutsi Administratzaile etiketa nire profilan",
"show_moderator_badge": "Erakutsi Moderatzaile etiketa nire profilan",
"nsfw_clickthrough": "Gaitu klika hunkigarri eranskinak ezkutatzeko",
@@ -456,8 +456,8 @@
"time": {
"day": "{0} egun",
"days": "{0} egun",
- "day_short": "{0}d",
- "days_short": "{0}d",
+ "day_short": "{0}e",
+ "days_short": "{0}e",
"hour": "{0} ordu",
"hours": "{0} ordu",
"hour_short": "{0}o",
@@ -492,7 +492,7 @@
"conversation": "Elkarrizketa",
"error_fetching": "Errorea eguneraketak eskuratzen",
"load_older": "Kargatu mezu zaharragoak",
- "no_retweet_hint": "Mezu hau jarraitzailentzko bakarrik markatuta dago eta ezin da errepikatu",
+ "no_retweet_hint": "Mezu hau jarraitzailentzako bakarrik markatuta dago eta ezin da errepikatu",
"repeated": "Errepikatuta",
"show_new": "Berriena erakutsi",
"up_to_date": "Eguneratuta",
@@ -507,8 +507,10 @@
"unpin": "Aingura ezeztatu profilatik",
"pinned": "Ainguratuta",
"delete_confirm": "Mezu hau benetan ezabatu nahi duzu?",
- "reply_to": "Erantzun",
- "replies_list": "Erantzunak:"
+ "reply_to": "Erantzuten",
+ "replies_list": "Erantzunak:",
+ "mute_conversation": "Elkarrizketa isilarazi",
+ "unmute_conversation": "Elkarrizketa aktibatu"
},
"user_card": {
"approve": "Onartu",
@@ -581,7 +583,7 @@
},
"tool_tip": {
"media_upload": "Multimedia igo",
- "repeat": "Erreplikatu",
+ "repeat": "Errepikatu",
"reply": "Erantzun",
"favorite": "Gogokoa",
"user_settings": "Erabiltzaile ezarpenak"
@@ -601,10 +603,21 @@
}
},
"search": {
- "people": "Gendea",
+ "people": "Erabiltzaileak",
"hashtags": "Traolak",
"person_talking": "{count} pertsona hitzegiten",
- "people_talking": "{count} gende hitzegiten",
+ "people_talking": "{count} jende hitzegiten",
"no_results": "Emaitzarik ez"
+ },
+ "password_reset": {
+ "forgot_password": "Pasahitza ahaztua?",
+ "password_reset": "Pasahitza berrezarri",
+ "instruction": "Idatzi zure helbide elektronikoa edo erabiltzaile izena. Pasahitza berrezartzeko esteka bidaliko dizugu.",
+ "placeholder": "Zure e-posta edo erabiltzaile izena",
+ "check_email": "Begiratu zure posta elektronikoa pasahitza berrezarri ahal izateko.",
+ "return_home": "Itzuli hasierara",
+ "not_found": "Ezin izan dugu helbide elektroniko edo erabiltzaile hori aurkitu.",
+ "too_many_requests": "Saiakera gehiegi burutu ditzu, saiatu berriro geroxeago.",
+ "password_reset_disabled": "Pasahitza berrezartzea debekatuta dago. Mesedez, jarri harremanetan instantzia administratzailearekin."
}
} \ No newline at end of file
diff --git a/src/i18n/oc.json b/src/i18n/oc.json
index 6100a4d2..680ad6dd 100644
--- a/src/i18n/oc.json
+++ b/src/i18n/oc.json
@@ -5,7 +5,7 @@
"exporter": {
"export": "Exportar",
"processing": "Tractament, vos demandarem lèu de telecargar lo fichièr"
- },
+ },
"features_panel": {
"chat": "Chat",
"gopher": "Gopher",
@@ -30,12 +30,12 @@
"cancel": "Anullar"
},
"image_cropper": {
- "crop_picture": "Talhar l’imatge",
- "save": "Salvar",
- "save_without_cropping": "Salvar sens talhada",
- "cancel": "Anullar"
+ "crop_picture": "Talhar l’imatge",
+ "save": "Salvar",
+ "save_without_cropping": "Salvar sens talhada",
+ "cancel": "Anullar"
},
- "importer": {
+ "importer": {
"submit": "Mandar",
"success": "Corrèctament importat.",
"error": "Una error s’es producha pendent l’importacion d’aqueste fichièr."
@@ -65,6 +65,7 @@
"timeline": "Flux d’actualitat",
"twkn": "Lo malhum conegut",
"user_search": "Cèrca d’utilizaires",
+ "search": "Cercar",
"who_to_follow": "Qual seguir",
"preferences": "Preferéncias"
},
@@ -79,19 +80,27 @@
"no_more_notifications": "Pas mai de notificacions"
},
"polls": {
-"add_poll": "Ajustar un sondatge",
+ "add_poll": "Ajustar un sondatge",
"add_option": "Ajustar d’opcions",
- "option": "Opcion",
- "votes": "vòtes",
- "vote": "Votar",
- "type": "Tipe de sondatge",
- "single_choice": "Causida unica",
- "multiple_choices": "Causida multipla",
- "expiry": "Durada del sondatge",
- "expires_in": "Lo sondatge s’acabarà {0}",
- "expired": "Sondatge acabat {0}",
- "not_enough_options": "I a pas pro d’opcions"
- },
+ "option": "Opcion",
+ "votes": "vòtes",
+ "vote": "Votar",
+ "type": "Tipe de sondatge",
+ "single_choice": "Causida unica",
+ "multiple_choices": "Causida multipla",
+ "expiry": "Durada del sondatge",
+ "expires_in": "Lo sondatge s’acabarà {0}",
+ "expired": "Sondatge acabat {0}",
+ "not_enough_options": "I a pas pro d’opcions"
+ },
+ "stickers": {
+ "add_sticker": "Ajustar un pegasolet"
+ },
+ "interactions": {
+ "favs_repeats": "Repeticions e favorits",
+ "follows": "Nòus seguidors",
+ "load_older": "Cargar d’interaccions anterioras"
+ },
"post_status": {
"new_status": "Publicar d’estatuts novèls",
"account_not_locked_warning": "Vòstre compte es pas {0}. Qual que siá pòt vos seguir per veire vòstras publicacions destinadas pas qu’a vòstres seguidors.",
@@ -137,8 +146,8 @@
}
},
"selectable_list": {
- "select_all": "O seleccionar tot"
- },
+ "select_all": "O seleccionar tot"
+ },
"settings": {
"app_name": "Nom de l’aplicacion",
"attachmentRadius": "Pèças juntas",
@@ -216,7 +225,6 @@
"use_contain_fit": "Talhar pas las pèças juntas per las vinhetas",
"name": "Nom",
"name_bio": "Nom & Bio",
-
"new_password": "Nòu senhal",
"notification_visibility_follows": "Abonaments",
"notification_visibility_likes": "Aimar",
@@ -264,12 +272,12 @@
"subject_line_email": "Coma los corrièls : \"re: subjècte\"",
"subject_line_mastodon": "Coma mastodon : copiar tal coma es",
"subject_line_noop": "Copiar pas",
-"post_status_content_type": "Publicar lo tipe de contengut dels estatuts",
+ "post_status_content_type": "Publicar lo tipe de contengut dels estatuts",
"stop_gifs": "Lançar los GIFs al subrevòl",
"streaming": "Activar lo cargament automatic dels novèls estatus en anar amont",
"text": "Tèxte",
"theme": "Tèma",
- "theme_help_v2_1": "You can also override certain component's colors and opacity by toggling the checkbox, use \"Clear all\" button to clear all overrides.",
+ "theme_help_v2_1": "Podètz tanben remplaçar la color d’unes compausants en clicant la case, utilizatz lo boton \"O escafar tot\" per escafar totes las subrecargadas.",
"theme_help_v2_2": "Icons underneath some entries are background/text contrast indicators, hover over for detailed info. Please keep in mind that when using transparency contrast indicators show the worst possible case.",
"theme_help": "Emplegatz los còdis de color hex (#rrggbb) per personalizar vòstre tèma de color.",
"tooltipRadius": "Astúcias/alèrtas",
@@ -280,14 +288,14 @@
"true": "òc"
},
"notifications": "Notificacions",
- "notification_setting": "Receber las notificacions de :",
+ "notification_setting": "Recebre las notificacions de :",
"notification_setting_follows": "Utilizaires que seguissètz",
"notification_setting_non_follows": "Utilizaires que seguissètz pas",
"notification_setting_followers": "Utilizaires que vos seguisson",
"notification_setting_non_followers": "Utilizaires que vos seguisson pas",
- "notification_mutes": "Per receber pas mai d’un utilizaire en particular, botatz-lo en silenci.",
+ "notification_mutes": "Per recebre pas mai d’un utilizaire en particular, botatz-lo en silenci.",
"notification_blocks": "Blocar un utilizaire arrèsta totas las notificacions tan coma quitar de los seguir.",
- "enable_web_push_notifications": "Activar las notificacions web push",
+ "enable_web_push_notifications": "Activar las notificacions web push",
"style": {
"switcher": {
"keep_color": "Gardar las colors",
@@ -442,7 +450,7 @@
"conversation": "Conversacion",
"error_fetching": "Error en cercant de mesas a jorn",
"load_older": "Ne veire mai",
- "no_retweet_hint": "Las publicacions marcadas pels seguidors solament o dirèctas se pòdon pas repetir",
+ "no_retweet_hint": "Las publicacions marcadas pels seguidors solament o dirèctas se pòdon pas repetir",
"repeated": "repetit",
"show_new": "Ne veire mai",
"up_to_date": "A jorn",
@@ -477,6 +485,8 @@
"per_day": "per jorn",
"remote_follow": "Seguir a distància",
"statuses": "Estatuts",
+ "subscribe": "S’abonar",
+ "unsubscribe": "Se desabonar",
"unblock": "Desblocar",
"unblock_progress": "Desblocatge...",
"block_progress": "Blocatge...",
@@ -501,7 +511,7 @@
"quarantine": "Defendre la federacion de las publicacions de l’utilizaire",
"delete_user": "Suprimir l’utilizaire",
"delete_user_confirmation": "Volètz vertadièrament far aquò ? Aquesta accion se pòt pas anullar."
- }
+ }
},
"user_profile": {
"timeline_title": "Flux utilizaire",
@@ -532,5 +542,12 @@
"GiB": "Gio",
"TiB": "Tio"
}
+ },
+ "search": {
+ "people": "Gent",
+ "hashtags": "Etiquetas",
+ "person_talking": "{count} persona ne parla",
+ "people_talking": "{count} personas ne parlan",
+ "no_results": "Cap de resultats"
}
} \ No newline at end of file
diff --git a/src/main.js b/src/main.js
index b3256e8e..7923ffe8 100644
--- a/src/main.js
+++ b/src/main.js
@@ -15,6 +15,7 @@ import mediaViewerModule from './modules/media_viewer.js'
import oauthTokensModule from './modules/oauth_tokens.js'
import reportsModule from './modules/reports.js'
import pollsModule from './modules/polls.js'
+import postStatusModule from './modules/postStatus.js'
import VueI18n from 'vue-i18n'
@@ -26,6 +27,7 @@ import messages from './i18n/messages.js'
import VueChatScroll from 'vue-chat-scroll'
import VueClickOutside from 'v-click-outside'
import PortalVue from 'portal-vue'
+import VBodyScrollLock from './directives/body_scroll_lock'
import VTooltip from 'v-tooltip'
import afterStoreSetup from './boot/after_store.js'
@@ -38,6 +40,7 @@ Vue.use(VueI18n)
Vue.use(VueChatScroll)
Vue.use(VueClickOutside)
Vue.use(PortalVue)
+Vue.use(VBodyScrollLock)
Vue.use(VTooltip)
const i18n = new VueI18n({
@@ -76,7 +79,8 @@ const persistedStateOptions = {
mediaViewer: mediaViewerModule,
oauthTokens: oauthTokensModule,
reports: reportsModule,
- polls: pollsModule
+ polls: pollsModule,
+ postStatus: postStatusModule
},
plugins: [persistedState, pushNotifications],
strict: false // Socket modifies itself, let's ignore this for now.
diff --git a/src/modules/config.js b/src/modules/config.js
index 2bfad8f6..cf04d14f 100644
--- a/src/modules/config.js
+++ b/src/modules/config.js
@@ -7,6 +7,7 @@ const defaultState = {
colors: {},
hideMutedPosts: undefined, // instance default
collapseMessageWithSubject: undefined, // instance default
+ padEmoji: true,
hideAttachments: false,
hideAttachmentsInConv: false,
maxThumbnails: 16,
diff --git a/src/modules/postStatus.js b/src/modules/postStatus.js
new file mode 100644
index 00000000..638c1fb2
--- /dev/null
+++ b/src/modules/postStatus.js
@@ -0,0 +1,25 @@
+const postStatus = {
+ state: {
+ params: null,
+ modalActivated: false
+ },
+ mutations: {
+ openPostStatusModal (state, params) {
+ state.params = params
+ state.modalActivated = true
+ },
+ closePostStatusModal (state) {
+ state.modalActivated = false
+ }
+ },
+ actions: {
+ openPostStatusModal ({ commit }, params) {
+ commit('openPostStatusModal', params)
+ },
+ closePostStatusModal ({ commit }) {
+ commit('closePostStatusModal')
+ }
+ }
+}
+
+export default postStatus
diff --git a/src/modules/statuses.js b/src/modules/statuses.js
index 4356d0a7..918065d2 100644
--- a/src/modules/statuses.js
+++ b/src/modules/statuses.js
@@ -426,9 +426,13 @@ export const mutations = {
newStatus.favoritedBy.push(user)
}
},
- setMuted (state, status) {
+ setMutedStatus (state, status) {
const newStatus = state.allStatusesObject[status.id]
- newStatus.muted = status.muted
+ newStatus.thread_muted = status.thread_muted
+
+ if (newStatus.thread_muted !== undefined) {
+ state.conversationsObject[newStatus.statusnet_conversation_id].forEach(status => { status.thread_muted = newStatus.thread_muted })
+ }
},
setRetweeted (state, { status, value }) {
const newStatus = state.allStatusesObject[status.id]
@@ -566,11 +570,11 @@ const statuses = {
},
muteConversation ({ rootState, commit }, statusId) {
return rootState.api.backendInteractor.muteConversation(statusId)
- .then((status) => commit('setMuted', status))
+ .then((status) => commit('setMutedStatus', status))
},
unmuteConversation ({ rootState, commit }, statusId) {
return rootState.api.backendInteractor.unmuteConversation(statusId)
- .then((status) => commit('setMuted', status))
+ .then((status) => commit('setMutedStatus', status))
},
retweet ({ rootState, commit }, status) {
// Optimistic retweeting...
diff --git a/src/services/notifications_fetcher/notifications_fetcher.service.js b/src/services/notifications_fetcher/notifications_fetcher.service.js
index f9ec3f6e..b6c4cf80 100644
--- a/src/services/notifications_fetcher/notifications_fetcher.service.js
+++ b/src/services/notifications_fetcher/notifications_fetcher.service.js
@@ -10,6 +10,11 @@ const fetchAndUpdate = ({ store, credentials, older = false }) => {
const args = { credentials }
const rootState = store.rootState || store.state
const timelineData = rootState.statuses.notifications
+ const hideMutedPosts = typeof rootState.config.hideMutedPosts === 'undefined'
+ ? rootState.instance.hideMutedPosts
+ : rootState.config.hideMutedPosts
+
+ args['withMuted'] = !hideMutedPosts
args['timeline'] = 'notifications'
if (older) {
diff --git a/src/services/offset_finder/offset_finder.service.js b/src/services/offset_finder/offset_finder.service.js
new file mode 100644
index 00000000..9034f8c8
--- /dev/null
+++ b/src/services/offset_finder/offset_finder.service.js
@@ -0,0 +1,31 @@
+export const findOffset = (child, parent, { top = 0, left = 0 } = {}, ignorePadding = true) => {
+ const result = {
+ top: top + child.offsetTop,
+ left: left + child.offsetLeft
+ }
+ if (!ignorePadding && child !== window) {
+ const { topPadding, leftPadding } = findPadding(child)
+ result.top += ignorePadding ? 0 : topPadding
+ result.left += ignorePadding ? 0 : leftPadding
+ }
+
+ if (child.offsetParent && (parent === window || parent.contains(child.offsetParent) || parent === child.offsetParent)) {
+ return findOffset(child.offsetParent, parent, result, false)
+ } else {
+ if (parent !== window) {
+ const { topPadding, leftPadding } = findPadding(parent)
+ result.top += topPadding
+ result.left += leftPadding
+ }
+ return result
+ }
+}
+
+const findPadding = (el) => {
+ const topPaddingStr = window.getComputedStyle(el)['padding-top']
+ const topPadding = Number(topPaddingStr.substring(0, topPaddingStr.length - 2))
+ const leftPaddingStr = window.getComputedStyle(el)['padding-left']
+ const leftPadding = Number(leftPaddingStr.substring(0, leftPaddingStr.length - 2))
+
+ return { topPadding, leftPadding }
+}