698 lines
16 KiB
Vue
698 lines
16 KiB
Vue
<!--
|
|
- @copyright Copyright (c) 2018 Team Popcorn <teampopcornberlin@gmail.com>
|
|
-
|
|
- @author Team Popcorn <teampopcornberlin@gmail.com>
|
|
- @author John Molakvoæ <skjnldsv@protonmail.com>
|
|
- @author Matthias Heinisch <nextcloud@matthiasheinisch.de>
|
|
-
|
|
- @license GNU AGPL version 3 or any later version
|
|
-
|
|
- This program is free software: you can redistribute it and/or modify
|
|
- it under the terms of the GNU Affero General Public License as
|
|
- published by the Free Software Foundation, either version 3 of the
|
|
- License, or (at your option) any later version.
|
|
-
|
|
- This program is distributed in the hope that it will be useful,
|
|
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
- GNU Affero General Public License for more details.
|
|
-
|
|
- You should have received a copy of the GNU Affero General Public License
|
|
- along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
-
|
|
-->
|
|
|
|
<template>
|
|
<div v-click-outside="closeMenu" class="contact-header-avatar__wrapper">
|
|
<input id="contact-avatar-upload"
|
|
ref="uploadInput"
|
|
type="file"
|
|
class="hidden"
|
|
accept="image/*"
|
|
@change="handleUploadedFile">
|
|
|
|
<!-- Avatar display -->
|
|
<Avatar :disable-tooltip="true"
|
|
:display-name="contact.displayName"
|
|
:is-no-user="true"
|
|
:size="75"
|
|
:url="photoUrl"
|
|
class="contact-header-avatar__photo" />
|
|
|
|
<NcModal :show.sync="showCropper" @close="cancel" size="small">
|
|
<div class="avatar__container">
|
|
<h2>{{ t('contacts', 'Crop contact photo') }}</h2>
|
|
<VueCropper ref="cropper"
|
|
class="avatar__cropper"
|
|
v-bind="cropperOptions" />
|
|
<div class="avatar__cropper-buttons">
|
|
<NcButton type="tertiary" @click="cancel">
|
|
{{ t('contacts', 'Cancel') }}
|
|
</NcButton>
|
|
<NcButton type="primary"
|
|
@click="saveAvatar">
|
|
{{ t('contacts', 'Save') }}
|
|
</NcButton>
|
|
</div>
|
|
</div>
|
|
</NcModal>
|
|
|
|
<Actions v-if="!isReadOnly || contact.photo"
|
|
:force-menu="true"
|
|
:open.sync="opened"
|
|
class="contact-header-avatar__menu">
|
|
<template #icon>
|
|
<IconImage :size="20" fill-color="#fff" />
|
|
</template>
|
|
<template v-if="!isReadOnly">
|
|
<ActionButton @click.stop.prevent="selectFileInput">
|
|
<template #icon>
|
|
<IconUpload :size="20" />
|
|
</template>
|
|
{{ t('contacts', 'Upload a new picture') }}
|
|
</ActionButton>
|
|
<ActionButton @click="selectFilePicker">
|
|
<template #icon>
|
|
<IconFolder :size="20" />
|
|
</template>
|
|
{{ t('contacts', 'Choose from Files') }}
|
|
</ActionButton>
|
|
<ActionButton v-for="network in supportedSocial"
|
|
:key="network"
|
|
@click="getSocialAvatar(network)">
|
|
<template #icon>
|
|
<IconCloudDownload :size="20" />
|
|
</template>
|
|
{{ t('contacts', 'Get from ' + network) }}
|
|
</ActionButton>
|
|
</template>
|
|
|
|
<template v-if="contact.photo">
|
|
<!-- FIXME: the link seems to have a bigger font size than the button caption -->
|
|
<ActionLink :href="`${contact.url}?photo`"
|
|
target="_blank">
|
|
<template #icon>
|
|
<IconDownload :size="20" />
|
|
</template>
|
|
{{ t('contacts', 'Download picture') }}
|
|
</ActionLink>
|
|
<ActionButton v-if="!isReadOnly"
|
|
@click="removePhoto">
|
|
<template #icon>
|
|
<IconDelete :size="20" />
|
|
</template>
|
|
{{ t('contacts', 'Delete picture') }}
|
|
</ActionButton>
|
|
</template>
|
|
</Actions>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import Avatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
|
|
import Actions from '@nextcloud/vue/dist/Components/NcActions.js'
|
|
import ActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
|
|
import ActionLink from '@nextcloud/vue/dist/Components/NcActionLink.js'
|
|
import IconDownload from 'vue-material-design-icons/Download.vue'
|
|
import IconCloudDownload from 'vue-material-design-icons/CloudDownload.vue'
|
|
import IconDelete from 'vue-material-design-icons/Delete.vue'
|
|
import IconUpload from 'vue-material-design-icons/Upload.vue'
|
|
import IconFolder from 'vue-material-design-icons/Folder.vue'
|
|
import IconImage from 'vue-material-design-icons/Image.vue'
|
|
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
|
|
import VueCropper from 'vue-cropperjs'
|
|
import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
|
|
// eslint-disable-next-line n/no-extraneous-import
|
|
import 'cropperjs/dist/cropper.css'
|
|
|
|
import { showError, showInfo, getFilePickerBuilder, showSuccess } from '@nextcloud/dialogs'
|
|
import { generateUrl, generateRemoteUrl } from '@nextcloud/router'
|
|
import { getCurrentUser } from '@nextcloud/auth'
|
|
import { loadState } from '@nextcloud/initial-state'
|
|
import sanitizeSVG from '@mattkrick/sanitize-svg'
|
|
|
|
import axios from '@nextcloud/axios'
|
|
|
|
const supportedNetworks = loadState('contacts', 'supportedNetworks')
|
|
|
|
export default {
|
|
name: 'ContactDetailsAvatar',
|
|
|
|
components: {
|
|
ActionButton,
|
|
ActionLink,
|
|
Actions,
|
|
Avatar,
|
|
IconCloudDownload,
|
|
IconDownload,
|
|
IconDelete,
|
|
IconUpload,
|
|
IconFolder,
|
|
IconImage,
|
|
NcButton,
|
|
VueCropper,
|
|
NcModal,
|
|
},
|
|
|
|
props: {
|
|
contact: {
|
|
type: Object,
|
|
required: true,
|
|
},
|
|
},
|
|
|
|
data() {
|
|
return {
|
|
opened: false,
|
|
loading: false,
|
|
photoUrl: undefined,
|
|
root: generateRemoteUrl(`dav/files/${getCurrentUser().uid}`),
|
|
showCropper: false,
|
|
cropperOptions: {
|
|
aspectRatio: 1 / 1,
|
|
viewMode: 3,
|
|
guides: false,
|
|
center: false,
|
|
highlight: false,
|
|
autoCropArea: 1,
|
|
dragMode: 'move',
|
|
minContainerWidth: 100,
|
|
minContainerHeight: 100,
|
|
},
|
|
}
|
|
},
|
|
|
|
computed: {
|
|
isReadOnly() {
|
|
if (this.contact.addressbook) {
|
|
return this.contact.addressbook.readOnly
|
|
}
|
|
return false
|
|
},
|
|
supportedSocial() {
|
|
const emails = this.contact.vCard.getAllProperties('email')
|
|
// get social networks set for the current contact
|
|
const availableSocial = this.contact.vCard.getAllProperties('x-socialprofile')
|
|
.map(a => a.jCal[1].type.toString().toLowerCase())
|
|
const availableMessenger = this.contact.vCard.getAllProperties('impp')
|
|
.map(a => a.jCal[1].type.toString().toLowerCase())
|
|
const available = [].concat(availableSocial, availableMessenger)
|
|
// get list of social networks that allow for avatar download
|
|
const supported = supportedNetworks.map(v => v.toLowerCase())
|
|
if (emails.length) {
|
|
available.push('gravatar')
|
|
}
|
|
// return supported social networks which are set
|
|
return supported.filter(i => available.includes(i))
|
|
.map(j => this.capitalize(j))
|
|
},
|
|
},
|
|
|
|
watch: {
|
|
async contact() {
|
|
await this.loadPhotoUrl()
|
|
},
|
|
},
|
|
|
|
async mounted() {
|
|
await this.loadPhotoUrl()
|
|
},
|
|
|
|
methods: {
|
|
onLoad() {
|
|
console.debug(...arguments)
|
|
},
|
|
|
|
/**
|
|
* Checks the selected image for mimetype
|
|
* and open the cropper if valid, else show error
|
|
*
|
|
* @param {Buffer} data the image
|
|
* @return {boolean}
|
|
*/
|
|
async processPicture(data) {
|
|
|
|
const type = this.getMimetype(data)
|
|
|
|
if (!type.startsWith('image/')) {
|
|
showError(t('contacts', 'Please select a valid format'))
|
|
return false
|
|
}
|
|
|
|
if (type === 'image/svg') {
|
|
const imageSvg = atob(data.toString('base64'))
|
|
const cleanSvg = await sanitizeSVG(imageSvg)
|
|
if (!cleanSvg) {
|
|
throw new Error('Unsafe svg image', imageSvg)
|
|
}
|
|
}
|
|
|
|
this.openCropper(data, type)
|
|
return true
|
|
},
|
|
|
|
/**
|
|
* Open the cropper-modal with the provided data
|
|
*
|
|
* @param {Buffer} data the image
|
|
* @param {string} type of the image
|
|
*/
|
|
openCropper(data, type) {
|
|
const ccc = `data:${type};base64,${data.toString('base64')}`
|
|
this.$refs.cropper.replace(ccc)
|
|
this.showCropper = true
|
|
},
|
|
|
|
/**
|
|
* Handle the uploaded file
|
|
*
|
|
* @param {object} event the event object containing the image
|
|
*/
|
|
handleUploadedFile(event) {
|
|
if (event.target.files && !this.loading) {
|
|
this.closeMenu()
|
|
|
|
const file = event.target.files[0]
|
|
|
|
const reader = new FileReader()
|
|
|
|
reader.onload = (e) => {
|
|
try {
|
|
if (typeof e.target.result === 'object') {
|
|
|
|
const data = Buffer.from(e.target.result, 'binary')
|
|
|
|
if (this.processPicture(data)) {
|
|
return
|
|
}
|
|
|
|
throw new Error('Wrong image mimetype')
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error(error)
|
|
showError(t('contacts', 'Invalid image'))
|
|
} finally {
|
|
this.resetPicker()
|
|
}
|
|
}
|
|
|
|
reader.readAsArrayBuffer(file)
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Reset image pciker input
|
|
*/
|
|
resetPicker() {
|
|
// reset input
|
|
this.$refs.uploadInput.value = ''
|
|
this.loading = false
|
|
},
|
|
/**
|
|
* Return the word with (only) the first letter capitalized
|
|
*
|
|
* @param {string} word the word to handle
|
|
* @return {string} the word with the first letter capitalized
|
|
*/
|
|
capitalize(word) {
|
|
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
|
},
|
|
/**
|
|
* Return the mimetype based on the first 4 byte
|
|
*
|
|
* @param {Uint8Array} uint content
|
|
* @return {string} the mimetype
|
|
*/
|
|
getMimetype(uint) {
|
|
const bytes = []
|
|
uint.slice(0, 4).forEach((byte) => {
|
|
bytes.push(byte.toString(16))
|
|
})
|
|
const hex = bytes.join('').toUpperCase()
|
|
|
|
switch (hex) {
|
|
case '89504E47':
|
|
return 'image/png'
|
|
case '47494638':
|
|
return 'image/gif'
|
|
case '3C3F786D':
|
|
case '3C737667':
|
|
return 'image/svg+xml'
|
|
case 'FFD8FFDB':
|
|
case 'FFD8FFE0':
|
|
case 'FFD8FFE1':
|
|
return 'image/jpeg'
|
|
default:
|
|
return 'application/octet-stream'
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Update the contact photo
|
|
*
|
|
* @param {string} data the photo as base64 binary string
|
|
* @param {string} type mimetype
|
|
*/
|
|
setPhoto(data, type) {
|
|
// Init with empty data
|
|
if (this.contact.photo) {
|
|
this.contact.vCard.addPropertyWithValue('photo', '')
|
|
}
|
|
|
|
// Vcard 3 and 4 have different syntax
|
|
// https://tools.ietf.org/html/rfc2426#page-11
|
|
if (this.contact.version === '3.0') {
|
|
// eslint-disable-next-line vue/no-mutating-props
|
|
this.contact.photo = data
|
|
|
|
const photo = this.contact.vCard.getFirstProperty('photo')
|
|
photo.setParameter('encoding', 'b')
|
|
if (type) {
|
|
photo.setParameter('type', type.split('/').pop())
|
|
}
|
|
} else {
|
|
// https://tools.ietf.org/html/rfc6350#section-6.2.4
|
|
// eslint-disable-next-line vue/no-mutating-props
|
|
this.contact.photo = `data:${type};base64,${data}`
|
|
}
|
|
|
|
this.$store.dispatch('updateContact', this.contact)
|
|
this.loading = false
|
|
},
|
|
|
|
async loadPhotoUrl() {
|
|
this.photoUrl = undefined
|
|
if (this.contact.photo) {
|
|
const photoUrl = await this.contact.getPhotoUrl()
|
|
if (!photoUrl) {
|
|
console.warn('contact has an invalid photo')
|
|
return
|
|
}
|
|
this.photoUrl = photoUrl
|
|
} else if (this.contact.url) {
|
|
this.photoUrl = `${this.contact.url}?photo`
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Save the cropped image
|
|
*/
|
|
saveAvatar() {
|
|
this.showCropper = false
|
|
this.loading = true
|
|
|
|
this.$refs.cropper.getCroppedCanvas({
|
|
minWidth: 16,
|
|
minHeight: 16,
|
|
maxWidth: 512,
|
|
maxHeight: 512,
|
|
}).toBlob(async (blob) => {
|
|
if (blob === null) {
|
|
showError(t('contacts', 'Error cropping picture'))
|
|
this.cancel()
|
|
return
|
|
}
|
|
|
|
const reader = new FileReader()
|
|
reader.readAsDataURL(blob)
|
|
reader.onloadend = () => {
|
|
const base64data = reader.result
|
|
this.setPhoto(base64data.split(',').pop(), blob.type)
|
|
}
|
|
})
|
|
},
|
|
|
|
/**
|
|
* Remove the contact's picture
|
|
*/
|
|
removePhoto() {
|
|
this.contact.vCard.removeAllProperties('photo')
|
|
this.$store.dispatch('updateContact', this.contact)
|
|
},
|
|
|
|
/**
|
|
* Cancel cropping
|
|
*/
|
|
cancel() {
|
|
this.showCropper = false
|
|
this.loading = false
|
|
},
|
|
|
|
/**
|
|
* Picker handlers Upload
|
|
*/
|
|
selectFileInput() {
|
|
if (!this.loading) {
|
|
this.$refs.uploadInput.click()
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Picker handlers from Files
|
|
*/
|
|
async selectFilePicker() {
|
|
if (!this.loading) {
|
|
this.closeMenu()
|
|
|
|
const picker = getFilePickerBuilder(t('contacts', 'Pick an avatar'))
|
|
.setMimeTypeFilter([
|
|
'image/png',
|
|
'image/jpeg',
|
|
'image/gif',
|
|
'image/x-xbitmap',
|
|
'image/bmp',
|
|
'image/svg+xml',
|
|
])
|
|
.build()
|
|
|
|
const file = await picker.pick()
|
|
|
|
if (file) {
|
|
this.loading = true
|
|
try {
|
|
|
|
const response = await axios.get(`${this.root}${file}`, {
|
|
responseType: 'arraybuffer',
|
|
})
|
|
|
|
const data = Buffer.from(response.data, 'binary')
|
|
|
|
this.processPicture(data)
|
|
|
|
} catch (error) {
|
|
showError(t('contacts', 'Error while processing the picture.'))
|
|
console.error(error)
|
|
this.loading = false
|
|
} finally {
|
|
this.resetPicker()
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Downloads the Avatar from social media
|
|
*
|
|
* @param {string} network the social network to use (or 'any' for first match)
|
|
*/
|
|
async getSocialAvatar(network) {
|
|
|
|
if (!this.loading) {
|
|
|
|
this.loading = true
|
|
try {
|
|
const response = await axios.put(generateUrl('/apps/contacts/api/v1/social/avatar/{network}/{id}/{uid}', {
|
|
network: network.toLowerCase(),
|
|
id: this.contact.addressbook.id,
|
|
uid: this.contact.uid,
|
|
}))
|
|
if (response?.status !== 200) {
|
|
throw new URIError('Download of social profile avatar failed')
|
|
}
|
|
|
|
// Fetch newly updated contact
|
|
await this.$store.dispatch('fetchFullContact', { contact: this.contact, forceReFetch: true })
|
|
|
|
// Update local clone
|
|
const contact = this.$store.getters.getContact(this.contact.key)
|
|
await this.$emit('update-local-contact', contact)
|
|
|
|
// Notify user
|
|
showSuccess(t('contacts', 'Avatar downloaded from social network'))
|
|
} catch (error) {
|
|
if (error?.response?.status === 304) {
|
|
showInfo(t('contacts', 'Avatar already up to date'))
|
|
} else {
|
|
showError(t('contacts', 'Avatar download failed'))
|
|
console.debug(error)
|
|
}
|
|
}
|
|
}
|
|
this.loading = false
|
|
},
|
|
|
|
closeMenu() {
|
|
this.opened = false
|
|
},
|
|
|
|
},
|
|
|
|
}
|
|
</script>
|
|
<style lang="scss" scoped>
|
|
.avatar__container {
|
|
padding: 24px;
|
|
}
|
|
|
|
.avatar {
|
|
&__container {
|
|
margin: 0 auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: center;
|
|
gap: 16px 0;
|
|
width: 300px;
|
|
|
|
span {
|
|
color: var(--color-text-lighter);
|
|
}
|
|
}
|
|
|
|
&__preview {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
width: 180px;
|
|
height: 180px;
|
|
}
|
|
|
|
&__buttons {
|
|
display: flex;
|
|
gap: 0 10px;
|
|
}
|
|
|
|
&__cropper {
|
|
overflow: hidden;
|
|
|
|
&-buttons {
|
|
width: 100%;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
&::v-deep .cropper-view-box {
|
|
border-radius: 50%;
|
|
}
|
|
}
|
|
}
|
|
|
|
.contact-header-avatar {
|
|
// Wrap and cut
|
|
&__wrapper {
|
|
position: relative;
|
|
width: var(--avatar-size);
|
|
height: var(--avatar-size);
|
|
}
|
|
&__background {
|
|
z-index: 0;
|
|
top: 50px;
|
|
left: 0;
|
|
opacity: .2;
|
|
}
|
|
|
|
&__photo,
|
|
&__menu {
|
|
overflow: hidden;
|
|
width: 100%;
|
|
height: 100%;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
&__photo {
|
|
z-index: 10;
|
|
cursor: pointer;
|
|
// White background for avatars with transparency, also in dark theme
|
|
background-color: #fff;
|
|
background-repeat: no-repeat;
|
|
background-position: center;
|
|
background-size: cover;
|
|
}
|
|
|
|
&__menu {
|
|
z-index: 11;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background-color: rgba(0, 0, 0, .2);
|
|
// Always show max opacity, let the background-color be the visual cue
|
|
&::v-deep .action-item__menutoggle {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
// Move the menu in the bottom right if there is a picture already
|
|
&__photo + &__menu {
|
|
position: absolute !important;
|
|
// bottom right
|
|
top: 100%;
|
|
left: 100%;
|
|
width: 44px;
|
|
height: 44px;
|
|
margin: -50%;
|
|
&::v-deep {
|
|
.action-item__menutoggle {
|
|
opacity: .7;
|
|
background-color: rgba(0, 0, 0, .2);
|
|
}
|
|
&.action-item--open .action-item__menutoggle,
|
|
.action-item__menutoggle:hover,
|
|
.action-item__menutoggle:active,
|
|
.action-item__menutoggle:focus {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.contact-header-modal {
|
|
// We use this nesting of containers and max/width-height
|
|
// to make automatically contain the image.
|
|
// Because of that, we now fill the modal-container,
|
|
// so we need to watch for click on the photo-wrapper to
|
|
// close on image click outside.
|
|
&::v-deep .modal-container {
|
|
background-color: transparent;
|
|
box-shadow: none;
|
|
|
|
&,
|
|
.contact-header-modal__photo-wrapper {
|
|
// center and align nested containers & image
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.contact-header-modal__photo-wrapper {
|
|
// contain image
|
|
width: 100%;
|
|
height: 100%;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.contact-header-modal__photo {
|
|
// preserve ratio
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
// animate zooming/resize
|
|
transition: height 100ms ease,
|
|
width 100ms ease;
|
|
border-radius: var(--border-radius-large);
|
|
// make sure transparent images are visible
|
|
background-color: white;
|
|
}
|
|
}
|
|
}
|
|
|
|
</style>
|