Merge branch 'develop' into 'iss-149/profile-fields-setting'

# Conflicts:
#   src/components/settings_modal/tabs/profile_tab.vue
This commit is contained in:
Shpuld Shpludson 2020-06-27 07:19:49 +00:00
commit ea0a12f604
50 changed files with 570 additions and 286 deletions

View file

@ -4,28 +4,38 @@ 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/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Changed ### Changed
- Greentext now has separate color slot for it
- Removed the use of with_move parameters when fetching notifications - Removed the use of with_move parameters when fetching notifications
- Push notifications now are the same as normal notfication, and are localized.
### Fixed ### Fixed
- Weird bug related to post being sent seemingly after pasting with keyboard (hopefully)
- Multiple issues with muted statuses/notifications - Multiple issues with muted statuses/notifications
## [Unreleased patch] ## [Unreleased patch]
### Add ### Add
- Added private notifications option for push notifications - Added private notifications option for push notifications
- 'Copy link' button for statuses (in the ellipsis menu) - 'Copy link' button for statuses (in the ellipsis menu)
- Autocomplete domains from list of known instances
- 'Bot' settings option and badge
### Changed ### Changed
- Registration page no longer requires email if the server is configured not to require it - Registration page no longer requires email if the server is configured not to require it
- Change heart to thumbs up in reaction picker - Change heart to thumbs up in reaction picker
- Close the media modal on navigation events - Close the media modal on navigation events
- Add colons to the emoji alt text, to make them copyable - Add colons to the emoji alt text, to make them copyable
- Add better visual indication for drag-and-drop for files
### Fixed ### Fixed
- Custom Emoji will display in poll options now.
- Status ellipsis menu closes properly when selecting certain options - Status ellipsis menu closes properly when selecting certain options
- Cropped images look correct in Chrome - Cropped images look correct in Chrome
- Newlines in the muted words settings work again - Newlines in the muted words settings work again
- Clicking on non-latin hashtags won't open a new window - Clicking on non-latin hashtags won't open a new window
- Uploading and drag-dropping multiple files works correctly now. - Uploading and drag-dropping multiple files works correctly now.
- Subject field now appears disabled when posting
- Fix status ellipsis menu being cut off in notifications column
- Fixed autocomplete sometimes not returning the right user when there's already some results
## [2.0.3] - 2020-05-02 ## [2.0.3] - 2020-05-02
### Fixed ### Fixed

View file

@ -22,12 +22,9 @@
"cropperjs": "^1.4.3", "cropperjs": "^1.4.3",
"diff": "^3.0.1", "diff": "^3.0.1",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"karma-mocha-reporter": "^2.2.1",
"localforage": "^1.5.0", "localforage": "^1.5.0",
"object-path": "^0.11.3",
"phoenix": "^1.3.0", "phoenix": "^1.3.0",
"portal-vue": "^2.1.4", "portal-vue": "^2.1.4",
"sanitize-html": "^1.13.0",
"v-click-outside": "^2.1.1", "v-click-outside": "^2.1.1",
"vue": "^2.6.11", "vue": "^2.6.11",
"vue-chat-scroll": "^1.2.1", "vue-chat-scroll": "^1.2.1",
@ -35,10 +32,10 @@
"vue-router": "^3.0.1", "vue-router": "^3.0.1",
"vue-template-compiler": "^2.6.11", "vue-template-compiler": "^2.6.11",
"vuelidate": "^0.7.4", "vuelidate": "^0.7.4",
"vuex": "^3.0.1", "vuex": "^3.0.1"
"whatwg-fetch": "^2.0.3"
}, },
"devDependencies": { "devDependencies": {
"karma-mocha-reporter": "^2.2.1",
"@babel/core": "^7.7.5", "@babel/core": "^7.7.5",
"@babel/plugin-transform-runtime": "^7.7.6", "@babel/plugin-transform-runtime": "^7.7.6",
"@babel/preset-env": "^7.7.6", "@babel/preset-env": "^7.7.6",

View file

@ -3,6 +3,7 @@
<Popover <Popover
trigger="click" trigger="click"
placement="bottom" placement="bottom"
:bound-to="{ x: 'container' }"
> >
<div <div
slot="content" slot="content"

View file

@ -5,9 +5,20 @@ const DomainMuteCard = {
components: { components: {
ProgressButton ProgressButton
}, },
computed: {
user () {
return this.$store.state.users.currentUser
},
muted () {
return this.user.domainMutes.includes(this.domain)
}
},
methods: { methods: {
unmuteDomain () { unmuteDomain () {
return this.$store.dispatch('unmuteDomain', this.domain) return this.$store.dispatch('unmuteDomain', this.domain)
},
muteDomain () {
return this.$store.dispatch('muteDomain', this.domain)
} }
} }
} }

View file

@ -4,6 +4,7 @@
{{ domain }} {{ domain }}
</div> </div>
<ProgressButton <ProgressButton
v-if="muted"
:click="unmuteDomain" :click="unmuteDomain"
class="btn btn-default" class="btn btn-default"
> >
@ -12,6 +13,16 @@
{{ $t('domain_mute_card.unmute_progress') }} {{ $t('domain_mute_card.unmute_progress') }}
</template> </template>
</ProgressButton> </ProgressButton>
<ProgressButton
v-else
:click="muteDomain"
class="btn btn-default"
>
{{ $t('domain_mute_card.mute') }}
<template slot="progress">
{{ $t('domain_mute_card.mute_progress') }}
</template>
</ProgressButton>
</div> </div>
</template> </template>
@ -34,5 +45,9 @@
button { button {
width: 10em; width: 10em;
} }
.autosuggest-results & {
padding-left: 1em;
}
} }
</style> </style>

View file

@ -13,7 +13,7 @@ import { debounce } from 'lodash'
const debounceUserSearch = debounce((data, input) => { const debounceUserSearch = debounce((data, input) => {
data.updateUsersList(input) data.updateUsersList(input)
}, 500, { leading: true, trailing: false }) }, 500)
export default data => input => { export default data => input => {
const firstChar = input[0] const firstChar = input[0]
@ -97,8 +97,8 @@ export const suggestUsers = data => input => {
replacement: '@' + screen_name + ' ' replacement: '@' + screen_name + ' '
})) }))
// BE search users if there are no matches // BE search users to get more comprehensive results
if (newUsers.length === 0 && data.updateUsersList) { if (data.updateUsersList) {
debounceUserSearch(data, noPrefix) debounceUserSearch(data, noPrefix)
} }
return newUsers return newUsers

View file

@ -3,6 +3,7 @@
trigger="click" trigger="click"
placement="top" placement="top"
class="extra-button-popover" class="extra-button-popover"
:bound-to="{ x: 'container' }"
> >
<div <div
slot="content" slot="content"

View file

@ -78,6 +78,7 @@
video, video,
canvas { canvas {
object-fit: contain; object-fit: contain;
height: 100%;
} }
} }

View file

