Merge branch 'develop' into feature/following_reblogs

This commit is contained in:
Maksim Pechnikov 2019-09-26 21:14:31 +03:00
commit 19cb98b85f
52 changed files with 1590 additions and 378 deletions

13
CHANGELOG.md Normal file
View file

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

View file

@ -23,6 +23,15 @@ Posts will contain the text you are posting, but some content will be modified:
**Depending on your instance some of the options might not be available or have different defaults** **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. 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. * **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). * **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: * **Visiblity scope** controls who will be able to see your posts. There are four scopes available:

BIN
docs/example_emoji.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 B

View file

@ -9,7 +9,7 @@
<link rel="stylesheet" href="/static/font/css/fontello.css"> <link rel="stylesheet" href="/static/font/css/fontello.css">
<link rel="stylesheet" href="/static/font/css/animation.css"> <link rel="stylesheet" href="/static/font/css/animation.css">
</head> </head>
<body> <body class="hidden">
<noscript>To use Pleroma, please enable JavaScript.</noscript> <noscript>To use Pleroma, please enable JavaScript.</noscript>
<div id="app"></div> <div id="app"></div>
<!-- built files will be auto injected --> <!-- built files will be auto injected -->

View file

@ -18,6 +18,7 @@
"@chenfengyuan/vue-qrcode": "^1.0.0", "@chenfengyuan/vue-qrcode": "^1.0.0",
"babel-plugin-add-module-exports": "^0.2.1", "babel-plugin-add-module-exports": "^0.2.1",
"babel-plugin-lodash": "^3.2.11", "babel-plugin-lodash": "^3.2.11",
"body-scroll-lock": "^2.6.4",
"chromatism": "^3.0.0", "chromatism": "^3.0.0",
"cropperjs": "^1.4.3", "cropperjs": "^1.4.3",
"diff": "^3.0.1", "diff": "^3.0.1",

View file

