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

* upstream/develop:
  remove &
  add a comment
  show ellipsis for long user name and screen name
  use default_scope parameter
  use json content type
  clean up
  refactoring
  add “export blocks” feature
  fix wrong function binding
  make reusable Exporter component
  add “block import” feature
  change api function name
  make Importer component reusable
  add uploading icon css
  move formData generating logic to api.service
  split out follow’s importer as a separate component
  Update avatar uploading
  Switch to mastoapi for updating user profile
  Switch to mastoapi for updating banner
  Switch to mastoapi for updating avatar
This commit is contained in:
Henry Jameson 2019-04-30 21:19:53 +03:00
commit 5d274ea908
13 changed files with 302 additions and 215 deletions

View file

@ -44,14 +44,15 @@
width: 16px;
vertical-align: middle;
}
}
&-value {
display: inline-block;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&-user-name-value,
&-screen-name {
display: inline-block;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&-expanded-content {

View file

@ -0,0 +1,48 @@
const Exporter = {
props: {
getContent: {
type: Function,
required: true
},
filename: {
type: String,
default: 'export.csv'
},
exportButtonLabel: {
type: String,
default () {
return this.$t('exporter.export')
}
},
processingMessage: {
type: String,
default () {
return this.$t('exporter.processing')
}
}
},
data () {
return {
processing: false
}
},
methods: {
process () {
this.processing = true
this.getContent()
.then((content) => {
const fileToDownload = document.createElement('a')
fileToDownload.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(content))
fileToDownload.setAttribute('download', this.filename)
fileToDownload.style.display = 'none'
document.body.appendChild(fileToDownload)
fileToDownload.click()
document.body.removeChild(fileToDownload)
// Add delay before hiding processing state since browser takes some time to handle file download
setTimeout(() => { this.processing = false }, 2000)
})
}
}
}
export default Exporter

View file

@ -0,0 +1,20 @@
<template>
<div class="exporter">
<div v-if="processing">
<i class="icon-spin4 animate-spin exporter-processing"></i>
<span>{{processingMessage}}</span>
</div>
<button class="btn btn-default" @click="process" v-else>{{exportButtonLabel}}</button>
</div>
</template>
<script src="./exporter.js"></script>
<style lang="scss">
.exporter {
&-processing {
font-size: 1.5em;
margin: 0.25em;
}
}
</style>

View file

@ -70,22 +70,10 @@ const ImageCropper = {
this.dataUrl = undefined
this.$emit('close')
},
submit () {
submit (cropping = true) {
this.submitting = true
this.avatarUploadError = null
this.submitHandler(this.cropper, this.file)
.then(() => this.destroy())
.catch((err) => {
this.submitError = err
})
.finally(() => {
this.submitting = false
})
},
submitWithoutCropping () {
this.submitting = true
this.avatarUploadError = null
this.submitHandler(false, this.dataUrl)
this.submitHandler(cropping && this.cropper, this.file)
.then(() => this.destroy())
.catch((err) => {
this.submitError = err

View file

@ -5,9 +5,9 @@
<img ref="img" :src="dataUrl" alt="" @load.stop="createCropper" />
</div>
<div class="image-cropper-buttons-wrapper">
<button class="btn" type="button" :disabled="submitting" @click="submit" v-text="saveText"></button>
<button class="btn" type="button" :disabled="submitting" @click="submit()" v-text="saveText"></button>
<button class="btn" type="button" :disabled="submitting" @click="destroy" v-text="cancelText"></button>
<button class="btn" type="button" :disabled="submitting" @click="submitWithoutCropping" v-text="saveWithoutCroppingText"></button>
<button class="btn" type="button" :disabled="submitting" @click="submit(false)" v-text="saveWithoutCroppingText"></button>
<i class="icon-spin4 animate-spin" v-if="submitting"></i>
</div>
<div class="alert error" v-if="submitError">

View file

@ -0,0 +1,53 @@
const Importer = {
props: {
submitHandler: {
type: Function,
required: true
},
submitButtonLabel: {
type: String,
default () {
return this.$t('importer.submit')
}
},
successMessage: {
type: String,
default () {
return this.$t('importer.success')
}
},
errorMessage: {
type: String,
default () {
return this.$t('importer.error')
}
}
},
data () {
return {
file: null,
error: false,
success: false,
submitting: false
}
},
methods: {
change () {
this.file = this.$refs.input.files[0]
},
submit () {
this.dismiss()
this.submitting = true
this.submitHandler(this.file)
.then(() => { this.success = true })
.catch(() => { this.error = true })
.finally(() => { this.submitting = false })
},
dismiss () {
this.success = false
this.error = false
}
}
}
export default Importer

View file

@ -0,0 +1,28 @@
<template>
<div class="importer">
<form>
<input type="file" ref="input" v-on:change="change" />
</form>
<i class="icon-spin4 animate-spin importer-uploading" v-if="submitting"></i>
<button class="btn btn-default" v-else @click="submit">{{submitButtonLabel}}</button>
<div v-if="success">
<i class="icon-cross" @click="dismiss"></i>
<p>{{successMessage}}</p>
</div>
<div v-else-if="error">
<i class="icon-cross" @click="dismiss"></i>
<p>{{errorMessage}}</p>
</div>
</div>
</template>
<script src="./importer.js"></script>
<style lang="scss">
.importer {
&-uploading {
font-size: 1.5em;
margin: 0.25em;
}
}
</style>

View file

@ -31,6 +31,10 @@
&-item-inner {
display: flex;
align-items: center;
> * {
min-width: 0;
}
}
&-item-selected-inner {

View file

@ -13,6 +13,8 @@ import SelectableList from '../selectable_list/selectable_list.vue'
import ProgressButton from '../progress_button/progress_button.vue'
import EmojiInput from '../emoji-input/emoji-input.vue'
import Autosuggest from '../autosuggest/autosuggest.vue'
import Importer from '../importer/importer.vue'
import Exporter from '../exporter/exporter.vue'
import withSubscription from '../../hocs/with_subscription/with_subscription'
import userSearchApi from '../../services/new_api/user_search.js'
@ -40,14 +42,9 @@ const UserSettings = {
hideFollowers: this.$store.state.users.currentUser.hide_followers,
showRole: this.$store.state.users.currentUser.show_role,
role: this.$store.state.users.currentUser.role,
followList: null,
followImportError: false,
followsImported: false,
enableFollowsExport: true,
pickAvatarBtnVisible: true,
bannerUploading: false,
backgroundUploading: false,
followListUploading: false,
bannerPreview: null,
backgroundPreview: null,
bannerUploadError: null,
@ -75,7 +72,9 @@ const UserSettings = {
Autosuggest,
BlockCard,
MuteCard,
ProgressButton
ProgressButton,
Importer,
Exporter
},
computed: {
user () {
@ -110,37 +109,23 @@ const UserSettings = {
},
methods: {
updateProfile () {
const name = this.newName
const description = this.newBio
const locked = this.newLocked
// Backend notation.
/* eslint-disable camelcase */
const default_scope = this.newDefaultScope
const no_rich_text = this.newNoRichText
const hide_follows = this.hideFollows
const hide_followers = this.hideFollowers
const show_role = this.showRole
/* eslint-enable camelcase */
this.$store.state.api.backendInteractor
.updateProfile({
params: {
name,
description,
locked,
note: this.newBio,
locked: this.newLocked,
// Backend notation.
/* eslint-disable camelcase */
default_scope,
no_rich_text,
hide_follows,
hide_followers,
show_role
display_name: this.newName,
default_scope: this.newDefaultScope,
no_rich_text: this.newNoRichText,
hide_follows: this.hideFollows,
hide_followers: this.hideFollowers,
show_role: this.showRole
/* eslint-enable camelcase */
}}).then((user) => {
if (!user.error) {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
}
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
})
},
changeVis (visibility) {
@ -160,23 +145,29 @@ const UserSettings = {
reader.onload = ({target}) => {
const img = target.result
this[slot + 'Preview'] = img
this[slot] = file
}
reader.readAsDataURL(file)
},
submitAvatar (cropper, file) {
let img
if (cropper) {
img = cropper.getCroppedCanvas().toDataURL(file.type)
} else {
img = file
}
const that = this
return new Promise((resolve, reject) => {
function updateAvatar (avatar) {
that.$store.state.api.backendInteractor.updateAvatar({ avatar })
.then((user) => {
that.$store.commit('addNewUsers', [user])
that.$store.commit('setCurrentUser', user)
resolve()
})
.catch((err) => {
reject(new Error(that.$t('upload.error.base') + ' ' + err.message))
})
}
return this.$store.state.api.backendInteractor.updateAvatar({ params: { img } }).then((user) => {
if (!user.error) {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
if (cropper) {
cropper.getCroppedCanvas().toBlob(updateAvatar, file.type)
} else {
throw new Error(this.$t('upload.error.base') + user.error)
updateAvatar(file)
}
})
},
@ -186,30 +177,17 @@ const UserSettings = {
submitBanner () {
if (!this.bannerPreview) { return }
let banner = this.bannerPreview
// eslint-disable-next-line no-undef
let imginfo = new Image()
/* eslint-disable camelcase */
let offset_top, offset_left, width, height
imginfo.src = banner
width = imginfo.width
height = imginfo.height
offset_top = 0
offset_left = 0
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.$store.state.api.backendInteractor.updateBanner({banner: this.banner})
.then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
this.bannerPreview = null
} else {
this.bannerUploadError = this.$t('upload.error.base') + data.error
}
this.bannerUploading = false
})
/* eslint-enable camelcase */
})
.catch((err) => {
this.bannerUploadError = this.$t('upload.error.base') + ' ' + err.message
})
.then(() => { this.bannerUploading = false })
},
submitBg () {
if (!this.backgroundPreview) { return }
@ -236,62 +214,41 @@ const UserSettings = {
this.backgroundUploading = false
})
},
importFollows () {
this.followListUploading = true
const followList = this.followList
this.$store.state.api.backendInteractor.followImport({params: followList})
importFollows (file) {
return this.$store.state.api.backendInteractor.importFollows(file)
.then((status) => {
if (status) {
this.followsImported = true
} else {
this.followImportError = true
if (!status) {
throw new Error('failed')
}
this.followListUploading = false
})
},
/* This function takes an Array of Users
* and outputs a file with all the addresses for the user to download
*/
exportPeople (users, filename) {
// Get all the friends addresses
var UserAddresses = users.map(function (user) {
importBlocks (file) {
return this.$store.state.api.backendInteractor.importBlocks(file)
.then((status) => {
if (!status) {
throw new Error('failed')
}
})
},
generateExportableUsersContent (users) {
// Get addresses
return users.map((user) => {
// check is it's a local user
if (user && user.is_local) {
// append the instance address
// eslint-disable-next-line no-undef
user.screen_name += '@' + location.hostname
return user.screen_name + '@' + location.hostname
}
return user.screen_name
}).join('\n')
// Make the user download the file
var fileToDownload = document.createElement('a')
fileToDownload.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(UserAddresses))
fileToDownload.setAttribute('download', filename)
fileToDownload.style.display = 'none'
document.body.appendChild(fileToDownload)
fileToDownload.click()
document.body.removeChild(fileToDownload)
},
exportFollows () {
this.enableFollowsExport = false
this.$store.state.api.backendInteractor
.exportFriends({
id: this.$store.state.users.currentUser.id
})
.then((friendList) => {
this.exportPeople(friendList, 'friends.csv')
setTimeout(() => { this.enableFollowsExport = true }, 2000)
})
getFollowsContent () {
return this.$store.state.api.backendInteractor.exportFriends({ id: this.$store.state.users.currentUser.id })
.then(this.generateExportableUsersContent)
},
followListChange () {
// eslint-disable-next-line no-undef
let formData = new FormData()
formData.append('list', this.$refs.followlist.files[0])
this.followList = formData
},
dismissImported () {
this.followsImported = false
this.followImportError = false
getBlocksContent () {
return this.$store.state.api.backendInteractor.fetchBlocks()
.then(this.generateExportableUsersContent)
},
confirmDelete () {
this.deletingAccount = true

View file

@ -171,26 +171,20 @@
<div class="setting-item">
<h2>{{$t('settings.follow_import')}}</h2>
<p>{{$t('settings.import_followers_from_a_csv_file')}}</p>
<form>
<input type="file" ref="followlist" v-on:change="followListChange" />
</form>
<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>
<p>{{$t('settings.follows_imported')}}</p>
</div>
<div v-else-if="followImportError">
<i class="icon-cross" @click="dismissImported"></i>
<p>{{$t('settings.follow_import_error')}}</p>
</div>
<Importer :submitHandler="importFollows" :successMessage="$t('settings.follows_imported')" :errorMessage="$t('settings.follow_import_error')" />
</div>
<div class="setting-item" v-if="enableFollowsExport">
<div class="setting-item">
<h2>{{$t('settings.follow_export')}}</h2>
<button class="btn btn-default" @click="exportFollows">{{$t('settings.follow_export_button')}}</button>
<Exporter :getContent="getFollowsContent" filename="friends.csv" :exportButtonLabel="$t('settings.follow_export_button')" />
</div>
<div class="setting-item" v-else>
<h2>{{$t('settings.follow_export_processing')}}</h2>
<div class="setting-item">
<h2>{{$t('settings.block_import')}}</h2>
<p>{{$t('settings.import_blocks_from_a_csv_file')}}</p>
<Importer :submitHandler="importBlocks" :successMessage="$t('settings.blocks_imported')" :errorMessage="$t('settings.block_import_error')" />
</div>
<div class="setting-item">
<h2>{{$t('settings.block_export')}}</h2>
<Exporter :getContent="getBlocksContent" filename="blocks.csv" :exportButtonLabel="$t('settings.block_export_button')" />
</div>
</div>

View file

@ -2,6 +2,10 @@
"chat": {
"title": "Chat"
},
"exporter": {
"export": "Export",
"processing": "Processing, you'll soon be asked to download your file"
},
"features_panel": {
"chat": "Chat",
"gopher": "Gopher",
@ -31,6 +35,11 @@
"save_without_cropping": "Save without cropping",
"cancel": "Cancel"
},
"importer": {
"submit": "Submit",
"success": "Imported successfully.",
"error": "An error occured while importing this file."
},
"login": {
"login": "Log in",
"description": "Log in with OAuth",
@ -126,6 +135,11 @@
"avatarRadius": "Avatars",
"background": "Background",
"bio": "Bio",
"block_export": "Block export",
"block_export_button": "Export your blocks to a csv file",
"block_import": "Block import",
"block_import_error": "Error importing blocks",
"blocks_imported": "Blocks imported! Processing them will take a while.",
"blocks_tab": "Blocks",
"btnRadius": "Buttons",
"cBlue": "Blue (Reply, follow)",
@ -153,7 +167,6 @@
"filtering_explanation": "All statuses containing these words will be muted, one per line",
"follow_export": "Follow export",
"follow_export_button": "Export your follows to a csv file",
"follow_export_processing": "Processing, you'll soon be asked to download your file",
"follow_import": "Follow import",
"follow_import_error": "Error importing followers",
"follows_imported": "Follows imported! Processing them will take a while.",
@ -169,6 +182,7 @@
"hide_post_stats": "Hide post statistics (e.g. the number of favorites)",
"hide_user_stats": "Hide user statistics (e.g. the number of followers)",
"hide_filtered_statuses": "Hide filtered statuses",
"import_blocks_from_a_csv_file": "Import blocks from a csv file",
"import_followers_from_a_csv_file": "Import follows from a csv file",
"import_theme": "Load preset",
"inputRadius": "Input fields",

View file

@ -3,12 +3,10 @@ const LOGIN_URL = '/api/account/verify_credentials.json'
const ALL_FOLLOWING_URL = '/api/qvitter/allfollowing'
const MENTIONS_URL = '/api/statuses/mentions.json'
const REGISTRATION_URL = '/api/account/register.json'
const AVATAR_UPDATE_URL = '/api/qvitter/update_avatar.json'
const BG_UPDATE_URL = '/api/qvitter/update_background_image.json'
const BANNER_UPDATE_URL = '/api/account/update_profile_banner.json'
const PROFILE_UPDATE_URL = '/api/account/update_profile.json'
const EXTERNAL_PROFILE_URL = '/api/externalprofile/show.json'
const QVITTER_USER_NOTIFICATIONS_READ_URL = '/api/qvitter/statuses/notifications/read.json'
const BLOCKS_IMPORT_URL = '/api/pleroma/blocks_import'
const FOLLOW_IMPORT_URL = '/api/pleroma/follow_import'
const DELETE_ACCOUNT_URL = '/api/pleroma/delete_account'
const CHANGE_PASSWORD_URL = '/api/pleroma/change_password'
@ -49,6 +47,7 @@ const MASTODON_MUTE_USER_URL = id => `/api/v1/accounts/${id}/mute`
const MASTODON_UNMUTE_USER_URL = id => `/api/v1/accounts/${id}/unmute`
const MASTODON_POST_STATUS_URL = '/api/v1/statuses'
const MASTODON_MEDIA_UPLOAD_URL = '/api/v1/media'
const MASTODON_PROFILE_UPDATE_URL = '/api/v1/accounts/update_credentials'
import { each, map, concat, last } from 'lodash'
import { parseStatus, parseUser, parseNotification, parseAttachment } from '../entity_normalizer/entity_normalizer.service.js'
@ -78,28 +77,16 @@ const promisedRequest = (url, options) => {
})
}
// Params
// cropH
// cropW
// cropX
// cropY
// img (base 64 encodend data url)
const updateAvatar = ({credentials, params}) => {
let url = AVATAR_UPDATE_URL
const updateAvatar = ({credentials, avatar}) => {
const form = new FormData()
each(params, (value, key) => {
if (value) {
form.append(key, value)
}
})
return fetch(url, {
form.append('avatar', avatar)
return fetch(MASTODON_PROFILE_UPDATE_URL, {
headers: authHeaders(credentials),
method: 'POST',
method: 'PATCH',
body: form
}).then((data) => data.json())
})
.then((data) => data.json())
.then((data) => parseUser(data))
}
const updateBg = ({credentials, params}) => {
@ -120,52 +107,29 @@ const updateBg = ({credentials, params}) => {
}).then((data) => data.json())
}
// Params
// height
// width
// offset_left
// offset_top
// banner (base 64 encodend data url)
const updateBanner = ({credentials, params}) => {
let url = BANNER_UPDATE_URL
const updateBanner = ({credentials, banner}) => {
const form = new FormData()
each(params, (value, key) => {
if (value) {
form.append(key, value)
}
})
return fetch(url, {
form.append('header', banner)
return fetch(MASTODON_PROFILE_UPDATE_URL, {
headers: authHeaders(credentials),
method: 'POST',
method: 'PATCH',
body: form
}).then((data) => data.json())
})
.then((data) => data.json())
.then((data) => parseUser(data))
}
// Params
// name
// url
// location
// description
const updateProfile = ({credentials, params}) => {
// Always include these fields, because they might be empty or false
const fields = ['description', 'locked', 'no_rich_text', 'hide_follows', 'hide_followers', 'show_role']
let url = PROFILE_UPDATE_URL
const form = new FormData()
each(params, (value, key) => {
if (fields.includes(key) || value) {
form.append(key, value)
}
return promisedRequest(MASTODON_PROFILE_UPDATE_URL, {
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
...authHeaders(credentials)
},
method: 'PATCH',
body: JSON.stringify(params)
})
return fetch(url, {
headers: authHeaders(credentials),
method: 'POST',
body: form
}).then((data) => data.json())
.then((data) => parseUser(data))
}
// Params needed:
@ -634,9 +598,22 @@ const uploadMedia = ({formData, credentials}) => {
.then((data) => parseAttachment(data))
}
const followImport = ({params, credentials}) => {
const importBlocks = ({file, credentials}) => {
const formData = new FormData()
formData.append('list', file)
return fetch(BLOCKS_IMPORT_URL, {
body: formData,
method: 'POST',
headers: authHeaders(credentials)
})
.then((response) => response.ok)
}
const importFollows = ({file, credentials}) => {
const formData = new FormData()
formData.append('list', file)
return fetch(FOLLOW_IMPORT_URL, {
body: params,
body: formData,
method: 'POST',
headers: authHeaders(credentials)
})
@ -776,7 +753,8 @@ const apiService = {
updateProfile,
updateBanner,
externalProfile,
followImport,
importBlocks,
importFollows,
deleteAccount,
changePassword,
fetchFollowRequests,

View file

@ -101,13 +101,14 @@ const backendInteractorService = (credentials) => {
const getCaptcha = () => apiService.getCaptcha()
const register = (params) => apiService.register(params)
const updateAvatar = ({params}) => apiService.updateAvatar({credentials, params})
const updateAvatar = ({avatar}) => apiService.updateAvatar({credentials, avatar})
const updateBg = ({params}) => apiService.updateBg({credentials, params})
const updateBanner = ({params}) => apiService.updateBanner({credentials, params})
const updateBanner = ({banner}) => apiService.updateBanner({credentials, banner})
const updateProfile = ({params}) => apiService.updateProfile({credentials, params})
const externalProfile = (profileUrl) => apiService.externalProfile({profileUrl, credentials})
const followImport = ({params}) => apiService.followImport({params, credentials})
const importBlocks = (file) => apiService.importBlocks({file, credentials})
const importFollows = (file) => apiService.importFollows({file, credentials})
const deleteAccount = ({password}) => apiService.deleteAccount({credentials, password})
const changePassword = ({password, newPassword, newPasswordConfirmation}) => apiService.changePassword({credentials, password, newPassword, newPasswordConfirmation})
@ -147,7 +148,8 @@ const backendInteractorService = (credentials) => {
updateBanner,
updateProfile,
externalProfile,
followImport,
importBlocks,
importFollows,
deleteAccount,
changePassword,
fetchFollowRequests,