@ -45,20 +45,6 @@ const mediaUpload = {
this.$emit('all-uploaded') this.$emit('all-uploaded')
} }
}, },
fileDrop (e) {
if (e.dataTransfer.files.length > 0) {
e.preventDefault() // allow dropping text like before
this.multiUpload(e.dataTransfer.files)
}
},
fileDrag (e) {
let types = e.dataTransfer.types
if (types.contains('Files')) {
e.dataTransfer.dropEffect = 'copy'
} else {
e.dataTransfer.dropEffect = 'none'
}
},
clearFile () { clearFile () {
this.uploadReady = false this.uploadReady = false
this.$nextTick(() => { this.$nextTick(() => {

View file

@ -1,10 +1,5 @@
<template> <template>
<div <div class="media-upload">
class="media-upload"
@drop.prevent
@dragover.prevent="fileDrag"
@drop="fileDrop"
>
<label <label
class="label" class="label"
:title="$t('tool_tip.media_upload')" :title="$t('tool_tip.media_upload')"

View file

@ -54,13 +54,13 @@
flex-wrap: nowrap; flex-wrap: nowrap;
padding: 0.6em; padding: 0.6em;
min-width: 0; min-width: 0;
.avatar-container { .avatar-container {
width: 32px; width: 32px;
height: 32px; height: 32px;
} }
.status-el {
.status { .status-body {
padding: 0.25em 0;
color: $fallback--faint; color: $fallback--faint;
color: var(--faint, $fallback--faint); color: var(--faint, $fallback--faint);
a { a {
@ -70,11 +70,6 @@
color: var(--postFaintLink); color: var(--postFaintLink);
} }
} }
padding: 0;
.media-body {
margin: 0;
}
}
} }
.follow-request-accept { .follow-request-accept {

View file

@ -17,7 +17,7 @@
<span class="result-percentage"> <span class="result-percentage">
{{ percentageForOption(option.votes_count) }}% {{ percentageForOption(option.votes_count) }}%
</span> </span>
<span>{{ option.title }}</span> <span v-html="option.title_html"></span>
</div> </div>
<div <div
class="result-fill" class="result-fill"

View file

@ -1,4 +1,3 @@
const Popover = { const Popover = {
name: 'Popover', name: 'Popover',
props: { props: {
@ -10,6 +9,9 @@ const Popover = {
// 'container' for using offsetParent as boundaries for either axis // 'container' for using offsetParent as boundaries for either axis
// or 'viewport' // or 'viewport'
boundTo: Object, boundTo: Object,
// Takes a selector to use as a replacement for the parent container
// for getting boundaries for x an y axis
boundToSelector: String,
// Takes a top/bottom/left/right object, how much space to leave // Takes a top/bottom/left/right object, how much space to leave
// between boundary and popover element // between boundary and popover element
margin: Object, margin: Object,
@ -27,6 +29,10 @@ const Popover = {
} }
}, },
methods: { methods: {
containerBoundingClientRect () {
const container = this.boundToSelector ? this.$el.closest(this.boundToSelector) : this.$el.offsetParent
return container.getBoundingClientRect()
},
updateStyles () { updateStyles () {
if (this.hidden) { if (this.hidden) {
this.styles = { this.styles = {
@ -45,7 +51,8 @@ const Popover = {
// Minor optimization, don't call a slow reflow call if we don't have to // Minor optimization, don't call a slow reflow call if we don't have to
const parentBounds = this.boundTo && const parentBounds = this.boundTo &&
(this.boundTo.x === 'container' || this.boundTo.y === 'container') && (this.boundTo.x === 'container' || this.boundTo.y === 'container') &&
this.$el.offsetParent.getBoundingClientRect() this.containerBoundingClientRect()
const margin = this.margin || {} const margin = this.margin || {}
// What are the screen bounds for the popover? Viewport vs container // What are the screen bounds for the popover? Viewport vs container

View file

@ -82,7 +82,9 @@ const PostStatusForm = {
contentType contentType
}, },
caret: 0, caret: 0,
pollFormVisible: false pollFormVisible: false,
showDropIcon: 'hide',
dropStopTimeout: null
} }
}, },
computed: { computed: {
@ -248,13 +250,27 @@ const PostStatusForm = {
} }
}, },
fileDrop (e) { fileDrop (e) {
if (e.dataTransfer.files.length > 0) { if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
e.preventDefault() // allow dropping text like before e.preventDefault() // allow dropping text like before
this.dropFiles = e.dataTransfer.files this.dropFiles = e.dataTransfer.files
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'hide'
} }
}, },
fileDragStop (e) {
// The false-setting is done with delay because just using leave-events
// directly caused unwanted flickering, this is not perfect either but
// much less noticable.
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'fade'
this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500)
},
fileDrag (e) { fileDrag (e) {
e.dataTransfer.dropEffect = 'copy' e.dataTransfer.dropEffect = 'copy'
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'show'
}
}, },
onEmojiInputInput (e) { onEmojiInputInput (e) {
this.$nextTick(() => { this.$nextTick(() => {

View file

@ -6,7 +6,15 @@
<form <form
autocomplete="off" autocomplete="off"
@submit.prevent="postStatus(newStatus)" @submit.prevent="postStatus(newStatus)"
@dragover.prevent="fileDrag"
> >
<div
v-show="showDropIcon !== 'hide'"
:style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }"
class="drop-indicator icon-upload"
@dragleave="fileDragStop"
@drop.stop="fileDrop"
/>
<div class="form-group"> <div class="form-group">
<i18n <i18n
v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private'" v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private'"
@ -73,6 +81,7 @@
v-model="newStatus.spoilerText" v-model="newStatus.spoilerText"
type="text" type="text"
:placeholder="$t('post_status.content_warning')" :placeholder="$t('post_status.content_warning')"
:disabled="posting"
class="form-post-subject" class="form-post-subject"
> >
</EmojiInput> </EmojiInput>
@ -96,9 +105,7 @@
:disabled="posting" :disabled="posting"
class="form-post-body" class="form-post-body"
@keydown.meta.enter="postStatus(newStatus)" @keydown.meta.enter="postStatus(newStatus)"
@keyup.ctrl.enter="postStatus(newStatus)" @keydown.ctrl.enter="postStatus(newStatus)"
@drop="fileDrop"
@dragover.prevent="fileDrag"
@input="resize" @input="resize"
@compositionupdate="resize" @compositionupdate="resize"
@paste="paste" @paste="paste"
@ -447,7 +454,8 @@
form { form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0.6em; margin: 0.6em;
position: relative;
} }
.form-group { .form-group {
@ -505,5 +513,35 @@
cursor: pointer; cursor: pointer;
z-index: 4; z-index: 4;
} }
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 0.6; }
}
@keyframes fade-out {
from { opacity: 0.6; }
to { opacity: 0; }
}
.drop-indicator {
position: absolute;
z-index: 1;
width: 100%;
height: 100%;
font-size: 5em;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.6;
color: $fallback--text;
color: var(--text, $fallback--text);
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
border: 2px dashed $fallback--text;
border: 2px dashed var(--text, $fallback--text);
}
} }
</style> </style>

View file

@ -13,6 +13,9 @@ const PostStatusModal = {
} }
}, },
computed: { computed: {
isLoggedIn () {
return !!this.$store.state.users.currentUser
},
modalActivated () { modalActivated () {
return this.$store.state.postStatus.modalActivated return this.$store.state.postStatus.modalActivated
}, },

View file

@ -1,5 +1,6 @@
<template> <template>
<Modal <Modal
v-if="isLoggedIn && !resettingForm"
:is-open="modalActivated" :is-open="modalActivated"
class="post-form-modal-view" class="post-form-modal-view"
@backdropClicked="closeModal" @backdropClicked="closeModal"

View file

@ -32,12 +32,12 @@ const DomainMuteList = withSubscription({
const MutesAndBlocks = { const MutesAndBlocks = {
data () { data () {
return { return {
activeTab: 'profile', activeTab: 'profile'
newDomainToMute: ''
} }
}, },
created () { created () {
this.$store.dispatch('fetchTokens') this.$store.dispatch('fetchTokens')
this.$store.dispatch('getKnownDomains')
}, },
components: { components: {
TabSwitcher, TabSwitcher,
@ -51,6 +51,14 @@ const MutesAndBlocks = {
Autosuggest, Autosuggest,
Checkbox Checkbox
}, },
computed: {
knownDomains () {
return this.$store.state.instance.knownDomains
},
user () {
return this.$store.state.users.currentUser
}
},
methods: { methods: {
importFollows (file) { importFollows (file) {
return this.$store.state.api.backendInteractor.importFollows({ file }) return this.$store.state.api.backendInteractor.importFollows({ file })
@ -86,13 +94,13 @@ const MutesAndBlocks = {
filterUnblockedUsers (userIds) { filterUnblockedUsers (userIds) {
return reject(userIds, (userId) => { return reject(userIds, (userId) => {
const relationship = this.$store.getters.relationship(this.userId) const relationship = this.$store.getters.relationship(this.userId)
return relationship.blocking || userId === this.$store.state.users.currentUser.id return relationship.blocking || userId === this.user.id
}) })
}, },
filterUnMutedUsers (userIds) { filterUnMutedUsers (userIds) {
return reject(userIds, (userId) => { return reject(userIds, (userId) => {
const relationship = this.$store.getters.relationship(this.userId) const relationship = this.$store.getters.relationship(this.userId)
return relationship.muting || userId === this.$store.state.users.currentUser.id return relationship.muting || userId === this.user.id
}) })
}, },
queryUserIds (query) { queryUserIds (query) {
@ -111,12 +119,16 @@ const MutesAndBlocks = {
unmuteUsers (ids) { unmuteUsers (ids) {
return this.$store.dispatch('unmuteUsers', ids) return this.$store.dispatch('unmuteUsers', ids)
}, },
filterUnMutedDomains (urls) {
return urls.filter(url => !this.user.domainMutes.includes(url))
},
queryKnownDomains (query) {
return new Promise((resolve, reject) => {
resolve(this.knownDomains.filter(url => url.toLowerCase().includes(query)))
})
},
unmuteDomains (domains) { unmuteDomains (domains) {
return this.$store.dispatch('unmuteDomains', domains) return this.$store.dispatch('unmuteDomains', domains)
},
muteDomain () {
return this.$store.dispatch('muteDomain', this.newDomainToMute)
.then(() => { this.newDomainToMute = '' })
} }
} }
} }

View file

@ -119,21 +119,16 @@
<div :label="$t('settings.domain_mutes')"> <div :label="$t('settings.domain_mutes')">
<div class="domain-mute-form"> <div class="domain-mute-form">
<input <Autosuggest
v-model="newDomainToMute" :filter="filterUnMutedDomains"
:query="queryKnownDomains"
:placeholder="$t('settings.type_domains_to_mute')" :placeholder="$t('settings.type_domains_to_mute')"
type="text"
@keyup.enter="muteDomain"
> >
<ProgressButton <DomainMuteCard
class="btn btn-default domain-mute-button" slot-scope="row"
:click="muteDomain" :domain="row.item"
> />
{{ $t('domain_mute_card.mute') }} </Autosuggest>
<template slot="progress">
{{ $t('domain_mute_card.mute_progress') }}
</template>
</ProgressButton>
</div> </div>
<DomainMuteList <DomainMuteList
:refresh="true" :refresh="true"

View file

@ -25,6 +25,7 @@ const ProfileTab = {
showRole: this.$store.state.users.currentUser.show_role, showRole: this.$store.state.users.currentUser.show_role,
role: this.$store.state.users.currentUser.role, role: this.$store.state.users.currentUser.role,
discoverable: this.$store.state.users.currentUser.discoverable, discoverable: this.$store.state.users.currentUser.discoverable,
bot: this.$store.state.users.currentUser.bot,
allowFollowingMove: this.$store.state.users.currentUser.allow_following_move, allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
pickAvatarBtnVisible: true, pickAvatarBtnVisible: true,
bannerUploading: false, bannerUploading: false,
@ -94,6 +95,7 @@ const ProfileTab = {
hide_follows: this.hideFollows, hide_follows: this.hideFollows,
hide_followers: this.hideFollowers, hide_followers: this.hideFollowers,
discoverable: this.discoverable, discoverable: this.discoverable,
bot: this.bot,
allow_following_move: this.allowFollowingMove, allow_following_move: this.allowFollowingMove,
hide_follows_count: this.hideFollowsCount, hide_follows_count: this.hideFollowsCount,
hide_followers_count: this.hideFollowersCount, hide_followers_count: this.hideFollowersCount,

View file

@ -143,6 +143,11 @@
{{ $t("settings.profile_fields.add_field") }} {{ $t("settings.profile_fields.add_field") }}
</a> </a>
</div> </div>
<p>
<Checkbox v-model="bot">
{{ $t('settings.bot') }}
</Checkbox>
</p>
<button <button
:disabled="newName && newName.length === 0" :disabled="newName && newName.length === 0"
class="btn btn-default" class="btn btn-default"

View file

@ -256,6 +256,13 @@
:label="$t('settings.links')" :label="$t('settings.links')"
/> />
<ContrastRatio :contrast="previewContrast.postLink" /> <ContrastRatio :contrast="previewContrast.postLink" />
<ColorInput
v-model="postGreentextColorLocal"
name="postGreentextColor"
:fallback="previewTheme.colors.cGreen"
:label="$t('settings.greentext')"
/>
<ContrastRatio :contrast="previewContrast.postGreentext" />
<h4>{{ $t('settings.style.advanced_colors.alert') }}</h4> <h4>{{ $t('settings.style.advanced_colors.alert') }}</h4>
<ColorInput <ColorInput
v-model="alertErrorColorLocal" v-model="alertErrorColorLocal"

View file

@ -418,7 +418,7 @@ $status-margin: 0.75em;
max-width: 85%; max-width: 85%;
font-weight: bold; font-weight: bold;
img { img.emoji {
width: 14px; width: 14px;
height: 14px; height: 14px;
vertical-align: middle; vertical-align: middle;

View file

@ -164,11 +164,6 @@ $status-margin: 0.75em;
word-break: break-all; word-break: break-all;
} }
.status-content {
font-family: var(--postFont, sans-serif);
line-height: 1.4em;
white-space: pre-wrap;
img, video { img, video {
max-width: 100%; max-width: 100%;
max-height: 400px; max-height: 400px;
@ -181,6 +176,11 @@ $status-margin: 0.75em;
} }
} }
.status-content {
font-family: var(--postFont, sans-serif);
line-height: 1.4em;
white-space: pre-wrap;
blockquote { blockquote {
margin: 0.2em 0 0.2em 2em; margin: 0.2em 0 0.2em 2em;
font-style: italic; font-style: italic;
@ -226,7 +226,7 @@ $status-margin: 0.75em;
.greentext { .greentext {
color: $fallback--cGreen; color: $fallback--cGreen;
color: var(--cGreen, $fallback--cGreen); color: var(--postGreentext, $fallback--cGreen);
} }
.timeline :not(.panel-disabled) > { .timeline :not(.panel-disabled) > {

View file

@ -23,13 +23,6 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
.contain-fit {
.still-image {
img {
height: 100%;
}
}
}
.still-image { .still-image {
position: relative; position: relative;
@ -38,6 +31,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex; display: flex;
align-items: center;
&:hover canvas { &:hover canvas {
display: none; display: none;
@ -45,8 +39,8 @@
img { img {
width: 100%; width: 100%;
min-height: 100%;
object-fit: contain; object-fit: contain;
align-self: center;
} }
&.animated { &.animated {

View file

@ -70,10 +70,20 @@
> >
@{{ user.screen_name }} @{{ user.screen_name }}
</router-link> </router-link>
<template v-if="!hideBio">
<span <span
v-if="!hideBio && !!visibleRole" v-if="!!visibleRole"
class="alert staff" class="alert user-role"
>{{ visibleRole }}</span> >
{{ visibleRole }}
</span>
<span
v-if="user.bot"
class="alert user-role"
>
bot
</span>
</template>
<span v-if="user.locked"><i class="icon icon-lock" /></span> <span v-if="user.locked"><i class="icon icon-lock" /></span>
<span <span
v-if="!mergedConfig.hideUserStats && !hideBio" v-if="!mergedConfig.hideUserStats && !hideBio"
@ -458,7 +468,7 @@
color: var(--text, $fallback--text); color: var(--text, $fallback--text);
} }
.staff { .user-role {
flex: none; flex: none;
text-transform: capitalize; text-transform: capitalize;
color: $fallback--text; color: $fallback--text;

View file

@ -124,6 +124,14 @@ const UserProfile = {
onTabSwitch (tab) { onTabSwitch (tab) {
this.tab = tab this.tab = tab
this.$router.replace({ query: { tab } }) this.$router.replace({ query: { tab } })
},
linkClicked ({ target }) {
if (target.tagName === 'SPAN') {
target = target.parentNode
}
if (target.tagName === 'A') {
window.open(target.href, '_blank')
}
} }
}, },
watch: { watch: {

View file

@ -11,6 +11,31 @@
:allow-zooming-avatar="true" :allow-zooming-avatar="true"
rounded="top" rounded="top"
/> />
<div
v-if="user.fields_html && user.fields_html.length > 0"
class="user-profile-fields"
>
<dl
v-for="(field, index) in user.fields_html"
:key="index"
class="user-profile-field"
>
<!-- eslint-disable vue/no-v-html -->
<dt
:title="user.fields_text[index].name"
class="user-profile-field-name"
@click.prevent="linkClicked"
v-html="field.name"
/>
<dd
:title="user.fields_text[index].value"
class="user-profile-field-value"
@click.prevent="linkClicked"
v-html="field.value"
/>
<!-- eslint-enable vue/no-v-html -->
</dl>
</div>
<tab-switcher <tab-switcher
:active-tab="tab" :active-tab="tab"
:render-only-focused="true" :render-only-focused="true"
@ -108,11 +133,60 @@
<script src="./user_profile.js"></script> <script src="./user_profile.js"></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss';
.user-profile { .user-profile {
flex: 2; flex: 2;
flex-basis: 500px; flex-basis: 500px;
.user-profile-fields {
margin: 0 0.5em;
img {
object-fit: contain;
vertical-align: middle;
max-width: 100%;
max-height: 400px;
&.emoji {
width: 18px;
height: 18px;
}
}
.user-profile-field {
display: flex;
margin: 0.25em auto;
max-width: 32em;
border: 1px solid var(--border, $fallback--border);
border-radius: $fallback--inputRadius;
border-radius: var(--inputRadius, $fallback--inputRadius);
.user-profile-field-name {
flex: 0 1 30%;
font-weight: 500;
text-align: right;
color: var(--lightText);
min-width: 120px;
border-right: 1px solid var(--border, $fallback--border);
}
.user-profile-field-value {
flex: 1 1 70%;
color: var(--text);
margin: 0 0 0 0.25em;
}
.user-profile-field-name, .user-profile-field-value {
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
padding: 0.5em 1.5em;
box-sizing: border-box;
}
}
}
.userlist-placeholder { .userlist-placeholder {
display: flex; display: flex;
justify-content: center; justify-content: center;

View file

@ -266,6 +266,7 @@
"block_import_error": "Error importing blocks", "block_import_error": "Error importing blocks",
"blocks_imported": "Blocks imported! Processing them will take a while.", "blocks_imported": "Blocks imported! Processing them will take a while.",
"blocks_tab": "Blocks", "blocks_tab": "Blocks",
"bot": "This is a bot account",
"btnRadius": "Buttons", "btnRadius": "Buttons",
"cBlue": "Blue (Reply, follow)", "cBlue": "Blue (Reply, follow)",
"cGreen": "Green (Retweet)", "cGreen": "Green (Retweet)",
@ -407,7 +408,7 @@
"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": "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_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.",
"tooltipRadius": "Tooltips/alerts", "tooltipRadius": "Tooltips/alerts",
"type_domains_to_mute": "Type in domains to mute", "type_domains_to_mute": "Search domains to mute",
"upload_a_photo": "Upload a photo", "upload_a_photo": "Upload a photo",
"user_settings": "User Settings", "user_settings": "User Settings",
"values": { "values": {

View file

@ -12,7 +12,12 @@
"disable": "Disabilita", "disable": "Disabilita",
"enable": "Abilita", "enable": "Abilita",
"confirm": "Conferma", "confirm": "Conferma",
"verify": "Verifica" "verify": "Verifica",
"peek": "Anteprima",
"close": "Chiudi",
"retry": "Riprova",
"error_retry": "Per favore, riprova",
"loading": "Carico…"
}, },
"nav": { "nav": {
"mentions": "Menzioni", "mentions": "Menzioni",
@ -212,7 +217,63 @@
}, },
"common": { "common": {
"opacity": "Opacità", "opacity": "Opacità",
"color": "Colore" "color": "Colore",
"contrast": {
"context": {
"text": "per il testo",
"18pt": "per il testo grande (oltre 17pt)"
},
"level": {
"bad": "non soddisfa le linee guida di alcun livello",
"aaa": "soddisfa le linee guida di livello AAA (ottimo)",
"aa": "soddisfa le linee guida di livello AA (sufficiente)"
},
"hint": "Il rapporto di contrasto è {ratio}, e {level} {context}"
}
},
"advanced_colors": {
"badge": "Sfondo medaglie",
"post": "Messaggi / Biografie",
"alert_neutral": "Neutro",
"alert_warning": "Attenzione",
"alert_error": "Errore",
"alert": "Sfondo degli avvertimenti",
"_tab_label": "Avanzate",
"tabs": "Etichette",
"disabled": "Disabilitato",
"selectedMenu": "Voce menù selezionata",
"selectedPost": "Messaggio selezionato",
"pressed": "Premuto",
"highlight": "Elementi evidenziati",
"icons": "Icone",
"poll": "Grafico sondaggi",
"underlay": "Sottostante",
"faint_text": "Testo sbiadito",
"inputs": "Campi d'immissione",
"buttons": "Pulsanti",
"borders": "Bordi",
"top_bar": "Barra superiore",
"panel_header": "Titolo pannello",
"badge_notification": "Notifica",
"popover": "Suggerimenti, menù, sbalzi"
},
"common_colors": {
"rgbo": "Icone, accenti, medaglie",
"foreground_hint": "Seleziona l'etichetta \"Avanzate\" per controlli più fini",
"main": "Colori comuni",
"_tab_label": "Comuni"
},
"shadows": {
"inset": "Includi",
"spread": "Spandi",
"blur": "Sfoca",
"shadow_id": "Ombra numero {value}",
"override": "Sostituisci",
"component": "Componente",
"_tab_label": "Luci ed ombre"
},
"radii": {
"_tab_label": "Raggio"
} }
}, },
"enable_web_push_notifications": "Abilita notifiche web push", "enable_web_push_notifications": "Abilita notifiche web push",
@ -229,7 +290,7 @@
"notifications": "Notifiche", "notifications": "Notifiche",
"greentext": "Frecce da meme", "greentext": "Frecce da meme",
"upload_a_photo": "Carica un'immagine", "upload_a_photo": "Carica un'immagine",
"type_domains_to_mute": "Inserisci domini da zittire", "type_domains_to_mute": "Cerca domini da zittire",
"theme_help_v2_2": "Le icone dietro alcuni elementi sono indicatori del contrasto fra testo e sfondo, passaci sopra col puntatore per ulteriori informazioni. Se si usano delle trasparenze, questi indicatori mostrano il peggior caso possibile.", "theme_help_v2_2": "Le icone dietro alcuni elementi sono indicatori del contrasto fra testo e sfondo, passaci sopra col puntatore per ulteriori informazioni. Se si usano delle trasparenze, questi indicatori mostrano il peggior caso possibile.",
"theme_help_v2_1": "Puoi anche forzare colore ed opacità di alcuni elementi selezionando la casella. Usa il pulsante \"Azzera\" per azzerare tutte le forzature.", "theme_help_v2_1": "Puoi anche forzare colore ed opacità di alcuni elementi selezionando la casella. Usa il pulsante \"Azzera\" per azzerare tutte le forzature.",
"useStreamingApiWarning": "(Sconsigliato, sperimentale, può saltare messaggi)", "useStreamingApiWarning": "(Sconsigliato, sperimentale, può saltare messaggi)",
@ -273,7 +334,8 @@
"accent": "Accento", "accent": "Accento",
"emoji_reactions_on_timeline": "Mostra emoji di reazione sulle sequenze", "emoji_reactions_on_timeline": "Mostra emoji di reazione sulle sequenze",
"pad_emoji": "Affianca spazi agli emoji inseriti tramite selettore", "pad_emoji": "Affianca spazi agli emoji inseriti tramite selettore",
"notification_blocks": "Bloccando un utente non riceverai più le sue notifiche né lo seguirai più." "notification_blocks": "Bloccando un utente non riceverai più le sue notifiche né lo seguirai più.",
"mutes_and_blocks": "Zittiti e bloccati"
}, },
"timeline": { "timeline": {
"error_fetching": "Errore nell'aggiornamento", "error_fetching": "Errore nell'aggiornamento",

View file

@ -130,6 +130,7 @@
"background": "Фон", "background": "Фон",
"bio": "Описание", "bio": "Описание",
"btnRadius": "Кнопки", "btnRadius": "Кнопки",
"bot": "Это аккаунт бота",
"cBlue": "Ответить, читать", "cBlue": "Ответить, читать",
"cGreen": "Повторить", "cGreen": "Повторить",
"cOrange": "Нравится", "cOrange": "Нравится",
@ -456,9 +457,9 @@
}, },
"domain_mute_card": { "domain_mute_card": {
"mute": "Игнорировать", "mute": "Игнорировать",
"mute_progress": "В процессе...", "mute_progress": "В процессе",
"unmute": "Прекратить игнорирование", "unmute": "Прекратить игнорирование",
"unmute_progress": "В процессе..." "unmute_progress": "В процессе"
}, },
"exporter": { "exporter": {
"export": "Экспорт", "export": "Экспорт",

View file

@ -0,0 +1,35 @@
/* eslint-disable import/no-webpack-loader-syntax */
// This module exports only the notification part of the i18n,
// which is useful for the service worker
const messages = {
ar: require('../lib/notification-i18n-loader.js!./ar.json'),
ca: require('../lib/notification-i18n-loader.js!./ca.json'),
cs: require('../lib/notification-i18n-loader.js!./cs.json'),
de: require('../lib/notification-i18n-loader.js!./de.json'),
eo: require('../lib/notification-i18n-loader.js!./eo.json'),
es: require('../lib/notification-i18n-loader.js!./es.json'),
et: require('../lib/notification-i18n-loader.js!./et.json'),
eu: require('../lib/notification-i18n-loader.js!./eu.json'),
fi: require('../lib/notification-i18n-loader.js!./fi.json'),
fr: require('../lib/notification-i18n-loader.js!./fr.json'),
ga: require('../lib/notification-i18n-loader.js!./ga.json'),
he: require('../lib/notification-i18n-loader.js!./he.json'),
hu: require('../lib/notification-i18n-loader.js!./hu.json'),
it: require('../lib/notification-i18n-loader.js!./it.json'),
ja: require('../lib/notification-i18n-loader.js!./ja_pedantic.json'),
ja_easy: require('../lib/notification-i18n-loader.js!./ja_easy.json'),
ko: require('../lib/notification-i18n-loader.js!./ko.json'),
nb: require('../lib/notification-i18n-loader.js!./nb.json'),
nl: require('../lib/notification-i18n-loader.js!./nl.json'),
oc: require('../lib/notification-i18n-loader.js!./oc.json'),
pl: require('../lib/notification-i18n-loader.js!./pl.json'),
pt: require('../lib/notification-i18n-loader.js!./pt.json'),
ro: require('../lib/notification-i18n-loader.js!./ro.json'),
ru: require('../lib/notification-i18n-loader.js!./ru.json'),
te: require('../lib/notification-i18n-loader.js!./te.json'),
zh: require('../lib/notification-i18n-loader.js!./zh.json'),
en: require('../lib/notification-i18n-loader.js!./en.json')
}
export default messages

View file

@ -0,0 +1,12 @@
// This somewhat mysterious module will load a json string
// and then extract only the 'notifications' part. This is
// meant to be used to load the partial i18n we need for
// the service worker.
module.exports = function (source) {
var object = JSON.parse(source)
var smol = {
notifications: object.notifications || {}
}
return JSON.stringify(smol)
}

View file

@ -1,13 +1,12 @@
import merge from 'lodash.merge' import merge from 'lodash.merge'
import objectPath from 'object-path'
import localforage from 'localforage' import localforage from 'localforage'
import { each } from 'lodash' import { each, get, set } from 'lodash'
let loaded = false let loaded = false
const defaultReducer = (state, paths) => ( const defaultReducer = (state, paths) => (
paths.length === 0 ? state : paths.reduce((substate, path) => { paths.length === 0 ? state : paths.reduce((substate, path) => {
objectPath.set(substate, path, objectPath.get(state, path)) set(substate, path, get(state, path))
return substate return substate
}, {}) }, {})
) )

View file

@ -1,6 +1,7 @@
import { set } from 'vue' import { set } from 'vue'
import { getPreset, applyTheme } from '../services/style_setter/style_setter.js' import { getPreset, applyTheme } from '../services/style_setter/style_setter.js'
import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js' import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js'
import apiService from '../services/api/api.service.js'
import { instanceDefaultProperties } from './config.js' import { instanceDefaultProperties } from './config.js'
const defaultState = { const defaultState = {
@ -48,6 +49,7 @@ const defaultState = {
postFormats: [], postFormats: [],
restrictedNicknames: [], restrictedNicknames: [],
safeDM: true, safeDM: true,
knownDomains: [],
// Feature-set, apparently, not everything here is reported... // Feature-set, apparently, not everything here is reported...
chatAvailable: false, chatAvailable: false,
@ -80,6 +82,9 @@ const instance = {
if (typeof value !== 'undefined') { if (typeof value !== 'undefined') {
set(state, name, value) set(state, name, value)
} }
},
setKnownDomains (state, domains) {
state.knownDomains = domains
} }
}, },
getters: { getters: {
@ -182,6 +187,18 @@ const instance = {
state.emojiFetched = true state.emojiFetched = true
dispatch('getStaticEmoji') dispatch('getStaticEmoji')
} }
},
async getKnownDomains ({ commit, rootState }) {
try {
const result = await apiService.fetchKnownDomains({
credentials: rootState.users.currentUser.credentials
})
commit('setKnownDomains', result)
} catch (e) {
console.warn("Can't load known domains")
console.warn(e)
}
} }
} }
} }

View file

@ -13,7 +13,7 @@ import {
omitBy omitBy
} from 'lodash' } from 'lodash'
import { set } from 'vue' import { set } from 'vue'
import { isStatusNotification } from '../services/notification_utils/notification_utils.js' import { isStatusNotification, prepareNotificationObject } from '../services/notification_utils/notification_utils.js'
import apiService from '../services/api/api.service.js' import apiService from '../services/api/api.service.js'
import { muteWordHits } from '../services/status_parser/status_parser.js' import { muteWordHits } from '../services/status_parser/status_parser.js'
@ -344,42 +344,7 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
state.notifications.idStore[notification.id] = notification state.notifications.idStore[notification.id] = notification
if ('Notification' in window && window.Notification.permission === 'granted') { if ('Notification' in window && window.Notification.permission === 'granted') {
const notifObj = {} const notifObj = prepareNotificationObject(notification, rootGetters.i18n)
const status = notification.status
const title = notification.from_profile.name
notifObj.icon = notification.from_profile.profile_image_url
let i18nString
switch (notification.type) {
case 'like':
i18nString = 'favorited_you'
break
case 'repeat':
i18nString = 'repeated_you'
break
case 'follow':
i18nString = 'followed_you'
break
case 'move':
i18nString = 'migrated_to'
break
case 'follow_request':
i18nString = 'follow_request'
break
}
if (notification.type === 'pleroma:emoji_reaction') {
notifObj.body = rootGetters.i18n.t('notifications.reacted_with', [notification.emoji])
} else if (i18nString) {
notifObj.body = rootGetters.i18n.t('notifications.' + i18nString)
} else if (isStatusNotification(notification.type)) {
notifObj.body = notification.status.text
}
// Shows first attached non-nsfw image, if any. Should add configuration for this somehow...
if (status && status.attachments && status.attachments.length > 0 && !status.nsfw &&
status.attachments[0].mimetype.startsWith('image/')) {
notifObj.image = status.attachments[0].url
}
const reasonsToMuteNotif = ( const reasonsToMuteNotif = (
notification.seen || notification.seen ||
@ -393,7 +358,7 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
) )
) )
if (!reasonsToMuteNotif) { if (!reasonsToMuteNotif) {
let desktopNotification = new window.Notification(title, notifObj) let desktopNotification = new window.Notification(notifObj.title, notifObj)
// Chrome is known for not closing notifications automatically // Chrome is known for not closing notifications automatically
// according to MDN, anyway. // according to MDN, anyway.
setTimeout(desktopNotification.close.bind(desktopNotification), 5000) setTimeout(desktopNotification.close.bind(desktopNotification), 5000)

View file

@ -435,10 +435,10 @@ const users = {
store.commit('setUserForNotification', notification) store.commit('setUserForNotification', notification)
}) })
}, },
searchUsers (store, { query }) { searchUsers ({ rootState, commit }, { query }) {
return store.rootState.api.backendInteractor.searchUsers({ query }) return rootState.api.backendInteractor.searchUsers({ query })
.then((users) => { .then((users) => {
store.commit('addNewUsers', users) commit('addNewUsers', users)
return users return users
}) })
}, },

View file

@ -1,6 +1,5 @@
import { each, map, concat, last, get } from 'lodash' import { each, map, concat, last, get } from 'lodash'
import { parseStatus, parseUser, parseNotification, parseAttachment } from '../entity_normalizer/entity_normalizer.service.js' import { parseStatus, parseUser, parseNotification, parseAttachment } from '../entity_normalizer/entity_normalizer.service.js'
import 'whatwg-fetch'
import { RegistrationError, StatusCodeError } from '../errors/errors' import { RegistrationError, StatusCodeError } from '../errors/errors'
/* eslint-env browser */ /* eslint-env browser */
@ -75,6 +74,7 @@ const MASTODON_SEARCH_2 = `/api/v2/search`
const MASTODON_USER_SEARCH_URL = '/api/v1/accounts/search' const MASTODON_USER_SEARCH_URL = '/api/v1/accounts/search'
const MASTODON_DOMAIN_BLOCKS_URL = '/api/v1/domain_blocks' const MASTODON_DOMAIN_BLOCKS_URL = '/api/v1/domain_blocks'
const MASTODON_STREAMING = '/api/v1/streaming' const MASTODON_STREAMING = '/api/v1/streaming'
const MASTODON_KNOWN_DOMAIN_LIST_URL = '/api/v1/instance/peers'
const PLEROMA_EMOJI_REACTIONS_URL = id => `/api/v1/pleroma/statuses/${id}/reactions` const PLEROMA_EMOJI_REACTIONS_URL = id => `/api/v1/pleroma/statuses/${id}/reactions`
const PLEROMA_EMOJI_REACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}` const PLEROMA_EMOJI_REACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
const PLEROMA_EMOJI_UNREACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}` const PLEROMA_EMOJI_UNREACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
@ -995,6 +995,10 @@ const search2 = ({ credentials, q, resolve, limit, offset, following }) => {
}) })
} }
const fetchKnownDomains = ({ credentials }) => {
return promisedRequest({ url: MASTODON_KNOWN_DOMAIN_LIST_URL, credentials })
}
const fetchDomainMutes = ({ credentials }) => { const fetchDomainMutes = ({ credentials }) => {
return promisedRequest({ url: MASTODON_DOMAIN_BLOCKS_URL, credentials }) return promisedRequest({ url: MASTODON_DOMAIN_BLOCKS_URL, credentials })
} }
@ -1193,6 +1197,7 @@ const apiService = {
updateNotificationSettings, updateNotificationSettings,
search2, search2,
searchUsers, searchUsers,
fetchKnownDomains,
fetchDomainMutes, fetchDomainMutes,
muteDomain, muteDomain,
unmuteDomain unmuteDomain

View file

@ -56,6 +56,12 @@ export const parseUser = (data) => {
value: addEmojis(field.value, data.emojis) value: addEmojis(field.value, data.emojis)
} }
}) })
output.fields_text = data.fields.map(field => {
return {
name: unescape(field.name.replace(/<[^>]*>/g, '')),
value: unescape(field.value.replace(/<[^>]*>/g, ''))
}
})
// Utilize avatar_static for gif avatars? // Utilize avatar_static for gif avatars?
output.profile_image_url = data.avatar output.profile_image_url = data.avatar
@ -258,6 +264,12 @@ export const parseStatus = (data) => {
output.summary_html = addEmojis(escape(data.spoiler_text), data.emojis) output.summary_html = addEmojis(escape(data.spoiler_text), data.emojis)
output.external_url = data.url output.external_url = data.url
output.poll = data.poll output.poll = data.poll
if (output.poll) {
output.poll.options = (output.poll.options || []).map(field => ({
...field,
title_html: addEmojis(field.title, data.emojis)
}))
}
output.pinned = data.pinned output.pinned = data.pinned
output.muted = data.muted output.muted = data.muted
} else { } else {

View file

@ -43,3 +43,47 @@ export const filteredNotificationsFromStore = (store, types) => {
export const unseenNotificationsFromStore = store => export const unseenNotificationsFromStore = store =>
filter(filteredNotificationsFromStore(store), ({ seen }) => !seen) filter(filteredNotificationsFromStore(store), ({ seen }) => !seen)
export const prepareNotificationObject = (notification, i18n) => {
const notifObj = {
tag: notification.id
}
const status = notification.status
const title = notification.from_profile.name
notifObj.title = title
notifObj.icon = notification.from_profile.profile_image_url
let i18nString
switch (notification.type) {
case 'like':
i18nString = 'favorited_you'
break
case 'repeat':
i18nString = 'repeated_you'
break
case 'follow':
i18nString = 'followed_you'
break
case 'move':
i18nString = 'migrated_to'
break
case 'follow_request':
i18nString = 'follow_request'
break
}
if (notification.type === 'pleroma:emoji_reaction') {
notifObj.body = i18n.t('notifications.reacted_with', [notification.emoji])
} else if (i18nString) {
notifObj.body = i18n.t('notifications.' + i18nString)
} else if (isStatusNotification(notification.type)) {
notifObj.body = notification.status.text
}
// Shows first attached non-nsfw image, if any. Should add configuration for this somehow...
if (status && status.attachments && status.attachments.length > 0 && !status.nsfw &&
status.attachments[0].mimetype.startsWith('image/')) {
notifObj.image = status.attachments[0].url
}
return notifObj
}

View file

@ -1,17 +1,4 @@
import { filter } from 'lodash' import { filter } from 'lodash'
import sanitize from 'sanitize-html'
export const removeAttachmentLinks = (html) => {
return sanitize(html, {
allowedTags: false,
allowedAttributes: false,
exclusiveFilter: ({ tag, attribs }) => tag === 'a' && typeof attribs.class === 'string' && attribs.class.match(/attachment/)
})
}
export const parse = (html) => {
return removeAttachmentLinks(html)
}
export const muteWordHits = (status, muteWords) => { export const muteWordHits = (status, muteWords) => {
const statusText = status.text.toLowerCase() const statusText = status.text.toLowerCase()
@ -22,5 +9,3 @@ export const muteWordHits = (status, muteWords) => {
return hits return hits
} }
export default parse

View file

@ -356,6 +356,12 @@ export const SLOT_INHERITANCE = {
textColor: 'preserve' textColor: 'preserve'
}, },
postGreentext: {
depends: ['cGreen'],
layer: 'bg',
textColor: 'preserve'
},
border: { border: {
depends: ['fg'], depends: ['fg'],
opacity: 'border', opacity: 'border',

View file

@ -1,6 +1,19 @@
/* eslint-env serviceworker */ /* eslint-env serviceworker */
import localForage from 'localforage' import localForage from 'localforage'
import { parseNotification } from './services/entity_normalizer/entity_normalizer.service.js'
import { prepareNotificationObject } from './services/notification_utils/notification_utils.js'
import Vue from 'vue'
import VueI18n from 'vue-i18n'
import messages from './i18n/service_worker_messages.js'
Vue.use(VueI18n)
const i18n = new VueI18n({
// By default, use the browser locale, we will update it if neccessary
locale: 'en',
fallbackLocale: 'en',
messages
})
function isEnabled () { function isEnabled () {
return localForage.getItem('vuex-lz') return localForage.getItem('vuex-lz')
@ -12,15 +25,33 @@ function getWindowClients () {
.then((clientList) => clientList.filter(({ type }) => type === 'window')) .then((clientList) => clientList.filter(({ type }) => type === 'window'))
} }
self.addEventListener('push', (event) => { const setLocale = async () => {
if (event.data) { const state = await localForage.getItem('vuex-lz')
event.waitUntil(isEnabled().then((isEnabled) => { const locale = state.config.interfaceLanguage || 'en'
return isEnabled && getWindowClients().then((list) => { i18n.locale = locale
}
const maybeShowNotification = async (event) => {
const enabled = await isEnabled()
const activeClients = await getWindowClients()
await setLocale()
if (enabled && (activeClients.length === 0)) {
const data = event.data.json() const data = event.data.json()
if (list.length === 0) return self.registration.showNotification(data.title, data) const url = `${self.registration.scope}api/v1/notifications/${data.notification_id}`
}) const notification = await fetch(url, { headers: { Authorization: 'Bearer ' + data.access_token } })
})) const notificationJson = await notification.json()
const parsedNotification = parseNotification(notificationJson)
const res = prepareNotificationObject(parsedNotification, i18n)
self.registration.showNotification(res.title, res)
}
}
self.addEventListener('push', async (event) => {
if (event.data) {
event.waitUntil(maybeShowNotification(event))
} }
}) })

View file

@ -1,4 +1,9 @@
<h4>Terms of Service</h4> <h4>Terms of Service</h4>
<p>This is a placeholder ToS. Edit <code>"/static/terms-of-service.html"</code> to make it fit the needs of your instance.</p> <p>This is the default placeholder ToS. You should copy it over to your static folder and edit it to fit the needs of your instance.</p>
<p>To do so, place a file at <code>"/instance/static/terms-of-service.html"</code> in your
Pleroma install containing the real ToS for your instance.</p>
<p>See the <a href='https://docs.pleroma.social/backend/configuration/static_dir/'>Pleroma documentation</a> for more information.</p>
<br>
<img src="/static/logo.png" style="display: block; margin: auto; max-width: 100%; height: 50px; object-fit: contain;" /> <img src="/static/logo.png" style="display: block; margin: auto; max-width: 100%; height: 50px; object-fit: contain;" />

View file

@ -286,7 +286,9 @@
"cGreen": "#008000", "cGreen": "#008000",
"cOrange": "#808000", "cOrange": "#808000",
"highlight": "--accent", "highlight": "--accent",
"selectedPost": "--bg,-10" "selectedPost": "--bg,-10",
"selectedMenu": "--accent",
"selectedMenuPopover": "--accent"
}, },
"radii": { "radii": {
"btn": "0", "btn": "0",

View file

@ -277,7 +277,9 @@
"cGreen": "#008000", "cGreen": "#008000",
"cOrange": "#808000", "cOrange": "#808000",
"highlight": "--accent", "highlight": "--accent",
"selectedPost": "--bg,-10" "selectedPost": "--bg,-10",
"selectedMenu": "--accent",
"selectedMenuPopover": "--accent"
}, },
"radii": { "radii": {
"btn": "0", "btn": "0",

View file

@ -259,7 +259,9 @@
"cGreen": "#669966", "cGreen": "#669966",
"cOrange": "#cc6633", "cOrange": "#cc6633",
"highlight": "--accent", "highlight": "--accent",
"selectedPost": "--bg,-10" "selectedPost": "--bg,-10",
"selectedMenu": "--accent",
"selectedMenuPopover": "--accent"
}, },
"radii": { "radii": {
"btn": "0", "btn": "0",

View file

@ -290,6 +290,19 @@ describe('API Entities normalizer', () => {
expect(field).to.have.property('value').that.contains('<img') expect(field).to.have.property('value').that.contains('<img')
}) })
it('removes html tags from user profile fields', () => {
const user = makeMockUserMasto({ emojis: makeMockEmojiMasto(), fields: [{ name: 'user', value: '<a rel="me" href="https://example.com/@user">@user</a>' }] })
const parsedUser = parseUser(user)
expect(parsedUser).to.have.property('fields_text').to.be.an('array')
const field = parsedUser.fields_text[0]
expect(field).to.have.property('name').that.equal('user')
expect(field).to.have.property('value').that.equal('@user')
})
it('adds hide_follows and hide_followers user settings', () => { it('adds hide_follows and hide_followers user settings', () => {
const user = makeMockUserMasto({ pleroma: { hide_followers: true, hide_follows: false, hide_followers_count: false, hide_follows_count: true } }) const user = makeMockUserMasto({ pleroma: { hide_followers: true, hide_follows: false, hide_followers_count: false, hide_follows_count: true } })

View file

@ -1,17 +0,0 @@
import { removeAttachmentLinks } from '../../../../../src/services/status_parser/status_parser.js'
const example = '<div class="status-content">@<a href="https://sealion.club/user/4" class="h-card mention" title="dewoo">dwmatiz</a> <a href="https://social.heldscal.la/file/3deb764ada10ce64a61b7a070b75dac45f86d2d5bf213bf18873da71d8714d86.png" title="https://social.heldscal.la/file/3deb764ada10ce64a61b7a070b75dac45f86d2d5bf213bf18873da71d8714d86.png" class="attachment" id="attachment-159853" rel="nofollow external">https://social.heldscal.la/attachment/159853</a></div>'
describe('statusParser.removeAttachmentLinks', () => {
const exampleWithoutAttachmentLinks = '<div class="status-content">@<a href="https://sealion.club/user/4" class="h-card mention" title="dewoo">dwmatiz</a> </div>'
it('removes attachment links', () => {
const parsed = removeAttachmentLinks(example)
expect(parsed).to.eql(exampleWithoutAttachmentLinks)
})
it('works when the class is empty', () => {
const parsed = removeAttachmentLinks('<a></a>')
expect(parsed).to.eql('<a></a>')
})
})

100
yarn.lock
View file

@ -1062,7 +1062,7 @@ array-union@^1.0.1:
dependencies: dependencies:
array-uniq "^1.0.1" array-uniq "^1.0.1"
array-uniq@^1.0.1, array-uniq@^1.0.2: array-uniq@^1.0.1:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6" resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6"
@ -2545,7 +2545,7 @@ domain-browser@^1.1.1:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda"
domelementtype@1, domelementtype@^1.3.0: domelementtype@1:
version "1.3.1" version "1.3.1"
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f" resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f"
@ -2559,12 +2559,6 @@ domhandler@2.1:
dependencies: dependencies:
domelementtype "1" domelementtype "1"
domhandler@^2.3.0:
version "2.4.2"
resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803"
dependencies:
domelementtype "1"
domutils@1.1: domutils@1.1:
version "1.1.6" version "1.1.6"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.1.6.tgz#bddc3de099b9a2efacc51c623f28f416ecc57485" resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.1.6.tgz#bddc3de099b9a2efacc51c623f28f416ecc57485"
@ -2578,13 +2572,6 @@ domutils@1.5.1:
dom-serializer "0" dom-serializer "0"
domelementtype "1" domelementtype "1"
domutils@^1.5.1:
version "1.7.0"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a"
dependencies:
dom-serializer "0"
domelementtype "1"
duplexer2@~0.1.4: duplexer2@~0.1.4:
version "0.1.4" version "0.1.4"
resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1" resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1"
@ -2711,7 +2698,7 @@ ent@~2.2.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.0.tgz#e964219325a21d05f44466a2f686ed6ce5f5dd1d" resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.0.tgz#e964219325a21d05f44466a2f686ed6ce5f5dd1d"
entities@^1.1.1, entities@~1.1.1: entities@~1.1.1:
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56"
@ -3762,17 +3749,6 @@ html-webpack-plugin@^3.0.0, html-webpack-plugin@^3.2.0:
toposort "^1.0.0" toposort "^1.0.0"
util.promisify "1.0.0" util.promisify "1.0.0"
htmlparser2@^3.10.0:
version "3.10.0"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.0.tgz#5f5e422dcf6119c0d983ed36260ce9ded0bee464"
dependencies:
domelementtype "^1.3.0"
domhandler "^2.3.0"
domutils "^1.5.1"
entities "^1.1.1"
inherits "^2.0.1"
readable-stream "^3.0.6"
htmlparser2@~3.3.0: htmlparser2@~3.3.0:
version "3.3.0" version "3.3.0"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.3.0.tgz#cc70d05a59f6542e43f0e685c982e14c924a9efe" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.3.0.tgz#cc70d05a59f6542e43f0e685c982e14c924a9efe"
@ -4757,10 +4733,6 @@ lodash.clone@3.0.3:
lodash._bindcallback "^3.0.0" lodash._bindcallback "^3.0.0"
lodash._isiterateecall "^3.0.0" lodash._isiterateecall "^3.0.0"
lodash.clonedeep@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
lodash.create@3.1.1: lodash.create@3.1.1:
version "3.1.1" version "3.1.1"
resolved "https://registry.yarnpkg.com/lodash.create/-/lodash.create-3.1.1.tgz#d7f2849f0dbda7e04682bb8cd72ab022461debe7" resolved "https://registry.yarnpkg.com/lodash.create/-/lodash.create-3.1.1.tgz#d7f2849f0dbda7e04682bb8cd72ab022461debe7"
@ -4780,10 +4752,6 @@ lodash.defaultsdeep@4.3.2:
lodash.mergewith "^4.0.0" lodash.mergewith "^4.0.0"
lodash.rest "^4.0.0" lodash.rest "^4.0.0"
lodash.escaperegexp@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347"
lodash.find@^3.2.1: lodash.find@^3.2.1:
version "3.2.1" version "3.2.1"
resolved "https://registry.yarnpkg.com/lodash.find/-/lodash.find-3.2.1.tgz#046e319f3ace912ac6c9246c7f683c5ec07b36ad" resolved "https://registry.yarnpkg.com/lodash.find/-/lodash.find-3.2.1.tgz#046e319f3ace912ac6c9246c7f683c5ec07b36ad"
@ -4815,14 +4783,10 @@ lodash.isplainobject@^3.0.0, lodash.isplainobject@^3.2.0:
lodash.isarguments "^3.0.0" lodash.isarguments "^3.0.0"
lodash.keysin "^3.0.0" lodash.keysin "^3.0.0"
lodash.isplainobject@^4.0.0, lodash.isplainobject@^4.0.6: lodash.isplainobject@^4.0.0:
version "4.0.6" version "4.0.6"
resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
lodash.isstring@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
lodash.istypedarray@^3.0.0: lodash.istypedarray@^3.0.0:
version "3.0.6" version "3.0.6"
resolved "https://registry.yarnpkg.com/lodash.istypedarray/-/lodash.istypedarray-3.0.6.tgz#c9a477498607501d8e8494d283b87c39281cef62" resolved "https://registry.yarnpkg.com/lodash.istypedarray/-/lodash.istypedarray-3.0.6.tgz#c9a477498607501d8e8494d283b87c39281cef62"
@ -4871,7 +4835,7 @@ lodash.merge@^3.3.2:
lodash.keysin "^3.0.0" lodash.keysin "^3.0.0"
lodash.toplainobject "^3.0.0" lodash.toplainobject "^3.0.0"
lodash.mergewith@^4.0.0, lodash.mergewith@^4.6.1: lodash.mergewith@^4.0.0:
version "4.6.1" version "4.6.1"
resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.1.tgz#639057e726c3afbdb3e7d42741caa8d6e4335927" resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.1.tgz#639057e726c3afbdb3e7d42741caa8d6e4335927"
@ -5538,10 +5502,6 @@ object-keys@^1.0.11, object-keys@^1.0.12:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
object-path@^0.11.3:
version "0.11.4"
resolved "https://registry.yarnpkg.com/object-path/-/object-path-0.11.4.tgz#370ae752fbf37de3ea70a861c23bba8915691949"
object-visit@^1.0.0: object-visit@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb"
@ -6245,14 +6205,6 @@ postcss@^7.0.0:
source-map "^0.6.1" source-map "^0.6.1"
supports-color "^6.1.0" supports-color "^6.1.0"
postcss@^7.0.5:
version "7.0.8"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.8.tgz#2a3c5f2bdd00240cd0d0901fd998347c93d36696"
dependencies:
chalk "^2.4.2"
source-map "^0.6.1"
supports-color "^6.0.0"
prelude-ls@~1.1.2: prelude-ls@~1.1.2:
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
@ -6521,14 +6473,6 @@ readable-stream@1.1.x:
isarray "0.0.1" isarray "0.0.1"
string_decoder "~0.10.x" string_decoder "~0.10.x"
readable-stream@^3.0.6:
version "3.1.1"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.1.1.tgz#ed6bbc6c5ba58b090039ff18ce670515795aeb06"
dependencies:
inherits "^2.0.3"
string_decoder "^1.1.1"
util-deprecate "^1.0.1"
readdirp@^2.2.1: readdirp@^2.2.1:
version "2.2.1" version "2.2.1"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525"
@ -6839,21 +6783,6 @@ samsam@1.x, samsam@^1.1.3:
version "1.3.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.3.0.tgz#8d1d9350e25622da30de3e44ba692b5221ab7c50" resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.3.0.tgz#8d1d9350e25622da30de3e44ba692b5221ab7c50"
sanitize-html@^1.13.0:
version "1.20.0"
resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-1.20.0.tgz#9a602beb1c9faf960fb31f9890f61911cc4d9156"
dependencies:
chalk "^2.4.1"
htmlparser2 "^3.10.0"
lodash.clonedeep "^4.5.0"
lodash.escaperegexp "^4.1.2"
lodash.isplainobject "^4.0.6"
lodash.isstring "^4.0.1"
lodash.mergewith "^4.6.1"
postcss "^7.0.5"
srcset "^1.0.0"
xtend "^4.0.1"
"sass-loader@git://github.com/webpack-contrib/sass-loader": "sass-loader@git://github.com/webpack-contrib/sass-loader":
version "7.1.0" version "7.1.0"
resolved "git://github.com/webpack-contrib/sass-loader#e279f2a129eee0bd0b624b5acd498f23a81ee35e" resolved "git://github.com/webpack-contrib/sass-loader#e279f2a129eee0bd0b624b5acd498f23a81ee35e"
@ -7225,13 +7154,6 @@ sprintf-js@~1.0.2:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
srcset@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/srcset/-/srcset-1.0.0.tgz#a5669de12b42f3b1d5e83ed03c71046fc48f41ef"
dependencies:
array-uniq "^1.0.2"
number-is-nan "^1.0.0"
sshpk@^1.7.0: sshpk@^1.7.0:
version "1.16.0" version "1.16.0"
resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.0.tgz#1d4963a2fbffe58050aa9084ca20be81741c07de" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.0.tgz#1d4963a2fbffe58050aa9084ca20be81741c07de"
@ -7331,7 +7253,7 @@ string-width@^3.0.0:
is-fullwidth-code-point "^2.0.0" is-fullwidth-code-point "^2.0.0"
strip-ansi "^5.1.0" strip-ansi "^5.1.0"
string_decoder@^1.0.0, string_decoder@^1.1.1: string_decoder@^1.0.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.2.0.tgz#fe86e738b19544afe70469243b2a1ee9240eae8d" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.2.0.tgz#fe86e738b19544afe70469243b2a1ee9240eae8d"
dependencies: dependencies:
@ -7415,7 +7337,7 @@ supports-color@^5.3.0, supports-color@^5.4.0:
dependencies: dependencies:
has-flag "^3.0.0" has-flag "^3.0.0"
supports-color@^6.0.0, supports-color@^6.1.0: supports-color@^6.1.0:
version "6.1.0" version "6.1.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3"
dependencies: dependencies:
@ -7780,7 +7702,7 @@ useragent@2.3.0:
lru-cache "4.1.x" lru-cache "4.1.x"
tmp "0.0.x" tmp "0.0.x"
util-deprecate@^1.0.1, util-deprecate@~1.0.1: util-deprecate@~1.0.1:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
@ -8015,10 +7937,6 @@ webpack@^4.0.0:
watchpack "^1.5.0" watchpack "^1.5.0"
webpack-sources "^1.3.0" webpack-sources "^1.3.0"
whatwg-fetch@^2.0.3:
version "2.0.4"
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz#dde6a5df315f9d39991aa17621853d720b85566f"
whet.extend@~0.9.9: whet.extend@~0.9.9:
version "0.9.9" version "0.9.9"
resolved "https://registry.yarnpkg.com/whet.extend/-/whet.extend-0.9.9.tgz#f877d5bf648c97e5aa542fadc16d6a259b9c11a1" resolved "https://registry.yarnpkg.com/whet.extend/-/whet.extend-0.9.9.tgz#f877d5bf648c97e5aa542fadc16d6a259b9c11a1"
@ -8090,7 +8008,7 @@ xregexp@2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/xregexp/-/xregexp-2.0.0.tgz#52a63e56ca0b84a7f3a5f3d61872f126ad7a5943" resolved "https://registry.yarnpkg.com/xregexp/-/xregexp-2.0.0.tgz#52a63e56ca0b84a7f3a5f3d61872f126ad7a5943"
xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1: xtend@^4.0.0, xtend@~4.0.1:
version "4.0.1" version "4.0.1"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"