diff options
| author | Henry Jameson <me@hjkos.com> | 2018-12-14 17:17:58 +0300 |
|---|---|---|
| committer | Henry Jameson <me@hjkos.com> | 2018-12-14 17:17:58 +0300 |
| commit | d7973b0b802cdeea42c8ca292574e52b4050525e (patch) | |
| tree | d443d01edb20682d408d57855ee4088c96826dea | |
| parent | 9dfff107177e4461734c54f60d79448dec34c002 (diff) | |
| parent | 5b6c1aa97c0d13101ebddb4c34b79a2e428ec30b (diff) | |
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
...
40 files changed, 569 insertions, 119 deletions
@@ -6,3 +6,4 @@ test/unit/coverage test/e2e/reports selenium-debug.log .idea/ +config/local.json @@ -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. diff --git a/build/webpack.base.conf.js b/build/webpack.base.conf.js index 198532ca..ea46ce6f 100644 --- a/build/webpack.base.conf.js +++ b/build/webpack.base.conf.js @@ -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') + }) + ] } diff --git a/build/webpack.dev.conf.js b/build/webpack.dev.conf.js index 7e1a104f..9f34619c 100644 --- a/build/webpack.dev.conf.js +++ b/build/webpack.dev.conf.js @@ -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(), diff --git a/build/webpack.prod.conf.js b/build/webpack.prod.conf.js index 6119f700..9699f221 100644 --- a/build/webpack.prod.conf.js +++ b/build/webpack.prod.conf.js @@ -7,8 +7,13 @@ var baseWebpackConfig = require('./webpack.base.conf') var ExtractTextPlugin = require('extract-text-webpack-plugin') var HtmlWebpackPlugin = require('html-webpack-plugin') var env = process.env.NODE_ENV === 'testing' - ? require('../config/test.env') - : config.build.env + ? 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: { @@ -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 }, diff --git a/config/index.js b/config/index.js index 7b0ef26c..56fa5940 100644 --- a/config/index.js +++ b/config/index.js @@ -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 diff --git a/config/local.example.json b/config/local.example.json new file mode 100644 index 00000000..2a3bd00d --- /dev/null +++ b/config/local.example.json @@ -0,0 +1,4 @@ +{ + "target": "https://pleroma.soykaf.com/", + "staticConfigPreference": false +} @@ -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"> diff --git a/package.json b/package.json index 7dcb88e7..60e5ca02 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.scss b/src/App.scss index 5355d899..0a303b15 100644 --- a/src/App.scss +++ b/src/App.scss @@ -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; diff --git a/src/App.vue b/src/App.vue index 16cd08d4..048c1e77 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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> diff --git a/src/boot/after_store.js b/src/boot/after_store.js index a80baaf5..24a8d3ac 100644 --- a/src/boot/after_store.js +++ b/src/boot/after_store.js @@ -17,17 +17,29 @@ import FollowRequests from '../components/follow_requests/follow_requests.vue' import OAuthCallback from '../components/oauth_callback/oauth_callback.vue' import UserSearch from '../components/user_search/user_search.vue' -const afterStoreSetup = ({store, i18n}) => { +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) diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js index 16114c30..97c4f283 100644 --- a/src/components/attachment/attachment.js +++ b/src/components/attachment/attachment.js @@ -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, diff --git a/src/components/media_upload/media_upload.js b/src/components/media_upload/media_upload.js index 66337c3f..42d900d3 100644 --- a/src/components/media_upload/media_upload.js +++ b/src/components/media_upload/media_upload.js @@ -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 }) }, diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index f9252f73..0ce2aff0 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -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 }, diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index fcf5c873..4776c819 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -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> diff --git a/src/components/settings/settings.js b/src/components/settings/settings.js index c9e12708..681ccda8 100644 --- a/src/components/settings/settings.js +++ b/src/components/settings/settings.js @@ -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') } } } diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue index 6cdc82da..3f920de5 100644 --- a/src/components/settings/settings.vue +++ b/src/components/settings/settings.vue @@ -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')" > diff --git a/src/components/status/status.vue b/src/components/status/status.vue index 96709084..067980ac 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -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"> diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js index a651f619..f28b85bd 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -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 }) diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js index a019627a..b8eb28e3 100644 --- a/src/components/user_card/user_card.js +++ b/src/components/user_card/user_card.js @@ -14,6 +14,9 @@ const UserCard = { components: { UserCardContent }, + computed: { + currentUser () { return this.$store.state.users.currentUser } + }, methods: { toggleUserExpanded () { this.userExpanded = !this.userExpanded diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue index 5a8e5531..eb0d7576 100644 --- a/src/components/user_card/user_card.vue +++ b/src/components/user_card/user_card.vue @@ -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 } }"> diff --git a/src/components/user_card_content/user_card_content.js b/src/components/user_card_content/user_card_content.js index 7e0bd725..8c6de8ec 100644 --- a/src/components/user_card_content/user_card_content.js +++ b/src/components/user_card_content/user_card_content.js @@ -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(', ') } diff --git a/src/components/user_card_content/user_card_content.vue b/src/components/user_card_content/user_card_content.vue index 2dc53c17..a40aff35 100644 --- a/src/components/user_card_content/user_card_content.vue +++ b/src/components/user_card_content/user_card_content.vue @@ -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; diff --git a/src/components/user_finder/user_finder.vue b/src/components/user_finder/user_finder.vue index 8786f6c7..4d9f6842 100644 --- a/src/components/user_finder/user_finder.vue +++ b/src/components/user_finder/user_finder.vue @@ -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%; } diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index 91d4acd2..4d2853a6 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -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> diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js index 761e674a..ca7bf319 100644 --- a/src/components/user_settings/user_settings.js +++ b/src/components/user_settings/user_settings.js @@ -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) => { - if (!user.error) { - this.$store.commit('addNewUsers', [user]) - this.$store.commit('setCurrentUser', 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) + } + }) }, 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 () { diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue index 234a7d86..67b65b57 100644 --- a/src/components/user_settings/user_settings.vue +++ b/src/components/user_settings/user_settings.vue @@ -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> diff --git a/src/i18n/en.json b/src/i18n/en.json index a5c02953..f445bfc2 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -29,6 +29,7 @@ "username": "Username" }, "nav": { + "back": "Back", "chat": "Local Chat", "friend_requests": "Follow Requests", "mentions": "Mentions", @@ -133,7 +134,7 @@ "inputRadius": "Input fields", "checkboxRadius": "Checkboxes", "instance_default": "(default: {value})", - "instance_default_simple" : "(default)", + "instance_default_simple": "(default)", "interface": "Interface", "interfaceLanguage": "Interface language", "invalid_theme_imported": "The selected file is not a supported Pleroma theme. No changes to your theme were made.", @@ -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" + } } } diff --git a/src/i18n/ru.json b/src/i18n/ru.json index d710662f..249eab69 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -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": "Приостановить загрузку когда вкладка не в фокусе", diff --git a/src/main.js b/src/main.js index 378fe95c..bf92e78e 100644 --- a/src/main.js +++ b/src/main.js @@ -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}) + 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 diff --git a/src/modules/config.js b/src/modules/config.js index 72839476..ccfd0190 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -24,6 +24,7 @@ const defaultState = { likes: true, repeats: true }, + webPushNotifications: true, muteWords: [], highlight: {}, interfaceLanguage: browserLocale, diff --git a/src/modules/instance.js b/src/modules/instance.js index 7c27d52a..ab88306f 100644 --- a/src/modules/instance.js +++ b/src/modules/instance.js @@ -25,6 +25,8 @@ const defaultState = { scopeCopy: true, subjectLineBehavior: 'email', loginMethod: 'password', + nsfwCensorImage: undefined, + vapidPublicKey: undefined, // Nasty stuff pleromaBackend: true, diff --git a/src/modules/interface.js b/src/modules/interface.js index 132fb08d..956c9cb3 100644 --- a/src/modules/interface.js +++ b/src/modules/interface.js @@ -3,12 +3,13 @@ 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 && ( window.CSS.supports('filter', 'drop-shadow(0 0)') || - window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)') + window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)') ) } } @@ -23,10 +24,13 @@ const interfaceMod = { } set(state.settings, 'currentSaveStateNotice', { error: false, data: success }) set(state.settings, 'noticeClearTimeout', - setTimeout(() => del(state.settings, 'currentSaveStateNotice'), 2000)) + setTimeout(() => del(state.settings, 'currentSaveStateNotice'), 2000)) } 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) } } } diff --git a/src/modules/users.js b/src/modules/users.js index 6d966c3b..25d1c81f 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -1,8 +1,9 @@ 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' +import { humanizeErrors } from './errors' // TODO: Unify with mergeOrAdd in statuses.js export const mergeOrAdd = (arr, obj, item) => { @@ -11,17 +12,28 @@ export const mergeOrAdd = (arr, obj, item) => { if (oldItem) { // We already have this, so only merge the new info. merge(oldItem, item) - return {item: oldItem, new: false} + return { item: oldItem, new: false } } else { // This is a new item, prepare it arr.push(item) obj[item.id] = item - return {item, new: true} + 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 }) { + setMuted (state, { user: { id }, muted }) { const user = state.usersObject[id] set(user, 'muted', muted) }, @@ -45,7 +57,7 @@ export const mutations = { setUserForStatus (state, status) { status.user = state.usersObject[status.user.id] }, - setColor (state, { user: {id}, highlighted }) { + setColor (state, { user: { id }, highlighted }) { const user = state.usersObject[id] set(user, 'highlight', highlighted) }, @@ -77,8 +89,15 @@ const users = { mutations, actions: { fetchUser (store, id) { - store.rootState.api.backendInteractor.fetchUser({id}) - .then((user) => store.commit('addNewUsers', user)) + store.rootState.api.backendInteractor.fetchUser({ id }) + .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,12 +183,8 @@ 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}) + store.rootState.api.backendInteractor.fetchFriends({ id: user.id }) .then((friends) => commit('addNewUsers', friends)) }) } else { diff --git a/src/services/file_size_format/file_size_format.js b/src/services/file_size_format/file_size_format.js new file mode 100644 index 00000000..add56ee0 --- /dev/null +++ b/src/services/file_size_format/file_size_format.js @@ -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 diff --git a/src/services/push/push.js b/src/services/push/push.js new file mode 100644 index 00000000..1ac304d1 --- /dev/null +++ b/src/services/push/push.js @@ -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}`)) + } +} diff --git a/src/sw.js b/src/sw.js new file mode 100644 index 00000000..6cecb3f3 --- /dev/null +++ b/src/sw.js @@ -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('/') + })) +}) diff --git a/test/unit/specs/services/file_size_format/file_size_format.spec.js b/test/unit/specs/services/file_size_format/file_size_format.spec.js new file mode 100644 index 00000000..0a5a82b7 --- /dev/null +++ b/test/unit/specs/services/file_size_format/file_size_format.spec.js @@ -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) + }) + }) @@ -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" |
