diff --git a/package-lock.json b/package-lock.json index f75e7268e4..70947f0482 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@ckeditor/ckeditor5-ui": "37.1.0", "@ckeditor/ckeditor5-upload": "37.1.0", "@ckeditor/ckeditor5-vue2": "^3.0.1", + "@mattkrick/sanitize-svg": "^0.4.0", "@mdi/svg": "^7.4.47", "@nextcloud/auth": "^2.3.0", "@nextcloud/axios": "^2.5.0", @@ -48,11 +49,14 @@ "@riophae/vue-treeselect": "^0.4.0", "@vue/babel-preset-app": "^5.0.8", "address-rfc2822": "^2.2.2", + "b64-to-blob": "^1.2.19", "color-convert": "^2.0.1", "core-js": "^3.37.1", + "debounce": "^2.1.0", "debounce-promise": "^3.1.2", "dompurify": "^3.1.6", "html-to-text": "^9.0.5", + "ical": "^0.8.0", "ical.js": "^1.5.0", "iframe-resizer": "^4.4.5", "js-base64": "^3.7.7", @@ -3928,6 +3932,17 @@ "unist-util-is": "^3.0.0" } }, + "node_modules/@mattkrick/sanitize-svg": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@mattkrick/sanitize-svg/-/sanitize-svg-0.4.0.tgz", + "integrity": "sha512-TnPI97WVAxo8SQcPy8aV3OF9/2WjXB5/+pRNVudIWR7Bhi5ZjtR/ur162So08GkvsvB914AXCW2sxFh1x6KhHA==", + "engines": { + "node": ">=6.0.0" + }, + "peerDependencies": { + "tslib": "^1.9.3" + } + }, "node_modules/@mdi/js": { "version": "7.4.47", "resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz", @@ -6926,6 +6941,11 @@ "form-data": "^4.0.0" } }, + "node_modules/b64-to-blob": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/b64-to-blob/-/b64-to-blob-1.2.19.tgz", + "integrity": "sha512-L3nSu8GgF4iEyNYakCQSfL2F5GI5aCXcot9mNTf+4N0/BMhpxqqHyOb6jIR24iq2xLjQZLG8FOt3gnUcV+9NVg==" + }, "node_modules/babel-helper-vue-jsx-merge-props": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-2.0.3.tgz", @@ -8685,7 +8705,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/debounce/-/debounce-2.1.0.tgz", "integrity": "sha512-OkL3+0pPWCqoBc/nhO9u6TIQNTK44fnBnzuVtJAbp13Naxw9R6u21x+8tVTka87AhDZ3htqZ2pSSsZl9fqL2Wg==", - "license": "MIT", "engines": { "node": ">=18" }, @@ -11318,6 +11337,14 @@ "node": ">=10.17.0" } }, + "node_modules/ical": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/ical/-/ical-0.8.0.tgz", + "integrity": "sha512-/viUSb/RGLLnlgm0lWRlPBtVeQguQRErSPYl3ugnUaKUnzQswKqOG3M8/P1v1AB5NJwlHTuvTq1cs4mpeG2rCg==", + "dependencies": { + "rrule": "2.4.1" + } + }, "node_modules/ical.js": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/ical.js/-/ical.js-1.5.0.tgz", @@ -13622,6 +13649,15 @@ "license": "MIT", "peer": true }, + "node_modules/luxon": { + "version": "1.28.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.28.1.tgz", + "integrity": "sha512-gYHAa180mKrNIUJCbwpmD0aTu9kV0dREDrwNnuyFAsO1Wt0EVYSZelPnJlbj9HplzXX/YWXHFTL45kvZ53M0pw==", + "optional": true, + "engines": { + "node": "*" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -17266,6 +17302,14 @@ "inherits": "^2.0.1" } }, + "node_modules/rrule": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.4.1.tgz", + "integrity": "sha512-+NcvhETefswZq13T8nkuEnnQ6YgUeZaqMqVbp+ZiFDPCbp3AVgQIwUvNVDdMNrP05bKZG9ddDULFp0qZZYDrxg==", + "optionalDependencies": { + "luxon": "^1.3.3" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -22782,6 +22826,12 @@ } } }, + "@mattkrick/sanitize-svg": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@mattkrick/sanitize-svg/-/sanitize-svg-0.4.0.tgz", + "integrity": "sha512-TnPI97WVAxo8SQcPy8aV3OF9/2WjXB5/+pRNVudIWR7Bhi5ZjtR/ur162So08GkvsvB914AXCW2sxFh1x6KhHA==", + "requires": {} + }, "@mdi/js": { "version": "7.4.47", "resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz", @@ -24976,6 +25026,11 @@ "form-data": "^4.0.0" } }, + "b64-to-blob": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/b64-to-blob/-/b64-to-blob-1.2.19.tgz", + "integrity": "sha512-L3nSu8GgF4iEyNYakCQSfL2F5GI5aCXcot9mNTf+4N0/BMhpxqqHyOb6jIR24iq2xLjQZLG8FOt3gnUcV+9NVg==" + }, "babel-helper-vue-jsx-merge-props": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-2.0.3.tgz", @@ -28208,6 +28263,14 @@ "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", "dev": true }, + "ical": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/ical/-/ical-0.8.0.tgz", + "integrity": "sha512-/viUSb/RGLLnlgm0lWRlPBtVeQguQRErSPYl3ugnUaKUnzQswKqOG3M8/P1v1AB5NJwlHTuvTq1cs4mpeG2rCg==", + "requires": { + "rrule": "2.4.1" + } + }, "ical.js": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/ical.js/-/ical.js-1.5.0.tgz", @@ -29886,6 +29949,12 @@ "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", "peer": true }, + "luxon": { + "version": "1.28.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.28.1.tgz", + "integrity": "sha512-gYHAa180mKrNIUJCbwpmD0aTu9kV0dREDrwNnuyFAsO1Wt0EVYSZelPnJlbj9HplzXX/YWXHFTL45kvZ53M0pw==", + "optional": true + }, "make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -32354,6 +32423,14 @@ "inherits": "^2.0.1" } }, + "rrule": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.4.1.tgz", + "integrity": "sha512-+NcvhETefswZq13T8nkuEnnQ6YgUeZaqMqVbp+ZiFDPCbp3AVgQIwUvNVDdMNrP05bKZG9ddDULFp0qZZYDrxg==", + "requires": { + "luxon": "^1.3.3" + } + }, "run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", diff --git a/package.json b/package.json index 406877c433..183b5e4365 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@ckeditor/ckeditor5-ui": "37.1.0", "@ckeditor/ckeditor5-upload": "37.1.0", "@ckeditor/ckeditor5-vue2": "^3.0.1", + "@mattkrick/sanitize-svg": "^0.4.0", "@mdi/svg": "^7.4.47", "@nextcloud/auth": "^2.3.0", "@nextcloud/axios": "^2.5.0", @@ -54,11 +55,14 @@ "@riophae/vue-treeselect": "^0.4.0", "@vue/babel-preset-app": "^5.0.8", "address-rfc2822": "^2.2.2", + "b64-to-blob": "^1.2.19", "color-convert": "^2.0.1", "core-js": "^3.37.1", + "debounce": "^2.1.0", "debounce-promise": "^3.1.2", "dompurify": "^3.1.6", "html-to-text": "^9.0.5", + "ical": "^0.8.0", "ical.js": "^1.5.0", "iframe-resizer": "^4.4.5", "js-base64": "^3.7.7", diff --git a/src/components/NewMessageModal.vue b/src/components/NewMessageModal.vue index 0beb0d2dcf..2acb1c61b1 100644 --- a/src/components/NewMessageModal.vue +++ b/src/components/NewMessageModal.vue @@ -4,7 +4,7 @@ --> - - + @@ -138,6 +146,7 @@ import DefaultComposerIcon from 'vue-material-design-icons/ArrowCollapse.vue' import { deleteDraft, saveDraft, updateDraft } from '../service/DraftService.js' import useOutboxStore from '../store/outboxStore.js' import { mapStores } from 'pinia' +import RecipientInfo from './RecipientInfo.vue' export default { name: 'NewMessageModal', @@ -150,6 +159,7 @@ export default { MinimizeIcon, MaximizeIcon, DefaultComposerIcon, + RecipientInfo, }, props: { accounts: { @@ -174,6 +184,10 @@ export default { cookedComposerData: undefined, changed: false, largerModal: false, + recipient: { + name: '', + email: '', + }, } }, computed: { @@ -206,6 +220,9 @@ export default { smartReply() { return this.composerData?.smartReply ?? null }, + modalSize() { + return this.composerData.to && this.composerData.to.length > 0 ? 'full' : (this.largerModal ? 'large' : 'normal') + }, }, created() { const id = this.composerData?.id @@ -591,4 +608,30 @@ export default { height: 100%; display: flex; } +.modal-content { + display: flex; + height: 100%; + flex-direction: row; + width: 100%; +} + +.left-pane { + flex: 1; + overflow-y: auto; +} + +.right-pane { + flex: 0 0 400px; + overflow-y: auto; + padding-left: 5px; + border-left: 1px solid #ccc; +} + +.modal-content.with-recipient .left-pane { + flex: 1; + width: calc(100% - 400px); +} +.modal-content .left-pane { + width: 100%; +} diff --git a/src/components/RecipientInfo.vue b/src/components/RecipientInfo.vue new file mode 100644 index 0000000000..279275a46e --- /dev/null +++ b/src/components/RecipientInfo.vue @@ -0,0 +1,204 @@ + + + + + diff --git a/src/nextcloud-contacts/DetailsHeaderRecipient.vue b/src/nextcloud-contacts/DetailsHeaderRecipient.vue new file mode 100644 index 0000000000..d193c29485 --- /dev/null +++ b/src/nextcloud-contacts/DetailsHeaderRecipient.vue @@ -0,0 +1,109 @@ + + + + + + + diff --git a/src/nextcloud-contacts/PropertyMixingRecipient.js b/src/nextcloud-contacts/PropertyMixingRecipient.js new file mode 100644 index 0000000000..11a9196119 --- /dev/null +++ b/src/nextcloud-contacts/PropertyMixingRecipient.js @@ -0,0 +1,151 @@ +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import debounce from 'debounce' +import Contact from './contact.js' +import { setPropertyAlias } from './updateDesignSet.js' + +export default { + props: { + // Default property type. e.g. "WORK,HOME" + selectType: { + type: [Object], + default: () => {}, + }, + // Coming from the rfcProps Model + propModel: { + type: Object, + default: () => {}, + required: true, + }, + propType: { + type: String, + default: 'text', + }, + // The current property passed as Object + property: { + type: Object, + default: () => {}, + required: true, + }, + // Allows us to know if we need to + // add the property header or not + isFirstProperty: { + type: Boolean, + default: true, + }, + // Allows us to know if we need to + // add an extra space at the end + isLastProperty: { + type: Boolean, + default: true, + }, + // Is it read-only? + isReadOnly: { + type: Boolean, + required: true, + }, + // The available TYPE options from the propModel + // not used on the PropertySelect + options: { + type: Array, + default: () => [], + }, + localContact: { + type: Contact, + default: null, + }, + isMultiple: { + type: Boolean, + default: false, + }, + bus: { + type: Object, + required: false, + }, + }, + + data() { + return { + // INIT data when the contact change. + // This is a simple copy that we can update as + // many times as we can and debounce-fire the update + // later + localValue: this.value, + localType: this.selectType, + } + }, + + computed: { + actions() { + return this.propModel.actions ? this.propModel.actions : [] + }, + haveAction() { + return this.actions && this.actions.length > 0 + }, + }, + + watch: { + /** + * Since we're updating a local data based on the value prop, + * we need to make sure to update the local data on contact change + * in case the v-Node is reused. + */ + value() { + this.localValue = this.value + }, + selectType() { + this.localType = this.selectType + }, + }, + + methods: { + /** + * Delete the property + */ + deleteProperty() { + this.$emit('delete') + }, + + /** + * Debounce and send update event to parent + */ + updateValue: debounce(function(e) { + // https://vuejs.org/v2/guide/components-custom-events.html#sync-Modifier + this.$emit('update:value', this.localValue) + }, 500), + + updateType: debounce(function(e) { + // https://vuejs.org/v2/guide/components-custom-events.html#sync-Modifier + this.$emit('update:selectType', this.localType) + }, 500), + + createLabel(label) { + let propGroup = this.property.name + if (!this.property.name.startsWith('nextcloud')) { + propGroup = `nextcloud${this.getNcGroupCount() + 1}.${this.property.name}` + this.property.jCal[0] = propGroup + } + const group = propGroup.split('.')[0] + const name = propGroup.split('.')[1] + + this.localContact.vCard.addPropertyWithValue(`${group}.x-ablabel`, label.name) + + // force update the main design sets + setPropertyAlias(name, propGroup) + + this.$emit('update') + }, + + getNcGroupCount() { + const props = this.localContact.jCal[1] + .map(prop => prop[0].split('.')[0]) // itemxxx.adr => itemxxx + .filter(name => name.startsWith('nextcloud')) // filter nextcloudxxx.adr + .map(prop => parseInt(prop.split('nextcloud')[1])) // nextcloudxxx => xxx + return props.length > 0 + ? Math.max.apply(null, props) // get max iteration of nextcloud grouped props + : 0 + }, + }, +} diff --git a/src/nextcloud-contacts/RecipientDetails.vue b/src/nextcloud-contacts/RecipientDetails.vue new file mode 100644 index 0000000000..bad048e886 --- /dev/null +++ b/src/nextcloud-contacts/RecipientDetails.vue @@ -0,0 +1,429 @@ + + + + + + + diff --git a/src/nextcloud-contacts/RecipientDetailsProperty.vue b/src/nextcloud-contacts/RecipientDetailsProperty.vue new file mode 100644 index 0000000000..9ff7e7f059 --- /dev/null +++ b/src/nextcloud-contacts/RecipientDetailsProperty.vue @@ -0,0 +1,339 @@ + + + + + diff --git a/src/nextcloud-contacts/RecipientPropertyText.vue b/src/nextcloud-contacts/RecipientPropertyText.vue new file mode 100644 index 0000000000..28634c08e6 --- /dev/null +++ b/src/nextcloud-contacts/RecipientPropertyText.vue @@ -0,0 +1,181 @@ + + + + + + + diff --git a/src/nextcloud-contacts/RecipientPropertyTitle.vue b/src/nextcloud-contacts/RecipientPropertyTitle.vue new file mode 100644 index 0000000000..a4aed39d40 --- /dev/null +++ b/src/nextcloud-contacts/RecipientPropertyTitle.vue @@ -0,0 +1,76 @@ + + + + + + + diff --git a/src/nextcloud-contacts/contact.js b/src/nextcloud-contacts/contact.js new file mode 100644 index 0000000000..89fe8b6e14 --- /dev/null +++ b/src/nextcloud-contacts/contact.js @@ -0,0 +1,571 @@ +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { v4 as uuid } from 'uuid' +import ICAL from 'ical.js' +import b64toBlob from 'b64-to-blob' + +import updateDesignSet from './updateDesignSet.js' +import sanitizeSVG from '@mattkrick/sanitize-svg' + +/** + * Check if the given value is an empty array or an empty string + * + * @param {string|Array} value the value to check + * @return {boolean} + */ +const isEmpty = value => { + return (Array.isArray(value) && value.join('') === '') || (!Array.isArray(value) && value === '') +} + +export const ContactKindProperties = ['KIND', 'X-ADDRESSBOOKSERVER-KIND'] + +export const MinimalContactProperties = [ + 'EMAIL', 'UID', 'CATEGORIES', 'FN', 'ORG', 'N', 'X-PHONETIC-FIRST-NAME', 'X-PHONETIC-LAST-NAME', 'X-MANAGERSNAME', 'TITLE', 'NOTES', 'RELATED', +].concat(ContactKindProperties) + +export default class Contact { + + /** + * Creates an instance of Contact + * + * @param {string} vcard the vcard data as string with proper new lines + * @param {object} addressbook the addressbook which the contat belongs to + * @memberof Contact + */ + constructor(vcard, addressbook) { + if (typeof vcard !== 'string' || vcard.length === 0) { + throw new Error('Invalid vCard') + } + + let jCal = ICAL.parse(vcard) + if (jCal[0] !== 'vcard') { + throw new Error('Only one contact is allowed in the vcard data') + } + + if (updateDesignSet(jCal)) { + jCal = ICAL.parse(vcard) + } + + this.jCal = jCal + this.addressbook = addressbook + this.vCard = new ICAL.Component(this.jCal) + + // used to state a contact is not up to date with + // the server and cannot be pushed (etag) + this.conflict = false + + // if no uid set, create one + if (!this.vCard.hasProperty('uid')) { + console.info('This contact did not have a proper uid. Setting a new one for ', this) + this.vCard.addPropertyWithValue('uid', uuid()) + } + + // if no rev set, init one + if (!this.vCard.hasProperty('rev')) { + const rev = new ICAL.VCardTime(null, null, 'date-time') + rev.fromUnixTime(Date.now() / 1000) + this.vCard.addPropertyWithValue('rev', rev) + } + } + + /** + * Update internal data of this contact + * + * @param {jCal} jCal jCal object from ICAL.js + * @memberof Contact + */ + updateContact(jCal) { + this.jCal = jCal + this.vCard = new ICAL.Component(this.jCal) + } + + /** + * Update linked addressbook of this contact + * + * @param {object} addressbook the addressbook + * @memberof Contact + */ + updateAddressbook(addressbook) { + this.addressbook = addressbook + } + + /** + * Ensure we're normalizing the possible arrays + * into a string by taking the first element + * e.g. ORG:ABC\, Inc.; will output an array because of the semi-colon + * + * @param {Array|string} data the data to normalize + * @return {string} + * @memberof Contact + */ + firstIfArray(data) { + return Array.isArray(data) ? data[0] : data + } + + /** + * Return the url + * + * @readonly + * @memberof Contact + */ + get url() { + if (this.dav) { + return this.dav.url + } + return '' + } + + /** + * Return the version + * + * @readonly + * @memberof Contact + */ + get version() { + return this.vCard.getFirstPropertyValue('version') + } + + /** + * Set the version + * + * @param {string} version the version to set + * @memberof Contact + */ + set version(version) { + this.vCard.updatePropertyWithValue('version', version) + } + + /** + * Return the uid + * + * @readonly + * @memberof Contact + */ + get uid() { + return this.vCard.getFirstPropertyValue('uid') + } + + /** + * Set the uid + * + * @param {string} uid the uid to set + * @memberof Contact + */ + set uid(uid) { + this.vCard.updatePropertyWithValue('uid', uid) + } + + /** + * Return the rev + * + * @readonly + * @memberof Contact + */ + get rev() { + return this.vCard.getFirstPropertyValue('rev') + } + + /** + * Set the rev + * + * @param {string} rev the rev to set + * @memberof Contact + */ + set rev(rev) { + this.vCard.updatePropertyWithValue('rev', rev) + } + + /** + * Return the key + * + * @readonly + * @memberof Contact + */ + get key() { + return this.uid + '~' + this.addressbook.id + } + + /** + * Return the photo + * + * @readonly + * @memberof Contact + */ + get photo() { + return this.vCard.getFirstPropertyValue('photo') + } + + /** + * Set the photo + * + * @param {string} photo the photo to set + * @memberof Contact + */ + set photo(photo) { + this.vCard.updatePropertyWithValue('photo', photo) + } + + /** + * Return the photo usable url + * We cannot fetch external url because of csp policies + * + * @memberof Contact + */ + async getPhotoUrl() { + const photo = this.vCard.getFirstProperty('photo') + if (!photo) { + return false + } + const encoding = photo.getFirstParameter('encoding') + let photoType = photo.getFirstParameter('type') + const photoB64 = this.photo + + const isBinary = photo.type === 'binary' || encoding === 'b' + + let photoB64Data = photoB64 + if (photo && photoB64.startsWith('data') && !isBinary) { + // get the last part = base64 + photoB64Data = photoB64.split(',').pop() + // 'data:image/png;base64' => 'png' + photoType = photoB64.split(';')[0].split('/').pop() + } + + // Verify if SVG is valid + if (photoType.toLowerCase().startsWith('svg')) { + const imageSvg = atob(photoB64Data) + const cleanSvg = await sanitizeSVG(imageSvg) + + if (!cleanSvg) { + console.error('Invalid SVG for the following contact. Ignoring...', this.contact, { photoB64, photoType }) + return false + } + } + + try { + // Create blob from url + const blob = b64toBlob(photoB64Data, `image/${photoType}`) + return URL.createObjectURL(blob) + } catch { + console.error('Invalid photo for the following contact. Ignoring...', this.contact, { photoB64, photoType }) + return false + } + } + + /** + * Return the groups + * + * @readonly + * @memberof Contact + */ + get groups() { + const groupsProp = this.vCard.getFirstProperty('categories') + if (groupsProp) { + return groupsProp.getValues() + .filter(group => typeof group === 'string') + .filter(group => group.trim() !== '') + } + return [] + } + + /** + * Set the groups + * + * @param {Array} groups the groups to set + * @memberof Contact + */ + set groups(groups) { + // delete the title if empty + if (isEmpty(groups)) { + this.vCard.removeProperty('categories') + return + } + + if (Array.isArray(groups)) { + let property = this.vCard.getFirstProperty('categories') + if (!property) { + // Init with empty group since we set everything afterwise + property = this.vCard.addPropertyWithValue('categories', '') + } + property.setValues(groups) + } else { + throw new Error('groups data is not an Array') + } + } + + /** + * Return the groups + * + * @readonly + * @memberof Contact + */ + get kind() { + return this.firstIfArray( + ContactKindProperties + .map(s => s.toLowerCase()) + .map(s => this.vCard.getFirstPropertyValue(s)) + .flat() + .filter(k => k), + ) + } + + /** + * Return the first email + * + * @readonly + * @memberof Contact + */ + get email() { + return this.firstIfArray(this.vCard.getFirstPropertyValue('email')) + } + + /** + * Return the first org + * + * @readonly + * @memberof Contact + */ + get org() { + return this.firstIfArray(this.vCard.getFirstPropertyValue('org')) + } + + /** + * Set the org + * + * @param {string} org the org data + * @memberof Contact + */ + set org(org) { + // delete the org if empty + if (isEmpty(org)) { + this.vCard.removeProperty('org') + return + } + this.vCard.updatePropertyWithValue('org', org) + } + + /** + * Return the first x-managersname + * + * @readonly + * @memberof Contact + */ + get managersName() { + const prop = this.vCard.getFirstProperty('x-managersname') + if (!prop) { + return null + } + return prop.getFirstParameter('uid') ?? null + } + + /** + * Return the first title + * + * @readonly + * @memberof Contact + */ + get title() { + return this.firstIfArray(this.vCard.getFirstPropertyValue('title')) + } + + /** + * Set the title + * + * @param {string} title the title + * @memberof Contact + */ + set title(title) { + // delete the title if empty + if (isEmpty(title)) { + this.vCard.removeProperty('title') + return + } + this.vCard.updatePropertyWithValue('title', title) + } + + /** + * Return the full name + * + * @readonly + * @memberof Contact + */ + get fullName() { + return this.vCard.getFirstPropertyValue('fn') + } + + /** + * Set the full name + * + * @param {string} name the fn data + * @memberof Contact + */ + set fullName(name) { + this.vCard.updatePropertyWithValue('fn', name) + } + + /** + * Formatted display name based on the order key + * + * @readonly + * @memberof Contact + */ + get displayName() { + const n = this.vCard.getFirstPropertyValue('n') + const fn = this.vCard.getFirstPropertyValue('fn') + const org = this.vCard.getFirstPropertyValue('org') + + // if ordered by last or first name we need the N property + // ! by checking the property we check for null AND empty string + // ! that means we can then check for empty array and be safe not to have + // ! 'xxxx'.join('') !== '' + // otherwise the FN is enough + if (fn) { + return fn + } + // BUT if no FN property use the N anyway + if (n && !isEmpty(n)) { + // Stevenson;John;Philip,Paul;Dr.;Jr.,M.D.,A.C.P. + // -> John Stevenson + if (isEmpty(n[0])) { + return n[1] + } + return n.slice(0, 2).reverse().join(' ') + } + // LAST chance, use the org ir that's the only thing we have + if (org && !isEmpty(org)) { + // org is supposed to be an array but is also used as plain string + return Array.isArray(org) ? org[0] : org + } + return '' + + } + + /** + * Return the first name if exists + * Returns the displayName otherwise + * + * @readonly + * @memberof Contact + * @return {string} firstName|displayName + */ + get firstName() { + if (this.vCard.hasProperty('n')) { + // reverse and join + return this.vCard.getFirstPropertyValue('n')[1] + } + return this.displayName + } + + /** + * Return the last name if exists + * Returns the displayName otherwise + * + * @readonly + * @memberof Contact + * @return {string} lastName|displayName + */ + get lastName() { + if (this.vCard.hasProperty('n')) { + // reverse and join + return this.vCard.getFirstPropertyValue('n')[0] + } + return this.displayName + } + + /** + * Return the phonetic first name if exists + * Returns the first name or displayName otherwise + * + * @readonly + * @memberof Contact + * @return {string} phoneticFirstName|firstName|displayName + */ + get phoneticFirstName() { + if (this.vCard.hasProperty('x-phonetic-first-name')) { + return this.vCard.getFirstPropertyValue('x-phonetic-first-name') + } + return this.firstName + } + + /** + * Return first matching link for provided type + * Returns empty string otherwise + * + * @param {string} type of social + * @readonly + * @memberof Contact + * @return {string} firstMatchingLink|'' + */ + socialLink(type) { + if (this.vCard.hasProperty('x-socialprofile')) { + const x = this.vCard.getAllProperties('x-socialprofile').filter(a => a.jCal[1].type.toString() === type) + + if (x.length > 0) { + return x[0].jCal[3].toString() + } + } + return '' + } + + /** + * Return the phonetic last name if exists + * Returns the displayName otherwise + * + * @readonly + * @memberof Contact + * @return {string} lastName|displayName + */ + get phoneticLastName() { + if (this.vCard.hasProperty('x-phonetic-last-name')) { + return this.vCard.getFirstPropertyValue('x-phonetic-last-name') + } + return this.lastName + } + + /** + * Return all the properties as Property objects + * + * @readonly + * @memberof Contact + * @return {Property[]} http://mozilla-comm.github.io/ical.js/api/ICAL.Property.html + */ + get properties() { + return this.vCard.getAllProperties() + } + + /** + * Return an array of formatted properties for the search + * + * @readonly + * @memberof Contact + * @return {string[]} + */ + get searchData() { + return this.jCal[1].map(x => x[0] + ':' + x[3]) + } + + /** + * Add the contact to the group + * + * @param {string} group the group to add the contact to + * @memberof Contact + */ + addToGroup(group) { + if (this.groups.indexOf(group) === -1) { + if (this.groups.length > 0) { + this.vCard.getFirstProperty('categories').setValues(this.groups.concat(group)) + } else { + this.vCard.updatePropertyWithValue('categories', [group]) + } + } + } + + toStringStripQuotes() { + const regexp = /TYPE="([a-zA-Z-,]+)"/gmi + const card = this.vCard.toString() + return card.replace(regexp, 'TYPE=$1') + } + +} diff --git a/src/nextcloud-contacts/rfcPropsRecipient.js b/src/nextcloud-contacts/rfcPropsRecipient.js new file mode 100644 index 0000000000..9e39172199 --- /dev/null +++ b/src/nextcloud-contacts/rfcPropsRecipient.js @@ -0,0 +1,404 @@ +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import ICAL from 'ical.js' +import { loadState } from '@nextcloud/initial-state' + +// Load the default profile (for example, home or work) configured by the user +const defaultProfileState = loadState('mail', 'defaultProfile', 'HOME') +const localesState = loadState('mail', 'locales', false) +const locales = localesState + ? localesState.map(({ code, name }) => ({ + id: code.toLowerCase().replace('_', '-'), + name, + })) + : [] + +console.debug('Initial state loaded', 'defaultProfileState', defaultProfileState) +console.debug('Initial state loaded', 'localesState', localesState) + +const properties = { + n: { + readableName: t('mail', 'Detailed name'), + readableValues: [ + t('mail', 'Last name'), + t('mail', 'First name'), + t('mail', 'Additional names'), + t('mail', 'Prefix'), + t('mail', 'Suffix'), + ], + displayOrder: [3, 1, 2, 0, 4], + defaultValue: { + value: ['', '', '', '', ''], + }, + icon: 'icon-detailed-name', + primary: false, + }, + nickname: { + readableName: t('mail', 'Nickname'), + icon: 'icon-detailed-name', + primary: false, + }, + 'x-phonetic-first-name': { + readableName: t('mail', 'Phonetic first name'), + icon: 'icon-detailed-name', + force: 'text', + primary: false, + }, + 'x-phonetic-last-name': { + readableName: t('mail', 'Phonetic last name'), + icon: 'icon-detailed-name', + force: 'text', + primary: false, + }, + note: { + readableName: t('mail', 'Notes'), + icon: 'icon-note', + primary: false, + }, + url: { + multiple: true, + readableName: t('mail', 'Website'), + icon: 'icon-public', + primary: true, + }, + geo: { + multiple: true, + readableName: t('mail', 'Location'), + icon: 'icon-location', + defaultjCal: { + '3.0': [{}, 'FLOAT', '90.000;0.000'], + '4.0': [{}, 'URI', 'geo:90.000,0.000'], + }, + primary: false, + }, + cloud: { + multiple: true, + icon: 'icon-federated-cloud-id', + readableName: t('mail', 'Federated Cloud ID'), + force: 'text', + defaultValue: { + value: [''], + type: [defaultProfileState], + }, + options: [ + { id: 'HOME', name: t('mail', 'Home') }, + { id: 'WORK', name: t('mail', 'Work') }, + { id: 'OTHER', name: t('mail', 'Other') }, + ], + primary: false, + }, + adr: { + multiple: true, + readableName: t('mail', 'Address'), + readableValues: [ + t('mail', 'Post office box'), + t('mail', 'Extended address'), + t('mail', 'Address'), + t('mail', 'City'), + t('mail', 'State or province'), + t('mail', 'Postal code'), + t('mail', 'Country'), + ], + displayOrder: [0, 2, 1, 5, 3, 4, 6], + icon: 'icon-address', + default: true, + defaultValue: { + value: ['', '', '', '', '', '', ''], + type: [defaultProfileState], + }, + options: [ + { id: 'HOME', name: t('mail', 'Home') }, + { id: 'WORK', name: t('mail', 'Work') }, + { id: 'OTHER', name: t('mail', 'Other') }, + ], + primary: true, + }, + bday: { + readableName: t('mail', 'Birthday'), + icon: 'icon-calendar-dark', + force: 'date', // most ppl prefer date for birthdays, time is usually irrelevant + defaultValue: { + value: new ICAL.VCardTime(null, null, 'date').fromJSDate(new Date()), + }, + primary: true, + }, + anniversary: { + readableName: t('mail', 'Anniversary'), + icon: 'icon-anniversary', + force: 'date', // most ppl prefer date for birthdays, time is usually irrelevant + defaultValue: { + value: new ICAL.VCardTime(null, null, 'date').fromJSDate(new Date()), + }, + primary: false, + }, + deathdate: { + readableName: t('mail', 'Date of death'), + icon: 'icon-death-day', + force: 'date', // most ppl prefer date for birthdays, time is usually irrelevant + defaultValue: { + value: new ICAL.VCardTime(null, null, 'date').fromJSDate(new Date()), + }, + primary: false, + }, + email: { + multiple: true, + readableName: t('mail', 'Email'), + icon: 'icon-mail', + default: true, + defaultValue: { + value: '', + type: [defaultProfileState], + }, + options: [ + { id: 'HOME', name: t('mail', 'Home') }, + { id: 'WORK', name: t('mail', 'Work') }, + { id: 'OTHER', name: t('mail', 'Other') }, + ], + primary: true, + }, + impp: { + multiple: true, + readableName: t('mail', 'Instant messaging'), + icon: 'icon-instant-message', + defaultValue: { + value: [''], + type: ['SKYPE'], + }, + options: [ + { id: 'IRC', name: 'IRC' }, + { id: 'KAKAOTALK', name: 'KakaoTalk' }, + { id: 'KIK', name: 'KiK' }, + { id: 'LINE', name: 'Line' }, + { id: 'MATRIX', name: 'Matrix' }, + { id: 'QQ', name: 'QQ' }, + { id: 'SIGNAL', name: 'Signal' }, + { id: 'SIP', name: 'SIP' }, + { id: 'SKYPE', name: 'Skype' }, + { id: 'TELEGRAM', name: 'Telegram' }, + { id: 'THREEMA', name: 'Threema' }, + { id: 'WECHAT', name: 'WeChat' }, + { id: 'XMPP', name: 'XMPP' }, + { id: 'ZOOM', name: 'Zoom' }, + ], + primary: false, + }, + tel: { + multiple: true, + readableName: t('mail', 'Phone'), + icon: 'icon-phone', + default: true, + defaultValue: { + value: '', + type: [defaultProfileState, 'VOICE'], + }, + options: [ + { id: 'HOME,VOICE', name: t('mail', 'Home') }, + { id: 'HOME', name: t('mail', 'Home') }, + { id: 'WORK,VOICE', name: t('mail', 'Work') }, + { id: 'WORK', name: t('mail', 'Work') }, + { id: 'CELL', name: t('mail', 'Mobile') }, + { id: 'CELL,VOICE', name: t('mail', 'Mobile') }, + { id: 'WORK,CELL', name: t('mail', 'Work mobile') }, + { id: 'HOME,CELL', name: t('mail', 'Home mobile') }, + { id: 'FAX', name: t('mail', 'Fax') }, + { id: 'HOME,FAX', name: t('mail', 'Fax home') }, + { id: 'WORK,FAX', name: t('mail', 'Fax work') }, + { id: 'PAGER', name: t('mail', 'Pager') }, + { id: 'VOICE', name: t('mail', 'Voice') }, + { id: 'CAR', name: t('mail', 'Car') }, + { id: 'WORK,PAGER', name: t('mail', 'Work pager') }, + ], + primary: true, + }, + 'x-managersname': { + multiple: false, + force: 'select', + // TRANSLATORS The supervisor of an employee + readableName: t('mail', 'Manager'), + icon: 'icon-manager', + default: false, + options({ contact, $store, selectType }) { + // Only allow contacts of the same address book + const contacts = otherContacts({ + $store, + self: contact, + }) + + // Reduce to an object to eliminate duplicates + return Object.values(contacts.reduce((prev, { key }) => { + const contact = $store.getters.getContact(key) + return { + ...prev, + [contact.uid]: { + id: contact.key, + name: contact.displayName, + }, + } + }, selectType ? { [selectType.value]: selectType } : {})) + }, + primary: true, + }, + 'x-socialprofile': { + multiple: true, + force: 'text', + icon: 'icon-social', + readableName: t('mail', 'Social network'), + defaultValue: { + value: '', + type: ['facebook'], + }, + options: [ + { id: 'FACEBOOK', name: 'Facebook', placeholder: 'https://facebook.com/…' }, + { id: 'GITHUB', name: 'GitHub', placeholder: 'https://github.com/…' }, + { id: 'GOOGLEPLUS', name: 'Google+', placeholder: 'https://plus.google.com/…' }, + { id: 'INSTAGRAM', name: 'Instagram', placeholder: 'https://instagram.com/…' }, + { id: 'LINKEDIN', name: 'LinkedIn', placeholder: 'https://linkedin.com/…' }, + { id: 'XING', name: 'Xing', placeholder: 'https://www.xing.com/profile/…' }, + { id: 'PINTEREST', name: 'Pinterest', placeholder: 'https://pinterest.com/…' }, + { id: 'QZONE', name: 'QZone', placeholder: 'https://qzone.com/…' }, + { id: 'TUMBLR', name: 'Tumblr', placeholder: 'https://tumblr.com/…' }, + { id: 'TWITTER', name: 'Twitter', placeholder: 'https://twitter.com/…' }, + { id: 'WECHAT', name: 'WeChat', placeholder: 'https://wechat.com/…' }, + { id: 'YOUTUBE', name: 'YouTube', placeholder: 'https://youtube.com/…' }, + { id: 'MASTODON', name: 'Mastodon', placeholder: 'https://mastodon.social/…' }, + { id: 'DIASPORA', name: 'Diaspora', placeholder: 'https://joindiaspora.com/…' }, + { id: 'NEXTCLOUD', name: 'Nextcloud', placeholder: 'Link to profile page (https://nextcloud.example.com/…)' }, + { id: 'OTHER', name: 'Other', placeholder: 'https://example.com/…' }, + ], + primary: true, + }, + relationship: { + readableName: t('mail', 'Relationship to you'), + force: 'select', + icon: 'icon-relation-to-you', + options: [ + { id: 'SPOUSE', name: t('mail', 'Spouse') }, + { id: 'CHILD', name: t('mail', 'Child') }, + { id: 'MOTHER', name: t('mail', 'Mother') }, + { id: 'FATHER', name: t('mail', 'Father') }, + { id: 'PARENT', name: t('mail', 'Parent') }, + { id: 'BROTHER', name: t('mail', 'Brother') }, + { id: 'SISTER', name: t('mail', 'Sister') }, + { id: 'RELATIVE', name: t('mail', 'Relative') }, + { id: 'FRIEND', name: t('mail', 'Friend') }, + { id: 'COLLEAGUE', name: t('mail', 'Colleague') }, + // TRANSLATORS The supervisor of an employee + { id: 'MANAGER', name: t('mail', 'Manager') }, + { id: 'ASSISTANT', name: t('mail', 'Assistant') }, + ], + primary: false, + }, + related: { + multiple: true, + readableName: t('mail', 'Related contacts'), + icon: 'icon-related-contact', + defaultValue: { + value: [''], + type: ['CONTACT'], + }, + options: [ + { id: 'CONTACT', name: t('mail', 'Contact') }, + { id: 'AGENT', name: t('mail', 'Agent') }, + { id: 'EMERGENCY', name: t('mail', 'Emergency') }, + { id: 'FRIEND', name: t('mail', 'Friend') }, + { id: 'COLLEAGUE', name: t('mail', 'Colleague') }, + { id: 'COWORKER', name: t('mail', 'Co-worker') }, + // TRANSLATORS The supervisor of an employee + { id: 'MANAGER', name: t('mail', 'Manager') }, + { id: 'ASSISTANT', name: t('mail', 'Assistant') }, + { id: 'SPOUSE', name: t('mail', 'Spouse') }, + { id: 'CHILD', name: t('mail', 'Child') }, + { id: 'MOTHER', name: t('mail', 'Mother') }, + { id: 'FATHER', name: t('mail', 'Father') }, + { id: 'PARENT', name: t('mail', 'Parent') }, + { id: 'BROTHER', name: t('mail', 'Brother') }, + { id: 'SISTER', name: t('mail', 'Sister') }, + { id: 'RELATIVE', name: t('mail', 'Relative') }, + ], + primary: false, + }, + gender: { + readableName: t('mail', 'Gender'), + defaultValue: { + // default to Female 🙋 + value: 'F', + }, + icon: 'icon-gender', + force: 'select', + options: [ + { id: 'F', name: t('mail', 'Female') }, + { id: 'M', name: t('mail', 'Male') }, + { id: 'O', name: t('mail', 'Other') }, + { id: 'N', name: t('mail', 'None') }, + { id: 'U', name: t('mail', 'Unknown') }, + ], + primary: false, + }, + tz: { + readableName: t('mail', 'Time zone'), + force: 'select', + icon: 'icon-timezone', + primary: false, + }, + lang: { + readableName: t('mail', 'Spoken languages'), + icon: 'icon-spoken-lang', + defaultValue: { + value: 'en', + }, + multiple: true, + primary: false, + }, +} + +if (locales.length > 0) { + properties.lang.force = 'select' + properties.lang.options = locales + properties.lang.greedyMatch = function(value, options) { + // each locale already have the base code (e.g. fr in fr_ca) + // in the list, meaning the only use case for this is a more + // complete language tag than the short one we have + // value: fr-ca-xxx... will be matched with option fr + return options.find(({ id }) => { + return id === value.split('-')[0] + }) + } +} + +const fieldOrder = [ + 'title', + 'org', + + // primary fields + 'tel', + 'email', + 'adr', + 'bday', + 'url', + 'x-socialprofile', + 'x-managersname', + + // secondary fields + 'anniversary', + 'deathdate', + 'n', + 'nickname', + 'x-phonetic-first-name', + 'x-phonetic-last-name', + 'gender', + 'cloud', + 'impp', + 'geo', + 'note', + 'lang', + 'related', + 'relationship', + 'tz', + + 'categories', + 'role', +] + +export default { properties, fieldOrder } diff --git a/src/nextcloud-contacts/updateDesignSet.js b/src/nextcloud-contacts/updateDesignSet.js new file mode 100644 index 0000000000..6409ef464f --- /dev/null +++ b/src/nextcloud-contacts/updateDesignSet.js @@ -0,0 +1,108 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import ICAL from 'ical.js' + +/** + * Prevents ical.js from adding 'VALUE=PHONE-NUMBER' in vCard 3.0. + * While not wrong according to the RFC, there's a bug in sabreio/vobject (used + * by Nextcloud Server) that prevents saving vCards with this parameters. + * + * @link https://github.com/nextcloud/contacts/pull/1393#issuecomment-570945735 + * @return {boolean} Whether or not the design set has been altered. + */ +const removePhoneNumberValueType = () => { + if (ICAL.design.vcard3.property.tel) { + delete ICAL.design.vcard3.property.tel + return true + } + + return false +} + +/** + * Some clients group properties by naming them something like 'ITEM1.URL'. + * These should be treated the same as their original (i.e. 'URL' in this + * example), so we iterate through the vCard to find these properties and + * add them to the ical.js design set. + * + * @link https://github.com/nextcloud/contacts/issues/42 + * @param {Array} vCard The ical.js vCard + * @return {boolean} Whether or not the design set has been altered. + */ +const addGroupedProperties = vCard => { + let madeChanges = false + vCard[1].forEach(prop => { + const propGroup = prop[0].split('.') + + // if this is a grouped property, update the designSet + if (propGroup.length === 2) { + madeChanges = setPropertyAlias(propGroup[1], prop[0]) + } + }) + return madeChanges +} + +/** + * Fixes misbehaviour with TYPE quotes and separated commas + * Seems to have been introduced with https://github.com/mozilla-comm/ical.js/pull/387 + * + * @return {boolean} Whether or not the design set has been altered. + */ +const setTypeMultiValueSeparateDQuote = () => { + if ( + !ICAL.design.vcard.param.type + || ICAL.design.vcard.param.type.multiValueSeparateDQuote !== false + || !ICAL.design.vcard3.param.type + || ICAL.design.vcard3.param.type.multiValueSeparateDQuote !== false + ) { + // https://github.com/mozilla-comm/ical.js/blob/ba8e2522ffd30ffbe65197a96a487689d6e6e9a1/lib/ical/stringify.js#L121 + ICAL.design.vcard.param.type.multiValueSeparateDQuote = false + ICAL.design.vcard3.param.type.multiValueSeparateDQuote = false + + return true + } + + return false +} + +/** +/** +* Check whether the ical.js design sets need updating (and if so, do it) + * + * @param {Array} vCard The ical.js vCard + * @return {boolean} Whether or not the design set has been altered. + */ +export default function(vCard) { + let madeChanges = false + + madeChanges |= setTypeMultiValueSeparateDQuote() + madeChanges |= removePhoneNumberValueType() + madeChanges |= addGroupedProperties(vCard) + + return madeChanges +} + +/** + * @param {string} original Name of the property whose settings should be copied + * @param {string} alias Name of the new property + * @return {boolean} Whether or not the design set has been altered. + */ +export function setPropertyAlias(original, alias) { + let madeChanges = false + original = original.toLowerCase() + alias = alias.toLowerCase() + + if (ICAL.design.vcard.property[original]) { + ICAL.design.vcard.property[alias] = ICAL.design.vcard.property[original] + madeChanges = true + } + + if (ICAL.design.vcard3.property[original]) { + ICAL.design.vcard3.property[alias] = ICAL.design.vcard3.property[original] + madeChanges = true + } + + return madeChanges +} diff --git a/src/service/caldavService.js b/src/service/caldavService.js index aaa4e86c77..f459b9f784 100644 --- a/src/service/caldavService.js +++ b/src/service/caldavService.js @@ -45,7 +45,10 @@ const getClient = () => { * Initializes the client for use in the user-view */ export async function initializeClientForUserView() { - await getClient().connect({ enableCalDAV: true }) + await getClient().connect({ + enableCalDAV: true, + enableCardDAV: true, + }) } /** @@ -66,11 +69,23 @@ export function getCalendarHome() { return getClient().calendarHomes[0] } +/** + * Fetch all address books from the server + * + * @return {Promise} + */ +export function getAddressBookHomes() { + return getClient().addressBookHomes[0] +} + /** * Fetch all collections in the calendar home from the server * * @return {Promise} */ export async function findAll() { - return await getCalendarHome().findAllCalDAVCollectionsGrouped() + return { + calendarGroups: await getCalendarHome().findAllCalDAVCollectionsGrouped(), + addressBooks: await getAddressBookHomes().findAllAddressBooks(), + } } diff --git a/src/store/actions.js b/src/store/actions.js index a6324b58cf..9b48f31cc3 100644 --- a/src/store/actions.js +++ b/src/store/actions.js @@ -1378,10 +1378,13 @@ export default { */ async loadCollections({ commit }) { await handleHttpAuthErrors(commit, async () => { - const { calendars } = await findAll() + const { calendarGroups: { calendars }, addressBooks } = await findAll() for (const calendar of calendars) { commit('addCalendar', { calendar }) } + for (const addressBook of addressBooks) { + commit('addAddressBook', { addressBook }) + } }) }, diff --git a/src/store/getters.js b/src/store/getters.js index d19e116ac9..deb177a796 100644 --- a/src/store/getters.js +++ b/src/store/getters.js @@ -159,4 +159,6 @@ export const getters = { isFollowUpFeatureAvailable: (state) => state.followUpFeatureAvailable, getInternalAddresses: (state) => state.internalAddress?.filter(internalAddress => internalAddress !== undefined), hasCurrentUserPrincipalAndCollections: (state) => state.hasCurrentUserPrincipalAndCollections, + showSettingsForAccount: (state) => (accountId) => state.showAccountSettings === accountId, + getAddressBooks: (state) => state.addressBooks, } diff --git a/src/store/index.js b/src/store/index.js index 98ddc42ad1..5dd3830bac 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -103,6 +103,7 @@ export default new Store({ masterPasswordEnabled: false, sieveScript: {}, calendars: [], + addressBooks: [], smimeCertificates: [], hasFetchedInitialEnvelopes: false, followUpFeatureAvailable: false, diff --git a/src/store/mutations.js b/src/store/mutations.js index a5cb937757..fb6b996996 100644 --- a/src/store/mutations.js +++ b/src/store/mutations.js @@ -486,6 +486,9 @@ export default { addCalendar(state, { calendar }) { state.calendars = [...state.calendars, calendar] }, + addAddressBook(state, { addressBook }) { + state.addressBooks = [...state.addressBooks, addressBook] + }, setGoogleOauthUrl(state, url) { state.googleOauthUrl = url },