diff --git a/.gitignore b/.gitignore
index faf39252..479d57c4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ test/unit/coverage
test/e2e/reports
selenium-debug.log
.idea/
+config/local.json
diff --git a/README.md b/README.md
index b6e5a586..181b6a0d 100644
--- a/README.md
+++ b/README.md
@@ -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
+}
diff --git a/index.html b/index.html
index f0872ec9..d8defc2e 100644
--- a/index.html
+++ b/index.html
@@ -4,6 +4,7 @@
- {{sitename}}
+
+
+
+ {{sitename}}
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 @@
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 @@
+
{{ $t('user_card.statuses') }}
@@ -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 @@
-
+
@@ -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 @@
+
+
+
+ {{ $t('settings.profile_tab') }}
+
+
+
+
+
+
@@ -21,4 +31,12 @@
align-items: stretch;
}
}
+.user-profile-placeholder {
+ .panel-body {
+ display: flex;
+ justify-content: center;
+ align-items: middle;
+ padding: 7em;
+ }
+}
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 @@
-
+
-
+
+
+
+
+
{{$t('settings.avatar')}}
{{$t('settings.current_avatar')}}
{{$t('settings.set_new_avatar')}}
-
+
-
+
+
+
+
+
+ Error: {{ avatarUploadError }}
+
-
-
{{$t('settings.profile_banner')}}
{{$t('settings.current_profile_banner')}}
{{$t('settings.set_new_profile_banner')}}
-
+
-
+
+
+
+
+
+ Error: {{ bannerUploadError }}
+
-
-
{{$t('settings.profile_background')}}
{{$t('settings.set_new_profile_background')}}
-
+
-
+
+
+
+
+
+ Error: {{ backgroundUploadError }}
+
-
-
@@ -113,7 +129,7 @@
-
+
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)
+ })
+ })
diff --git a/yarn.lock b/yarn.lock
index 1547bfc8..6b8a97cb 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"