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 test/e2e/reports
selenium-debug.log selenium-debug.log
.idea/ .idea/
config/local.json

View file

@ -29,6 +29,15 @@ npm run build
npm run unit 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 # Configuration
Edit config.json for configuration. scopeOptionsEnabled gives you input fields for CWs and the scope settings. 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 config = require('../config')
var utils = require('./utils') var utils = require('./utils')
var projectRoot = path.resolve(__dirname, '../') var projectRoot = path.resolve(__dirname, '../')
var ServiceWorkerWebpackPlugin = require('serviceworker-webpack-plugin')
var env = process.env.NODE_ENV var env = process.env.NODE_ENV
// check env & config/index.js to decide weither to enable CSS Sourcemaps for the // check env & config/index.js to decide weither to enable CSS Sourcemaps for the
@ -91,5 +92,10 @@ module.exports = {
browsers: ['last 2 versions'] 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', devtool: '#eval-source-map',
plugins: [ plugins: [
new webpack.DefinePlugin({ 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 // https://github.com/glenjamin/webpack-hot-middleware#installation--usage
new webpack.optimize.OccurenceOrderPlugin(), new webpack.optimize.OccurenceOrderPlugin(),

View file

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

View file

@ -1,5 +1,15 @@
// see http://vuejs-templates.github.io/webpack for documentation. // 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 = { module.exports = {
build: { build: {
@ -19,21 +29,22 @@ module.exports = {
dev: { dev: {
env: require('./dev.env'), env: require('./dev.env'),
port: 8080, port: 8080,
settings,
assetsSubDirectory: 'static', assetsSubDirectory: 'static',
assetsPublicPath: '/', assetsPublicPath: '/',
proxyTable: { proxyTable: {
'/api': { '/api': {
target: 'http://localhost:4000/', target,
changeOrigin: true, changeOrigin: true,
cookieDomainRewrite: 'localhost' cookieDomainRewrite: 'localhost'
}, },
'/nodeinfo': { '/nodeinfo': {
target: 'http://localhost:4000/', target,
changeOrigin: true, changeOrigin: true,
cookieDomainRewrite: 'localhost' cookieDomainRewrite: 'localhost'
}, },
'/socket': { '/socket': {
target: 'http://localhost:4000/', target,
changeOrigin: true, changeOrigin: true,
cookieDomainRewrite: 'localhost', cookieDomainRewrite: 'localhost',
ws: true 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 charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pleroma</title> <title>Pleroma</title>
<!--server-generated-meta-->
<link rel="icon" type="image/png" href="/favicon.png"> <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/fontello.css">
<link rel="stylesheet" href="/static/font/css/animation.css"> <link rel="stylesheet" href="/static/font/css/animation.css">

View file

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

View file

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

View file

@ -7,7 +7,10 @@
</div> </div>
<div class='inner-nav'> <div class='inner-nav'>
<div class='item'> <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>
<div class='item right'> <div class='item right'>
<user-finder class="nav-icon"></user-finder> <user-finder class="nav-icon"></user-finder>

View file

@ -21,13 +21,25 @@ const afterStoreSetup = ({store, i18n}) => {
window.fetch('/api/statusnet/config.json') window.fetch('/api/statusnet/config.json')
.then((res) => res.json()) .then((res) => res.json())
.then((data) => { .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: 'name', value: name })
store.dispatch('setInstanceOption', { name: 'registrationOpen', value: (registrationClosed === '0') }) store.dispatch('setInstanceOption', { name: 'registrationOpen', value: (registrationClosed === '0') })
store.dispatch('setInstanceOption', { name: 'textlimit', value: parseInt(textlimit) }) 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 }) 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 var apiConfig = data.site.pleromafe
window.fetch('/static/config.json') window.fetch('/static/config.json')
@ -38,8 +50,17 @@ const afterStoreSetup = ({store, i18n}) => {
return {} return {}
}) })
.then((staticConfig) => { .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 // 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 theme = (config.theme)
var background = (config.background) var background = (config.background)

View file

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

View file

@ -1,5 +1,6 @@
/* eslint-env browser */ /* eslint-env browser */
import statusPosterService from '../../services/status_poster/status_poster.service.js' import statusPosterService from '../../services/status_poster/status_poster.service.js'
import fileSizeFormatService from '../../services/file_size_format/file_size_format.js'
const mediaUpload = { const mediaUpload = {
mounted () { mounted () {
@ -21,6 +22,12 @@ const mediaUpload = {
uploadFile (file) { uploadFile (file) {
const self = this const self = this
const store = this.$store 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() const formData = new FormData()
formData.append('media', file) formData.append('media', file)
@ -32,7 +39,7 @@ const mediaUpload = {
self.$emit('uploaded', fileData) self.$emit('uploaded', fileData)
self.uploading = false self.uploading = false
}, (error) => { // eslint-disable-line handle-callback-err }, (error) => { // eslint-disable-line handle-callback-err
self.$emit('upload-failed') self.$emit('upload-failed', 'default')
self.uploading = false self.uploading = false
}) })
}, },

View file

@ -262,6 +262,11 @@ const PostStatusForm = {
let index = this.newStatus.files.indexOf(fileInfo) let index = this.newStatus.files.indexOf(fileInfo)
this.newStatus.files.splice(index, 1) 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 () { disableSubmit () {
this.submitDisabled = true this.submitDisabled = true
}, },

View file

@ -64,7 +64,7 @@
</div> </div>
</div> </div>
<div class='form-bottom'> <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 v-if="isOverLengthLimit" class="error">{{ charactersLeft }}</p>
<p class="faint" v-else-if="hasStatusLengthLimit">{{ charactersLeft }}</p> <p class="faint" v-else-if="hasStatusLengthLimit">{{ charactersLeft }}</p>

View file

@ -47,6 +47,7 @@ const settings = {
scopeCopyLocal: user.scopeCopy, scopeCopyLocal: user.scopeCopy,
scopeCopyDefault: this.$t('settings.values.' + instance.scopeCopy), scopeCopyDefault: this.$t('settings.values.' + instance.scopeCopy),
stopGifs: user.stopGifs, stopGifs: user.stopGifs,
webPushNotificationsLocal: user.webPushNotifications,
loopSilentAvailable: loopSilentAvailable:
// Firefox // Firefox
Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') || Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') ||
@ -142,6 +143,10 @@ const settings = {
}, },
stopGifs (value) { stopGifs (value) {
this.$store.dispatch('setOption', { name: '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> </li>
</ul> </ul>
</div> </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>
<div :label="$t('settings.theme')" > <div :label="$t('settings.theme')" >

View file

@ -54,7 +54,7 @@
</h4> </h4>
</div> </div>
<div class="media-heading-right"> <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> <timeago :since="status.created_at" :auto-update="60"></timeago>
</router-link> </router-link>
<div class="visibility-icon" v-if="status.visibility"> <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 timelineFetcher from '../../services/timeline_fetcher/timeline_fetcher.service.js'
import StatusOrConversation from '../status_or_conversation/status_or_conversation.vue' import StatusOrConversation from '../status_or_conversation/status_or_conversation.vue'
import UserCard from '../user_card/user_card.vue' import UserCard from '../user_card/user_card.vue'
import { throttle } from 'lodash'
const Timeline = { const Timeline = {
props: [ props: [
@ -88,7 +89,7 @@ const Timeline = {
this.paused = false this.paused = false
} }
}, },
fetchOlderStatuses () { fetchOlderStatuses: throttle(function () {
const store = this.$store const store = this.$store
const credentials = store.state.users.currentUser.credentials const credentials = store.state.users.currentUser.credentials
store.commit('setLoading', { timeline: this.timelineName, value: true }) store.commit('setLoading', { timeline: this.timelineName, value: true })
@ -101,7 +102,7 @@ const Timeline = {
userId: this.userId, userId: this.userId,
tag: this.tag tag: this.tag
}).then(() => store.commit('setLoading', { timeline: this.timelineName, value: false })) }).then(() => store.commit('setLoading', { timeline: this.timelineName, value: false }))
}, }, 1000, this),
fetchFollowers () { fetchFollowers () {
const id = this.userId const id = this.userId
this.$store.state.api.backendInteractor.fetchFollowers({ id }) this.$store.state.api.backendInteractor.fetchFollowers({ id })

View file

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

View file

@ -10,13 +10,13 @@
<div :title="user.name" v-if="user.name_html" class="user-name"> <div :title="user.name" v-if="user.name_html" class="user-name">
<span v-html="user.name_html"></span> <span v-html="user.name_html"></span>
<span class="follows-you" v-if="!userExpanded && showFollows && user.follows_you"> <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> </span>
</div> </div>
<div :title="user.name" v-else class="user-name"> <div :title="user.name" v-else class="user-name">
{{ user.name }} {{ user.name }}
<span class="follows-you" v-if="!userExpanded && showFollows && user.follows_you"> <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> </span>
</div> </div>
<router-link class='user-screen-name' :to="{ name: 'user-profile', params: { id: user.id } }"> <router-link class='user-screen-name' :to="{ name: 'user-profile', params: { id: user.id } }">

View file

@ -22,10 +22,20 @@ export default {
if (color) { if (color) {
const rgb = (typeof color === 'string') ? hex2rgb(color) : color const rgb = (typeof color === 'string') ? hex2rgb(color) : color
const tintColor = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .5)` const tintColor = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .5)`
const gradient = [
[tintColor, this.hideBio ? '60%' : ''],
this.hideBio ? [
color, '100%'
] : [
tintColor, ''
]
].map(_ => _.join(' ')).join(', ')
return { return {
backgroundColor: `rgb(${Math.floor(rgb.r * 0.53)}, ${Math.floor(rgb.g * 0.56)}, ${Math.floor(rgb.b * 0.59)})`, backgroundColor: `rgb(${Math.floor(rgb.r * 0.53)}, ${Math.floor(rgb.g * 0.56)}, ${Math.floor(rgb.b * 0.59)})`,
backgroundImage: [ backgroundImage: [
`linear-gradient(to bottom, ${tintColor}, ${tintColor})`, `linear-gradient(to bottom, ${gradient})`,
`url(${this.user.cover_photo})` `url(${this.user.cover_photo})`
].join(', ') ].join(', ')
} }

View file

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

View file

@ -1,12 +1,12 @@
<template> <template>
<span class="user-finder-container"> <div class="user-finder-container">
<i class="icon-spin4 user-finder-icon animate-spin-slow" v-if="loading" /> <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> <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> <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"/> <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"/> <i class="icon-cancel user-finder-icon" @click.prevent.stop="toggleHidden"/>
</span> </span>
</span> </div>
</template> </template>
<script src="./user_finder.js"></script> <script src="./user_finder.js"></script>
@ -15,7 +15,6 @@
@import '../../_variables.scss'; @import '../../_variables.scss';
.user-finder-container { .user-finder-container {
height: 29px;
max-width: 100%; max-width: 100%;
} }

View file

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

View file

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

View file

@ -9,11 +9,11 @@
<div class="setting-item" > <div class="setting-item" >
<h2>{{$t('settings.name_bio')}}</h2> <h2>{{$t('settings.name_bio')}}</h2>
<p>{{$t('settings.name')}}</p> <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> <p>{{$t('settings.bio')}}</p>
<textarea class="bio" v-model="newbio"></textarea> <textarea class="bio" v-model="newBio"></textarea>
<p> <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> <label for="account-locked">{{$t('settings.lock_account_description')}}</label>
</p> </p>
<div v-if="scopeOptionsEnabled"> <div v-if="scopeOptionsEnabled">
@ -26,47 +26,63 @@
</div> </div>
</div> </div>
<p> <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> <label for="account-no-rich-text">{{$t('settings.no_rich_text_description')}}</label>
</p> </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>
<div class="setting-item"> <div class="setting-item">
<h2>{{$t('settings.avatar')}}</h2> <h2>{{$t('settings.avatar')}}</h2>
<p>{{$t('settings.current_avatar')}}</p> <p>{{$t('settings.current_avatar')}}</p>
<img :src="user.profile_image_url_original" class="old-avatar"></img> <img :src="user.profile_image_url_original" class="old-avatar"></img>
<p>{{$t('settings.set_new_avatar')}}</p> <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> </img>
<div> <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> </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>
<div class="setting-item"> <div class="setting-item">
<h2>{{$t('settings.profile_banner')}}</h2> <h2>{{$t('settings.profile_banner')}}</h2>
<p>{{$t('settings.current_profile_banner')}}</p> <p>{{$t('settings.current_profile_banner')}}</p>
<img :src="user.cover_photo" class="banner"></img> <img :src="user.cover_photo" class="banner"></img>
<p>{{$t('settings.set_new_profile_banner')}}</p> <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> </img>
<div> <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> </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>
<div class="setting-item"> <div class="setting-item">
<h2>{{$t('settings.profile_background')}}</h2> <h2>{{$t('settings.profile_background')}}</h2>
<p>{{$t('settings.set_new_profile_background')}}</p> <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> </img>
<div> <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> </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>
</div> </div>
@ -113,7 +129,7 @@
<form v-model="followImportForm"> <form v-model="followImportForm">
<input type="file" ref="followlist" v-on:change="followListChange"></input> <input type="file" ref="followlist" v-on:change="followListChange"></input>
</form> </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> <button class="btn btn-default" v-else @click="importFollows">{{$t('general.submit')}}</button>
<div v-if="followsImported"> <div v-if="followsImported">
<i class="icon-cross" @click="dismissImported"></i> <i class="icon-cross" @click="dismissImported"></i>

View file

@ -29,6 +29,7 @@
"username": "Username" "username": "Username"
}, },
"nav": { "nav": {
"back": "Back",
"chat": "Local Chat", "chat": "Local Chat",
"friend_requests": "Follow Requests", "friend_requests": "Follow Requests",
"mentions": "Mentions", "mentions": "Mentions",
@ -151,6 +152,7 @@
"notification_visibility_mentions": "Mentions", "notification_visibility_mentions": "Mentions",
"notification_visibility_repeats": "Repeats", "notification_visibility_repeats": "Repeats",
"no_rich_text_description": "Strip rich text formatting from all posts", "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", "nsfw_clickthrough": "Enable clickthrough NSFW attachment hiding",
"panelRadius": "Panels", "panelRadius": "Panels",
"pause_on_unfocused": "Pause streaming when tab is not focused", "pause_on_unfocused": "Pause streaming when tab is not focused",
@ -190,6 +192,8 @@
"false": "no", "false": "no",
"true": "yes" "true": "yes"
}, },
"notifications": "Notifications",
"enable_web_push_notifications": "Enable web push notifications",
"style": { "style": {
"switcher": { "switcher": {
"keep_color": "Keep colors", "keep_color": "Keep colors",
@ -324,6 +328,7 @@
"followers": "Followers", "followers": "Followers",
"following": "Following!", "following": "Following!",
"follows_you": "Follows you!", "follows_you": "Follows you!",
"its_you": "It's you!",
"mute": "Mute", "mute": "Mute",
"muted": "Muted", "muted": "Muted",
"per_day": "per day", "per_day": "per day",
@ -343,5 +348,19 @@
"reply": "Reply", "reply": "Reply",
"favorite": "Favorite", "favorite": "Favorite",
"user_settings": "User Settings" "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": "Имя пользователя" "username": "Имя пользователя"
}, },
"nav": { "nav": {
"back": "Назад",
"chat": "Локальный чат", "chat": "Локальный чат",
"mentions": "Упоминания", "mentions": "Упоминания",
"public_tl": "Публичная лента", "public_tl": "Публичная лента",
@ -126,6 +127,7 @@
"notification_visibility_mentions": "Упоминания", "notification_visibility_mentions": "Упоминания",
"notification_visibility_repeats": "Повторы", "notification_visibility_repeats": "Повторы",
"no_rich_text_description": "Убрать форматирование из всех постов", "no_rich_text_description": "Убрать форматирование из всех постов",
"hide_network_description": "Не показывать кого я читаю и кто меня читает",
"nsfw_clickthrough": "Включить скрытие NSFW вложений", "nsfw_clickthrough": "Включить скрытие NSFW вложений",
"panelRadius": "Панели", "panelRadius": "Панели",
"pause_on_unfocused": "Приостановить загрузку когда вкладка не в фокусе", "pause_on_unfocused": "Приостановить загрузку когда вкладка не в фокусе",

View file

@ -50,6 +50,32 @@ const persistedStateOptions = {
'oauth' '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) => { createPersistedState(persistedStateOptions).then((persistedState) => {
const store = new Vuex.Store({ const store = new Vuex.Store({
modules: { modules: {
@ -62,10 +88,16 @@ createPersistedState(persistedStateOptions).then((persistedState) => {
chat: chatModule, chat: chatModule,
oauth: oauthModule oauth: oauthModule
}, },
plugins: [persistedState], plugins: [persistedState, registerPushNotifications],
strict: false // Socket modifies itself, let's ignore this for now. strict: false // Socket modifies itself, let's ignore this for now.
// strict: process.env.NODE_ENV !== 'production' // strict: process.env.NODE_ENV !== 'production'
}) })
afterStoreSetup({ store, i18n }) 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, likes: true,
repeats: true repeats: true
}, },
webPushNotifications: true,
muteWords: [], muteWords: [],
highlight: {}, highlight: {},
interfaceLanguage: browserLocale, interfaceLanguage: browserLocale,

View file

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

View file

@ -3,7 +3,8 @@ import { set, delete as del } from 'vue'
const defaultState = { const defaultState = {
settings: { settings: {
currentSaveStateNotice: null, currentSaveStateNotice: null,
noticeClearTimeout: null noticeClearTimeout: null,
notificationPermission: null
}, },
browserSupport: { browserSupport: {
cssFilter: window.CSS && window.CSS.supports && ( cssFilter: window.CSS && window.CSS.supports && (
@ -27,6 +28,9 @@ const interfaceMod = {
} else { } else {
set(state.settings, 'currentSaveStateNotice', { error: true, errorData: error }) set(state.settings, 'currentSaveStateNotice', { error: true, errorData: error })
} }
},
setNotificationPermission (state, permission) {
state.notificationPermission = permission
} }
}, },
actions: { actions: {
@ -35,6 +39,9 @@ const interfaceMod = {
}, },
settingsSaved ({ commit, dispatch }, { success, error }) { settingsSaved ({ commit, dispatch }, { success, error }) {
commit('settingsSaved', { 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 backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { compact, map, each, merge } from 'lodash' import { compact, map, each, merge } from 'lodash'
import { set } from 'vue' import { set } from 'vue'
import registerPushNotifications from '../services/push/push.js'
import oauthApi from '../services/new_api/oauth' import oauthApi from '../services/new_api/oauth'
import { humanizeErrors } from './errors' import { humanizeErrors } from './errors'
@ -16,10 +17,21 @@ export const mergeOrAdd = (arr, obj, item) => {
// This is a new item, prepare it // This is a new item, prepare it
arr.push(item) arr.push(item)
obj[item.id] = item obj[item.id] = item
if (item.screen_name && !item.screen_name.includes('@')) {
obj[item.screen_name] = item
}
return { item, new: true } 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 = { export const mutations = {
setMuted (state, { user: { id }, muted }) { setMuted (state, { user: { id }, muted }) {
const user = state.usersObject[id] const user = state.usersObject[id]
@ -78,7 +90,14 @@ const users = {
actions: { actions: {
fetchUser (store, id) { fetchUser (store, id) {
store.rootState.api.backendInteractor.fetchUser({ 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 }) { addNewStatuses (store, { statuses }) {
const users = map(statuses, 'user') const users = map(statuses, 'user')
@ -143,6 +162,9 @@ const users = {
commit('setCurrentUser', user) commit('setCurrentUser', user)
commit('addNewUsers', [user]) commit('addNewUsers', [user])
getNotificationPermission()
.then(permission => commit('setNotificationPermission', permission))
// Set our new backend interactor // Set our new backend interactor
commit('setBackendInteractor', backendInteractorService(accessToken)) commit('setBackendInteractor', backendInteractorService(accessToken))
@ -161,10 +183,6 @@ const users = {
store.commit('addNewUsers', mutedUsers) store.commit('addNewUsers', mutedUsers)
}) })
if ('Notification' in window && window.Notification.permission === 'default') {
window.Notification.requestPermission()
}
// Fetch our friends // Fetch our friends
store.rootState.api.backendInteractor.fetchFriends({ id: user.id }) store.rootState.api.backendInteractor.fetchFriends({ id: user.id })
.then((friends) => commit('addNewUsers', friends)) .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" version "1.6.0"
resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" 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" version "3.0.4"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
dependencies: dependencies:
@ -5289,6 +5289,12 @@ serve-static@1.13.1:
parseurl "~1.3.2" parseurl "~1.3.2"
send "0.16.1" 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: set-blocking@^2.0.0, set-blocking@~2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"