@ -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 ChatPanel from './components/chat_panel/chat_panel.vue'
import MediaModal from './components/media_modal/media_modal.vue' import MediaModal from './components/media_modal/media_modal.vue'
import SideDrawer from './components/side_drawer/side_drawer.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 MobileNav from './components/mobile_nav/mobile_nav.vue'
import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.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' import { windowWidth } from './services/window_utils/window_utils'
export default { export default {
@ -26,9 +27,10 @@ export default {
ChatPanel, ChatPanel,
MediaModal, MediaModal,
SideDrawer, SideDrawer,
MobilePostStatusModal, MobilePostStatusButton,
MobileNav, MobileNav,
UserReportingModal UserReportingModal,
PostStatusModal
}, },
data: () => ({ data: () => ({
mobileActivePanel: 'timeline', mobileActivePanel: 'timeline',

View file

@ -10,7 +10,8 @@
position: fixed; position: fixed;
z-index: -1; z-index: -1;
height: 100%; height: 100%;
width: 100%; left: 0;
right: -20px;
background-size: cover; background-size: cover;
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: 0 50%; background-position: 0 50%;
@ -347,6 +348,7 @@ i[class*=icon-] {
align-items: center; align-items: center;
position: fixed; position: fixed;
height: 50px; height: 50px;
box-sizing: border-box;
.logo { .logo {
display: flex; display: flex;
@ -386,6 +388,7 @@ i[class*=icon-] {
} }
.inner-nav { .inner-nav {
position: relative;
margin: auto; margin: auto;
box-sizing: border-box; box-sizing: border-box;
padding-left: 10px; padding-left: 10px;

View file

@ -4,6 +4,7 @@
:style="bgAppStyle" :style="bgAppStyle"
> >
<div <div
id="app_bg_wrapper"
class="app-bg-wrapper" class="app-bg-wrapper"
:style="bgStyle" :style="bgStyle"
/> />
@ -14,6 +15,7 @@
class="nav-bar container" class="nav-bar container"
@click="scrollToTop()" @click="scrollToTop()"
> >
<div class="inner-nav">
<div <div
class="logo" class="logo"
:style="logoBgStyle" :style="logoBgStyle"
@ -27,7 +29,6 @@
:style="logoStyle" :style="logoStyle"
> >
</div> </div>
<div class="inner-nav">
<div class="item"> <div class="item">
<router-link <router-link
class="site-name" class="site-name"
@ -107,8 +108,9 @@
:floating="true" :floating="true"
class="floating-chat mobile-hidden" class="floating-chat mobile-hidden"
/> />
<MobilePostStatusModal /> <MobilePostStatusButton />
<UserReportingModal /> <UserReportingModal />
<PostStatusModal />
<portal-target name="modal" /> <portal-target name="modal" />
</div> </div>
</template> </template>

View file

@ -184,7 +184,7 @@ const getStaticEmoji = async ({ store }) => {
imageUrl: false, imageUrl: false,
replacement: values[key] replacement: values[key]
} }
}) }).sort((a, b) => a.displayText - b.displayText)
store.dispatch('setInstanceOption', { name: 'emoji', value: emoji }) store.dispatch('setInstanceOption', { name: 'emoji', value: emoji })
} else { } else {
throw (res) throw (res)
@ -203,14 +203,16 @@ const getCustomEmoji = async ({ store }) => {
if (res.ok) { if (res.ok) {
const result = await res.json() const result = await res.json()
const values = Array.isArray(result) ? Object.assign({}, ...result) : result const values = Array.isArray(result) ? Object.assign({}, ...result) : result
const emoji = Object.keys(values).map((key) => { const emoji = Object.entries(values).map(([key, value]) => {
const imageUrl = values[key].image_url const imageUrl = value.image_url
return { return {
displayText: key, 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}: ` 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: 'customEmoji', value: emoji })
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: true }) store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: true })
} else { } else {

View file

@ -1,5 +1,7 @@
import Completion from '../../services/completion/completion.js' import Completion from '../../services/completion/completion.js'
import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import { take } from 'lodash' import { take } from 'lodash'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
/** /**
* EmojiInput - augmented inputs for emoji and autocomplete support in inputs * EmojiInput - augmented inputs for emoji and autocomplete support in inputs
@ -52,6 +54,31 @@ const EmojiInput = {
*/ */
required: true, required: true,
type: String 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 () { data () {
@ -60,10 +87,20 @@ const EmojiInput = {
highlighted: 0, highlighted: 0,
caret: 0, caret: 0,
focused: false, focused: false,
blurTimeout: null blurTimeout: null,
showPicker: false,
temporarilyHideSuggestions: false,
keepOpen: false,
disableClickOutside: false
} }
}, },
components: {
EmojiPicker
},
computed: { computed: {
padEmoji () {
return this.$store.state.config.padEmoji
},
suggestions () { suggestions () {
const firstchar = this.textAtCaret.charAt(0) const firstchar = this.textAtCaret.charAt(0)
if (this.textAtCaret === firstchar) { return [] } if (this.textAtCaret === firstchar) { return [] }
@ -79,8 +116,12 @@ const EmojiInput = {
highlighted: index === this.highlighted highlighted: index === this.highlighted
})) }))
}, },
showPopup () { showSuggestions () {
return this.focused && this.suggestions && this.suggestions.length > 0 return this.focused &&
this.suggestions &&
this.suggestions.length > 0 &&
!this.showPicker &&
!this.temporarilyHideSuggestions
}, },
textAtCaret () { textAtCaret () {
return (this.wordAtCaret || {}).word || '' return (this.wordAtCaret || {}).word || ''
@ -104,6 +145,7 @@ const EmojiInput = {
input.elm.addEventListener('paste', this.onPaste) input.elm.addEventListener('paste', this.onPaste)
input.elm.addEventListener('keyup', this.onKeyUp) input.elm.addEventListener('keyup', this.onKeyUp)
input.elm.addEventListener('keydown', this.onKeyDown) input.elm.addEventListener('keydown', this.onKeyDown)
input.elm.addEventListener('click', this.onClickInput)
input.elm.addEventListener('transitionend', this.onTransition) input.elm.addEventListener('transitionend', this.onTransition)
input.elm.addEventListener('compositionupdate', this.onCompositionUpdate) input.elm.addEventListener('compositionupdate', this.onCompositionUpdate)
}, },
@ -115,16 +157,80 @@ const EmojiInput = {
input.elm.removeEventListener('paste', this.onPaste) input.elm.removeEventListener('paste', this.onPaste)
input.elm.removeEventListener('keyup', this.onKeyUp) input.elm.removeEventListener('keyup', this.onKeyUp)
input.elm.removeEventListener('keydown', this.onKeyDown) input.elm.removeEventListener('keydown', this.onKeyDown)
input.elm.removeEventListener('click', this.onClickInput)
input.elm.removeEventListener('transitionend', this.onTransition) input.elm.removeEventListener('transitionend', this.onTransition)
input.elm.removeEventListener('compositionupdate', this.onCompositionUpdate) input.elm.removeEventListener('compositionupdate', this.onCompositionUpdate)
} }
}, },
methods: { 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) { replace (replacement) {
const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement) const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
this.$emit('input', newValue) this.$emit('input', newValue)
this.caret = 0 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) { replaceText (e, suggestion) {
const len = this.suggestions.length || 0 const len = this.suggestions.length || 0
if (this.textAtCaret.length === 1) { return } if (this.textAtCaret.length === 1) { return }
@ -148,7 +254,7 @@ const EmojiInput = {
}, },
cycleBackward (e) { cycleBackward (e) {
const len = this.suggestions.length || 0 const len = this.suggestions.length || 0
if (len > 0) { if (len > 1) {
this.highlighted -= 1 this.highlighted -= 1
if (this.highlighted < 0) { if (this.highlighted < 0) {
this.highlighted = this.suggestions.length - 1 this.highlighted = this.suggestions.length - 1
@ -160,7 +266,7 @@ const EmojiInput = {
}, },
cycleForward (e) { cycleForward (e) {
const len = this.suggestions.length || 0 const len = this.suggestions.length || 0
if (len > 0) { if (len > 1) {
this.highlighted += 1 this.highlighted += 1
if (this.highlighted >= len) { if (this.highlighted >= len) {
this.highlighted = 0 this.highlighted = 0
@ -170,6 +276,37 @@ const EmojiInput = {
this.highlighted = 0 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) { onTransition (e) {
this.resize() this.resize()
}, },
@ -191,23 +328,35 @@ const EmojiInput = {
this.blurTimeout = null this.blurTimeout = null
} }
if (!this.keepOpen) {
this.showPicker = false
}
this.focused = true this.focused = true
this.setCaret(e) this.setCaret(e)
this.resize() this.resize()
this.temporarilyHideSuggestions = false
}, },
onKeyUp (e) { onKeyUp (e) {
const { key } = e
this.setCaret(e) this.setCaret(e)
this.resize() 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) { onPaste (e) {
this.setCaret(e) this.setCaret(e)
this.resize() this.resize()
}, },
onKeyDown (e) { onKeyDown (e) {
this.setCaret(e)
this.resize()
const { ctrlKey, shiftKey, key } = e const { ctrlKey, shiftKey, key } = e
// Disable suggestions hotkeys if suggestions are hidden
if (!this.temporarilyHideSuggestions) {
if (key === 'Tab') { if (key === 'Tab') {
if (shiftKey) { if (shiftKey) {
this.cycleBackward(e) this.cycleBackward(e)
@ -225,16 +374,47 @@ const EmojiInput = {
this.replaceText(e) 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) { onInput (e) {
this.setCaret(e) this.showPicker = false
this.$emit('input', e.target.value)
},
onCompositionUpdate (e) {
this.setCaret(e) this.setCaret(e)
this.resize() this.resize()
this.$emit('input', e.target.value) 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 } }) { setCaret ({ target: { selectionStart } }) {
this.caret = selectionStart this.caret = selectionStart
}, },
@ -243,6 +423,7 @@ const EmojiInput = {
if (!panel) return if (!panel) return
const { offsetHeight, offsetTop } = this.input.elm const { offsetHeight, offsetTop } = this.input.elm
this.$refs.panel.style.top = (offsetTop + offsetHeight) + 'px' this.$refs.panel.style.top = (offsetTop + offsetHeight) + 'px'
this.$refs.picker.$el.style.top = (offsetTop + offsetHeight) + 'px'
} }
} }
} }

View file

@ -1,10 +1,32 @@
<template> <template>
<div class="emoji-input"> <div
v-click-outside="onClickOutside"
class="emoji-input"
>
<slot /> <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 <div
ref="panel" ref="panel"
class="autocomplete-panel" class="autocomplete-panel"
:class="{ hide: !showPopup }" :class="{ hide: !showSuggestions }"
> >
<div class="autocomplete-panel-body"> <div class="autocomplete-panel-body">
<div <div
@ -31,7 +53,7 @@
</div> </div>
</template> </template>
<script src="./emoji-input.js"></script> <script src="./emoji_input.js"></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
@ -39,11 +61,36 @@
.emoji-input { .emoji-input {
display: flex; display: flex;
flex-direction: column; 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 { .autocomplete {
&-panel { &-panel {
position: absolute; position: absolute;
z-index: 9; z-index: 20;
margin-top: 2px; margin-top: 2px;
&.hide { &.hide {

View file

@ -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

View file

@ -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%;
}
}
}
}

View file

@ -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>

View file

@ -10,14 +10,14 @@
<div slot="popover"> <div slot="popover">
<div class="dropdown-menu"> <div class="dropdown-menu">
<button <button
v-if="canMute && !status.muted" v-if="canMute && !status.thread_muted"
class="dropdown-item dropdown-item-icon" class="dropdown-item dropdown-item-icon"
@click.prevent="muteConversation" @click.prevent="muteConversation"
> >
<i class="icon-eye-off" /><span>{{ $t("status.mute_conversation") }}</span> <i class="icon-eye-off" /><span>{{ $t("status.mute_conversation") }}</span>
</button> </button>
<button <button
v-if="canMute && status.muted" v-if="canMute && status.thread_muted"
class="dropdown-item dropdown-item-icon" class="dropdown-item dropdown-item-icon"
@click.prevent="unmuteConversation" @click.prevent="unmuteConversation"
> >

View file

@ -1,6 +1,7 @@
<template> <template>
<div <div
v-if="showing" v-if="showing"
v-body-scroll-lock="showing"
class="modal-view media-modal-view" class="modal-view media-modal-view"
@click.prevent="hide" @click.prevent="hide"
> >
@ -43,6 +44,10 @@
.media-modal-view { .media-modal-view {
z-index: 1001; z-index: 1001;
body:not(.scroll-locked) & {
display: none;
}
&:hover { &:hover {
.modal-view-button-arrow { .modal-view-button-arrow {
opacity: 0.75; opacity: 0.75;

View file

@ -32,11 +32,13 @@
<style> <style>
.media-upload { .media-upload {
font-size: 26px;
min-width: 50px;
}
.icon-upload { .icon-upload {
cursor: pointer; cursor: pointer;
} }
label {
display: block;
width: 100%;
}
}
</style> </style>

View file

@ -1,14 +1,9 @@
import PostStatusForm from '../post_status_form/post_status_form.vue'
import { debounce } from 'lodash' import { debounce } from 'lodash'
const MobilePostStatusModal = { const MobilePostStatusButton = {
components: {
PostStatusForm
},
data () { data () {
return { return {
hidden: false, hidden: false,
postFormOpen: false,
scrollingDown: false, scrollingDown: false,
inputActive: false, inputActive: false,
oldScrollPos: 0, oldScrollPos: 0,
@ -28,8 +23,8 @@ const MobilePostStatusModal = {
window.removeEventListener('resize', this.handleOSK) window.removeEventListener('resize', this.handleOSK)
}, },
computed: { computed: {
currentUser () { isLoggedIn () {
return this.$store.state.users.currentUser return !!this.$store.state.users.currentUser
}, },
isHidden () { isHidden () {
return this.autohideFloatingPostButton && (this.hidden || this.inputActive) return this.autohideFloatingPostButton && (this.hidden || this.inputActive)
@ -57,17 +52,7 @@ const MobilePostStatusModal = {
window.removeEventListener('scroll', this.handleScrollEnd) window.removeEventListener('scroll', this.handleScrollEnd)
}, },
openPostForm () { openPostForm () {
this.postFormOpen = true this.$store.dispatch('openPostStatusModal')
this.hidden = true
const el = this.$el.querySelector('textarea')
this.$nextTick(function () {
el.focus()
})
},
closePostForm () {
this.postFormOpen = false
this.hidden = false
}, },
handleOSK () { handleOSK () {
// This is a big hack: we're guessing from changed window sizes if the // 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

View file

@ -1,23 +1,5 @@
<template> <template>
<div v-if="currentUser"> <div v-if="isLoggedIn">
<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>
<button <button
class="new-status-button" class="new-status-button"
:class="{ 'hidden': isHidden }" :class="{ 'hidden': isHidden }"
@ -28,27 +10,11 @@
</div> </div>
</template> </template>
<script src="./mobile_post_status_modal.js"></script> <script src="./mobile_post_status_button.js"></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.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 { .new-status-button {
width: 5em; width: 5em;
height: 5em; height: 5em;

View file

@ -9,7 +9,8 @@ const Notification = {
data () { data () {
return { return {
userExpanded: false, userExpanded: false,
betterShadow: this.$store.state.interface.browserSupport.cssFilter betterShadow: this.$store.state.interface.browserSupport.cssFilter,
unmuted: false
} }
}, },
props: [ 'notification' ], props: [ 'notification' ],
@ -23,11 +24,14 @@ const Notification = {
toggleUserExpanded () { toggleUserExpanded () {
this.userExpanded = !this.userExpanded this.userExpanded = !this.userExpanded
}, },
userProfileLink (user) { generateUserProfileLink (user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames) return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
}, },
getUser (notification) { getUser (notification) {
return this.$store.state.users.usersObject[notification.from_profile.id] return this.$store.state.users.usersObject[notification.from_profile.id]
},
toggleMute () {
this.unmuted = !this.unmuted
} }
}, },
computed: { computed: {
@ -47,6 +51,12 @@ const Notification = {
return this.userInStore return this.userInStore
} }
return this.notification.from_profile return this.notification.from_profile
},
userProfileLink () {
return this.generateUserProfileLink(this.user)
},
needMute () {
return this.user.muted
} }
} }
} }

View file

@ -4,6 +4,22 @@
:compact="true" :compact="true"
:statusoid="notification.status" :statusoid="notification.status"
/> />
<div v-else>
<div
v-if="needMute && !unmuted"
class="container muted"
>
<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 <div
v-else v-else
class="non-mention" class="non-mention"
@ -85,12 +101,17 @@
/> />
</router-link> </router-link>
</div> </div>
<a
v-if="needMute"
href="#"
@click.prevent="toggleMute"
><i class="button-icon icon-eye-off" /></a>
</span> </span>
<div <div
v-if="notification.type === 'follow'" v-if="notification.type === 'follow'"
class="follow-text" class="follow-text"
> >
<router-link :to="userProfileLink(notification.from_profile)"> <router-link :to="userProfileLink">
@{{ notification.from_profile.screen_name }} @{{ notification.from_profile.screen_name }}
</router-link> </router-link>
</div> </div>
@ -104,6 +125,7 @@
</template> </template>
</div> </div>
</div> </div>
</div>
</template> </template>
<script src="./notification.js"></script> <script src="./notification.js"></script>

View file

@ -33,7 +33,6 @@
.notification { .notification {
box-sizing: border-box; box-sizing: border-box;
display: flex;
border-bottom: 1px solid; border-bottom: 1px solid;
border-color: $fallback--border; border-color: $fallback--border;
border-color: var(--border, $fallback--border); border-color: var(--border, $fallback--border);
@ -47,6 +46,10 @@
} }
} }
.muted {
padding: .25em .6em;
}
.non-mention { .non-mention {
display: flex; display: flex;
flex: 1; flex: 1;

View file

@ -1,14 +1,14 @@
import statusPoster from '../../services/status_poster/status_poster.service.js' import statusPoster from '../../services/status_poster/status_poster.service.js'
import MediaUpload from '../media_upload/media_upload.vue' import MediaUpload from '../media_upload/media_upload.vue'
import ScopeSelector from '../scope_selector/scope_selector.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 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 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 { 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] let allAttentions = [...attentions]
allAttentions.unshift(user) allAttentions.unshift(user)
@ -35,7 +35,6 @@ const PostStatusForm = {
MediaUpload, MediaUpload,
EmojiInput, EmojiInput,
PollForm, PollForm,
StickerPicker,
ScopeSelector ScopeSelector
}, },
mounted () { mounted () {
@ -84,8 +83,7 @@ const PostStatusForm = {
contentType contentType
}, },
caret: 0, caret: 0,
pollFormVisible: false, pollFormVisible: false
stickerPickerVisible: false
} }
}, },
computed: { computed: {
@ -161,12 +159,6 @@ const PostStatusForm = {
safeDMEnabled () { safeDMEnabled () {
return this.$store.state.instance.safeDM return this.$store.state.instance.safeDM
}, },
stickersAvailable () {
if (this.$store.state.instance.stickers) {
return this.$store.state.instance.stickers.length > 0
}
return 0
},
pollsAvailable () { pollsAvailable () {
return this.$store.state.instance.pollsAvailable && return this.$store.state.instance.pollsAvailable &&
this.$store.state.instance.pollLimits.max_options >= 2 this.$store.state.instance.pollLimits.max_options >= 2
@ -222,7 +214,6 @@ const PostStatusForm = {
poll: {} poll: {}
} }
this.pollFormVisible = false this.pollFormVisible = false
this.stickerPickerVisible = false
this.$refs.mediaUpload.clearFile() this.$refs.mediaUpload.clearFile()
this.clearPollForm() this.clearPollForm()
this.$emit('posted') this.$emit('posted')
@ -239,7 +230,6 @@ const PostStatusForm = {
addMediaFile (fileInfo) { addMediaFile (fileInfo) {
this.newStatus.files.push(fileInfo) this.newStatus.files.push(fileInfo)
this.enableSubmit() this.enableSubmit()
this.stickerPickerVisible = false
}, },
removeMediaFile (fileInfo) { removeMediaFile (fileInfo) {
let index = this.newStatus.files.indexOf(fileInfo) let index = this.newStatus.files.indexOf(fileInfo)
@ -260,6 +250,7 @@ const PostStatusForm = {
return fileTypeService.fileType(fileInfo.mimetype) return fileTypeService.fileType(fileInfo.mimetype)
}, },
paste (e) { paste (e) {
this.resize(e)
if (e.clipboardData.files.length > 0) { if (e.clipboardData.files.length > 0) {
// prevent pasting of file as text // prevent pasting of file as text
e.preventDefault() e.preventDefault()
@ -278,20 +269,96 @@ const PostStatusForm = {
fileDrag (e) { fileDrag (e) {
e.dataTransfer.dropEffect = 'copy' e.dataTransfer.dropEffect = 'copy'
}, },
onEmojiInputInput (e) {
this.$nextTick(() => {
this.resize(this.$refs['textarea'])
})
},
resize (e) { resize (e) {
const target = e.target || e const target = e.target || e
if (!(target instanceof window.Element)) { return } if (!(target instanceof window.Element)) { return }
const topPaddingStr = window.getComputedStyle(target)['padding-top']
const bottomPaddingStr = window.getComputedStyle(target)['padding-bottom'] // Reset to default height for empty form, nothing else to do here.
// 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
target.style.height = 'auto'
target.style.height = `${target.scrollHeight - vertPadding}px`
if (target.value === '') { if (target.value === '') {
target.style.height = null 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']
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'
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 () { clearError () {
this.error = null this.error = null
@ -299,14 +366,6 @@ const PostStatusForm = {
changeVis (visibility) { changeVis (visibility) {
this.newStatus.visibility = visibility this.newStatus.visibility = visibility
}, },
toggleStickerPicker () {
this.stickerPickerVisible = !this.stickerPickerVisible
},
clearStickerPicker () {
if (this.$refs.stickerPicker) {
this.$refs.stickerPicker.clear()
}
},
togglePollForm () { togglePollForm () {
this.pollFormVisible = !this.pollFormVisible this.pollFormVisible = !this.pollFormVisible
}, },

View file

@ -1,5 +1,8 @@
<template> <template>
<div class="post-status-form"> <div
ref="root"
class="post-status-form"
>
<form <form
autocomplete="off" autocomplete="off"
@submit.prevent="postStatus(newStatus)" @submit.prevent="postStatus(newStatus)"
@ -61,6 +64,7 @@
<EmojiInput <EmojiInput
v-if="newStatus.spoilerText || alwaysShowSubject" v-if="newStatus.spoilerText || alwaysShowSubject"
v-model="newStatus.spoilerText" v-model="newStatus.spoilerText"
enable-emoji-picker
:suggest="emojiSuggestor" :suggest="emojiSuggestor"
class="form-control" class="form-control"
> >
@ -73,9 +77,16 @@
> >
</EmojiInput> </EmojiInput>
<EmojiInput <EmojiInput
ref="emoji-input"
v-model="newStatus.status" v-model="newStatus.status"
:suggest="emojiUserSuggestor" :suggest="emojiUserSuggestor"
class="form-control main-input" class="form-control main-input"
enable-emoji-picker
hide-emoji-button
enable-sticker-picker
@input="onEmojiInputInput"
@sticker-uploaded="addMediaFile"
@sticker-upload-failed="uploadFailed"
> >
<textarea <textarea
ref="textarea" ref="textarea"
@ -89,6 +100,7 @@
@drop="fileDrop" @drop="fileDrop"
@dragover.prevent="fileDrag" @dragover.prevent="fileDrag"
@input="resize" @input="resize"
@compositionupdate="resize"
@paste="paste" @paste="paste"
/> />
<p <p
@ -152,30 +164,29 @@
<div class="form-bottom-left"> <div class="form-bottom-left">
<media-upload <media-upload
ref="mediaUpload" ref="mediaUpload"
class="media-upload-icon"
:drop-files="dropFiles" :drop-files="dropFiles"
@uploading="disableSubmit" @uploading="disableSubmit"
@uploaded="addMediaFile" @uploaded="addMediaFile"
@upload-failed="uploadFailed" @upload-failed="uploadFailed"
/> />
<div <div
v-if="stickersAvailable" class="emoji-icon"
class="sticker-icon"
> >
<i <i
:title="$t('stickers.add_sticker')" :title="$t('emoji.add_emoji')"
class="icon-picture btn btn-default" class="icon-smile btn btn-default"
:class="{ selected: stickerPickerVisible }" @click="showEmojiPicker"
@click="toggleStickerPicker"
/> />
</div> </div>
<div <div
v-if="pollsAvailable" v-if="pollsAvailable"
class="poll-icon" class="poll-icon"
:class="{ selected: pollFormVisible }"
> >
<i <i
:title="$t('polls.add_poll')" :title="$t('polls.add_poll')"
class="icon-chart-bar btn btn-default" class="icon-chart-bar btn btn-default"
:class="pollFormVisible && 'selected'"
@click="togglePollForm" @click="togglePollForm"
/> />
</div> </div>
@ -258,11 +269,6 @@
<label for="filesSensitive">{{ $t('post_status.attachments_sensitive') }}</label> <label for="filesSensitive">{{ $t('post_status.attachments_sensitive') }}</label>
</div> </div>
</form> </form>
<sticker-picker
v-if="stickerPickerVisible"
ref="stickerPicker"
@uploaded="addMediaFile"
/>
</div> </div>
</template> </template>
@ -299,6 +305,7 @@
.post-status-form { .post-status-form {
.form-bottom { .form-bottom {
display: flex; display: flex;
justify-content: space-between;
padding: 0.5em; padding: 0.5em;
height: 32px; height: 32px;
@ -316,6 +323,9 @@
.form-bottom-left { .form-bottom-left {
display: flex; display: flex;
flex: 1; flex: 1;
padding-right: 7px;
margin-right: 7px;
max-width: 10em;
} }
.text-format { .text-format {
@ -325,19 +335,38 @@
} }
} }
.poll-icon, .sticker-icon { .media-upload-icon, .poll-icon, .emoji-icon {
font-size: 26px; font-size: 26px;
flex: 1; flex: 1;
.selected { i {
display: block;
width: 100%;
}
&.selected, &:hover {
// needs to be specific to override icon default color
i, label {
color: $fallback--lightText; color: $fallback--lightText;
color: var(--lightText, $fallback--lightText); color: var(--lightText, $fallback--lightText);
} }
} }
}
.sticker-icon { // Order is not necessary but a good indicator
flex: 0; .media-upload-icon {
min-width: 50px; order: 1;
text-align: left;
}
.emoji-icon {
order: 2;
text-align: center;
}
.poll-icon {
order: 3;
text-align: right;
} }
.icon-chart-bar { .icon-chart-bar {
@ -369,6 +398,13 @@
} }
} }
.status-input-wrapper {
display: flex;
position: relative;
width: 100%;
flex-direction: column;
}
.attachments { .attachments {
padding: 0 0.5em; padding: 0 0.5em;
@ -444,10 +480,6 @@
box-sizing: content-box; box-sizing: content-box;
} }
.form-post-body:focus {
min-height: 48px;
}
.main-input { .main-input {
position: relative; position: relative;
} }

View file

@ -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

View file

@ -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>

View file

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

View file

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

View file

@ -413,7 +413,7 @@
v-if="replying" v-if="replying"
class="container" class="container"
> >
<post-status-form <PostStatusForm
class="reply-body" class="reply-body"
:reply-to="status.id" :reply-to="status.id"
:attentions="status.attentions" :attentions="status.attentions"
@ -665,6 +665,15 @@ $status-margin: 0.75em;
height: 220px; height: 220px;
overflow-x: hidden; overflow-x: hidden;
overflow-y: 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 { .tall-status-hider {
@ -676,12 +685,7 @@ $status-margin: 0.75em;
width: 100%; width: 100%;
text-align: center; text-align: center;
line-height: 110px; line-height: 110px;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), $fallback--bg 80%); z-index: 2;
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%);
}
} }
.status-unhider, .cw-status-hider { .status-unhider, .cw-status-hider {

View file

@ -3,9 +3,9 @@ import statusPosterService from '../../services/status_poster/status_poster.serv
import TabSwitcher from '../tab_switcher/tab_switcher.js' import TabSwitcher from '../tab_switcher/tab_switcher.js'
const StickerPicker = { const StickerPicker = {
components: [ components: {
TabSwitcher TabSwitcher
], },
data () { data () {
return { return {
meta: { meta: {

View file

@ -1,12 +1,11 @@
<template> <template>
<div <div
class="sticker-picker" class="sticker-picker"
>
<div
class="sticker-picker-panel"
> >
<tab-switcher <tab-switcher
class="tab-switcher"
:render-only-focused="true" :render-only-focused="true"
scrollable-tabs
> >
<div <div
v-for="stickerpack in pack" v-for="stickerpack in pack"
@ -19,7 +18,7 @@
v-for="sticker in stickerpack.meta.stickers" v-for="sticker in stickerpack.meta.stickers"
:key="sticker" :key="sticker"
class="sticker" class="sticker"
@click="pick(stickerpack.path + sticker, stickerpack.meta.title)" @click.stop.prevent="pick(stickerpack.path + sticker, stickerpack.meta.title)"
> >
<img <img
:src="stickerpack.path + sticker" :src="stickerpack.path + sticker"
@ -28,7 +27,6 @@
</div> </div>
</tab-switcher> </tab-switcher>
</div> </div>
</div>
</template> </template>
<script src="./sticker_picker.js"></script> <script src="./sticker_picker.js"></script>
@ -37,13 +35,16 @@
@import '../../_variables.scss'; @import '../../_variables.scss';
.sticker-picker { .sticker-picker {
.sticker-picker-panel {
display: inline-block;
width: 100%; width: 100%;
position: relative;
.tab-switcher {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.sticker-picker-content { .sticker-picker-content {
max-height: 300px;
overflow-y: scroll;
overflow-x: auto;
.sticker { .sticker {
display: inline-block; display: inline-block;
width: 20%; width: 20%;
@ -57,6 +58,5 @@
} }
} }
} }
}
</style> </style>

View file

@ -4,7 +4,26 @@ import './tab_switcher.scss'
export default Vue.component('tab-switcher', { export default Vue.component('tab-switcher', {
name: 'TabSwitcher', 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 () { data () {
return { return {
active: this.$slots.default.findIndex(_ => _.tag) active: this.$slots.default.findIndex(_ => _.tag)
@ -28,7 +47,8 @@ export default Vue.component('tab-switcher', {
}, },
methods: { methods: {
activateTab (index) { activateTab (index) {
return () => { return (e) => {
e.preventDefault()
if (typeof this.onSwitch === 'function') { if (typeof this.onSwitch === 'function') {
this.onSwitch.call(null, this.$slots.default[index].key) this.onSwitch.call(null, this.$slots.default[index].key)
} }
@ -87,7 +107,7 @@ export default Vue.component('tab-switcher', {
<div class="tabs"> <div class="tabs">
{tabs} {tabs}
</div> </div>
<div class="contents"> <div class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')}>
{contents} {contents}
</div> </div>
</div> </div>

View file

@ -1,10 +1,21 @@
@import '../../_variables.scss'; @import '../../_variables.scss';
.tab-switcher { .tab-switcher {
display: flex;
flex-direction: column;
.contents { .contents {
flex: 1 0 auto;
min-height: 0px;
.hidden { .hidden {
display: none; display: none;
} }
&.scrollable-tabs {
flex-basis: 0;
overflow-y: auto;
}
} }
.tabs { .tabs {
display: flex; display: flex;

View file

@ -39,19 +39,10 @@ export default {
const rgb = (typeof color === 'string') ? hex2rgb(color) : color 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 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 { return {
backgroundColor: `rgb(${Math.floor(rgb.r * 0.53)}, ${Math.floor(rgb.g * 0.56)}, ${Math.floor(rgb.b * 0.59)})`, backgroundColor: `rgb(${Math.floor(rgb.r * 0.53)}, ${Math.floor(rgb.g * 0.56)}, ${Math.floor(rgb.b * 0.59)})`,
backgroundImage: [ backgroundImage: [
`linear-gradient(to bottom, ${gradient})`, `linear-gradient(to bottom, ${tintColor}, ${tintColor})`,
`url(${this.user.cover_photo})` `url(${this.user.cover_photo})`
].join(', ') ].join(', ')
} }
@ -179,6 +170,9 @@ export default {
} }
this.$store.dispatch('setMedia', [attachment]) this.$store.dispatch('setMedia', [attachment])
this.$store.dispatch('setCurrent', attachment) this.$store.dispatch('setCurrent', attachment)
},
mentionUser () {
this.$store.dispatch('openPostStatusModal', { replyTo: true, repliedUser: this.user })
} }
} }
} }

View file

@ -2,8 +2,12 @@
<div <div
class="user-card" class="user-card"
:class="classes" :class="classes"
:style="style"
> >
<div
:class="{ 'hide-bio': hideBio }"
:style="style"
class="background-image"
/>
<div class="panel-heading"> <div class="panel-heading">
<div class="user-info"> <div class="user-info">
<div class="container"> <div class="container">
@ -204,6 +208,15 @@
</button> </button>
</div> </div>
<div>
<button
class="btn btn-default btn-block"
@click="mentionUser"
>
{{ $t('user_card.mention') }}
</button>
</div>
<div> <div>
<button <button
v-if="user.muted" v-if="user.muted"
@ -314,7 +327,7 @@
@import '../../_variables.scss'; @import '../../_variables.scss';
.user-card { .user-card {
background-size: cover; position: relative;
.panel-heading { .panel-heading {
padding: .5em 0; padding: .5em 0;
@ -323,14 +336,35 @@
background: transparent; background: transparent;
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
// create new stacking context
position: relative;
} }
.panel-body { .panel-body {
word-wrap: break-word; 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-right-radius: inherit;
border-bottom-left-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 { p {

View file

@ -11,7 +11,7 @@
rounded="top" rounded="top"
/> />
<div class="panel-footer"> <div class="panel-footer">
<post-status-form v-if="user" /> <PostStatusForm />
</div> </div>
</div> </div>
<auth-form <auth-form

View file

@ -11,8 +11,8 @@ import BlockCard from '../block_card/block_card.vue'
import MuteCard from '../mute_card/mute_card.vue' import MuteCard from '../mute_card/mute_card.vue'
import SelectableList from '../selectable_list/selectable_list.vue' import SelectableList from '../selectable_list/selectable_list.vue'
import ProgressButton from '../progress_button/progress_button.vue' import ProgressButton from '../progress_button/progress_button.vue'
import EmojiInput from '../emoji-input/emoji-input.vue' import EmojiInput from '../emoji_input/emoji_input.vue'
import suggestor from '../emoji-input/suggestor.js' import suggestor from '../emoji_input/suggestor.js'
import Autosuggest from '../autosuggest/autosuggest.vue' import Autosuggest from '../autosuggest/autosuggest.vue'
import Importer from '../importer/importer.vue' import Importer from '../importer/importer.vue'
import Exporter from '../exporter/exporter.vue' import Exporter from '../exporter/exporter.vue'

View file

@ -32,6 +32,7 @@
<p>{{ $t('settings.name') }}</p> <p>{{ $t('settings.name') }}</p>
<EmojiInput <EmojiInput
v-model="newName" v-model="newName"
enable-emoji-picker
:suggest="emojiSuggestor" :suggest="emojiSuggestor"
> >
<input <input
@ -43,6 +44,7 @@
<p>{{ $t('settings.bio') }}</p> <p>{{ $t('settings.bio') }}</p>
<EmojiInput <EmojiInput
v-model="newBio" v-model="newBio"
enable-emoji-picker
:suggest="emojiUserSuggestor" :suggest="emojiUserSuggestor"
> >
<textarea <textarea

View file

@ -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)
}

View file

@ -106,8 +106,14 @@
"expired": "Poll ended {0} ago", "expired": "Poll ended {0} ago",
"not_enough_options": "Too few unique options in poll" "not_enough_options": "Too few unique options in poll"
}, },
"stickers": { "emoji": {
"add_sticker": "Add Sticker" "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": { "interactions": {
"favs_repeats": "Repeats and Favorites", "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_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.", "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.", "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", "export_theme": "Save preset",
"filtering": "Filtering", "filtering": "Filtering",
"filtering_explanation": "All statuses containing these words will be muted, one per line", "filtering_explanation": "All statuses containing these words will be muted, one per line",
@ -529,6 +536,7 @@
"follows_you": "Follows you!", "follows_you": "Follows you!",
"its_you": "It's you!", "its_you": "It's you!",
"media": "Media", "media": "Media",
"mention": "Mention",
"mute": "Mute", "mute": "Mute",
"muted": "Muted", "muted": "Muted",
"per_day": "per day", "per_day": "per day",

View file

@ -508,7 +508,9 @@
"pinned": "Fijado", "pinned": "Fijado",
"delete_confirm": "¿Realmente quieres borrar la publicación?", "delete_confirm": "¿Realmente quieres borrar la publicación?",
"reply_to": "Respondiendo a", "reply_to": "Respondiendo a",
"replies_list": "Respuestas:" "replies_list": "Respuestas:",
"mute_conversation": "Silenciar la conversación",
"unmute_conversation": "Mostrar la conversación"
}, },
"user_card": { "user_card": {
"approve": "Aprobar", "approve": "Aprobar",
@ -606,5 +608,16 @@
"person_talking": "{count} personas hablando", "person_talking": "{count} personas hablando",
"people_talking": "{count} gente hablando", "people_talking": "{count} gente hablando",
"no_results": "Sin resultados" "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."
} }
} }

View file

@ -88,7 +88,7 @@
"followed_you": "Zu jarraitzen zaitu", "followed_you": "Zu jarraitzen zaitu",
"load_older": "Kargatu jakinarazpen zaharragoak", "load_older": "Kargatu jakinarazpen zaharragoak",
"notifications": "Jakinarazpenak", "notifications": "Jakinarazpenak",
"read": "Irakurri!", "read": "Irakurrita!",
"repeated_you": "zure mezua errepikatu du", "repeated_you": "zure mezua errepikatu du",
"no_more_notifications": "Ez dago jakinarazpen gehiago" "no_more_notifications": "Ez dago jakinarazpen gehiago"
}, },
@ -116,7 +116,7 @@
}, },
"post_status": { "post_status": {
"new_status": "Mezu berri bat idatzi", "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", "account_not_locked_warning_link": "Blokeatuta",
"attachments_sensitive": "Nabarmendu eranskinak hunkigarri gisa ", "attachments_sensitive": "Nabarmendu eranskinak hunkigarri gisa ",
"content_type": { "content_type": {
@ -136,10 +136,10 @@
"unlisted": "Mezu hau ez da argitaratuko Denbora-lerro Publikoan ezta Ezagutzen den Sarean" "unlisted": "Mezu hau ez da argitaratuko Denbora-lerro Publikoan ezta Ezagutzen den Sarean"
}, },
"scope": { "scope": {
"direct": "Zuzena - Bidali aipatutako erabiltzaileei besterik ez", "direct": "Zuzena: Bidali aipatutako erabiltzaileei besterik ez",
"private": "Jarraitzaileentzako bakarrik- Bidali jarraitzaileentzat bakarrik", "private": "Jarraitzaileentzako bakarrik: Bidali jarraitzaileentzat bakarrik",
"public": "Publickoa - Bistaratu denbora-lerro publikoetan", "public": "Publikoa: Bistaratu denbora-lerro publikoetan",
"unlisted": "Zerrendatu gabea - ez bidali denbora-lerro publikoetan" "unlisted": "Zerrendatu gabea: ez bidali denbora-lerro publikoetara"
} }
}, },
"registration": { "registration": {
@ -228,7 +228,7 @@
"avatar_size_instruction": "Avatar irudien gomendatutako gutxieneko tamaina 150x150 pixel dira.", "avatar_size_instruction": "Avatar irudien gomendatutako gutxieneko tamaina 150x150 pixel dira.",
"export_theme": "Gorde aurre-ezarpena", "export_theme": "Gorde aurre-ezarpena",
"filtering": "Iragazten", "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": "Jarraitzen dituzunak esportatu",
"follow_export_button": "Esportatu zure jarraitzaileak csv fitxategi batean", "follow_export_button": "Esportatu zure jarraitzaileak csv fitxategi batean",
"follow_import": "Jarraitzen dituzunak inportatu", "follow_import": "Jarraitzen dituzunak inportatu",
@ -276,7 +276,7 @@
"no_blocks": "Ez daude erabiltzaile blokeatutak", "no_blocks": "Ez daude erabiltzaile blokeatutak",
"no_mutes": "Ez daude erabiltzaile mututuak", "no_mutes": "Ez daude erabiltzaile mututuak",
"hide_follows_description": "Ez erakutsi nor jarraitzen ari naizen", "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_admin_badge": "Erakutsi Administratzaile etiketa nire profilan",
"show_moderator_badge": "Erakutsi Moderatzaile etiketa nire profilan", "show_moderator_badge": "Erakutsi Moderatzaile etiketa nire profilan",
"nsfw_clickthrough": "Gaitu klika hunkigarri eranskinak ezkutatzeko", "nsfw_clickthrough": "Gaitu klika hunkigarri eranskinak ezkutatzeko",
@ -456,8 +456,8 @@
"time": { "time": {
"day": "{0} egun", "day": "{0} egun",
"days": "{0} egun", "days": "{0} egun",
"day_short": "{0}d", "day_short": "{0}e",
"days_short": "{0}d", "days_short": "{0}e",
"hour": "{0} ordu", "hour": "{0} ordu",
"hours": "{0} ordu", "hours": "{0} ordu",
"hour_short": "{0}o", "hour_short": "{0}o",
@ -492,7 +492,7 @@
"conversation": "Elkarrizketa", "conversation": "Elkarrizketa",
"error_fetching": "Errorea eguneraketak eskuratzen", "error_fetching": "Errorea eguneraketak eskuratzen",
"load_older": "Kargatu mezu zaharragoak", "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", "repeated": "Errepikatuta",
"show_new": "Berriena erakutsi", "show_new": "Berriena erakutsi",
"up_to_date": "Eguneratuta", "up_to_date": "Eguneratuta",
@ -507,8 +507,10 @@
"unpin": "Aingura ezeztatu profilatik", "unpin": "Aingura ezeztatu profilatik",
"pinned": "Ainguratuta", "pinned": "Ainguratuta",
"delete_confirm": "Mezu hau benetan ezabatu nahi duzu?", "delete_confirm": "Mezu hau benetan ezabatu nahi duzu?",
"reply_to": "Erantzun", "reply_to": "Erantzuten",
"replies_list": "Erantzunak:" "replies_list": "Erantzunak:",
"mute_conversation": "Elkarrizketa isilarazi",
"unmute_conversation": "Elkarrizketa aktibatu"
}, },
"user_card": { "user_card": {
"approve": "Onartu", "approve": "Onartu",
@ -581,7 +583,7 @@
}, },
"tool_tip": { "tool_tip": {
"media_upload": "Multimedia igo", "media_upload": "Multimedia igo",
"repeat": "Erreplikatu", "repeat": "Errepikatu",
"reply": "Erantzun", "reply": "Erantzun",
"favorite": "Gogokoa", "favorite": "Gogokoa",
"user_settings": "Erabiltzaile ezarpenak" "user_settings": "Erabiltzaile ezarpenak"
@ -601,10 +603,21 @@
} }
}, },
"search": { "search": {
"people": "Gendea", "people": "Erabiltzaileak",
"hashtags": "Traolak", "hashtags": "Traolak",
"person_talking": "{count} pertsona hitzegiten", "person_talking": "{count} pertsona hitzegiten",
"people_talking": "{count} gende hitzegiten", "people_talking": "{count} jende hitzegiten",
"no_results": "Emaitzarik ez" "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."
} }
} }

View file

@ -65,6 +65,7 @@
"timeline": "Flux dactualitat", "timeline": "Flux dactualitat",
"twkn": "Lo malhum conegut", "twkn": "Lo malhum conegut",
"user_search": "Cèrca dutilizaires", "user_search": "Cèrca dutilizaires",
"search": "Cercar",
"who_to_follow": "Qual seguir", "who_to_follow": "Qual seguir",
"preferences": "Preferéncias" "preferences": "Preferéncias"
}, },
@ -92,6 +93,14 @@
"expired": "Sondatge acabat {0}", "expired": "Sondatge acabat {0}",
"not_enough_options": "I a pas pro dopcions" "not_enough_options": "I a pas pro dopcions"
}, },
"stickers": {
"add_sticker": "Ajustar un pegasolet"
},
"interactions": {
"favs_repeats": "Repeticions e favorits",
"follows": "Nòus seguidors",
"load_older": "Cargar dinteraccions anterioras"
},
"post_status": { "post_status": {
"new_status": "Publicar destatuts novèls", "new_status": "Publicar destatuts 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 qua vòstres seguidors.", "account_not_locked_warning": "Vòstre compte es pas {0}. Qual que siá pòt vos seguir per veire vòstras publicacions destinadas pas qua vòstres seguidors.",
@ -216,7 +225,6 @@
"use_contain_fit": "Talhar pas las pèças juntas per las vinhetas", "use_contain_fit": "Talhar pas las pèças juntas per las vinhetas",
"name": "Nom", "name": "Nom",
"name_bio": "Nom & Bio", "name_bio": "Nom & Bio",
"new_password": "Nòu senhal", "new_password": "Nòu senhal",
"notification_visibility_follows": "Abonaments", "notification_visibility_follows": "Abonaments",
"notification_visibility_likes": "Aimar", "notification_visibility_likes": "Aimar",
@ -269,7 +277,7 @@
"streaming": "Activar lo cargament automatic dels novèls estatus en anar amont", "streaming": "Activar lo cargament automatic dels novèls estatus en anar amont",
"text": "Tèxte", "text": "Tèxte",
"theme": "Tèma", "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 dunes 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_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.", "theme_help": "Emplegatz los còdis de color hex (#rrggbb) per personalizar vòstre tèma de color.",
"tooltipRadius": "Astúcias/alèrtas", "tooltipRadius": "Astúcias/alèrtas",
@ -280,12 +288,12 @@
"true": "òc" "true": "òc"
}, },
"notifications": "Notificacions", "notifications": "Notificacions",
"notification_setting": "Receber las notificacions de:", "notification_setting": "Recebre las notificacions de:",
"notification_setting_follows": "Utilizaires que seguissètz", "notification_setting_follows": "Utilizaires que seguissètz",
"notification_setting_non_follows": "Utilizaires que seguissètz pas", "notification_setting_non_follows": "Utilizaires que seguissètz pas",
"notification_setting_followers": "Utilizaires que vos seguisson", "notification_setting_followers": "Utilizaires que vos seguisson",
"notification_setting_non_followers": "Utilizaires que vos seguisson pas", "notification_setting_non_followers": "Utilizaires que vos seguisson pas",
"notification_mutes": "Per receber pas mai dun utilizaire en particular, botatz-lo en silenci.", "notification_mutes": "Per recebre pas mai dun utilizaire en particular, botatz-lo en silenci.",
"notification_blocks": "Blocar un utilizaire arrèsta totas las notificacions tan coma quitar de los seguir.", "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": { "style": {
@ -477,6 +485,8 @@
"per_day": "per jorn", "per_day": "per jorn",
"remote_follow": "Seguir a distància", "remote_follow": "Seguir a distància",
"statuses": "Estatuts", "statuses": "Estatuts",
"subscribe": "Sabonar",
"unsubscribe": "Se desabonar",
"unblock": "Desblocar", "unblock": "Desblocar",
"unblock_progress": "Desblocatge...", "unblock_progress": "Desblocatge...",
"block_progress": "Blocatge...", "block_progress": "Blocatge...",
@ -532,5 +542,12 @@
"GiB": "Gio", "GiB": "Gio",
"TiB": "Tio" "TiB": "Tio"
} }
},
"search": {
"people": "Gent",
"hashtags": "Etiquetas",
"person_talking": "{count} persona ne parla",
"people_talking": "{count} personas ne parlan",
"no_results": "Cap de resultats"
} }
} }

View file

@ -15,6 +15,7 @@ import mediaViewerModule from './modules/media_viewer.js'
import oauthTokensModule from './modules/oauth_tokens.js' import oauthTokensModule from './modules/oauth_tokens.js'
import reportsModule from './modules/reports.js' import reportsModule from './modules/reports.js'
import pollsModule from './modules/polls.js' import pollsModule from './modules/polls.js'
import postStatusModule from './modules/postStatus.js'
import VueI18n from 'vue-i18n' import VueI18n from 'vue-i18n'
@ -26,6 +27,7 @@ import messages from './i18n/messages.js'
import VueChatScroll from 'vue-chat-scroll' import VueChatScroll from 'vue-chat-scroll'
import VueClickOutside from 'v-click-outside' import VueClickOutside from 'v-click-outside'
import PortalVue from 'portal-vue' import PortalVue from 'portal-vue'
import VBodyScrollLock from './directives/body_scroll_lock'
import VTooltip from 'v-tooltip' import VTooltip from 'v-tooltip'
import afterStoreSetup from './boot/after_store.js' import afterStoreSetup from './boot/after_store.js'
@ -38,6 +40,7 @@ Vue.use(VueI18n)
Vue.use(VueChatScroll) Vue.use(VueChatScroll)
Vue.use(VueClickOutside) Vue.use(VueClickOutside)
Vue.use(PortalVue) Vue.use(PortalVue)
Vue.use(VBodyScrollLock)
Vue.use(VTooltip) Vue.use(VTooltip)
const i18n = new VueI18n({ const i18n = new VueI18n({
@ -76,7 +79,8 @@ const persistedStateOptions = {
mediaViewer: mediaViewerModule, mediaViewer: mediaViewerModule,
oauthTokens: oauthTokensModule, oauthTokens: oauthTokensModule,
reports: reportsModule, reports: reportsModule,
polls: pollsModule polls: pollsModule,
postStatus: postStatusModule
}, },
plugins: [persistedState, pushNotifications], plugins: [persistedState, pushNotifications],
strict: false // Socket modifies itself, let's ignore this for now. strict: false // Socket modifies itself, let's ignore this for now.

View file

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

25
src/modules/postStatus.js Normal file
View file

@ -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

View file

@ -426,9 +426,13 @@ export const mutations = {
newStatus.favoritedBy.push(user) newStatus.favoritedBy.push(user)
} }
}, },
setMuted (state, status) { setMutedStatus (state, status) {
const newStatus = state.allStatusesObject[status.id] 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 }) { setRetweeted (state, { status, value }) {
const newStatus = state.allStatusesObject[status.id] const newStatus = state.allStatusesObject[status.id]
@ -566,11 +570,11 @@ const statuses = {
}, },
muteConversation ({ rootState, commit }, statusId) { muteConversation ({ rootState, commit }, statusId) {
return rootState.api.backendInteractor.muteConversation(statusId) return rootState.api.backendInteractor.muteConversation(statusId)
.then((status) => commit('setMuted', status)) .then((status) => commit('setMutedStatus', status))
}, },
unmuteConversation ({ rootState, commit }, statusId) { unmuteConversation ({ rootState, commit }, statusId) {
return rootState.api.backendInteractor.unmuteConversation(statusId) return rootState.api.backendInteractor.unmuteConversation(statusId)
.then((status) => commit('setMuted', status)) .then((status) => commit('setMutedStatus', status))
}, },
retweet ({ rootState, commit }, status) { retweet ({ rootState, commit }, status) {
// Optimistic retweeting... // Optimistic retweeting...

View file

@ -10,6 +10,11 @@ const fetchAndUpdate = ({ store, credentials, older = false }) => {
const args = { credentials } const args = { credentials }
const rootState = store.rootState || store.state const rootState = store.rootState || store.state
const timelineData = rootState.statuses.notifications const timelineData = rootState.statuses.notifications
const hideMutedPosts = typeof rootState.config.hideMutedPosts === 'undefined'
? rootState.instance.hideMutedPosts
: rootState.config.hideMutedPosts
args['withMuted'] = !hideMutedPosts
args['timeline'] = 'notifications' args['timeline'] = 'notifications'
if (older) { if (older) {

View file

@ -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 }
}

View file

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

View file

@ -1196,6 +1196,11 @@ body-parser@1.18.3, body-parser@^1.16.1:
raw-body "2.3.3" raw-body "2.3.3"
type-is "~1.6.16" type-is "~1.6.16"
body-scroll-lock@^2.6.4:
version "2.6.4"
resolved "https://registry.yarnpkg.com/body-scroll-lock/-/body-scroll-lock-2.6.4.tgz#567abc60ef4d656a79156781771398ef40462e94"
integrity sha512-NP08WsovlmxEoZP9pdlqrE+AhNaivlTrz9a0FF37BQsnOrpN48eNqivKkE7SYpM9N+YIPjsdVzfLAUQDBm6OQw==
boolbase@~1.0.0: boolbase@~1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"