Merge remote-tracking branch 'upstream/develop' into async_follow

* upstream/develop: (45 commits)
  fix chrome
  Prevent html-minifier to remove placeholder comment in index.html template
  Add placeholder to insert server generated metatags. Related to #430
  added condition to check for logined user
  fix gradients and minor artifacts
  keep track of new instance options
  fix old MR
  oof
  get rid of slots
  fix timeago font
  added hide_network option, fixed properties naming
  Fix fetching new users, add storing local users in usersObjects with their screen_name as well as id, so that they could be fetched zero-state with screen-name link.
  improve notification subscription
  Refactor arrays to individual options
  Reset enableFollowsExport to true after 2 sec when an export file is available to download
  Write a unit test for fileSizeFormatService
  add checkbox to disable web push
  I am dumb
  Handle errors from server
  Moved upload errors in user_settings to an array. Moved upload error strings to its separate section in i18n
  ...
This commit is contained in:
Henry Jameson 2018-12-14 17:17:58 +03:00
commit d7973b0b80
40 changed files with 569 additions and 119 deletions

1
.gitignore vendored
View file

@ -6,3 +6,4 @@ test/unit/coverage
test/e2e/reports
selenium-debug.log
.idea/
config/local.json

View file

@ -29,6 +29,15 @@ npm run build
npm run unit
```
# For Contributors:
You can create file `/config/local.json` (see [example](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/config/local.example.json)) to enable some convenience dev options:
* `target`: makes local dev server redirect to some existing instance's BE instead of local BE, useful for testing things in near-production environment and searching for real-life use-cases.
* `staticConfigPreference`: makes FE's `/static/config.json` take preference of BE-served `/api/statusnet/config.json`. Only works in dev mode.
FE Build process also leaves current commit hash in global variable `___pleromafe_commit_hash` so that you can easily see which pleroma-fe commit instance is running, also helps pinpointing which commit was used when FE was bundled into BE.
# Configuration
Edit config.json for configuration. scopeOptionsEnabled gives you input fields for CWs and the scope settings.

View file

@ -2,6 +2,7 @@ var path = require('path')
var config = require('../config')
var utils = require('./utils')
var projectRoot = path.resolve(__dirname, '../')
var ServiceWorkerWebpackPlugin = require('serviceworker-webpack-plugin')
var env = process.env.NODE_ENV
// check env & config/index.js to decide weither to enable CSS Sourcemaps for the
@ -91,5 +92,10 @@ module.exports = {
browsers: ['last 2 versions']
})
]
}
},
plugins: [
new ServiceWorkerWebpackPlugin({
entry: path.join(__dirname, '..', 'src/sw.js')
})
]
}

View file

@ -18,7 +18,9 @@ module.exports = merge(baseWebpackConfig, {
devtool: '#eval-source-map',
plugins: [
new webpack.DefinePlugin({
'process.env': config.dev.env
'process.env': config.dev.env,
'COMMIT_HASH': JSON.stringify('DEV'),
'DEV_OVERRIDES': JSON.stringify(config.dev.settings)
}),
// https://github.com/glenjamin/webpack-hot-middleware#installation--usage
new webpack.optimize.OccurenceOrderPlugin(),

View file

@ -10,6 +10,11 @@ var env = process.env.NODE_ENV === 'testing'
? require('../config/test.env')
: config.build.env
let commitHash = require('child_process')
.execSync('git rev-parse --short HEAD')
.toString();
console.log(commitHash)
var webpackConfig = merge(baseWebpackConfig, {
module: {
loaders: utils.styleLoaders({ sourceMap: config.build.productionSourceMap, extract: true })
@ -29,7 +34,9 @@ var webpackConfig = merge(baseWebpackConfig, {
plugins: [
// http://vuejs.github.io/vue-loader/workflow/production.html
new webpack.DefinePlugin({
'process.env': env
'process.env': env,
'COMMIT_HASH': JSON.stringify(commitHash),
'DEV_OVERRIDES': JSON.stringify(undefined)
}),
new webpack.optimize.UglifyJsPlugin({
compress: {
@ -51,7 +58,8 @@ var webpackConfig = merge(baseWebpackConfig, {
minify: {
removeComments: true,
collapseWhitespace: true,
removeAttributeQuotes: true
removeAttributeQuotes: true,
ignoreCustomComments: [/server-generated-meta/]
// more options:
// https://github.com/kangax/html-minifier#options-quick-reference
},

View file

@ -1,5 +1,15 @@
// see http://vuejs-templates.github.io/webpack for documentation.
var path = require('path')
const path = require('path')
let settings = {}
try {
settings = require('./local.json')
console.log('Using local dev server settings (/config/local.json):')
console.log(JSON.stringify(settings, null, 2))
} catch (e) {
console.log('Local dev server settings not found (/config/local.json)')
}
const target = settings.target || 'http://localhost:4000/'
module.exports = {
build: {
@ -19,21 +29,22 @@ module.exports = {
dev: {
env: require('./dev.env'),
port: 8080,
settings,
assetsSubDirectory: 'static',
assetsPublicPath: '/',
proxyTable: {
'/api': {
target: 'http://localhost:4000/',
target,
changeOrigin: true,
cookieDomainRewrite: 'localhost'
},
'/nodeinfo': {
target: 'http://localhost:4000/',
target,
changeOrigin: true,
cookieDomainRewrite: 'localhost'
},
'/socket': {
target: 'http://localhost:4000/',
target,
changeOrigin: true,
cookieDomainRewrite: 'localhost',
ws: true

View file

@ -0,0 +1,4 @@
{
"target": "https://pleroma.soykaf.com/",
"staticConfigPreference": false
}

View file

@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pleroma</title>
<!--server-generated-meta-->
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="stylesheet" href="/static/font/css/fontello.css">
<link rel="stylesheet" href="/static/font/css/animation.css">

View file

@ -90,6 +90,7 @@
"raw-loader": "^0.5.1",
"selenium-server": "2.53.1",
"semver": "^5.3.0",
"serviceworker-webpack-plugin": "0.2.3",
"shelljs": "^0.7.4",
"sinon": "^1.17.3",
"sinon-chai": "^2.8.0",

View file

@ -228,24 +228,23 @@ i[class*=icon-] {
padding: 0 10px 0 10px;
}
.gaps {
margin: -1em 0 0 -1em;
}
.item {
flex: 1;
line-height: 50px;
height: 50px;
overflow: hidden;
display: flex;
flex-wrap: wrap;
.nav-icon {
font-size: 1.1em;
margin-left: 0.4em;
}
}
.gaps > .item {
padding: 1em 0 0 1em;
&.right {
justify-content: flex-end;
padding-right: 20px;
}
}
.auto-size {
@ -293,8 +292,6 @@ nav {
}
.inner-nav {
padding-left: 20px;
padding-right: 20px;
display: flex;
align-items: center;
flex-basis: 970px;
@ -452,6 +449,23 @@ nav {
color: var(--faint, $fallback--faint);
box-shadow: 0px 0px 4px rgba(0,0,0,.6);
box-shadow: var(--topBarShadow);
.back-button {
display: block;
max-width: 99px;
transition-property: opacity, max-width;
transition-duration: 300ms;
transition-timing-function: ease-out;
i {
margin: 0 1em;
}
&.hidden {
opacity: 0;
max-width: 20px;
}
}
}
.fade-enter-active, .fade-leave-active {
@ -486,6 +500,7 @@ nav {
display: none;
width: 100%;
height: 46px;
button {
display: block;
flex: 1;
@ -499,6 +514,16 @@ nav {
body {
overflow-y: scroll;
}
nav {
.back-button {
display: none;
}
.site-name {
padding-left: 20px;
}
}
.sidebar-bounds {
overflow: hidden;
max-height: 100vh;
@ -591,11 +616,6 @@ nav {
}
}
.item.right {
text-align: right;
padding-right: 20px;
}
.visibility-tray {
font-size: 1.2em;
padding: 3px;

View file

@ -7,7 +7,10 @@
</div>
<div class='inner-nav'>
<div class='item'>
<router-link :to="{ name: 'root'}">{{sitename}}</router-link>
<router-link class="back-button" @click.native="activatePanel('timeline')" :to="{ name: 'root' }" active-class="hidden">
<i class="icon-left-open" :title="$t('nav.back')"></i>
</router-link>
<router-link class="site-name" :to="{ name: 'root' }" active-class="home">{{sitename}}</router-link>
</div>
<div class='item right'>
<user-finder class="nav-icon"></user-finder>

View file

@ -21,13 +21,25 @@ const afterStoreSetup = ({store, i18n}) => {
window.fetch('/api/statusnet/config.json')
.then((res) => res.json())
.then((data) => {
const {name, closed: registrationClosed, textlimit, server} = data.site
const { name, closed: registrationClosed, textlimit, uploadlimit, server, vapidPublicKey } = data.site
store.dispatch('setInstanceOption', { name: 'name', value: name })
store.dispatch('setInstanceOption', { name: 'registrationOpen', value: (registrationClosed === '0') })
store.dispatch('setInstanceOption', { name: 'textlimit', value: parseInt(textlimit) })
store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadlimit.uploadlimit) })
store.dispatch('setInstanceOption', { name: 'avatarlimit', value: parseInt(uploadlimit.avatarlimit) })
store.dispatch('setInstanceOption', { name: 'backgroundlimit', value: parseInt(uploadlimit.backgroundlimit) })
store.dispatch('setInstanceOption', { name: 'bannerlimit', value: parseInt(uploadlimit.bannerlimit) })
store.dispatch('setInstanceOption', { name: 'server', value: server })
if (data.nsfwCensorImage) {
store.dispatch('setInstanceOption', { name: 'nsfwCensorImage', value: data.nsfwCensorImage })
}
if (vapidPublicKey) {
store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey })
}
var apiConfig = data.site.pleromafe
window.fetch('/static/config.json')
@ -38,8 +50,17 @@ const afterStoreSetup = ({store, i18n}) => {
return {}
})
.then((staticConfig) => {
const overrides = window.___pleromafe_dev_overrides || {}
const env = window.___pleromafe_mode.NODE_ENV
// This takes static config and overrides properties that are present in apiConfig
var config = Object.assign({}, staticConfig, apiConfig)
let config = {}
if (overrides.staticConfigPreference && env === 'development') {
console.warn('OVERRIDING API CONFIG WITH STATIC CONFIG')
config = Object.assign({}, apiConfig, staticConfig)
} else {
config = Object.assign({}, staticConfig, apiConfig)
}
var theme = (config.theme)
var background = (config.background)

View file

@ -11,7 +11,7 @@ const Attachment = {
],
data () {
return {
nsfwImage,
nsfwImage: this.$store.state.config.nsfwCensorImage || nsfwImage,
hideNsfwLocal: this.$store.state.config.hideNsfw,
preloadImage: this.$store.state.config.preloadImage,
loopVideo: this.$store.state.config.loopVideo,

View file

@ -1,5 +1,6 @@
/* eslint-env browser */
import statusPosterService from '../../services/status_poster/status_poster.service.js'
import fileSizeFormatService from '../../services/file_size_format/file_size_format.js'
const mediaUpload = {
mounted () {
@ -21,6 +22,12 @@ const mediaUpload = {
uploadFile (file) {
const self = this
const store = this.$store
if (file.size > store.state.instance.uploadlimit) {
const filesize = fileSizeFormatService.fileSizeFormat(file.size)
const allowedsize = fileSizeFormatService.fileSizeFormat(store.state.instance.uploadlimit)
self.$emit('upload-failed', 'file_too_big', {filesize: filesize.num, filesizeunit: filesize.unit, allowedsize: allowedsize.num, allowedsizeunit: allowedsize.unit})
return
}
const formData = new FormData()
formData.append('media', file)
@ -32,7 +39,7 @@ const mediaUpload = {
self.$emit('uploaded', fileData)
self.uploading = false
}, (error) => { // eslint-disable-line handle-callback-err
self.$emit('upload-failed')
self.$emit('upload-failed', 'default')
self.uploading = false
})
},

View file

@ -262,6 +262,11 @@ const PostStatusForm = {
let index = this.newStatus.files.indexOf(fileInfo)
this.newStatus.files.splice(index, 1)
},
uploadFailed (errString, templateArgs) {
templateArgs = templateArgs || {}
this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs)
this.enableSubmit()
},
disableSubmit () {
this.submitDisabled = true
},

View file

@ -64,7 +64,7 @@
</div>
</div>
<div class='form-bottom'>
<media-upload @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="enableSubmit" :drop-files="dropFiles"></media-upload>
<media-upload @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload>
<p v-if="isOverLengthLimit" class="error">{{ charactersLeft }}</p>
<p class="faint" v-else-if="hasStatusLengthLimit">{{ charactersLeft }}</p>

View file

@ -47,6 +47,7 @@ const settings = {
scopeCopyLocal: user.scopeCopy,
scopeCopyDefault: this.$t('settings.values.' + instance.scopeCopy),
stopGifs: user.stopGifs,
webPushNotificationsLocal: user.webPushNotifications,
loopSilentAvailable:
// Firefox
Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') ||
@ -142,6 +143,10 @@ const settings = {
},
stopGifs (value) {
this.$store.dispatch('setOption', { name: 'stopGifs', value })
},
webPushNotificationsLocal (value) {
this.$store.dispatch('setOption', { name: 'webPushNotifications', value })
if (value) this.$store.dispatch('registerPushNotifications')
}
}
}

View file

@ -143,6 +143,18 @@
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{$t('settings.notifications')}}</h2>
<ul class="setting-list">
<li>
<input type="checkbox" id="webPushNotifications" v-model="webPushNotificationsLocal">
<label for="webPushNotifications">
{{$t('settings.enable_web_push_notifications')}}
</label>
</li>
</ul>
</div>
</div>
<div :label="$t('settings.theme')" >

View file

@ -54,7 +54,7 @@
</h4>
</div>
<div class="media-heading-right">
<router-link @click.native="activatePanel('timeline')" :to="{ name: 'conversation', params: { id: status.id } }">
<router-link class="timeago" @click.native="activatePanel('timeline')" :to="{ name: 'conversation', params: { id: status.id } }">
<timeago :since="status.created_at" :auto-update="60"></timeago>
</router-link>
<div class="visibility-icon" v-if="status.visibility">

View file

@ -2,6 +2,7 @@ import Status from '../status/status.vue'
import timelineFetcher from '../../services/timeline_fetcher/timeline_fetcher.service.js'
import StatusOrConversation from '../status_or_conversation/status_or_conversation.vue'
import UserCard from '../user_card/user_card.vue'
import { throttle } from 'lodash'
const Timeline = {
props: [
@ -88,7 +89,7 @@ const Timeline = {
this.paused = false
}
},
fetchOlderStatuses () {
fetchOlderStatuses: throttle(function () {
const store = this.$store
const credentials = store.state.users.currentUser.credentials
store.commit('setLoading', { timeline: this.timelineName, value: true })
@ -101,7 +102,7 @@ const Timeline = {
userId: this.userId,
tag: this.tag
}).then(() => store.commit('setLoading', { timeline: this.timelineName, value: false }))
},
}, 1000, this),
fetchFollowers () {
const id = this.userId
this.$store.state.api.backendInteractor.fetchFollowers({ id })

View file

@ -14,6 +14,9 @@ const UserCard = {
components: {
UserCardContent
},
computed: {
currentUser () { return this.$store.state.users.currentUser }
},
methods: {
toggleUserExpanded () {
this.userExpanded = !this.userExpanded

View file

@ -10,13 +10,13 @@
<div :title="user.name" v-if="user.name_html" class="user-name">
<span v-html="user.name_html"></span>
<span class="follows-you" v-if="!userExpanded && showFollows && user.follows_you">
{{ $t('user_card.follows_you') }}
{{ currentUser.id == user.id ? $t('user_card.its_you') : $t('user_card.follows_you') }}
</span>
</div>
<div :title="user.name" v-else class="user-name">
{{ user.name }}
<span class="follows-you" v-if="!userExpanded && showFollows && user.follows_you">
{{ $t('user_card.follows_you') }}
{{ currentUser.id == user.id ? $t('user_card.its_you') : $t('user_card.follows_you') }}
</span>
</div>
<router-link class='user-screen-name' :to="{ name: 'user-profile', params: { id: user.id } }">

View file

@ -22,10 +22,20 @@ export default {
if (color) {
const rgb = (typeof color === 'string') ? hex2rgb(color) : color
const tintColor = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .5)`
const 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, ${tintColor}, ${tintColor})`,
`linear-gradient(to bottom, ${gradient})`,
`url(${this.user.cover_photo})`
].join(', ')
}

View file

@ -103,7 +103,7 @@
</div>
</div>
</div>
<div class="panel-body profile-panel-body" v-if="switcher">
<div class="panel-body profile-panel-body" v-if="!hideBio">
<div v-if="!hideUserStatsLocal || switcher" class="user-counts" :class="{clickable: switcher}">
<div class="user-count" v-on:click.prevent="setProfileView('statuses')" :class="{selected: selected === 'statuses'}">
<h5>{{ $t('user_card.statuses') }}</h5>
@ -135,6 +135,9 @@
border-radius: var(--panelRadius, $fallback--panelRadius);
overflow: hidden;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
.panel-heading {
padding: 0.6em 0em;
text-align: center;

View file

@ -1,12 +1,12 @@
<template>
<span class="user-finder-container">
<div class="user-finder-container">
<i class="icon-spin4 user-finder-icon animate-spin-slow" v-if="loading" />
<a href="#" v-if="hidden" :title="$t('finder.find_user')" ><i class="icon-user-plus user-finder-icon" @click.prevent.stop="toggleHidden" /></a>
<span v-else>
<input class="user-finder-input" @keyup.enter="findUser(username)" v-model="username" :placeholder="$t('finder.find_user')" id="user-finder-input" type="text"/>
<i class="icon-cancel user-finder-icon" @click.prevent.stop="toggleHidden"/>
</span>
</span>
</div>
</template>
<script src="./user_finder.js"></script>
@ -15,7 +15,6 @@
@import '../../_variables.scss';
.user-finder-container {
height: 29px;
max-width: 100%;
}

View file

@ -3,6 +3,16 @@
<div v-if="user" class="user-profile panel panel-default">
<user-card-content :user="user" :switcher="true" :selected="timeline.viewing"></user-card-content>
</div>
<div v-else class="panel user-profile-placeholder">
<div class="panel-heading">
<div class="title">
{{ $t('settings.profile_tab') }}
</div>
</div>
<div class="panel-body">
<i class="icon-spin3 animate-spin"></i>
</div>
</div>
<Timeline :title="$t('user_profile.timeline_title')" :timeline="timeline" :timeline-name="'user'" :user-id="userId"/>
</div>
</template>
@ -21,4 +31,12 @@
align-items: stretch;
}
}
.user-profile-placeholder {
.panel-body {
display: flex;
justify-content: center;
align-items: middle;
padding: 7em;
}
}
</style>

View file

@ -1,20 +1,30 @@
import TabSwitcher from '../tab_switcher/tab_switcher.jsx'
import StyleSwitcher from '../style_switcher/style_switcher.vue'
import fileSizeFormatService from '../../services/file_size_format/file_size_format.js'
const UserSettings = {
data () {
return {
newname: this.$store.state.users.currentUser.name,
newbio: this.$store.state.users.currentUser.description,
newlocked: this.$store.state.users.currentUser.locked,
newnorichtext: this.$store.state.users.currentUser.no_rich_text,
newdefaultScope: this.$store.state.users.currentUser.default_scope,
newName: this.$store.state.users.currentUser.name,
newBio: this.$store.state.users.currentUser.description,
newLocked: this.$store.state.users.currentUser.locked,
newNoRichText: this.$store.state.users.currentUser.no_rich_text,
newDefaultScope: this.$store.state.users.currentUser.default_scope,
newHideNetwork: this.$store.state.users.currentUser.hide_network,
followList: null,
followImportError: false,
followsImported: false,
enableFollowsExport: true,
uploading: [ false, false, false, false ],
previews: [ null, null, null ],
avatarUploading: false,
bannerUploading: false,
backgroundUploading: false,
followListUploading: false,
avatarPreview: null,
bannerPreview: null,
backgroundPreview: null,
avatarUploadError: null,
bannerUploadError: null,
backgroundUploadError: null,
deletingAccount: false,
deleteAccountConfirmPasswordInput: '',
deleteAccountError: false,
@ -40,48 +50,67 @@ const UserSettings = {
},
vis () {
return {
public: { selected: this.newdefaultScope === 'public' },
unlisted: { selected: this.newdefaultScope === 'unlisted' },
private: { selected: this.newdefaultScope === 'private' },
direct: { selected: this.newdefaultScope === 'direct' }
public: { selected: this.newDefaultScope === 'public' },
unlisted: { selected: this.newDefaultScope === 'unlisted' },
private: { selected: this.newDefaultScope === 'private' },
direct: { selected: this.newDefaultScope === 'direct' }
}
}
},
methods: {
updateProfile () {
const name = this.newname
const description = this.newbio
const locked = this.newlocked
const description = this.newBio
const locked = this.newLocked
// Backend notation.
/* eslint-disable camelcase */
const default_scope = this.newdefaultScope
const no_rich_text = this.newnorichtext
this.$store.state.api.backendInteractor.updateProfile({params: {name, description, locked, default_scope, no_rich_text}}).then((user) => {
const default_scope = this.newDefaultScope
const no_rich_text = this.newNoRichText
const hide_network = this.newHideNetwork
/* eslint-enable camelcase */
this.$store.state.api.backendInteractor
.updateProfile({
params: {
name,
description,
locked,
// Backend notation.
/* eslint-disable camelcase */
default_scope,
no_rich_text,
hide_network
/* eslint-enable camelcase */
}}).then((user) => {
if (!user.error) {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
}
})
/* eslint-enable camelcase */
},
changeVis (visibility) {
this.newdefaultScope = visibility
this.newDefaultScope = visibility
},
uploadFile (slot, e) {
const file = e.target.files[0]
if (!file) { return }
if (file.size > this.$store.state.instance[slot + 'limit']) {
const filesize = fileSizeFormatService.fileSizeFormat(file.size)
const allowedsize = fileSizeFormatService.fileSizeFormat(this.$store.state.instance[slot + 'limit'])
this[slot + 'UploadError'] = this.$t('upload.error.base') + ' ' + this.$t('upload.error.file_too_big', {filesize: filesize.num, filesizeunit: filesize.unit, allowedsize: allowedsize.num, allowedsizeunit: allowedsize.unit})
return
}
// eslint-disable-next-line no-undef
const reader = new FileReader()
reader.onload = ({target}) => {
const img = target.result
this.previews[slot] = img
this.$forceUpdate() // just changing the array with the index doesn't update the view
this[slot + 'Preview'] = img
}
reader.readAsDataURL(file)
},
submitAvatar () {
if (!this.previews[0]) { return }
if (!this.avatarPreview) { return }
let img = this.previews[0]
let img = this.avatarPreview
// eslint-disable-next-line no-undef
let imginfo = new Image()
let cropX, cropY, cropW, cropH
@ -97,20 +126,25 @@ const UserSettings = {
cropX = Math.floor((imginfo.width - imginfo.height) / 2)
cropW = imginfo.height
}
this.uploading[0] = true
this.avatarUploading = true
this.$store.state.api.backendInteractor.updateAvatar({params: {img, cropX, cropY, cropW, cropH}}).then((user) => {
if (!user.error) {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
this.previews[0] = null
this.avatarPreview = null
} else {
this.avatarUploadError = this.$t('upload.error.base') + user.error
}
this.uploading[0] = false
this.avatarUploading = false
})
},
clearUploadError (slot) {
this[slot + 'UploadError'] = null
},
submitBanner () {
if (!this.previews[1]) { return }
if (!this.bannerPreview) { return }
let banner = this.previews[1]
let banner = this.bannerPreview
// eslint-disable-next-line no-undef
let imginfo = new Image()
/* eslint-disable camelcase */
@ -120,22 +154,24 @@ const UserSettings = {
height = imginfo.height
offset_top = 0
offset_left = 0
this.uploading[1] = true
this.bannerUploading = true
this.$store.state.api.backendInteractor.updateBanner({params: {banner, offset_top, offset_left, width, height}}).then((data) => {
if (!data.error) {
let clone = JSON.parse(JSON.stringify(this.$store.state.users.currentUser))
clone.cover_photo = data.url
this.$store.commit('addNewUsers', [clone])
this.$store.commit('setCurrentUser', clone)
this.previews[1] = null
this.bannerPreview = null
} else {
this.bannerUploadError = this.$t('upload.error.base') + data.error
}
this.uploading[1] = false
this.bannerUploading = false
})
/* eslint-enable camelcase */
},
submitBg () {
if (!this.previews[2]) { return }
let img = this.previews[2]
if (!this.backgroundPreview) { return }
let img = this.backgroundPreview
// eslint-disable-next-line no-undef
let imginfo = new Image()
let cropX, cropY, cropW, cropH
@ -144,20 +180,22 @@ const UserSettings = {
cropY = 0
cropW = imginfo.width
cropH = imginfo.width
this.uploading[2] = true
this.backgroundUploading = true
this.$store.state.api.backendInteractor.updateBg({params: {img, cropX, cropY, cropW, cropH}}).then((data) => {
if (!data.error) {
let clone = JSON.parse(JSON.stringify(this.$store.state.users.currentUser))
clone.background_image = data.url
this.$store.commit('addNewUsers', [clone])
this.$store.commit('setCurrentUser', clone)
this.previews[2] = null
this.backgroundPreview = null
} else {
this.backgroundUploadError = this.$t('upload.error.base') + data.error
}
this.uploading[2] = false
this.backgroundUploading = false
})
},
importFollows () {
this.uploading[3] = true
this.followListUploading = true
const followList = this.followList
this.$store.state.api.backendInteractor.followImport({params: followList})
.then((status) => {
@ -166,7 +204,7 @@ const UserSettings = {
} else {
this.followImportError = true
}
this.uploading[3] = false
this.followListUploading = false
})
},
/* This function takes an Array of Users
@ -198,6 +236,7 @@ const UserSettings = {
.fetchFriends({id: this.$store.state.users.currentUser.id})
.then((friendList) => {
this.exportPeople(friendList, 'friends.csv')
setTimeout(() => { this.enableFollowsExport = true }, 2000)
})
},
followListChange () {

View file

@ -9,11 +9,11 @@
<div class="setting-item" >
<h2>{{$t('settings.name_bio')}}</h2>
<p>{{$t('settings.name')}}</p>
<input class='name-changer' id='username' v-model="newname"></input>
<input class='name-changer' id='username' v-model="newName"></input>
<p>{{$t('settings.bio')}}</p>
<textarea class="bio" v-model="newbio"></textarea>
<textarea class="bio" v-model="newBio"></textarea>
<p>
<input type="checkbox" v-model="newlocked" id="account-locked">
<input type="checkbox" v-model="newLocked" id="account-locked">
<label for="account-locked">{{$t('settings.lock_account_description')}}</label>
</p>
<div v-if="scopeOptionsEnabled">
@ -26,47 +26,63 @@
</div>
</div>
<p>
<input type="checkbox" v-model="newnorichtext" id="account-no-rich-text">
<input type="checkbox" v-model="newNoRichText" id="account-no-rich-text">
<label for="account-no-rich-text">{{$t('settings.no_rich_text_description')}}</label>
</p>
<button :disabled='newname.length <= 0' class="btn btn-default" @click="updateProfile">{{$t('general.submit')}}</button>
<p>
<input type="checkbox" v-model="newHideNetwork" id="account-hide-network">
<label for="account-no-rich-text">{{$t('settings.hide_network_description')}}</label>
</p>
<button :disabled='newName.length <= 0' class="btn btn-default" @click="updateProfile">{{$t('general.submit')}}</button>
</div>
<div class="setting-item">
<h2>{{$t('settings.avatar')}}</h2>
<p>{{$t('settings.current_avatar')}}</p>
<img :src="user.profile_image_url_original" class="old-avatar"></img>
<p>{{$t('settings.set_new_avatar')}}</p>
<img class="new-avatar" v-bind:src="previews[0]" v-if="previews[0]">
<img class="new-avatar" v-bind:src="avatarPreview" v-if="avatarPreview">
</img>
<div>
<input type="file" @change="uploadFile(0, $event)" ></input>
<input type="file" @change="uploadFile('avatar', $event)" ></input>
</div>
<i class="icon-spin4 animate-spin" v-if="avatarUploading"></i>
<button class="btn btn-default" v-else-if="avatarPreview" @click="submitAvatar">{{$t('general.submit')}}</button>
<div class='alert error' v-if="avatarUploadError">
Error: {{ avatarUploadError }}
<i class="icon-cancel" @click="clearUploadError('avatar')"></i>
</div>
<i class="icon-spin4 animate-spin" v-if="uploading[0]"></i>
<button class="btn btn-default" v-else-if="previews[0]" @click="submitAvatar">{{$t('general.submit')}}</button>
</div>
<div class="setting-item">
<h2>{{$t('settings.profile_banner')}}</h2>
<p>{{$t('settings.current_profile_banner')}}</p>
<img :src="user.cover_photo" class="banner"></img>
<p>{{$t('settings.set_new_profile_banner')}}</p>
<img class="banner" v-bind:src="previews[1]" v-if="previews[1]">
<img class="banner" v-bind:src="bannerPreview" v-if="bannerPreview">
</img>
<div>
<input type="file" @change="uploadFile(1, $event)" ></input>
<input type="file" @change="uploadFile('banner', $event)" ></input>
</div>
<i class=" icon-spin4 animate-spin uploading" v-if="bannerUploading"></i>
<button class="btn btn-default" v-else-if="bannerPreview" @click="submitBanner">{{$t('general.submit')}}</button>
<div class='alert error' v-if="bannerUploadError">
Error: {{ bannerUploadError }}
<i class="icon-cancel" @click="clearUploadError('banner')"></i>
</div>
<i class=" icon-spin4 animate-spin uploading" v-if="uploading[1]"></i>
<button class="btn btn-default" v-else-if="previews[1]" @click="submitBanner">{{$t('general.submit')}}</button>
</div>
<div class="setting-item">
<h2>{{$t('settings.profile_background')}}</h2>
<p>{{$t('settings.set_new_profile_background')}}</p>
<img class="bg" v-bind:src="previews[2]" v-if="previews[2]">
<img class="bg" v-bind:src="backgroundPreview" v-if="backgroundPreview">
</img>
<div>
<input type="file" @change="uploadFile(2, $event)" ></input>
<input type="file" @change="uploadFile('background', $event)" ></input>
</div>
<i class=" icon-spin4 animate-spin uploading" v-if="backgroundUploading"></i>
<button class="btn btn-default" v-else-if="backgroundPreview" @click="submitBg">{{$t('general.submit')}}</button>
<div class='alert error' v-if="backgroundUploadError">
Error: {{ backgroundUploadError }}
<i class="icon-cancel" @click="clearUploadError('background')"></i>
</div>
<i class=" icon-spin4 animate-spin uploading" v-if="uploading[2]"></i>
<button class="btn btn-default" v-else-if="previews[2]" @click="submitBg">{{$t('general.submit')}}</button>
</div>
</div>
@ -113,7 +129,7 @@
<form v-model="followImportForm">
<input type="file" ref="followlist" v-on:change="followListChange"></input>
</form>
<i class=" icon-spin4 animate-spin uploading" v-if="uploading[3]"></i>
<i class=" icon-spin4 animate-spin uploading" v-if="followListUploading"></i>
<button class="btn btn-default" v-else @click="importFollows">{{$t('general.submit')}}</button>
<div v-if="followsImported">
<i class="icon-cross" @click="dismissImported"></i>

View file

@ -29,6 +29,7 @@
"username": "Username"
},
"nav": {
"back": "Back",
"chat": "Local Chat",
"friend_requests": "Follow Requests",
"mentions": "Mentions",
@ -151,6 +152,7 @@
"notification_visibility_mentions": "Mentions",
"notification_visibility_repeats": "Repeats",
"no_rich_text_description": "Strip rich text formatting from all posts",
"hide_network_description": "Don't show who I'm following and who's following me",
"nsfw_clickthrough": "Enable clickthrough NSFW attachment hiding",
"panelRadius": "Panels",
"pause_on_unfocused": "Pause streaming when tab is not focused",
@ -190,6 +192,8 @@
"false": "no",
"true": "yes"
},
"notifications": "Notifications",
"enable_web_push_notifications": "Enable web push notifications",
"style": {
"switcher": {
"keep_color": "Keep colors",
@ -324,6 +328,7 @@
"followers": "Followers",
"following": "Following!",
"follows_you": "Follows you!",
"its_you": "It's you!",
"mute": "Mute",
"muted": "Muted",
"per_day": "per day",
@ -343,5 +348,19 @@
"reply": "Reply",
"favorite": "Favorite",
"user_settings": "User Settings"
},
"upload":{
"error": {
"base": "Upload failed.",
"file_too_big": "File too big [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"default": "Try again later"
},
"file_size_units": {
"B": "B",
"KiB": "KiB",
"MiB": "MiB",
"GiB": "GiB",
"TiB": "TiB"
}
}
}

View file

@ -19,6 +19,7 @@
"username": "Имя пользователя"
},
"nav": {
"back": "Назад",
"chat": "Локальный чат",
"mentions": "Упоминания",
"public_tl": "Публичная лента",
@ -126,6 +127,7 @@
"notification_visibility_mentions": "Упоминания",
"notification_visibility_repeats": "Повторы",
"no_rich_text_description": "Убрать форматирование из всех постов",
"hide_network_description": "Не показывать кого я читаю и кто меня читает",
"nsfw_clickthrough": "Включить скрытие NSFW вложений",
"panelRadius": "Панели",
"pause_on_unfocused": "Приостановить загрузку когда вкладка не в фокусе",

View file

@ -50,6 +50,32 @@ const persistedStateOptions = {
'oauth'
]
}
const registerPushNotifications = store => {
store.subscribe((mutation, state) => {
const vapidPublicKey = state.instance.vapidPublicKey
const permission = state.interface.notificationPermission === 'granted'
const isUserMutation = mutation.type === 'setCurrentUser'
if (isUserMutation && vapidPublicKey && permission) {
return store.dispatch('registerPushNotifications')
}
const user = state.users.currentUser
const isVapidMutation = mutation.type === 'setInstanceOption' && mutation.payload.name === 'vapidPublicKey'
if (isVapidMutation && user && permission) {
return store.dispatch('registerPushNotifications')
}
const isPermMutation = mutation.type === 'setNotificationPermission' && mutation.payload === 'granted'
if (isPermMutation && user && vapidPublicKey) {
return store.dispatch('registerPushNotifications')
}
})
}
createPersistedState(persistedStateOptions).then((persistedState) => {
const store = new Vuex.Store({
modules: {
@ -62,10 +88,16 @@ createPersistedState(persistedStateOptions).then((persistedState) => {
chat: chatModule,
oauth: oauthModule
},
plugins: [persistedState],
plugins: [persistedState, registerPushNotifications],
strict: false // Socket modifies itself, let's ignore this for now.
// strict: process.env.NODE_ENV !== 'production'
})
afterStoreSetup({ store, i18n })
})
// These are inlined by webpack's DefinePlugin
/* eslint-disable */
window.___pleromafe_mode = process.env
window.___pleromafe_commit_hash = COMMIT_HASH
window.___pleromafe_dev_overrides = DEV_OVERRIDES

View file

@ -24,6 +24,7 @@ const defaultState = {
likes: true,
repeats: true
},
webPushNotifications: true,
muteWords: [],
highlight: {},
interfaceLanguage: browserLocale,

View file

@ -25,6 +25,8 @@ const defaultState = {
scopeCopy: true,
subjectLineBehavior: 'email',
loginMethod: 'password',
nsfwCensorImage: undefined,
vapidPublicKey: undefined,
// Nasty stuff
pleromaBackend: true,

View file

@ -3,7 +3,8 @@ import { set, delete as del } from 'vue'
const defaultState = {
settings: {
currentSaveStateNotice: null,
noticeClearTimeout: null
noticeClearTimeout: null,
notificationPermission: null
},
browserSupport: {
cssFilter: window.CSS && window.CSS.supports && (
@ -27,6 +28,9 @@ const interfaceMod = {
} else {
set(state.settings, 'currentSaveStateNotice', { error: true, errorData: error })
}
},
setNotificationPermission (state, permission) {
state.notificationPermission = permission
}
},
actions: {
@ -35,6 +39,9 @@ const interfaceMod = {
},
settingsSaved ({ commit, dispatch }, { success, error }) {
commit('settingsSaved', { success, error })
},
setNotificationPermission ({ commit }, permission) {
commit('setNotificationPermission', permission)
}
}
}

View file

@ -1,6 +1,7 @@
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { compact, map, each, merge } from 'lodash'
import { set } from 'vue'
import registerPushNotifications from '../services/push/push.js'
import oauthApi from '../services/new_api/oauth'
import { humanizeErrors } from './errors'
@ -16,10 +17,21 @@ export const mergeOrAdd = (arr, obj, item) => {
// This is a new item, prepare it
arr.push(item)
obj[item.id] = item
if (item.screen_name && !item.screen_name.includes('@')) {
obj[item.screen_name] = item
}
return { item, new: true }
}
}
const getNotificationPermission = () => {
const Notification = window.Notification
if (!Notification) return Promise.resolve(null)
if (Notification.permission === 'default') return Notification.requestPermission()
return Promise.resolve(Notification.permission)
}
export const mutations = {
setMuted (state, { user: { id }, muted }) {
const user = state.usersObject[id]
@ -78,7 +90,14 @@ const users = {
actions: {
fetchUser (store, id) {
store.rootState.api.backendInteractor.fetchUser({ id })
.then((user) => store.commit('addNewUsers', user))
.then((user) => store.commit('addNewUsers', [user]))
},
registerPushNotifications (store) {
const token = store.state.currentUser.credentials
const vapidPublicKey = store.rootState.instance.vapidPublicKey
const isEnabled = store.rootState.config.webPushNotifications
registerPushNotifications(isEnabled, vapidPublicKey, token)
},
addNewStatuses (store, { statuses }) {
const users = map(statuses, 'user')
@ -143,6 +162,9 @@ const users = {
commit('setCurrentUser', user)
commit('addNewUsers', [user])
getNotificationPermission()
.then(permission => commit('setNotificationPermission', permission))
// Set our new backend interactor
commit('setBackendInteractor', backendInteractorService(accessToken))
@ -161,10 +183,6 @@ const users = {
store.commit('addNewUsers', mutedUsers)
})
if ('Notification' in window && window.Notification.permission === 'default') {
window.Notification.requestPermission()
}
// Fetch our friends
store.rootState.api.backendInteractor.fetchFriends({ id: user.id })
.then((friends) => commit('addNewUsers', friends))

View file

@ -0,0 +1,17 @@
const fileSizeFormat = (num) => {
var exponent
var unit
var units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
if (num < 1) {
return num + ' ' + units[0]
}
exponent = Math.min(Math.floor(Math.log(num) / Math.log(1024)), units.length - 1)
num = (num / Math.pow(1024, exponent)).toFixed(2) * 1
unit = units[exponent]
return {num: num, unit: unit}
}
const fileSizeFormatService = {
fileSizeFormat
}
export default fileSizeFormatService

69
src/services/push/push.js Normal file
View file

@ -0,0 +1,69 @@
import runtime from 'serviceworker-webpack-plugin/lib/runtime'
function urlBase64ToUint8Array (base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4)
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/')
const rawData = window.atob(base64)
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)))
}
function isPushSupported () {
return 'serviceWorker' in navigator && 'PushManager' in window
}
function registerServiceWorker () {
return runtime.register()
.catch((err) => console.error('Unable to register service worker.', err))
}
function subscribe (registration, isEnabled, vapidPublicKey) {
if (!isEnabled) return Promise.reject(new Error('Web Push is disabled in config'))
if (!vapidPublicKey) return Promise.reject(new Error('VAPID public key is not found'))
const subscribeOptions = {
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
}
return registration.pushManager.subscribe(subscribeOptions)
}
function sendSubscriptionToBackEnd (subscription, token) {
return window.fetch('/api/v1/push/subscription/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
subscription,
data: {
alerts: {
follow: true,
favourite: true,
mention: true,
reblog: true
}
}
})
})
.then((response) => {
if (!response.ok) throw new Error('Bad status code from server.')
return response.json()
})
.then((responseData) => {
if (!responseData.id) throw new Error('Bad response from server.')
return responseData
})
}
export default function registerPushNotifications (isEnabled, vapidPublicKey, token) {
if (isPushSupported()) {
registerServiceWorker()
.then((registration) => subscribe(registration, isEnabled, vapidPublicKey))
.then((subscription) => sendSubscriptionToBackEnd(subscription, token))
.catch((e) => console.warn(`Failed to setup Web Push Notifications: ${e.message}`))
}
}

38
src/sw.js Normal file
View file

@ -0,0 +1,38 @@
/* eslint-env serviceworker */
import localForage from 'localforage'
function isEnabled () {
return localForage.getItem('vuex-lz')
.then(data => data.config.webPushNotifications)
}
function getWindowClients () {
return clients.matchAll({ includeUncontrolled: true })
.then((clientList) => clientList.filter(({ type }) => type === 'window'))
}
self.addEventListener('push', (event) => {
if (event.data) {
event.waitUntil(isEnabled().then((isEnabled) => {
return isEnabled && getWindowClients().then((list) => {
const data = event.data.json()
if (list.length === 0) return self.registration.showNotification(data.title, data)
})
}))
}
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
event.waitUntil(getWindowClients().then((list) => {
for (var i = 0; i < list.length; i++) {
var client = list[i]
if (client.url === '/' && 'focus' in client) { return client.focus() }
}
if (clients.openWindow) return clients.openWindow('/')
}))
})

View file

@ -0,0 +1,34 @@
import fileSizeFormatService from '../../../../../src/services/file_size_format/file_size_format.js'
describe('fileSizeFormat', () => {
it('Formats file size', () => {
const values = [1, 1024, 1048576, 1073741824, 1099511627776]
const expected = [
{
num: 1,
unit: 'B'
},
{
num: 1,
unit: 'KiB'
},
{
num: 1,
unit: 'MiB'
},
{
num: 1,
unit: 'GiB'
},
{
num: 1,
unit: 'TiB'
}
]
var res = []
for (var value in values) {
res.push(fileSizeFormatService.fileSizeFormat(values[value]))
}
expect(res).to.eql(expected)
})
})

View file

@ -3925,7 +3925,7 @@ mime@^1.3.4, mime@^1.5.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
"minimatch@2 || 3", minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@~3.0.2:
"minimatch@2 || 3", minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4, minimatch@~3.0.2:
version "3.0.4"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
dependencies:
@ -5289,6 +5289,12 @@ serve-static@1.13.1:
parseurl "~1.3.2"
send "0.16.1"
serviceworker-webpack-plugin@0.2.3:
version "0.2.3"
resolved "https://registry.yarnpkg.com/serviceworker-webpack-plugin/-/serviceworker-webpack-plugin-0.2.3.tgz#1873ed6fc83c873ac8240fac443c615d374feeb2"
dependencies:
minimatch "^3.0.3"
set-blocking@^2.0.0, set-blocking@~2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"