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

View file

@ -1,5 +1,7 @@
import Completion from '../../services/completion/completion.js'
import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import { take } from 'lodash'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
/**
* EmojiInput - augmented inputs for emoji and autocomplete support in inputs
@ -52,6 +54,31 @@ const EmojiInput = {
*/
required: true,
type: String
},
enableEmojiPicker: {
/**
* Enables emoji picker support, this implies that custom emoji are supported
*/
required: false,
type: Boolean,
default: false
},
hideEmojiButton: {
/**
* intended to use with external picker trigger, i.e. you have a button outside
* input that will open up the picker, see triggerShowPicker()
*/
required: false,
type: Boolean,
default: false
},
enableStickerPicker: {
/**
* Enables sticker picker support, only makes sense when enableEmojiPicker=true
*/
required: false,
type: Boolean,
default: false
}
},
data () {
@ -60,10 +87,20 @@ const EmojiInput = {
highlighted: 0,
caret: 0,
focused: false,
blurTimeout: null
blurTimeout: null,
showPicker: false,
temporarilyHideSuggestions: false,
keepOpen: false,
disableClickOutside: false
}
},
components: {
EmojiPicker
},
computed: {
padEmoji () {
return this.$store.state.config.padEmoji
},
suggestions () {
const firstchar = this.textAtCaret.charAt(0)
if (this.textAtCaret === firstchar) { return [] }
@ -79,8 +116,12 @@ const EmojiInput = {
highlighted: index === this.highlighted
}))
},
showPopup () {
return this.focused && this.suggestions && this.suggestions.length > 0
showSuggestions () {
return this.focused &&
this.suggestions &&
this.suggestions.length > 0 &&
!this.showPicker &&
!this.temporarilyHideSuggestions
},
textAtCaret () {
return (this.wordAtCaret || {}).word || ''
@ -104,6 +145,7 @@ const EmojiInput = {
input.elm.addEventListener('paste', this.onPaste)
input.elm.addEventListener('keyup', this.onKeyUp)
input.elm.addEventListener('keydown', this.onKeyDown)
input.elm.addEventListener('click', this.onClickInput)
input.elm.addEventListener('transitionend', this.onTransition)
input.elm.addEventListener('compositionupdate', this.onCompositionUpdate)
},
@ -115,16 +157,80 @@ const EmojiInput = {
input.elm.removeEventListener('paste', this.onPaste)
input.elm.removeEventListener('keyup', this.onKeyUp)
input.elm.removeEventListener('keydown', this.onKeyDown)
input.elm.removeEventListener('click', this.onClickInput)
input.elm.removeEventListener('transitionend', this.onTransition)
input.elm.removeEventListener('compositionupdate', this.onCompositionUpdate)
}
},
methods: {
triggerShowPicker () {
this.showPicker = true
this.$nextTick(() => {
this.scrollIntoView()
})
// This temporarily disables "click outside" handler
// since external trigger also means click originates
// from outside, thus preventing picker from opening
this.disableClickOutside = true
setTimeout(() => {
this.disableClickOutside = false
}, 0)
},
togglePicker () {
this.input.elm.focus()
this.showPicker = !this.showPicker
if (this.showPicker) {
this.scrollIntoView()
}
},
replace (replacement) {
const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
this.$emit('input', newValue)
this.caret = 0
},
insert ({ insertion, keepOpen }) {
const before = this.value.substring(0, this.caret) || ''
const after = this.value.substring(this.caret) || ''
/* Using a bit more smart approach to padding emojis with spaces:
* - put a space before cursor if there isn't one already, unless we
* are at the beginning of post or in spam mode
* - put a space after emoji if there isn't one already unless we are
* in spam mode
*
* The idea is that when you put a cursor somewhere in between sentence
* inserting just ' :emoji: ' will add more spaces to post which might
* break the flow/spacing, as well as the case where user ends sentence
* with a space before adding emoji.
*
* Spam mode is intended for creating multi-part emojis and overall spamming
* them, masto seem to be rendering :emoji::emoji: correctly now so why not
*/
const isSpaceRegex = /\s/
const spaceBefore = !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0 ? ' ' : ''
const spaceAfter = !isSpaceRegex.exec(after[0]) && this.padEmoji ? ' ' : ''
const newValue = [
before,
spaceBefore,
insertion,
spaceAfter,
after
].join('')
this.keepOpen = keepOpen
this.$emit('input', newValue)
const position = this.caret + (insertion + spaceAfter + spaceBefore).length
if (!keepOpen) {
this.input.elm.focus()
}
this.$nextTick(function () {
// Re-focus inputbox after clicking suggestion
// Set selection right after the replacement instead of the very end
this.input.elm.setSelectionRange(position, position)
this.caret = position
})
},
replaceText (e, suggestion) {
const len = this.suggestions.length || 0
if (this.textAtCaret.length === 1) { return }
@ -148,7 +254,7 @@ const EmojiInput = {
},
cycleBackward (e) {
const len = this.suggestions.length || 0
if (len > 0) {
if (len > 1) {
this.highlighted -= 1
if (this.highlighted < 0) {
this.highlighted = this.suggestions.length - 1
@ -160,7 +266,7 @@ const EmojiInput = {
},
cycleForward (e) {
const len = this.suggestions.length || 0
if (len > 0) {
if (len > 1) {
this.highlighted += 1
if (this.highlighted >= len) {
this.highlighted = 0
@ -170,6 +276,37 @@ const EmojiInput = {
this.highlighted = 0
}
},
scrollIntoView () {
const rootRef = this.$refs['picker'].$el
/* Scroller is either `window` (replies in TL), sidebar (main post form,
* replies in notifs) or mobile post form. Note that getting and setting
* scroll is different for `Window` and `Element`s
*/
const scrollerRef = this.$el.closest('.sidebar-scroller') ||
this.$el.closest('.post-form-modal-view') ||
window
const currentScroll = scrollerRef === window
? scrollerRef.scrollY
: scrollerRef.scrollTop
const scrollerHeight = scrollerRef === window
? scrollerRef.innerHeight
: scrollerRef.offsetHeight
const scrollerBottomBorder = currentScroll + scrollerHeight
// We check where the bottom border of root element is, this uses findOffset
// to find offset relative to scrollable container (scroller)
const rootBottomBorder = rootRef.offsetHeight + findOffset(rootRef, scrollerRef).top
const bottomDelta = Math.max(0, rootBottomBorder - scrollerBottomBorder)
// could also check top delta but there's no case for it
const targetScroll = currentScroll + bottomDelta
if (scrollerRef === window) {
scrollerRef.scroll(0, targetScroll)
} else {
scrollerRef.scrollTop = targetScroll
}
},
onTransition (e) {
this.resize()
},
@ -191,50 +328,93 @@ const EmojiInput = {
this.blurTimeout = null
}
if (!this.keepOpen) {
this.showPicker = false
}
this.focused = true
this.setCaret(e)
this.resize()
this.temporarilyHideSuggestions = false
},
onKeyUp (e) {
const { key } = e
this.setCaret(e)
this.resize()
// Setting hider in keyUp to prevent suggestions from blinking
// when moving away from suggested spot
if (key === 'Escape') {
this.temporarilyHideSuggestions = true
} else {
this.temporarilyHideSuggestions = false
}
},
onPaste (e) {
this.setCaret(e)
this.resize()
},
onKeyDown (e) {
this.setCaret(e)
this.resize()
const { ctrlKey, shiftKey, key } = e
if (key === 'Tab') {
if (shiftKey) {
// Disable suggestions hotkeys if suggestions are hidden
if (!this.temporarilyHideSuggestions) {
if (key === 'Tab') {
if (shiftKey) {
this.cycleBackward(e)
} else {
this.cycleForward(e)
}
}
if (key === 'ArrowUp') {
this.cycleBackward(e)
} else {
} else if (key === 'ArrowDown') {
this.cycleForward(e)
}
}
if (key === 'ArrowUp') {
this.cycleBackward(e)
} else if (key === 'ArrowDown') {
this.cycleForward(e)
}
if (key === 'Enter') {
if (!ctrlKey) {
this.replaceText(e)
if (key === 'Enter') {
if (!ctrlKey) {
this.replaceText(e)
}
}
}
// Probably add optional keyboard controls for emoji picker?
// Escape hides suggestions, if suggestions are hidden it
// de-focuses the element (i.e. default browser behavior)
if (key === 'Escape') {
if (!this.temporarilyHideSuggestions) {
this.input.elm.focus()
}
}
this.showPicker = false
this.resize()
},
onInput (e) {
this.showPicker = false
this.setCaret(e)
this.resize()
this.$emit('input', e.target.value)
},
onCompositionUpdate (e) {
this.showPicker = false
this.setCaret(e)
this.resize()
this.$emit('input', e.target.value)
},
onClickInput (e) {
this.showPicker = false
},
onClickOutside (e) {
if (this.disableClickOutside) return
this.showPicker = false
},
onStickerUploaded (e) {
this.showPicker = false
this.$emit('sticker-uploaded', e)
},
onStickerUploadFailed (e) {
this.showPicker = false
this.$emit('sticker-upload-Failed', e)
},
setCaret ({ target: { selectionStart } }) {
this.caret = selectionStart
},
@ -243,6 +423,7 @@ const EmojiInput = {
if (!panel) return
const { offsetHeight, offsetTop } = this.input.elm
this.$refs.panel.style.top = (offsetTop + offsetHeight) + 'px'
this.$refs.picker.$el.style.top = (offsetTop + offsetHeight) + 'px'
}
}
}

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"
class="follow-text"
>
<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 :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>

View file

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

View file

@ -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 }
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
target.style.height = 'auto'
target.style.height = `${target.scrollHeight - vertPadding}px`
// 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']
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 () {
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
},

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -39,19 +39,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(', ')
}
@ -179,6 +170,9 @@ export default {
}
this.$store.dispatch('setMedia', [attachment])
this.$store.dispatch('setCurrent', attachment)
},
mentionUser () {
this.$store.dispatch('openPostStatusModal', { replyTo: true, repliedUser: this.user })
}
}
}

View file

@ -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">
@ -204,6 +208,15 @@
</button>
</div>
<div>
<button
class="btn btn-default btn-block"
@click="mentionUser"
>
{{ $t('user_card.mention') }}
</button>
</div>
<div>
<button
v-if="user.muted"
@ -314,7 +327,7 @@
@import '../../_variables.scss';
.user-card {
background-size: cover;
position: relative;
.panel-heading {
padding: .5em 0;
@ -323,14 +336,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 {

View file

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

View file

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