Browse Source

Profile frontend

Signed-off-by: Christopher Ng <chrng8@gmail.com>
Signed-off-by: nextcloud-command <nextcloud-command@users.noreply.github.com>
tags/v23.0.0beta1
Christopher Ng 2 years ago
parent
commit
3be9d3ca8f
93 changed files with 4323 additions and 627 deletions
  1. 11
    5
      apps/settings/css/settings.scss
  2. 13
    13
      apps/settings/js/vue-settings-admin-delegation.js
  3. 1
    1
      apps/settings/js/vue-settings-admin-delegation.js.map
  4. 26
    26
      apps/settings/js/vue-settings-admin-security.js
  5. 1
    1
      apps/settings/js/vue-settings-admin-security.js.map
  6. 5
    5
      apps/settings/js/vue-settings-apps-users-management.js
  7. 1
    1
      apps/settings/js/vue-settings-apps-users-management.js.map
  8. 2
    2
      apps/settings/js/vue-settings-apps.js
  9. 1
    1
      apps/settings/js/vue-settings-apps.js.map
  10. 2
    2
      apps/settings/js/vue-settings-nextcloud-pdf.js
  11. 1
    1
      apps/settings/js/vue-settings-nextcloud-pdf.js.map
  12. 504
    21
      apps/settings/js/vue-settings-personal-info.js
  13. 1
    1
      apps/settings/js/vue-settings-personal-info.js.map
  14. 24
    24
      apps/settings/js/vue-settings-personal-security.js
  15. 1
    1
      apps/settings/js/vue-settings-personal-security.js.map
  16. 9
    9
      apps/settings/js/vue-settings-personal-webauthn.js
  17. 1
    1
      apps/settings/js/vue-settings-personal-webauthn.js.map
  18. 3
    3
      apps/settings/js/vue-settings-users.js
  19. 1
    1
      apps/settings/js/vue-settings-users.js.map
  20. 19
    19
      apps/settings/js/vue-vendors-settings-apps-settings-users.js
  21. 1
    1
      apps/settings/js/vue-vendors-settings-apps-settings-users.js.map
  22. 7
    7
      apps/settings/js/vue-vendors-settings-apps.js
  23. 1
    1
      apps/settings/js/vue-vendors-settings-apps.js.map
  24. 26
    26
      apps/settings/js/vue-vendors-settings-users.js
  25. 1
    1
      apps/settings/js/vue-vendors-settings-users.js.map
  26. 182
    0
      apps/settings/src/components/PersonalInfo/BiographySection/Biography.vue
  27. 81
    0
      apps/settings/src/components/PersonalInfo/BiographySection/BiographySection.vue
  28. 10
    9
      apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayName.vue
  29. 15
    7
      apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayNameSection.vue
  30. 12
    13
      apps/settings/src/components/PersonalInfo/EmailSection/Email.vue
  31. 49
    16
      apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue
  32. 175
    0
      apps/settings/src/components/PersonalInfo/HeadlineSection/Headline.vue
  33. 81
    0
      apps/settings/src/components/PersonalInfo/HeadlineSection/HeadlineSection.vue
  34. 6
    7
      apps/settings/src/components/PersonalInfo/LanguageSection/Language.vue
  35. 5
    10
      apps/settings/src/components/PersonalInfo/LanguageSection/LanguageSection.vue
  36. 175
    0
      apps/settings/src/components/PersonalInfo/OrganisationSection/Organisation.vue
  37. 81
    0
      apps/settings/src/components/PersonalInfo/OrganisationSection/OrganisationSection.vue
  38. 101
    0
      apps/settings/src/components/PersonalInfo/ProfileSection/ProfileCheckbox.vue
  39. 192
    0
      apps/settings/src/components/PersonalInfo/ProfileSection/ProfilePreviewCard.vue
  40. 105
    0
      apps/settings/src/components/PersonalInfo/ProfileSection/ProfileSection.vue
  41. 117
    0
      apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/ProfileVisibilitySection.vue
  42. 175
    0
      apps/settings/src/components/PersonalInfo/RoleSection/Role.vue
  43. 81
    0
      apps/settings/src/components/PersonalInfo/RoleSection/RoleSection.vue
  44. 1
    0
      apps/settings/src/components/PersonalInfo/shared/AddButton.vue
  45. 24
    37
      apps/settings/src/components/PersonalInfo/shared/FederationControl.vue
  46. 105
    0
      apps/settings/src/components/PersonalInfo/shared/FederationControlAction.vue
  47. 16
    12
      apps/settings/src/components/PersonalInfo/shared/HeaderBar.vue
  48. 178
    0
      apps/settings/src/components/PersonalInfo/shared/VisibilityDropdown.vue
  49. 61
    16
      apps/settings/src/constants/AccountPropertyConstants.js
  50. 50
    0
      apps/settings/src/constants/ProfileConstants.js
  51. 44
    3
      apps/settings/src/main-personal-info.js
  52. 17
    9
      apps/settings/src/service/PersonalInfo/PersonalInfoService.js
  53. 9
    10
      apps/settings/src/service/ProfileService.js
  54. 14
    2
      apps/settings/src/utils/validate.js
  55. 85
    73
      apps/settings/templates/settings/personal/personal.info.php
  56. 8
    2
      apps/theming/css/theming.scss
  57. 14
    14
      apps/user_status/js/dashboard.js
  58. 1
    1
      apps/user_status/js/dashboard.js.map
  59. 16
    16
      apps/user_status/js/user-status-menu.js
  60. 1
    1
      apps/user_status/js/user-status-menu.js.map
  61. 2
    2
      apps/user_status/js/user-status-modal.js
  62. 1
    1
      apps/user_status/js/user-status-modal.js.map
  63. 4
    4
      apps/user_status/js/vendors-user-status-modal.js
  64. 1
    1
      apps/user_status/js/vendors-user-status-modal.js.map
  65. 95
    23
      apps/user_status/src/UserStatus.vue
  66. 0
    1
      core/css/css-variables.scss
  67. 2
    0
      core/css/variables.scss
  68. 3
    3
      core/js/dist/files_client.js
  69. 1
    1
      core/js/dist/files_client.js.map
  70. 1
    1
      core/js/dist/files_fileinfo.js
  71. 1
    1
      core/js/dist/files_fileinfo.js.map
  72. 1
    1
      core/js/dist/files_iedavclient.js
  73. 1
    1
      core/js/dist/files_iedavclient.js.map
  74. 38
    38
      core/js/dist/install.js
  75. 1
    1
      core/js/dist/install.js.map
  76. 25
    25
      core/js/dist/login.js
  77. 1
    1
      core/js/dist/login.js.map
  78. 33
    33
      core/js/dist/main.js
  79. 1
    1
      core/js/dist/main.js.map
  80. 1
    1
      core/js/dist/maintenance.js
  81. 1
    1
      core/js/dist/maintenance.js.map
  82. 389
    0
      core/js/dist/profile.js
  83. 1
    0
      core/js/dist/profile.js.map
  84. 3
    3
      core/js/dist/recommendedapps.js
  85. 1
    1
      core/js/dist/recommendedapps.js.map
  86. 39
    39
      core/js/dist/unified-search.js
  87. 1
    1
      core/js/dist/unified-search.js.map
  88. 33
    7
      core/src/OC/contactsmenu/contact.handlebars
  89. 103
    0
      core/src/components/Profile/PrimaryActionButton.vue
  90. 9
    3
      core/src/components/UserMenu.js
  91. 48
    0
      core/src/profile.js
  92. 531
    0
      core/src/views/Profile.vue
  93. 1
    0
      core/webpack.js

+ 11
- 5
apps/settings/css/settings.scss View File

@@ -107,14 +107,20 @@ input {
#personal-settings-avatar-container {
display: inline-grid;
grid-template-columns: 1fr;
grid-template-rows: 2fr 1fr;
grid-template-rows: 2fr 1fr 2fr;
vertical-align: top;
}

.profile-settings-container {
display: inline-grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr 2fr 1fr;
grid-template-rows: 1fr 1fr 1fr 2fr;

#locale {
h3 {
height: 32px;
}
}
}

.personal-show-container {
@@ -217,7 +223,7 @@ select {

.personal-settings-container {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr 1fr;
grid-template-rows: 1fr 1fr 1fr 1fr;
}

.profile-settings-container {
@@ -239,7 +245,7 @@ select {

.personal-settings-container {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr 1fr;
grid-template-rows: 1fr 1fr 1fr 1fr;
}

.profile-settings-container {
@@ -273,7 +279,7 @@ select {
.personal-settings-container {
display: inline-grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr 2fr;
grid-template-rows: 1fr 1fr 1fr 1fr 1fr;

&:after {
clear: both;

+ 13
- 13
apps/settings/js/vue-settings-admin-delegation.js
File diff suppressed because it is too large
View File


+ 1
- 1
apps/settings/js/vue-settings-admin-delegation.js.map
File diff suppressed because it is too large
View File


+ 26
- 26
apps/settings/js/vue-settings-admin-security.js
File diff suppressed because it is too large
View File


+ 1
- 1
apps/settings/js/vue-settings-admin-security.js.map
File diff suppressed because it is too large
View File


+ 5
- 5
apps/settings/js/vue-settings-apps-users-management.js
File diff suppressed because it is too large
View File


+ 1
- 1
apps/settings/js/vue-settings-apps-users-management.js.map
File diff suppressed because it is too large
View File


+ 2
- 2
apps/settings/js/vue-settings-apps.js
File diff suppressed because it is too large
View File


+ 1
- 1
apps/settings/js/vue-settings-apps.js.map
File diff suppressed because it is too large
View File


+ 2
- 2
apps/settings/js/vue-settings-nextcloud-pdf.js
File diff suppressed because it is too large
View File


+ 1
- 1
apps/settings/js/vue-settings-nextcloud-pdf.js.map
File diff suppressed because it is too large
View File


+ 504
- 21
apps/settings/js/vue-settings-personal-info.js
File diff suppressed because it is too large
View File


+ 1
- 1
apps/settings/js/vue-settings-personal-info.js.map
File diff suppressed because it is too large
View File


+ 24
- 24
apps/settings/js/vue-settings-personal-security.js
File diff suppressed because it is too large
View File


+ 1
- 1
apps/settings/js/vue-settings-personal-security.js.map
File diff suppressed because it is too large
View File


+ 9
- 9
apps/settings/js/vue-settings-personal-webauthn.js
File diff suppressed because it is too large
View File


+ 1
- 1
apps/settings/js/vue-settings-personal-webauthn.js.map
File diff suppressed because it is too large
View File


+ 3
- 3
apps/settings/js/vue-settings-users.js
File diff suppressed because it is too large
View File


+ 1
- 1
apps/settings/js/vue-settings-users.js.map
File diff suppressed because it is too large
View File


+ 19
- 19
apps/settings/js/vue-vendors-settings-apps-settings-users.js
File diff suppressed because it is too large
View File


+ 1
- 1
apps/settings/js/vue-vendors-settings-apps-settings-users.js.map
File diff suppressed because it is too large
View File


+ 7
- 7
apps/settings/js/vue-vendors-settings-apps.js
File diff suppressed because it is too large
View File


+ 1
- 1
apps/settings/js/vue-vendors-settings-apps.js.map
File diff suppressed because it is too large
View File


+ 26
- 26
apps/settings/js/vue-vendors-settings-users.js
File diff suppressed because it is too large
View File


+ 1
- 1
apps/settings/js/vue-vendors-settings-users.js.map
File diff suppressed because it is too large
View File


+ 182
- 0
apps/settings/src/components/PersonalInfo/BiographySection/Biography.vue View File

@@ -0,0 +1,182 @@
<!--
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @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 class="biography">
<textarea
id="biography"
:placeholder="t('settings', 'Your biography')"
:value="biography"
rows="8"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
@input="onBiographyChange" />

<div class="biography__actions-container">
<transition name="fade">
<span v-if="showCheckmarkIcon" class="icon-checkmark" />
<span v-else-if="showErrorIcon" class="icon-error" />
</transition>
</div>
</div>
</template>

<script>
import { showError } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import debounce from 'debounce'

import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'
import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService'

export default {
name: 'Biography',

props: {
biography: {
type: String,
required: true,
},
scope: {
type: String,
required: true,
},
},

data() {
return {
initialBiography: this.biography,
localScope: this.scope,
showCheckmarkIcon: false,
showErrorIcon: false,
}
},

methods: {
onBiographyChange(e) {
this.$emit('update:biography', e.target.value)
this.debounceBiographyChange(e.target.value.trim())
},

debounceBiographyChange: debounce(async function(biography) {
await this.updatePrimaryBiography(biography)
}, 500),

async updatePrimaryBiography(biography) {
try {
const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.BIOGRAPHY, biography)
this.handleResponse({
biography,
status: responseData.ocs?.meta?.status,
})
} catch (e) {
this.handleResponse({
errorMessage: t('settings', 'Unable to update biography'),
error: e,
})
}
},

handleResponse({ biography, status, errorMessage, error }) {
if (status === 'ok') {
// Ensure that local state reflects server state
this.initialBiography = biography
emit('settings:biography:updated', biography)
this.showCheckmarkIcon = true
setTimeout(() => { this.showCheckmarkIcon = false }, 2000)
} else {
showError(errorMessage)
this.logger.error(errorMessage, error)
this.showErrorIcon = true
setTimeout(() => { this.showErrorIcon = false }, 2000)
}
},

onScopeChange(scope) {
this.$emit('update:scope', scope)
},
},
}
</script>

<style lang="scss" scoped>
.biography {
display: grid;
align-items: center;

textarea {
resize: none;
grid-area: 1 / 1;
width: 100%;
margin: 3px 3px 3px 0;
padding: 7px 6px;
color: var(--color-main-text);
border: 1px solid var(--color-border-dark);
border-radius: var(--border-radius);
background-color: var(--color-main-background);
font-family: var(--font-face);
cursor: text;

&:hover {
border-color: var(--color-primary-element) !important;
outline: none !important;
}
}

.biography__actions-container {
grid-area: 1 / 1;
justify-self: flex-end;
align-self: flex-end;
height: 30px;

display: flex;
gap: 0 2px;
margin-right: 5px;
margin-bottom: 5px;

.icon-checkmark,
.icon-error {
height: 30px !important;
min-height: 30px !important;
width: 30px !important;
min-width: 30px !important;
top: 0;
right: 0;
float: none;
}
}
}

.fade-enter,
.fade-leave-to {
opacity: 0;
}

.fade-enter-active {
transition: opacity 200ms ease-out;
}

.fade-leave-active {
transition: opacity 300ms ease-out;
}
</style>

+ 81
- 0
apps/settings/src/components/PersonalInfo/BiographySection/BiographySection.vue View File

@@ -0,0 +1,81 @@
<!--
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @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>
<section>
<HeaderBar
:account-property="accountProperty"
label-for="biography"
:scope.sync="primaryBiography.scope" />

<Biography
:biography.sync="primaryBiography.value"
:scope.sync="primaryBiography.scope" />

<VisibilityDropdown
:param-id="accountPropertyId"
:display-id="accountProperty"
:visibility.sync="visibility" />
</section>
</template>

<script>
import { loadState } from '@nextcloud/initial-state'

import Biography from './Biography'
import HeaderBar from '../shared/HeaderBar'
import VisibilityDropdown from '../shared/VisibilityDropdown'

import { ACCOUNT_PROPERTY_ENUM, ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'

const { biographyMap: { primaryBiography } } = loadState('settings', 'personalInfoParameters', {})
const { profileConfig: { biography: { visibility } } } = loadState('settings', 'profileParameters', {})

export default {
name: 'BiographySection',

components: {
Biography,
HeaderBar,
VisibilityDropdown,
},

data() {
return {
accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.BIOGRAPHY,
accountPropertyId: ACCOUNT_PROPERTY_ENUM.BIOGRAPHY,
primaryBiography,
visibility,
}
},
}
</script>

<style lang="scss" scoped>
section {
padding: 10px 10px;

&::v-deep button:disabled {
cursor: default;
}
}
</style>

+ 10
- 9
apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayName.vue View File

@@ -17,21 +17,19 @@
-
- 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 class="displayname">
<input
id="displayname"
ref="displayName"
type="text"
name="displayname"
:placeholder="t('settings', 'Your full name')"
:value="displayName"
autocapitalize="none"
autocomplete="on"
autocorrect="off"
required
@input="onDisplayNameChange">

<div class="displayname__actions-container">
@@ -45,10 +43,12 @@

<script>
import { showError } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import debounce from 'debounce'

import { savePrimaryDisplayName } from '../../../service/PersonalInfo/DisplayNameService'
import { validateDisplayName } from '../../../utils/validate'
import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'
import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService'
import { validateStringInput } from '../../../utils/validate'

// TODO Global avatar updating on events (e.g. updating the displayname) is currently being handled by global js, investigate using https://github.com/nextcloud/nextcloud-event-bus for global avatar updating

@@ -82,21 +82,21 @@ export default {
},

debounceDisplayNameChange: debounce(async function(displayName) {
if (validateDisplayName(displayName)) {
if (validateStringInput(displayName)) {
await this.updatePrimaryDisplayName(displayName)
}
}, 500),

async updatePrimaryDisplayName(displayName) {
try {
const responseData = await savePrimaryDisplayName(displayName)
const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.DISPLAYNAME, displayName)
this.handleResponse({
displayName,
status: responseData.ocs?.meta?.status,
})
} catch (e) {
this.handleResponse({
errorMessage: 'Unable to update full name',
errorMessage: t('settings', 'Unable to update full name'),
error: e,
})
}
@@ -106,10 +106,11 @@ export default {
if (status === 'ok') {
// Ensure that local state reflects server state
this.initialDisplayName = displayName
emit('settings:display-name:updated', displayName)
this.showCheckmarkIcon = true
setTimeout(() => { this.showCheckmarkIcon = false }, 2000)
} else {
showError(t('settings', errorMessage))
showError(errorMessage)
this.logger.error(errorMessage, error)
this.showErrorIcon = true
setTimeout(() => { this.showErrorIcon = false }, 2000)

+ 15
- 7
apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayNameSection.vue View File

@@ -17,6 +17,7 @@
-
- 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>
@@ -26,13 +27,17 @@
label-for="displayname"
:is-editable="displayNameChangeSupported"
:is-valid-section="isValidSection"
:handle-scope-change="savePrimaryDisplayNameScope"
:scope.sync="primaryDisplayName.scope" />

<template v-if="displayNameChangeSupported">
<DisplayName
:display-name.sync="primaryDisplayName.value"
:scope.sync="primaryDisplayName.scope" />

<VisibilityDropdown
:param-id="accountPropertyId"
:display-id="accountProperty"
:visibility.sync="visibility" />
</template>

<span v-else>
@@ -46,13 +51,14 @@ import { loadState } from '@nextcloud/initial-state'

import DisplayName from './DisplayName'
import HeaderBar from '../shared/HeaderBar'
import VisibilityDropdown from '../shared/VisibilityDropdown'

import { ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
import { savePrimaryDisplayNameScope } from '../../../service/PersonalInfo/DisplayNameService'
import { validateDisplayName } from '../../../utils/validate'
import { ACCOUNT_PROPERTY_ENUM, ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
import { validateStringInput } from '../../../utils/validate'

const { displayNames: { primaryDisplayName } } = loadState('settings', 'personalInfoParameters', {})
const { displayNameMap: { primaryDisplayName } } = loadState('settings', 'personalInfoParameters', {})
const { displayNameChangeSupported } = loadState('settings', 'accountParameters', {})
const { profileConfig: { displayname: { visibility } } } = loadState('settings', 'profileParameters', {})

export default {
name: 'DisplayNameSection',
@@ -60,20 +66,22 @@ export default {
components: {
DisplayName,
HeaderBar,
VisibilityDropdown,
},

data() {
return {
accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.DISPLAYNAME,
accountPropertyId: ACCOUNT_PROPERTY_ENUM.DISPLAYNAME,
displayNameChangeSupported,
primaryDisplayName,
savePrimaryDisplayNameScope,
visibility,
}
},

computed: {
isValidSection() {
return validateDisplayName(this.primaryDisplayName.value)
return validateStringInput(this.primaryDisplayName.value)
},
},
}

+ 12
- 13
apps/settings/src/components/PersonalInfo/EmailSection/Email.vue View File

@@ -17,22 +17,21 @@
-
- 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>
<div class="email">
<input
id="email"
:id="inputId"
ref="email"
type="email"
:name="inputName"
:placeholder="inputPlaceholder"
:value="email"
autocapitalize="none"
autocomplete="on"
autocorrect="off"
required
@input="onEmailChange">

<div class="email__actions-container">
@@ -47,7 +46,7 @@
:additional="true"
:additional-value="email"
:disabled="federationDisabled"
:handle-scope-change="saveAdditionalEmailScope"
:handle-additional-scope-change="saveAdditionalEmailScope"
:scope.sync="localScope"
@update:scope="onScopeChange" />
</template>
@@ -185,11 +184,11 @@ export default {
return !this.initialEmail
},

inputName() {
inputId() {
if (this.primary) {
return 'email'
}
return 'additionalEmail[]'
return `email-${this.index}`
},

inputPlaceholder() {
@@ -253,12 +252,12 @@ export default {
} catch (e) {
if (email === '') {
this.handleResponse({
errorMessage: 'Unable to delete primary email address',
errorMessage: t('settings', 'Unable to delete primary email address'),
error: e,
})
} else {
this.handleResponse({
errorMessage: 'Unable to update primary email address',
errorMessage: t('settings', 'Unable to update primary email address'),
error: e,
})
}
@@ -274,7 +273,7 @@ export default {
})
} catch (e) {
this.handleResponse({
errorMessage: 'Unable to add additional email address',
errorMessage: t('settings', 'Unable to add additional email address'),
error: e,
})
}
@@ -305,7 +304,7 @@ export default {
})
} catch (e) {
this.handleResponse({
errorMessage: 'Unable to update additional email address',
errorMessage: t('settings', 'Unable to update additional email address'),
error: e,
})
}
@@ -317,7 +316,7 @@ export default {
this.handleDeleteAdditionalEmail(responseData.ocs?.meta?.status)
} catch (e) {
this.handleResponse({
errorMessage: 'Unable to delete additional email address',
errorMessage: t('settings', 'Unable to delete additional email address'),
error: e,
})
}
@@ -328,7 +327,7 @@ export default {
this.$emit('delete-additional-email')
} else {
this.handleResponse({
errorMessage: 'Unable to delete additional email address',
errorMessage: t('settings', 'Unable to delete additional email address'),
})
}
},
@@ -344,7 +343,7 @@ export default {
this.showCheckmarkIcon = true
setTimeout(() => { this.showCheckmarkIcon = false }, 2000)
} else {
showError(t('settings', errorMessage))
showError(errorMessage)
this.logger.error(errorMessage, error)
this.showErrorIcon = true
setTimeout(() => { this.showErrorIcon = false }, 2000)

+ 49
- 16
apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue View File

@@ -17,6 +17,7 @@
-
- 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>
@@ -39,20 +40,30 @@
:active-notification-email.sync="notificationEmail"
@update:email="onUpdateEmail"
@update:notification-email="onUpdateNotificationEmail" />

<VisibilityDropdown
:param-id="accountPropertyId"
:display-id="accountProperty"
:visibility.sync="visibility" />
</template>

<span v-else>
{{ primaryEmail.value || t('settings', 'No email address set') }}
</span>
<Email v-for="(additionalEmail, index) in additionalEmails"
:key="index"
:index="index"
:scope.sync="additionalEmail.scope"
:email.sync="additionalEmail.value"
:local-verification-state="parseInt(additionalEmail.locallyVerified, 10)"
:active-notification-email.sync="notificationEmail"
@update:email="onUpdateEmail"
@update:notification-email="onUpdateNotificationEmail"
@delete-additional-email="onDeleteAdditionalEmail(index)" />

<template v-if="additionalEmails.length">
<em class="additional-emails-label">{{ t('settings', 'Additional emails') }}</em>
<Email v-for="(additionalEmail, index) in additionalEmails"
:key="index"
:index="index"
:scope.sync="additionalEmail.scope"
:email.sync="additionalEmail.value"
:local-verification-state="parseInt(additionalEmail.locallyVerified, 10)"
:active-notification-email.sync="notificationEmail"
@update:email="onUpdateEmail"
@update:notification-email="onUpdateNotificationEmail"
@delete-additional-email="onDeleteAdditionalEmail(index)" />
</template>
</section>
</template>

@@ -62,13 +73,15 @@ import { showError } from '@nextcloud/dialogs'

import Email from './Email'
import HeaderBar from '../shared/HeaderBar'
import VisibilityDropdown from '../shared/VisibilityDropdown'

import { ACCOUNT_PROPERTY_READABLE_ENUM, DEFAULT_ADDITIONAL_EMAIL_SCOPE } from '../../../constants/AccountPropertyConstants'
import { ACCOUNT_PROPERTY_ENUM, ACCOUNT_PROPERTY_READABLE_ENUM, DEFAULT_ADDITIONAL_EMAIL_SCOPE } from '../../../constants/AccountPropertyConstants'
import { savePrimaryEmail, savePrimaryEmailScope, removeAdditionalEmail } from '../../../service/PersonalInfo/EmailService'
import { validateEmail } from '../../../utils/validate'

const { emails: { additionalEmails, primaryEmail, notificationEmail } } = loadState('settings', 'personalInfoParameters', {})
const { emailMap: { additionalEmails, primaryEmail, notificationEmail } } = loadState('settings', 'personalInfoParameters', {})
const { displayNameChangeSupported } = loadState('settings', 'accountParameters', {})
const { profileConfig: { email: { visibility } } } = loadState('settings', 'profileParameters', {})

export default {
name: 'EmailSection',
@@ -76,16 +89,19 @@ export default {
components: {
HeaderBar,
Email,
VisibilityDropdown,
},

data() {
return {
accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL,
accountPropertyId: ACCOUNT_PROPERTY_ENUM.EMAIL,
additionalEmails,
displayNameChangeSupported,
primaryEmail,
savePrimaryEmailScope,
notificationEmail,
visibility,
}
},

@@ -141,7 +157,11 @@ export default {
const responseData = await savePrimaryEmail(this.primaryEmailValue)
this.handleResponse(responseData.ocs?.meta?.status)
} catch (e) {
this.handleResponse('error', 'Unable to update primary email address', e)
this.handleResponse(
'error',
t('settings', 'Unable to update primary email address'),
e
)
}
},

@@ -150,7 +170,11 @@ export default {
const responseData = await removeAdditionalEmail(this.firstAdditionalEmail)
this.handleDeleteFirstAdditionalEmail(responseData.ocs?.meta?.status)
} catch (e) {
this.handleResponse('error', 'Unable to delete additional email address', e)
this.handleResponse(
'error',
t('settings', 'Unable to delete additional email address'),
e
)
}
},

@@ -158,13 +182,17 @@ export default {
if (status === 'ok') {
this.$delete(this.additionalEmails, 0)
} else {
this.handleResponse('error', 'Unable to delete additional email address', {})
this.handleResponse(
'error',
t('settings', 'Unable to delete additional email address'),
{}
)
}
},

handleResponse(status, errorMessage, error) {
if (status !== 'ok') {
showError(t('settings', errorMessage))
showError(errorMessage)
this.logger.error(errorMessage, error)
}
},
@@ -179,5 +207,10 @@ section {
&::v-deep button:disabled {
cursor: default;
}

.additional-emails-label {
display: block;
margin-top: 16px;
}
}
</style>

+ 175
- 0
apps/settings/src/components/PersonalInfo/HeadlineSection/Headline.vue View File

@@ -0,0 +1,175 @@
<!--
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @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 class="headline">
<input
id="headline"
type="text"
:placeholder="t('settings', 'Your headline')"
:value="headline"
autocapitalize="none"
autocomplete="on"
autocorrect="off"
@input="onHeadlineChange">

<div class="headline__actions-container">
<transition name="fade">
<span v-if="showCheckmarkIcon" class="icon-checkmark" />
<span v-else-if="showErrorIcon" class="icon-error" />
</transition>
</div>
</div>
</template>

<script>
import { showError } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import debounce from 'debounce'

import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'
import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService'

export default {
name: 'Headline',

props: {
headline: {
type: String,
required: true,
},
scope: {
type: String,
required: true,
},
},

data() {
return {
initialHeadline: this.headline,
localScope: this.scope,
showCheckmarkIcon: false,
showErrorIcon: false,
}
},

methods: {
onHeadlineChange(e) {
this.$emit('update:headline', e.target.value)
this.debounceHeadlineChange(e.target.value.trim())
},

debounceHeadlineChange: debounce(async function(headline) {
await this.updatePrimaryHeadline(headline)
}, 500),

async updatePrimaryHeadline(headline) {
try {
const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.HEADLINE, headline)
this.handleResponse({
headline,
status: responseData.ocs?.meta?.status,
})
} catch (e) {
this.handleResponse({
errorMessage: t('settings', 'Unable to update headline'),
error: e,
})
}
},

handleResponse({ headline, status, errorMessage, error }) {
if (status === 'ok') {
// Ensure that local state reflects server state
this.initialHeadline = headline
emit('settings:headline:updated', headline)
this.showCheckmarkIcon = true
setTimeout(() => { this.showCheckmarkIcon = false }, 2000)
} else {
showError(errorMessage)
this.logger.error(errorMessage, error)
this.showErrorIcon = true
setTimeout(() => { this.showErrorIcon = false }, 2000)
}
},

onScopeChange(scope) {
this.$emit('update:scope', scope)
},
},
}
</script>

<style lang="scss" scoped>
.headline {
display: grid;
align-items: center;

input {
grid-area: 1 / 1;
width: 100%;
height: 34px;
margin: 3px 3px 3px 0;
padding: 7px 6px;
color: var(--color-main-text);
border: 1px solid var(--color-border-dark);
border-radius: var(--border-radius);
background-color: var(--color-main-background);
font-family: var(--font-face);
cursor: text;
}

.headline__actions-container {
grid-area: 1 / 1;
justify-self: flex-end;
height: 30px;

display: flex;
gap: 0 2px;
margin-right: 5px;

.icon-checkmark,
.icon-error {
height: 30px !important;
min-height: 30px !important;
width: 30px !important;
min-width: 30px !important;
top: 0;
right: 0;
float: none;
}
}
}

.fade-enter,
.fade-leave-to {
opacity: 0;
}

.fade-enter-active {
transition: opacity 200ms ease-out;
}

.fade-leave-active {
transition: opacity 300ms ease-out;
}
</style>

+ 81
- 0
apps/settings/src/components/PersonalInfo/HeadlineSection/HeadlineSection.vue View File

@@ -0,0 +1,81 @@
<!--
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @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>
<section>
<HeaderBar
:account-property="accountProperty"
label-for="headline"
:scope.sync="primaryHeadline.scope" />

<Headline
:headline.sync="primaryHeadline.value"
:scope.sync="primaryHeadline.scope" />

<VisibilityDropdown
:param-id="accountPropertyId"
:display-id="accountProperty"
:visibility.sync="visibility" />
</section>
</template>

<script>
import { loadState } from '@nextcloud/initial-state'

import Headline from './Headline'
import HeaderBar from '../shared/HeaderBar'
import VisibilityDropdown from '../shared/VisibilityDropdown'

import { ACCOUNT_PROPERTY_ENUM, ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'

const { headlineMap: { primaryHeadline } } = loadState('settings', 'personalInfoParameters', {})
const { profileConfig: { headline: { visibility } } } = loadState('settings', 'profileParameters', {})

export default {
name: 'HeadlineSection',

components: {
Headline,
HeaderBar,
VisibilityDropdown,
},

data() {
return {
accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.HEADLINE,
accountPropertyId: ACCOUNT_PROPERTY_ENUM.HEADLINE,
primaryHeadline,
visibility,
}
},
}
</script>

<style lang="scss" scoped>
section {
padding: 10px 10px;

&::v-deep button:disabled {
cursor: default;
}
}
</style>

+ 6
- 7
apps/settings/src/components/PersonalInfo/LanguageSection/Language.vue View File

@@ -17,16 +17,14 @@
-
- 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 class="language">
<select
id="language"
ref="language"
name="language"
:placeholder="t('settings', 'Language')"
required
@change="onLanguageChange">
<option v-for="commonLanguage in commonLanguages"
:key="commonLanguage.code"
@@ -57,7 +55,8 @@
<script>
import { showError } from '@nextcloud/dialogs'

import { saveLanguage } from '../../../service/PersonalInfo/LanguageService'
import { ACCOUNT_SETTING_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'
import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService'
import { validateLanguage } from '../../../utils/validate'

export default {
@@ -105,7 +104,7 @@ export default {

async updateLanguage(language) {
try {
const responseData = await saveLanguage(language.code)
const responseData = await savePrimaryAccountProperty(ACCOUNT_SETTING_PROPERTY_ENUM.LANGUAGE, language.code)
this.handleResponse({
language,
status: responseData.ocs?.meta?.status,
@@ -113,7 +112,7 @@ export default {
this.reloadPage()
} catch (e) {
this.handleResponse({
errorMessage: 'Unable to update language',
errorMessage: t('settings', 'Unable to update language'),
error: e,
})
}
@@ -131,7 +130,7 @@ export default {
// Ensure that local state reflects server state
this.initialLanguage = language
} else {
showError(t('settings', errorMessage))
showError(errorMessage)
this.logger.error(errorMessage, error)
}
},

+ 5
- 10
apps/settings/src/components/PersonalInfo/LanguageSection/LanguageSection.vue View File

@@ -17,14 +17,14 @@
-
- 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>
<section>
<HeaderBar
:account-property="accountProperty"
label-for="language"
:is-valid-section="isValidSection" />
label-for="language" />

<template v-if="isEditable">
<Language
@@ -45,10 +45,9 @@ import { loadState } from '@nextcloud/initial-state'
import Language from './Language'
import HeaderBar from '../shared/HeaderBar'

import { SETTING_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
import { validateLanguage } from '../../../utils/validate'
import { ACCOUNT_SETTING_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'

const { languages: { activeLanguage, commonLanguages, otherLanguages } } = loadState('settings', 'personalInfoParameters', {})
const { languageMap: { activeLanguage, commonLanguages, otherLanguages } } = loadState('settings', 'personalInfoParameters', {})

export default {
name: 'LanguageSection',
@@ -60,7 +59,7 @@ export default {

data() {
return {
accountProperty: SETTING_PROPERTY_READABLE_ENUM.LANGUAGE,
accountProperty: ACCOUNT_SETTING_PROPERTY_READABLE_ENUM.LANGUAGE,
commonLanguages,
otherLanguages,
language: activeLanguage,
@@ -71,10 +70,6 @@ export default {
isEditable() {
return Boolean(this.language)
},

isValidSection() {
return validateLanguage(this.language)
},
},
}
</script>

+ 175
- 0
apps/settings/src/components/PersonalInfo/OrganisationSection/Organisation.vue View File

@@ -0,0 +1,175 @@
<!--
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @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 class="organisation">
<input
id="organisation"
type="text"
:placeholder="t('settings', 'Your organisation')"
:value="organisation"
autocapitalize="none"
autocomplete="on"
autocorrect="off"
@input="onOrganisationChange">

<div class="organisation__actions-container">
<transition name="fade">
<span v-if="showCheckmarkIcon" class="icon-checkmark" />
<span v-else-if="showErrorIcon" class="icon-error" />
</transition>
</div>
</div>
</template>

<script>
import { showError } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import debounce from 'debounce'

import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'
import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService'

export default {
name: 'Organisation',

props: {
organisation: {
type: String,
required: true,
},
scope: {
type: String,
required: true,
},
},

data() {
return {
initialOrganisation: this.organisation,
localScope: this.scope,
showCheckmarkIcon: false,
showErrorIcon: false,
}
},

methods: {
onOrganisationChange(e) {
this.$emit('update:organisation', e.target.value)
this.debounceOrganisationChange(e.target.value.trim())
},

debounceOrganisationChange: debounce(async function(organisation) {
await this.updatePrimaryOrganisation(organisation)
}, 500),

async updatePrimaryOrganisation(organisation) {
try {
const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.ORGANISATION, organisation)
this.handleResponse({
organisation,
status: responseData.ocs?.meta?.status,
})
} catch (e) {
this.handleResponse({
errorMessage: t('settings', 'Unable to update organisation'),
error: e,
})
}
},

handleResponse({ organisation, status, errorMessage, error }) {
if (status === 'ok') {
// Ensure that local state reflects server state
this.initialOrganisation = organisation
emit('settings:organisation:updated', organisation)
this.showCheckmarkIcon = true
setTimeout(() => { this.showCheckmarkIcon = false }, 2000)
} else {
showError(errorMessage)
this.logger.error(errorMessage, error)
this.showErrorIcon = true
setTimeout(() => { this.showErrorIcon = false }, 2000)
}
},

onScopeChange(scope) {
this.$emit('update:scope', scope)
},
},
}
</script>

<style lang="scss" scoped>
.organisation {
display: grid;
align-items: center;

input {
grid-area: 1 / 1;
width: 100%;
height: 34px;
margin: 3px 3px 3px 0;
padding: 7px 6px;
color: var(--color-main-text);
border: 1px solid var(--color-border-dark);
border-radius: var(--border-radius);
background-color: var(--color-main-background);
font-family: var(--font-face);
cursor: text;
}

.organisation__actions-container {
grid-area: 1 / 1;
justify-self: flex-end;
height: 30px;

display: flex;
gap: 0 2px;
margin-right: 5px;

.icon-checkmark,
.icon-error {
height: 30px !important;
min-height: 30px !important;
width: 30px !important;
min-width: 30px !important;
top: 0;
right: 0;
float: none;
}
}
}

.fade-enter,
.fade-leave-to {
opacity: 0;
}

.fade-enter-active {
transition: opacity 200ms ease-out;
}

.fade-leave-active {
transition: opacity 300ms ease-out;
}
</style>

+ 81
- 0
apps/settings/src/components/PersonalInfo/OrganisationSection/OrganisationSection.vue View File

@@ -0,0 +1,81 @@
<!--
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @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>
<section>
<HeaderBar
:account-property="accountProperty"
label-for="organisation"
:scope.sync="primaryOrganisation.scope" />

<Organisation
:organisation.sync="primaryOrganisation.value"
:scope.sync="primaryOrganisation.scope" />

<VisibilityDropdown
:param-id="accountPropertyId"
:display-id="accountProperty"
:visibility.sync="visibility" />
</section>
</template>

<script>
import { loadState } from '@nextcloud/initial-state'

import Organisation from './Organisation'
import HeaderBar from '../shared/HeaderBar'
import VisibilityDropdown from '../shared/VisibilityDropdown'

import { ACCOUNT_PROPERTY_ENUM, ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'

const { organisationMap: { primaryOrganisation } } = loadState('settings', 'personalInfoParameters', {})
const { profileConfig: { organisation: { visibility } } } = loadState('settings', 'profileParameters', {})

export default {
name: 'OrganisationSection',

components: {
Organisation,
HeaderBar,
VisibilityDropdown,
},

data() {
return {
accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.ORGANISATION,
accountPropertyId: ACCOUNT_PROPERTY_ENUM.ORGANISATION,
primaryOrganisation,
visibility,
}
},
}
</script>

<style lang="scss" scoped>
section {
padding: 10px 10px;

&::v-deep button:disabled {
cursor: default;
}
}
</style>

+ 101
- 0
apps/settings/src/components/PersonalInfo/ProfileSection/ProfileCheckbox.vue View File

@@ -0,0 +1,101 @@
<!--
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @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 class="checkbox-container">
<input
id="enable-profile"
class="checkbox"
type="checkbox"
:checked="profileEnabled"
@change="onEnableProfileChange">
<label for="enable-profile">
{{ t('settings', 'Enable Profile') }}
</label>
</div>
</template>

<script>
import { showError } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'

import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService'
import { validateBoolean } from '../../../utils/validate'
import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'

export default {
name: 'ProfileCheckbox',

props: {
profileEnabled: {
type: Boolean,
required: true,
},
},

data() {
return {
initialProfileEnabled: this.profileEnabled,
}
},

methods: {
async onEnableProfileChange(e) {
const isEnabled = e.target.checked
this.$emit('update:profile-enabled', isEnabled)

if (validateBoolean(isEnabled)) {
await this.updateEnableProfile(isEnabled)
}
},

async updateEnableProfile(isEnabled) {
try {
const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.PROFILE_ENABLED, isEnabled)
this.handleResponse({
isEnabled,
status: responseData.ocs?.meta?.status,
})
} catch (e) {
this.handleResponse({
errorMessage: t('settings', 'Unable to update profile enabled state'),
error: e,
})
}
},

handleResponse({ isEnabled, status, errorMessage, error }) {
if (status === 'ok') {
// Ensure that local state reflects server state
this.initialProfileEnabled = isEnabled
emit('settings:profile-enabled:updated', isEnabled)
} else {
showError(errorMessage)
this.logger.error(errorMessage, error)
}
},
},
}
</script>

<style lang="scss" scoped>
</style>

+ 192
- 0
apps/settings/src/components/PersonalInfo/ProfileSection/ProfilePreviewCard.vue View File

@@ -0,0 +1,192 @@
<!--
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @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>
<a
class="preview-card"
:class="{ disabled }"
:href="profilePageLink">
<Avatar
class="preview-card__avatar"
:user="userId"
:size="48"
:show-user-status="true"
:show-user-status-compact="false"
:disable-menu="true"
:disable-tooltip="true"
@click.native.prevent.stop="openStatusModal" />
<div class="preview-card__header">
<span>{{ displayName }}</span>
</div>
<div class="preview-card__footer">
<span>{{ organisation }}</span>
</div>
</a>
</template>

<script>
import { getCurrentUser } from '@nextcloud/auth'
import { generateUrl } from '@nextcloud/router'

import Avatar from '@nextcloud/vue/dist/Components/Avatar'

export default {
name: 'ProfilePreviewCard',

components: {
Avatar,
},

props: {
organisation: {
type: String,
required: true,
},
displayName: {
type: String,
required: true,
},
profileEnabled: {
type: Boolean,
required: true,
},
userId: {
type: String,
required: true,
},
},

data() {
return {
}
},

computed: {
disabled() {
return !this.profileEnabled
},

profilePageLink() {
if (this.profileEnabled) {
return generateUrl('/u/{userId}', { userId: getCurrentUser().uid })
}
// Since an anchor element is used rather than a button for better UX,
// this hack removes href if the profile is disabled so that disabling pointer-events is not needed to prevent a click from opening a page
// and to allow the hover event (which disabling pointer-events wouldn't allow) for styling
return null
},
},

methods: {
},
}
</script>

<style lang="scss" scoped>
.preview-card {
display: flex;
flex-direction: column;
position: relative;
width: 290px;
height: 116px;
margin: 14px auto;
border-radius: var(--border-radius-large);
background-color: var(--color-main-background);
font-weight: bold;
box-shadow: 0 2px 9px var(--color-box-shadow);

&:hover {
box-shadow: 0 2px 12px var(--color-box-shadow);
}

&.disabled {
filter: grayscale(1);
opacity: 0.5;
cursor: default;
box-shadow: 0 0 3px var(--color-box-shadow);

& *,
&::v-deep * {
cursor: default;
}
}

&__avatar {
// Override Avatar component position to fix positioning on rerender
position: absolute !important;
top: 40px;
left: 18px;
z-index: 1;

&:not(.avatardiv--unknown) {
box-shadow: 0 0 0 3px var(--color-main-background) !important;
}
}

&__header {
position: relative !important;
width: auto !important;
height: 70px !important;
border-radius: var(--border-radius-large) var(--border-radius-large) 0 0 !important;

span {
position: absolute;
bottom: 0;
left: 78px;
color: var(--color-primary-text);
font-size: 18px;
font-weight: bold;
margin-bottom: 8px;
}
}

&__footer {
position: relative;
width: auto;
height: 46px;

span {
position: absolute;
top: 0;
left: 78px;
color: var(--color-text-maxcontrast);
font-size: 14px;
font-weight: normal;
margin-top: 4px;
line-height: 1.3;

overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;

@supports (-webkit-line-clamp: 2) {
overflow: hidden;
white-space: initial;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
}
}
</style>

+ 105
- 0
apps/settings/src/components/PersonalInfo/ProfileSection/ProfileSection.vue View File

@@ -0,0 +1,105 @@
<!--
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @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>
<section>
<HeaderBar
:account-property="accountProperty" />

<ProfileCheckbox
:profile-enabled.sync="profileEnabled" />

<ProfilePreviewCard
:organisation="organisation"
:display-name="displayName"
:profile-enabled="profileEnabled"
:user-id="userId" />
</section>
</template>

<script>
import { loadState } from '@nextcloud/initial-state'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'

import HeaderBar from '../shared/HeaderBar'
import ProfileCheckbox from './ProfileCheckbox'
import ProfilePreviewCard from './ProfilePreviewCard'

import { ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'

const {
organisationMap: { primaryOrganisation: { value: organisation } },
displayNameMap: { primaryDisplayName: { value: displayName } },
profileEnabled,
userId,
} = loadState('settings', 'personalInfoParameters', {})

export default {
name: 'ProfileSection',

components: {
HeaderBar,
ProfileCheckbox,
ProfilePreviewCard,
},

data() {
return {
accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED,
organisation,
displayName,
profileEnabled,
userId,
}
},

mounted() {
subscribe('settings:display-name:updated', this.handleDisplayNameUpdate)
subscribe('settings:organisation:updated', this.handleOrganisationUpdate)
},

beforeDestroy() {
unsubscribe('settings:display-name:updated', this.handleDisplayNameUpdate)
unsubscribe('settings:organisation:updated', this.handleOrganisationUpdate)
},

methods: {
handleDisplayNameUpdate(displayName) {
this.displayName = displayName
},

handleOrganisationUpdate(organisation) {
this.organisation = organisation
},
},
}
</script>

<style lang="scss" scoped>
section {
padding: 10px 10px;

&::v-deep button:disabled {
cursor: default;
}
}
</style>

+ 117
- 0
apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/ProfileVisibilitySection.vue View File

@@ -0,0 +1,117 @@
<!--
- @copyright 2021 Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @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>
<section>
<HeaderBar
:account-property="heading" />

<VisibilityDropdown v-for="parameter in visibilityArray"
:key="parameter.id"
:param-id="parameter.id"
:display-id="parameter.displayId"
:show-display-id="true"
:visibility.sync="parameter.visibility" />

<em :class="{ disabled }">{{ t('settings', 'The more restrictive setting of either visibility or scope is respected on your Profile — For example, when visibility is set to "Show to everyone" and scope is set to "Private", "Private" will be respected') }}</em>
</section>
</template>

<script>
import { loadState } from '@nextcloud/initial-state'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'

import HeaderBar from '../shared/HeaderBar'
import VisibilityDropdown from '../shared/VisibilityDropdown'
import { ACCOUNT_PROPERTY_ENUM, PROFILE_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'

const { profileConfig } = loadState('settings', 'profileParameters', {})
const { profileEnabled } = loadState('settings', 'personalInfoParameters', false)

export default {
name: 'ProfileVisibilitySection',

components: {
HeaderBar,
VisibilityDropdown,
},

data() {
return {
heading: PROFILE_READABLE_ENUM.PROFILE_VISIBILITY,
profileEnabled,
visibilityArray: Object.entries(profileConfig)
// Filter for profile parameters registered by apps in this section as visibility controls for the rest (account properties) are handled in their respective property sections
.filter(([paramId, { displayId, visibility }]) => !Object.values(ACCOUNT_PROPERTY_ENUM).includes(paramId))
.map(([paramId, { displayId, visibility }]) => ({ id: paramId, displayId, visibility })),
}
},

computed: {
disabled() {
return !this.profileEnabled
},
},

mounted() {
subscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate)
},

beforeDestroy() {
unsubscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate)
},

methods: {
handleProfileEnabledUpdate(profileEnabled) {
this.profileEnabled = profileEnabled
},
},
}
</script>

<style lang="scss" scoped>
section {
padding: 10px 10px;

em {
display: block;
margin-top: 16px;

&.disabled {
filter: grayscale(1);
opacity: 0.5;
cursor: default;
pointer-events: none;

& *,
&::v-deep * {
cursor: default;
pointer-events: none;
}
}
}

&::v-deep button:disabled {
cursor: default;
}
}
</style>

+ 175
- 0
apps/settings/src/components/PersonalInfo/RoleSection/Role.vue View File

@@ -0,0 +1,175 @@
<!--
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @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 class="role">
<input
id="role"
type="text"
:placeholder="t('settings', 'Your role')"
:value="role"
autocapitalize="none"
autocomplete="on"
autocorrect="off"
@input="onRoleChange">

<div class="role__actions-container">
<transition name="fade">
<span v-if="showCheckmarkIcon" class="icon-checkmark" />
<span v-else-if="showErrorIcon" class="icon-error" />
</transition>
</div>
</div>
</template>

<script>
import { showError } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import debounce from 'debounce'

import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'
import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService'

export default {
name: 'Role',

props: {
role: {
type: String,
required: true,
},
scope: {
type: String,
required: true,
},
},

data() {
return {
initialRole: this.role,
localScope: this.scope,
showCheckmarkIcon: false,
showErrorIcon: false,
}
},

methods: {
onRoleChange(e) {
this.$emit('update:role', e.target.value)
this.debounceRoleChange(e.target.value.trim())
},

debounceRoleChange: debounce(async function(role) {
await this.updatePrimaryRole(role)
}, 500),

async updatePrimaryRole(role) {
try {
const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.ROLE, role)
this.handleResponse({
role,
status: responseData.ocs?.meta?.status,
})
} catch (e) {
this.handleResponse({
errorMessage: t('settings', 'Unable to update role'),
error: e,
})
}
},

handleResponse({ role, status, errorMessage, error }) {
if (status === 'ok') {
// Ensure that local state reflects server state
this.initialRole = role
emit('settings:role:updated', role)
this.showCheckmarkIcon = true
setTimeout(() => { this.showCheckmarkIcon = false }, 2000)
} else {
showError(errorMessage)
this.logger.error(errorMessage, error)
this.showErrorIcon = true
setTimeout(() => { this.showErrorIcon = false }, 2000)
}
},

onScopeChange(scope) {
this.$emit('update:scope', scope)
},
},
}
</script>

<style lang="scss" scoped>
.role {
display: grid;
align-items: center;

input {
grid-area: 1 / 1;
width: 100%;
height: 34px;
margin: 3px 3px 3px 0;
padding: 7px 6px;
color: var(--color-main-text);
border: 1px solid var(--color-border-dark);
border-radius: var(--border-radius);
background-color: var(--color-main-background);
font-family: var(--font-face);
cursor: text;
}

.role__actions-container {
grid-area: 1 / 1;
justify-self: flex-end;
height: 30px;

display: flex;
gap: 0 2px;
margin-right: 5px;

.icon-checkmark,
.icon-error {
height: 30px !important;
min-height: 30px !important;
width: 30px !important;
min-width: 30px !important;
top: 0;
right: 0;
float: none;
}
}
}

.fade-enter,
.fade-leave-to {
opacity: 0;
}

.fade-enter-active {
transition: opacity 200ms ease-out;
}

.fade-leave-active {
transition: opacity 300ms ease-out;
}
</style>

+ 81
- 0
apps/settings/src/components/PersonalInfo/RoleSection/RoleSection.vue View File

@@ -0,0 +1,81 @@
<!--
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @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>
<section>
<HeaderBar
:account-property="accountProperty"
label-for="role"
:scope.sync="primaryRole.scope" />

<Role
:role.sync="primaryRole.value"
:scope.sync="primaryRole.scope" />

<VisibilityDropdown
:param-id="accountPropertyId"
:display-id="accountProperty"
:visibility.sync="visibility" />
</section>
</template>

<script>
import { loadState } from '@nextcloud/initial-state'

import Role from './Role'
import HeaderBar from '../shared/HeaderBar'
import VisibilityDropdown from '../shared/VisibilityDropdown'

import { ACCOUNT_PROPERTY_ENUM, ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'

const { roleMap: { primaryRole } } = loadState('settings', 'personalInfoParameters', {})
const { profileConfig: { role: { visibility } } } = loadState('settings', 'profileParameters', {})

export default {
name: 'RoleSection',

components: {
Role,
HeaderBar,
VisibilityDropdown,
},

data() {
return {
accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.ROLE,
accountPropertyId: ACCOUNT_PROPERTY_ENUM.ROLE,
primaryRole,
visibility,
}
},
}
</script>

<style lang="scss" scoped>
section {
padding: 10px 10px;

&::v-deep button:disabled {
cursor: default;
}
}
</style>

+ 1
- 0
apps/settings/src/components/PersonalInfo/shared/AddButton.vue View File

@@ -17,6 +17,7 @@
-
- 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>

+ 24
- 37
apps/settings/src/components/PersonalInfo/shared/FederationControl.vue View File

@@ -17,6 +17,7 @@
-
- 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>
@@ -25,27 +26,28 @@
:aria-label="ariaLabel"
:default-icon="scopeIcon"
:disabled="disabled">
<ActionButton v-for="federationScope in federationScopes"
<FederationControlAction v-for="federationScope in federationScopes"
:key="federationScope.name"
:aria-label="federationScope.tooltip"
class="federation-actions__btn"
:class="{ 'federation-actions__btn--active': scope === federationScope.name }"
:close-after-click="true"
:icon="federationScope.iconClass"
:title="federationScope.displayName"
@click.stop.prevent="changeScope(federationScope.name)">
{{ federationScope.tooltip }}
</ActionButton>
:active-scope="scope"
:display-name="federationScope.displayName"
:handle-scope-change="changeScope"
:icon-class="federationScope.iconClass"
:is-supported-scope="supportedScopes.includes(federationScope.name)"
:name="federationScope.name"
:tooltip-disabled="federationScope.tooltipDisabled"
:tooltip="federationScope.tooltip" />
</Actions>
</template>

<script>
import Actions from '@nextcloud/vue/dist/Components/Actions'
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
import { loadState } from '@nextcloud/initial-state'
import { showError } from '@nextcloud/dialogs'

import { ACCOUNT_PROPERTY_READABLE_ENUM, PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM, SCOPE_ENUM, SCOPE_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'
import FederationControlAction from './FederationControlAction'

import { ACCOUNT_PROPERTY_READABLE_ENUM, PROPERTY_READABLE_KEYS_ENUM, PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM, SCOPE_ENUM, SCOPE_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'
import { savePrimaryAccountPropertyScope } from '../../../service/PersonalInfo/PersonalInfoService'

const { lookupServerUploadEnabled } = loadState('settings', 'accountParameters', {})

@@ -54,7 +56,7 @@ export default {

components: {
Actions,
ActionButton,
FederationControlAction,
},

props: {
@@ -75,9 +77,9 @@ export default {
type: Boolean,
default: false,
},
handleScopeChange: {
handleAdditionalScopeChange: {
type: Function,
required: true,
default: null,
},
scope: {
type: String,
@@ -94,17 +96,17 @@ export default {

computed: {
ariaLabel() {
return t('settings', 'Change privacy level of {accountProperty}', { accountProperty: this.accountPropertyLowerCase })
},

federationScopes() {
return Object.values(SCOPE_PROPERTY_ENUM).filter(({ name }) => this.supportedScopes.includes(name))
return t('settings', 'Change scope level of {accountProperty}', { accountProperty: this.accountPropertyLowerCase })
},

scopeIcon() {
return SCOPE_PROPERTY_ENUM[this.scope].iconClass
},

federationScopes() {
return Object.values(SCOPE_PROPERTY_ENUM)
},

supportedScopes() {
if (lookupServerUploadEnabled) {
return [
@@ -131,7 +133,7 @@ export default {

async updatePrimaryScope(scope) {
try {
const responseData = await this.handleScopeChange(scope)
const responseData = await savePrimaryAccountPropertyScope(PROPERTY_READABLE_KEYS_ENUM[this.accountProperty], scope)
this.handleResponse({
scope,
status: responseData.ocs?.meta?.status,
@@ -146,7 +148,7 @@ export default {

async updateAdditionalScope(scope) {
try {
const responseData = await this.handleScopeChange(this.additionalValue, scope)
const responseData = await this.handleAdditionalScopeChange(this.additionalValue, scope)
this.handleResponse({
scope,
status: responseData.ocs?.meta?.status,
@@ -192,19 +194,4 @@ export default {
min-width: 30px !important;
}
}

.federation-actions__btn {
&::v-deep p {
width: 150px !important;
padding: 8px 0 !important;
color: var(--color-main-text) !important;
font-size: 12.8px !important;
line-height: 1.5em !important;
}
}

.federation-actions__btn--active {
background-color: var(--color-primary-light) !important;
box-shadow: inset 2px 0 var(--color-primary) !important;
}
</style>

+ 105
- 0
apps/settings/src/components/PersonalInfo/shared/FederationControlAction.vue View File

@@ -0,0 +1,105 @@
<!--
- @copyright 2021 Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @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>
<ActionButton
:aria-label="isSupportedScope ? tooltip : tooltipDisabled"
class="federation-actions__btn"
:class="{ 'federation-actions__btn--active': activeScope === name }"
:close-after-click="true"
:disabled="!isSupportedScope"
:icon="iconClass"
:title="displayName"
@click.stop.prevent="updateScope">
{{ isSupportedScope ? tooltip : tooltipDisabled }}
</ActionButton>
</template>

<script>
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'

export default {
name: 'FederationControlAction',

components: {
ActionButton,
},

props: {
activeScope: {
type: String,
required: true,
},
displayName: {
type: String,
required: true,
},
handleScopeChange: {
type: Function,
default: () => {},
},
iconClass: {
type: String,
required: true,
},
isSupportedScope: {
type: Boolean,
required: true,
},
name: {
type: String,
required: true,
},
tooltipDisabled: {
type: String,
default: '',
},
tooltip: {
type: String,
required: true,
},
},

methods: {
updateScope() {
this.handleScopeChange(this.name)
},
},
}
</script>

<style lang="scss" scoped>
.federation-actions__btn {
&::v-deep p {
width: 150px !important;
padding: 8px 0 !important;
color: var(--color-main-text) !important;
font-size: 12.8px !important;
line-height: 1.5em !important;
}
}

.federation-actions__btn--active {
background-color: var(--color-primary-light) !important;
box-shadow: inset 2px 0 var(--color-primary) !important;
}
</style>

+ 16
- 12
apps/settings/src/components/PersonalInfo/shared/HeaderBar.vue View File

@@ -17,21 +17,21 @@
-
- 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>
<h3
:class="{ 'setting-property': isSettingProperty }">
:class="{ 'setting-property': isSettingProperty, 'profile-property': isProfileProperty }">
<label :for="labelFor">
<!-- Already translated as required by prop validator -->
{{ accountProperty }}
</label>

<template v-if="scope && handleScopeChange">
<template v-if="scope">
<FederationControl
class="federation-control"
:account-property="accountProperty"
:handle-scope-change="handleScopeChange"
:scope.sync="localScope"
@update:scope="onScopeChange" />
</template>
@@ -49,7 +49,7 @@
import AddButton from './AddButton'
import FederationControl from './FederationControl'

import { ACCOUNT_PROPERTY_READABLE_ENUM, SETTING_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
import { ACCOUNT_PROPERTY_READABLE_ENUM, ACCOUNT_SETTING_PROPERTY_READABLE_ENUM, PROFILE_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'

export default {
name: 'HeaderBar',
@@ -63,11 +63,7 @@ export default {
accountProperty: {
type: String,
required: true,
validator: (value) => Object.values(ACCOUNT_PROPERTY_READABLE_ENUM).includes(value) || Object.values(SETTING_PROPERTY_READABLE_ENUM).includes(value),
},
handleScopeChange: {
type: Function,
default: null,
validator: (value) => Object.values(ACCOUNT_PROPERTY_READABLE_ENUM).includes(value) || Object.values(ACCOUNT_SETTING_PROPERTY_READABLE_ENUM).includes(value) || value === PROFILE_READABLE_ENUM.PROFILE_VISIBILITY,
},
isEditable: {
type: Boolean,
@@ -83,7 +79,7 @@ export default {
},
labelFor: {
type: String,
required: true,
default: '',
},
scope: {
type: String,
@@ -98,8 +94,12 @@ export default {
},

computed: {
isProfileProperty() {
return this.accountProperty === ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED
},

isSettingProperty() {
return Object.values(SETTING_PROPERTY_READABLE_ENUM).includes(this.accountProperty)
return Object.values(ACCOUNT_SETTING_PROPERTY_READABLE_ENUM).includes(this.accountProperty)
},
},

@@ -123,10 +123,14 @@ export default {
font-size: 16px;
color: var(--color-text-light);

&.setting-property {
&.profile-property {
height: 38px;
}

&.setting-property {
height: 32px;
}

label {
cursor: pointer;
}

+ 178
- 0
apps/settings/src/components/PersonalInfo/shared/VisibilityDropdown.vue View File

@@ -0,0 +1,178 @@
<!--
- @copyright 2021 Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @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
class="visibility-container"
:class="{ disabled }">
<label :for="inputId">
{{ showDisplayId ? t('settings', '{displayId} visibility', { displayId }) : t('settings', 'Visibility on Profile') }}
</label>
<Multiselect
:id="inputId"
:options="visibilityOptions"
track-by="name"
label="label"
:value="visibilityObject"
@change="onVisibilityChange" />
</div>
</template>

<script>
import { showError } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'

import Multiselect from '@nextcloud/vue/dist/Components/Multiselect'

import { saveProfileParameterVisibility } from '../../../service/ProfileService'
import { validateStringInput } from '../../../utils/validate'
import { VISIBILITY_PROPERTY_ENUM } from '../../../constants/ProfileConstants'

const { profileConfig } = loadState('settings', 'profileParameters', {})
const { profileEnabled } = loadState('settings', 'personalInfoParameters', false)

export default {
name: 'VisibilityDropdown',

components: {
Multiselect,
},

props: {
paramId: {
type: String,
required: true,
},
displayId: {
type: String,
required: true,
},
showDisplayId: {
type: Boolean,
default: false,
},
},

data() {
return {
initialVisibility: profileConfig[this.paramId].visibility,
profileEnabled,
visibility: profileConfig[this.paramId].visibility,
}
},

computed: {
disabled() {
return !this.profileEnabled
},

inputId() {
return `profile-visibility-${this.paramId}`
},

visibilityObject() {
return VISIBILITY_PROPERTY_ENUM[this.visibility]
},

visibilityOptions() {
return Object.values(VISIBILITY_PROPERTY_ENUM)
},
},

mounted() {
subscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate)
},

beforeDestroy() {
unsubscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate)
},

methods: {
async onVisibilityChange(visibilityObject) {
// This check is needed as the argument is null when selecting the same option
if (visibilityObject !== null) {
const { name: visibility } = visibilityObject
this.visibility = visibility

if (validateStringInput(visibility)) {
await this.updateVisibility(visibility)
}
}
},

async updateVisibility(visibility) {
try {
const responseData = await saveProfileParameterVisibility(this.paramId, visibility)
this.handleResponse({
visibility,
status: responseData.ocs?.meta?.status,
})
} catch (e) {
this.handleResponse({
errorMessage: t('settings', 'Unable to update visibility of {displayId}', { displayId: this.displayId }),
error: e,
})
}
},

handleResponse({ visibility, status, errorMessage, error }) {
if (status === 'ok') {
// Ensure that local state reflects server state
this.initialVisibility = visibility
} else {
showError(errorMessage)
this.logger.error(errorMessage, error)
}
},

handleProfileEnabledUpdate(profileEnabled) {
this.profileEnabled = profileEnabled
},
},
}
</script>

<style lang="scss" scoped>
.visibility-container {
margin-top: 16px;
display: grid;

&.disabled {
filter: grayscale(1);
opacity: 0.5;
cursor: default;
pointer-events: none;

& *,
&::v-deep * {
cursor: default;
pointer-events: none;
}
}

label {
color: var(--color-text-lighter);
margin-bottom: 3px;
}
}
</style>

+ 61
- 16
apps/settings/src/constants/AccountPropertyConstants.js View File

@@ -21,7 +21,7 @@
*/

/*
* SYNC to be kept in sync with lib/public/Accounts/IAccountManager.php
* SYNC to be kept in sync with `lib/public/Accounts/IAccountManager.php`
*/

import { translate as t } from '@nextcloud/l10n'
@@ -30,11 +30,16 @@ import { translate as t } from '@nextcloud/l10n'
export const ACCOUNT_PROPERTY_ENUM = Object.freeze({
ADDRESS: 'address',
AVATAR: 'avatar',
BIOGRAPHY: 'biography',
DISPLAYNAME: 'displayname',
EMAIL: 'email',
EMAIL_COLLECTION: 'additional_mail',
EMAIL: 'email',
HEADLINE: 'headline',
NOTIFICATION_EMAIL: 'notify_email',
ORGANISATION: 'organisation',
PHONE: 'phone',
PROFILE_ENABLED: 'profile_enabled',
ROLE: 'role',
TWITTER: 'twitter',
WEBSITE: 'website',
})
@@ -43,28 +48,59 @@ export const ACCOUNT_PROPERTY_ENUM = Object.freeze({
export const ACCOUNT_PROPERTY_READABLE_ENUM = Object.freeze({
ADDRESS: t('settings', 'Address'),
AVATAR: t('settings', 'Avatar'),
BIOGRAPHY: t('settings', 'About'),
DISPLAYNAME: t('settings', 'Full name'),
EMAIL: t('settings', 'Email'),
EMAIL_COLLECTION: t('settings', 'Additional email'),
EMAIL: t('settings', 'Email'),
HEADLINE: t('settings', 'Headline'),
ORGANISATION: t('settings', 'Organisation'),
PHONE: t('settings', 'Phone number'),
PROFILE_ENABLED: t('settings', 'Profile'),
ROLE: t('settings', 'Role'),
TWITTER: t('settings', 'Twitter'),
WEBSITE: t('settings', 'Website'),
})

/** Enum of setting properties */
export const SETTING_PROPERTY_ENUM = Object.freeze({
/** Enum of profile specific sections to human readable names */
export const PROFILE_READABLE_ENUM = Object.freeze({
PROFILE_VISIBILITY: t('settings', 'Profile Visibility'),
})

/** Enum of readable account properties to account property keys used by the server */
export const PROPERTY_READABLE_KEYS_ENUM = Object.freeze({
[ACCOUNT_PROPERTY_READABLE_ENUM.ADDRESS]: ACCOUNT_PROPERTY_ENUM.ADDRESS,
[ACCOUNT_PROPERTY_READABLE_ENUM.AVATAR]: ACCOUNT_PROPERTY_ENUM.AVATAR,
[ACCOUNT_PROPERTY_READABLE_ENUM.BIOGRAPHY]: ACCOUNT_PROPERTY_ENUM.BIOGRAPHY,
[ACCOUNT_PROPERTY_READABLE_ENUM.DISPLAYNAME]: ACCOUNT_PROPERTY_ENUM.DISPLAYNAME,
[ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL_COLLECTION]: ACCOUNT_PROPERTY_ENUM.EMAIL_COLLECTION,
[ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL]: ACCOUNT_PROPERTY_ENUM.EMAIL,
[ACCOUNT_PROPERTY_READABLE_ENUM.HEADLINE]: ACCOUNT_PROPERTY_ENUM.HEADLINE,
[ACCOUNT_PROPERTY_READABLE_ENUM.ORGANISATION]: ACCOUNT_PROPERTY_ENUM.ORGANISATION,
[ACCOUNT_PROPERTY_READABLE_ENUM.PHONE]: ACCOUNT_PROPERTY_ENUM.PHONE,
[ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED]: ACCOUNT_PROPERTY_ENUM.PROFILE_ENABLED,
[ACCOUNT_PROPERTY_READABLE_ENUM.ROLE]: ACCOUNT_PROPERTY_ENUM.ROLE,
[ACCOUNT_PROPERTY_READABLE_ENUM.TWITTER]: ACCOUNT_PROPERTY_ENUM.TWITTER,
[ACCOUNT_PROPERTY_READABLE_ENUM.WEBSITE]: ACCOUNT_PROPERTY_ENUM.WEBSITE,
})

/**
* Enum of account setting properties
*
* *Account setting properties unlike account properties do not support scopes*
*/
export const ACCOUNT_SETTING_PROPERTY_ENUM = Object.freeze({
LANGUAGE: 'language',
})

/** Enum of setting properties to human readable setting properties */
export const SETTING_PROPERTY_READABLE_ENUM = Object.freeze({
LANGUAGE: 'Language',
/** Enum of account setting properties to human readable setting properties */
export const ACCOUNT_SETTING_PROPERTY_READABLE_ENUM = Object.freeze({
LANGUAGE: t('settings', 'Language'),
})

/** Enum of scopes */
export const SCOPE_ENUM = Object.freeze({
LOCAL: 'v2-local',
PRIVATE: 'v2-private',
LOCAL: 'v2-local',
FEDERATED: 'v2-federated',
PUBLISHED: 'v2-published',
})
@@ -73,10 +109,15 @@ export const SCOPE_ENUM = Object.freeze({
export const PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM = Object.freeze({
[ACCOUNT_PROPERTY_READABLE_ENUM.ADDRESS]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.AVATAR]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.BIOGRAPHY]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.DISPLAYNAME]: [SCOPE_ENUM.LOCAL],
[ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL]: [SCOPE_ENUM.LOCAL],
[ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL_COLLECTION]: [SCOPE_ENUM.LOCAL],
[ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL]: [SCOPE_ENUM.LOCAL],
[ACCOUNT_PROPERTY_READABLE_ENUM.HEADLINE]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.ORGANISATION]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.PHONE]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.ROLE]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.TWITTER]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.WEBSITE]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
})
@@ -90,28 +131,32 @@ export const SCOPE_SUFFIX = 'Scope'
* *Used for federation control*
*/
export const SCOPE_PROPERTY_ENUM = Object.freeze({
[SCOPE_ENUM.LOCAL]: {
name: SCOPE_ENUM.LOCAL,
displayName: t('settings', 'Local'),
tooltip: t('settings', 'Only visible to people on this instance and guests'),
iconClass: 'icon-password',
},
[SCOPE_ENUM.PRIVATE]: {
name: SCOPE_ENUM.PRIVATE,
displayName: t('settings', 'Private'),
tooltip: t('settings', 'Only visible to people matched via phone number integration through Talk on mobile'),
tooltipDisabled: t('settings', 'Unavailable as this property is required for core functionality including file sharing and calendar invitations\n\nOnly visible to people matched via phone number integration through Talk on mobile'),
iconClass: 'icon-phone',
},
[SCOPE_ENUM.LOCAL]: {
name: SCOPE_ENUM.LOCAL,
displayName: t('settings', 'Local'),
tooltip: t('settings', 'Only visible to people on this instance and guests'),
// tooltipDisabled is not required here as this scope is supported by all account properties
iconClass: 'icon-password',
},
[SCOPE_ENUM.FEDERATED]: {
name: SCOPE_ENUM.FEDERATED,
displayName: t('settings', 'Federated'),
tooltip: t('settings', 'Only synchronize to trusted servers'),
tooltipDisabled: t('settings', 'Unavailable as publishing user specific data to the lookup server is not allowed, contact your system administrator if you have any questions\n\nOnly synchronize to trusted servers'),
iconClass: 'icon-contacts-dark',
},
[SCOPE_ENUM.PUBLISHED]: {
name: SCOPE_ENUM.PUBLISHED,
displayName: t('settings', 'Published'),
tooltip: t('settings', 'Synchronize to trusted servers and the global and public address book'),
tooltipDisabled: t('settings', 'Unavailable as publishing user specific data to the lookup server is not allowed, contact your system administrator if you have any questions\n\nSynchronize to trusted servers and the global and public address book'),
iconClass: 'icon-link',
},
})

+ 50
- 0
apps/settings/src/constants/ProfileConstants.js View File

@@ -0,0 +1,50 @@
/**
* @copyright 2021 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
* @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/>.
*
*/

/*
* SYNC to be kept in sync with `core/Db/ProfileConfig.php`
*/

/** Enum of profile visibility constants */
export const VISIBILITY_ENUM = Object.freeze({
SHOW: 'show',
SHOW_USERS_ONLY: 'show_users_only',
HIDE: 'hide',
})

/**
* Enum of profile visibility constants to properties
*/
export const VISIBILITY_PROPERTY_ENUM = Object.freeze({
[VISIBILITY_ENUM.SHOW]: {
name: VISIBILITY_ENUM.SHOW,
label: t('settings', 'Show to everyone'),
},
[VISIBILITY_ENUM.SHOW_USERS_ONLY]: {
name: VISIBILITY_ENUM.SHOW_USERS_ONLY,
label: t('settings', 'Show to logged in users only'),
},
[VISIBILITY_ENUM.HIDE]: {
name: VISIBILITY_ENUM.HIDE,
label: t('settings', 'Hide'),
},
})

+ 44
- 3
apps/settings/src/main-personal-info.js View File

@@ -22,6 +22,7 @@

import Vue from 'vue'
import { getRequestToken } from '@nextcloud/auth'
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import '@nextcloud/dialogs/styles/toast.scss'

@@ -30,6 +31,13 @@ import logger from './logger'
import DisplayNameSection from './components/PersonalInfo/DisplayNameSection/DisplayNameSection'
import EmailSection from './components/PersonalInfo/EmailSection/EmailSection'
import LanguageSection from './components/PersonalInfo/LanguageSection/LanguageSection'
import ProfileSection from './components/PersonalInfo/ProfileSection/ProfileSection'
import OrganisationSection from './components/PersonalInfo/OrganisationSection/OrganisationSection'
import RoleSection from './components/PersonalInfo/RoleSection/RoleSection'
import HeadlineSection from './components/PersonalInfo/HeadlineSection/HeadlineSection'
import BiographySection from './components/PersonalInfo/BiographySection/BiographySection'
import ProfileVisibilitySection from './components/PersonalInfo/ProfileVisibilitySection/ProfileVisibilitySection'
import VisibilityDropdown from './components/PersonalInfo/shared/VisibilityDropdown'

__webpack_nonce__ = btoa(getRequestToken())

@@ -45,7 +53,40 @@ Vue.mixin({
const DisplayNameView = Vue.extend(DisplayNameSection)
const EmailView = Vue.extend(EmailSection)
const LanguageView = Vue.extend(LanguageSection)
const ProfileView = Vue.extend(ProfileSection)
const OrganisationView = Vue.extend(OrganisationSection)
const RoleView = Vue.extend(RoleSection)
const HeadlineView = Vue.extend(HeadlineSection)
const BiographyView = Vue.extend(BiographySection)
const ProfileVisibilityView = Vue.extend(ProfileVisibilitySection)
const VisibilityDropdownView = Vue.extend(VisibilityDropdown)

new DisplayNameView().$mount('#vue-displaynamesection')
new EmailView().$mount('#vue-emailsection')
new LanguageView().$mount('#vue-languagesection')
new DisplayNameView().$mount('#vue-displayname-section')
new EmailView().$mount('#vue-email-section')
new LanguageView().$mount('#vue-language-section')
new ProfileView().$mount('#vue-profile-section')
new OrganisationView().$mount('#vue-organisation-section')
new RoleView().$mount('#vue-role-section')
new HeadlineView().$mount('#vue-headline-section')
new BiographyView().$mount('#vue-biography-section')
new ProfileVisibilityView().$mount('#vue-profile-visibility-section')

// Profile visibility dropdowns
const { profileConfig } = loadState('settings', 'profileParameters', {})
const visibilityDropdownParamIds = [
'avatar',
'phone',
'address',
'website',
'twitter',
]

for (const paramId of visibilityDropdownParamIds) {
const { displayId } = profileConfig[paramId]
new VisibilityDropdownView({
propsData: {
paramId,
displayId,
},
}).$mount(`#vue-profile-visibility-${paramId}`)
}

apps/settings/src/service/PersonalInfo/DisplayNameService.js → apps/settings/src/service/PersonalInfo/PersonalInfoService.js View File

@@ -25,42 +25,50 @@ import { getCurrentUser } from '@nextcloud/auth'
import { generateOcsUrl } from '@nextcloud/router'
import confirmPassword from '@nextcloud/password-confirmation'

import { ACCOUNT_PROPERTY_ENUM, SCOPE_SUFFIX } from '../../constants/AccountPropertyConstants'
import { SCOPE_SUFFIX } from '../../constants/AccountPropertyConstants'

/**
* Save the primary display name of the user
* Save the primary account property value for the user
*
* @param {string} displayName the primary display name
* @param {string} accountProperty the account property
* @param {string|boolean} value the primary value
* @returns {object}
*/
export const savePrimaryDisplayName = async(displayName) => {
export const savePrimaryAccountProperty = async(accountProperty, value) => {
// TODO allow boolean values on backend route handler
// Convert boolean to string for compatibility
if (typeof value === 'boolean') {
value = value ? '1' : '0'
}

const userId = getCurrentUser().uid
const url = generateOcsUrl('cloud/users/{userId}', { userId })

await confirmPassword()

const res = await axios.put(url, {
key: ACCOUNT_PROPERTY_ENUM.DISPLAYNAME,
value: displayName,
key: accountProperty,
value,
})

return res.data
}

/**
* Save the federation scope for the primary display name of the user
* Save the federation scope of the primary account property for the user
*
* @param {string} accountProperty the account property
* @param {string} scope the federation scope
* @returns {object}
*/
export const savePrimaryDisplayNameScope = async(scope) => {
export const savePrimaryAccountPropertyScope = async(accountProperty, scope) => {
const userId = getCurrentUser().uid
const url = generateOcsUrl('cloud/users/{userId}', { userId })

await confirmPassword()

const res = await axios.put(url, {
key: `${ACCOUNT_PROPERTY_ENUM.DISPLAYNAME}${SCOPE_SUFFIX}`,
key: `${accountProperty}${SCOPE_SUFFIX}`,
value: scope,
})


apps/settings/src/service/PersonalInfo/LanguageService.js → apps/settings/src/service/ProfileService.js View File

@@ -1,5 +1,5 @@
/**
* @copyright 2021, Christopher Ng <chrng8@gmail.com>
* @copyright 2021 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
@@ -12,7 +12,7 @@
*
* 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
* 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
@@ -25,23 +25,22 @@ import { getCurrentUser } from '@nextcloud/auth'
import { generateOcsUrl } from '@nextcloud/router'
import confirmPassword from '@nextcloud/password-confirmation'

import { SETTING_PROPERTY_ENUM } from '../../constants/AccountPropertyConstants'

/**
* Save the language of the user
* Save the visibility of the profile parameter
*
* @param {string} languageCode the language code
* @param {string} paramId the profile parameter ID
* @param {string} visibility the visibility
* @returns {object}
*/
export const saveLanguage = async(languageCode) => {
export const saveProfileParameterVisibility = async(paramId, visibility) => {
const userId = getCurrentUser().uid
const url = generateOcsUrl('cloud/users/{userId}', { userId })
const url = generateOcsUrl('/profile/{userId}', { userId })

await confirmPassword()

const res = await axios.put(url, {
key: SETTING_PROPERTY_ENUM.LANGUAGE,
value: languageCode,
paramId,
visibility,
})

return res.data

+ 14
- 2
apps/settings/src/utils/validate.js View File

@@ -29,12 +29,14 @@
import { VALIDATE_EMAIL_REGEX } from '../constants/AccountPropertyConstants'

/**
* Validate the display name input
* Validate the string input
*
* *Generic validator just to check that input is not an empty string*
*
* @param {string} input the input
* @returns {boolean}
*/
export function validateDisplayName(input) {
export function validateStringInput(input) {
return input !== ''
}

@@ -67,3 +69,13 @@ export function validateLanguage(input) {
&& input.name !== ''
&& input.name !== undefined
}

/**
* Validate boolean input
*
* @param {boolean} input the input
* @returns {boolean}
*/
export function validateBoolean(input) {
return typeof input === 'boolean'
}

+ 85
- 73
apps/settings/templates/settings/personal/personal.info.php View File

@@ -1,4 +1,5 @@
<?php

/**
* @copyright Copyright (c) 2017 Arthur Schiwon <blizzz@arthur-schiwon.de>
*
@@ -51,13 +52,13 @@ script('settings', [
<div id="displayavatar">
<div class="avatardiv"></div>
<div class="warning hidden"></div>
<?php if ($_['avatarChangeSupported']): ?>
<?php if ($_['avatarChangeSupported']) : ?>
<label for="uploadavatar" class="inlineblock button icon-upload svg" id="uploadavatarbutton" title="<?php p($l->t('Upload new')); ?>" tabindex="0"></label>
<button class="inlineblock button icon-folder svg" id="selectavatar" title="<?php p($l->t('Select from Files')); ?>"></button>
<button class="hidden button icon-delete svg" id="removeavatar" title="<?php p($l->t('Remove image')); ?>"></button>
<input type="file" name="files[]" id="uploadavatar" class="hiddenuploadfield" accept="image/*">
<p><em><?php p($l->t('png or jpg, max. 20 MB')); ?></em></p>
<?php else: ?>
<?php else : ?>
<?php p($l->t('Picture provided by original account')); ?>
<?php endif; ?>
</div>
@@ -69,8 +70,9 @@ script('settings', [
</div>
</div>
<span class="icon-checkmark hidden"></span>
<span class="icon-error hidden" ></span>
<span class="icon-error hidden"></span>
<input type="hidden" id="avatarscope" value="<?php p($_['avatarScope']) ?>">
<div id="vue-profile-visibility-avatar"></div>
</form>
</div>
<div class="personal-settings-setting-box personal-settings-group-box section">
@@ -84,26 +86,30 @@ script('settings', [
<div id="quota" class="personal-info icon-quota">
<div class="quotatext-bg">
<p class="quotatext">
<?php if ($_['quota'] === \OCP\Files\FileInfo::SPACE_UNLIMITED): ?>
<?php print_unescaped($l->t('You are using <strong>%s</strong>',
[$_['usage']]));?>
<?php else: ?>
<?php print_unescaped($l->t('You are using <strong>%1$s</strong> of <strong>%2$s</strong> (<strong>%3$s %%</strong>)',
[$_['usage'], $_['total_space'], $_['usage_relative']]));?>
<?php if ($_['quota'] === \OCP\Files\FileInfo::SPACE_UNLIMITED) : ?>
<?php print_unescaped($l->t(
'You are using <strong>%s</strong>',
[$_['usage']]
)); ?>
<?php else : ?>
<?php print_unescaped($l->t(
'You are using <strong>%1$s</strong> of <strong>%2$s</strong> (<strong>%3$s %%</strong>)',
[$_['usage'], $_['total_space'], $_['usage_relative']]
)); ?>
<?php endif ?>
</p>
</div>
<progress value="<?php p($_['usage_relative']); ?>" max="100"<?php if ($_['usage_relative'] > 80): ?> class="warn" <?php endif; ?>></progress>
<progress value="<?php p($_['usage_relative']); ?>" max="100" <?php if ($_['usage_relative'] > 80) : ?> class="warn" <?php endif; ?>></progress>
</div>
</div>
</div>

<div class="personal-settings-container">
<div class="personal-settings-setting-box">
<div id="vue-displaynamesection" class="section"></div>
<div id="vue-displayname-section"></div>
</div>
<div class="personal-settings-setting-box">
<div id="vue-emailsection" class="section"></div>
<div id="vue-email-section"></div>
</div>
<div class="personal-settings-setting-box">
<form id="phoneform" class="section">
@@ -115,13 +121,11 @@ script('settings', [
</span>
</a>
</h3>
<input type="tel" id="phone" name="phone"
value="<?php p($_['phone']) ?>"
placeholder="<?php p($l->t('Your phone number')); ?>"
autocomplete="on" autocapitalize="none" autocorrect="off" />
<input type="tel" id="phone" name="phone" value="<?php p($_['phone']) ?>" placeholder="<?php p($l->t('Your phone number')); ?>" autocomplete="on" autocapitalize="none" autocorrect="off" />
<span class="icon-checkmark hidden"></span>
<span class="icon-error hidden" ></span>
<span class="icon-error hidden"></span>
<input type="hidden" id="phonescope" value="<?php p($_['phoneScope']) ?>">
<div id="vue-profile-visibility-phone"></div>
</form>
</div>
<div class="personal-settings-setting-box">
@@ -134,13 +138,11 @@ script('settings', [
</span>
</a>
</h3>
<input type="text" id="address" name="address"
placeholder="<?php p($l->t('Your postal address')); ?>"
value="<?php p($_['address']) ?>"
autocomplete="on" autocapitalize="none" autocorrect="off" />
<input type="text" id="address" name="address" placeholder="<?php p($l->t('Your postal address')); ?>" value="<?php p($_['address']) ?>" autocomplete="on" autocapitalize="none" autocorrect="off" />
<span class="icon-checkmark hidden"></span>
<span class="icon-error hidden" ></span>
<span class="icon-error hidden"></span>
<input type="hidden" id="addressscope" value="<?php p($_['addressScope']) ?>">
<div id="vue-profile-visibility-address"></div>
</form>
</div>
<div class="personal-settings-setting-box">
@@ -154,10 +156,10 @@ script('settings', [
</a>
</h3>
<?php if ($_['lookupServerUploadEnabled']) { ?>
<div class="verify <?php if ($_['website'] === '' || $_['websiteScope'] !== 'public') {
p('hidden');
} ?>">
<img id="verify-website" title="<?php p($_['websiteMessage']); ?>" data-status="<?php p($_['websiteVerification']) ?>" src="
<div class="verify <?php if ($_['website'] === '' || $_['websiteScope'] !== 'public') {
p('hidden');
} ?>">
<img id="verify-website" title="<?php p($_['websiteMessage']); ?>" data-status="<?php p($_['websiteVerification']) ?>" src="
<?php
switch ($_['websiteVerification']) {
case \OC\Accounts\AccountManager::VERIFICATION_IN_PROGRESS:
@@ -169,27 +171,23 @@ script('settings', [
default:
p(image_path('core', 'actions/verify.svg'));
}
?>"
<?php if ($_['websiteVerification'] === \OC\Accounts\AccountManager::VERIFICATION_IN_PROGRESS || $_['websiteVerification'] === \OC\Accounts\AccountManager::NOT_VERIFIED) {
?>" <?php if ($_['websiteVerification'] === \OC\Accounts\AccountManager::VERIFICATION_IN_PROGRESS || $_['websiteVerification'] === \OC\Accounts\AccountManager::NOT_VERIFIED) {
print_unescaped(' class="verify-action"');
} ?>
>
<div class="verification-dialog popovermenu bubble menu">
<div class="verification-dialog-content">
<p class="explainVerification"></p>
<p class="verificationCode"></p>
<p><?php p($l->t('It can take up to 24 hours before the account is displayed as verified.'));?></p>
} ?>>
<div class="verification-dialog popovermenu bubble menu">
<div class="verification-dialog-content">
<p class="explainVerification"></p>
<p class="verificationCode"></p>
<p><?php p($l->t('It can take up to 24 hours before the account is displayed as verified.')); ?></p>
</div>
</div>
</div>
</div>
<?php } ?>
<input type="url" name="website" id="website" value="<?php p($_['website']); ?>"
placeholder="<?php p($l->t('Link https://…')); ?>"
autocomplete="on" autocapitalize="none" autocorrect="off"
/>
<input type="url" name="website" id="website" value="<?php p($_['website']); ?>" placeholder="<?php p($l->t('Link https://…')); ?>" autocomplete="on" autocapitalize="none" autocorrect="off" />
<span class="icon-checkmark hidden"></span>
<span class="icon-error hidden" ></span>
<span class="icon-error hidden"></span>
<input type="hidden" id="websitescope" value="<?php p($_['websiteScope']) ?>">
<div id="vue-profile-visibility-website"></div>
</form>
</div>
<div class="personal-settings-setting-box">
@@ -203,10 +201,10 @@ script('settings', [
</a>
</h3>
<?php if ($_['lookupServerUploadEnabled']) { ?>
<div class="verify <?php if ($_['twitter'] === '' || $_['twitterScope'] !== 'public') {
<div class="verify <?php if ($_['twitter'] === '' || $_['twitterScope'] !== 'public') {
p('hidden');
} ?>">
<img id="verify-twitter" title="<?php p($_['twitterMessage']); ?>" data-status="<?php p($_['twitterVerification']) ?>" src="
<img id="verify-twitter" title="<?php p($_['twitterMessage']); ?>" data-status="<?php p($_['twitterVerification']) ?>" src="
<?php
switch ($_['twitterVerification']) {
case \OC\Accounts\AccountManager::VERIFICATION_IN_PROGRESS:
@@ -218,60 +216,74 @@ script('settings', [
default:
p(image_path('core', 'actions/verify.svg'));
}
?>"
<?php if ($_['twitterVerification'] === \OC\Accounts\AccountManager::VERIFICATION_IN_PROGRESS || $_['twitterVerification'] === \OC\Accounts\AccountManager::NOT_VERIFIED) {
?>" <?php if ($_['twitterVerification'] === \OC\Accounts\AccountManager::VERIFICATION_IN_PROGRESS || $_['twitterVerification'] === \OC\Accounts\AccountManager::NOT_VERIFIED) {
print_unescaped(' class="verify-action"');
} ?>
>
<div class="verification-dialog popovermenu bubble menu">
<div class="verification-dialog-content">
<p class="explainVerification"></p>
<p class="verificationCode"></p>
<p><?php p($l->t('It can take up to 24 hours before the account is displayed as verified.'));?></p>
} ?>>
<div class="verification-dialog popovermenu bubble menu">
<div class="verification-dialog-content">
<p class="explainVerification"></p>
<p class="verificationCode"></p>
<p><?php p($l->t('It can take up to 24 hours before the account is displayed as verified.')); ?></p>
</div>
</div>
</div>
</div>
<?php } ?>
<input type="text" name="twitter" id="twitter" value="<?php p($_['twitter']); ?>"
placeholder="<?php p($l->t('Twitter handle @…')); ?>"
autocomplete="on" autocapitalize="none" autocorrect="off"
/>
<input type="text" name="twitter" id="twitter" value="<?php p($_['twitter']); ?>" placeholder="<?php p($l->t('Twitter handle @…')); ?>" autocomplete="on" autocapitalize="none" autocorrect="off" />
<span class="icon-checkmark hidden"></span>
<span class="icon-error hidden" ></span>
<span class="icon-error hidden"></span>
<input type="hidden" id="twitterscope" value="<?php p($_['twitterScope']) ?>">
<div id="vue-profile-visibility-twitter"></div>
</form>
</div>
<div class="personal-settings-setting-box">
<div id="vue-organisation-section"></div>
</div>
<div class="personal-settings-setting-box">
<div id="vue-role-section"></div>
</div>
<div class="personal-settings-setting-box">
<div id="vue-headline-section"></div>
</div>
<div class="personal-settings-setting-box">
<div id="vue-biography-section"></div>
</div>
</div>

<div class="profile-settings-container">
<div class="personal-settings-setting-box">
<div id="vue-profile-section"></div>
</div>
<div class="personal-settings-setting-box">
<div id="vue-profile-visibility-section"></div>
</div>
<div class="personal-settings-setting-box personal-settings-language-box">
<div id="vue-languagesection" class="section"></div>
<div id="vue-language-section"></div>
</div>
<div class="personal-settings-setting-box personal-settings-locale-box">
<?php if (isset($_['activelocale'])) { ?>
<form id="locale" class="section">
<h3>
<label for="localeinput"><?php p($l->t('Locale'));?></label>
<label for="localeinput"><?php p($l->t('Locale')); ?></label>
</h3>
<select id="localeinput" name="lang" data-placeholder="<?php p($l->t('Locale'));?>">
<option value="<?php p($_['activelocale']['code']);?>">
<?php p($l->t($_['activelocale']['name']));?>
<select id="localeinput" name="lang" data-placeholder="<?php p($l->t('Locale')); ?>">
<option value="<?php p($_['activelocale']['code']); ?>">
<?php p($l->t($_['activelocale']['name'])); ?>
</option>
<optgroup label="––––––––––"></optgroup>
<?php foreach ($_['localesForLanguage'] as $locale):?>
<option value="<?php p($locale['code']);?>">
<?php p($l->t($locale['name']));?>
<?php foreach ($_['localesForLanguage'] as $locale) : ?>
<option value="<?php p($locale['code']); ?>">
<?php p($l->t($locale['name'])); ?>
</option>
<?php endforeach;?>
<?php endforeach; ?>
<optgroup label="––––––––––"></optgroup>
<option value="<?php p($_['activelocale']['code']);?>">
<?php p($l->t($_['activelocale']['name']));?>
<option value="<?php p($_['activelocale']['code']); ?>">
<?php p($l->t($_['activelocale']['name'])); ?>
</option>
<?php foreach ($_['locales'] as $locale):?>
<option value="<?php p($locale['code']);?>">
<?php p($l->t($locale['name']));?>
<?php foreach ($_['locales'] as $locale) : ?>
<option value="<?php p($locale['code']); ?>">
<?php p($l->t($locale['name'])); ?>
</option>
<?php endforeach;?>
<?php endforeach; ?>
</select>
<div id="localeexample" class="personal-info icon-timezone">
<p>

+ 8
- 2
apps/theming/css/theming.scss View File

@@ -109,8 +109,14 @@ $invert: luma($color-primary) > 0.6;
background-image: $image-logo;
}

#body-user #header, #body-settings #header, #body-public #header {
@include faded-background;
#body-user,
#body-settings,
#body-public {
#header,
.profile__header,
.preview-card__header {
@include faded-background;
}
}

#body-login,

+ 14
- 14
apps/user_status/js/dashboard.js
File diff suppressed because it is too large
View File


+ 1
- 1
apps/user_status/js/dashboard.js.map
File diff suppressed because it is too large
View File


+ 16
- 16
apps/user_status/js/user-status-menu.js
File diff suppressed because it is too large
View File


+ 1
- 1
apps/user_status/js/user-status-menu.js.map
File diff suppressed because it is too large
View File


+ 2
- 2
apps/user_status/js/user-status-modal.js
File diff suppressed because it is too large
View File


+ 1
- 1
apps/user_status/js/user-status-modal.js.map
File diff suppressed because it is too large
View File


+ 4
- 4
apps/user_status/js/vendors-user-status-modal.js
File diff suppressed because it is too large
View File


+ 1
- 1
apps/user_status/js/vendors-user-status-modal.js.map
File diff suppressed because it is too large
View File


+ 95
- 23
apps/user_status/src/UserStatus.vue View File

@@ -23,12 +23,20 @@
<li>
<div class="user-status-menu-item">
<!-- Username display -->
<span
<a
v-if="!inline"
class="user-status-menu-item__header"
:title="displayName">
{{ displayName }}
</span>
:href="profilePageLink"
@click="loadProfilePage">
<div class="user-status-menu-item__header-content">
<div class="user-status-menu-item__header-content-displayname">{{ displayName }}</div>
<div v-if="!loadingProfilePage" class="user-status-menu-item__header-content-placeholder" />
<div v-else class="icon-loading-small" />
</div>
<div v-if="profileEnabled">
{{ t('user_status', 'View profile') }}
</div>
</a>

<!-- Status modal toggle -->
<toggle :is="inline ? 'button' : 'a'"
@@ -50,11 +58,16 @@

<script>
import { getCurrentUser } from '@nextcloud/auth'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { loadState } from '@nextcloud/initial-state'
import { generateUrl } from '@nextcloud/router'
import debounce from 'debounce'

import { sendHeartbeat } from './services/heartbeatService'
import OnlineStatusMixin from './mixins/OnlineStatusMixin'

const { profileEnabled } = loadState('user_status', 'profileEnabled', false)

export default {
name: 'UserStatus',

@@ -72,21 +85,30 @@ export default {

data() {
return {
isModalOpen: false,
displayName: getCurrentUser().displayName,
heartbeatInterval: null,
setAwayTimeout: null,
mouseMoveListener: null,
isAway: false,
isModalOpen: false,
loadingProfilePage: false,
mouseMoveListener: null,
profileEnabled,
setAwayTimeout: null,
}
},
computed: {
/**
* The display-name of the current user
* The profile page link
*
* @returns {String}
* @returns {String|null}
*/
displayName() {
return getCurrentUser().displayName
profilePageLink() {
if (this.profileEnabled) {
return generateUrl('/u/{userId}', { userId: getCurrentUser().uid })
}
// Since an anchor element is used rather than a button,
// this hack removes href if the profile is disabled so that disabling pointer-events is not needed to prevent a click from opening a page
// and to allow the hover event for styling
return null
},
},

@@ -95,6 +117,9 @@ export default {
* and stores it in Vuex
*/
mounted() {
subscribe('settings:display-name:updated', this.handleDisplayNameUpdate)
subscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate)

this.$store.dispatch('loadStatusFromInitialState')

if (OC.config.session_keepalive) {
@@ -130,11 +155,27 @@ export default {
* Some housekeeping before destroying the component
*/
beforeDestroy() {
unsubscribe('settings:display-name:updated', this.handleDisplayNameUpdate)
unsubscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate)
window.removeEventListener('mouseMove', this.mouseMoveListener)
clearInterval(this.heartbeatInterval)
},

methods: {
handleDisplayNameUpdate(displayName) {
this.displayName = displayName
},

handleProfileEnabledUpdate(profileEnabled) {
this.profileEnabled = profileEnabled
},

loadProfilePage() {
if (this.profileEnabled) {
this.loadingProfilePage = true
}
},

/**
* Opens the modal to set a custom status
*/
@@ -171,20 +212,51 @@ export default {
</script>

<style lang="scss" scoped>
$max-width-user-status: 200px;

.user-status-menu-item {
&__header {
display: block;
overflow: hidden;
box-sizing: border-box;
max-width: $max-width-user-status;
padding: 10px 12px 5px 38px;
text-align: left;
white-space: nowrap;
text-overflow: ellipsis;
opacity: 1;
color: var(--color-text-maxcontrast);
display: flex !important;
flex-direction: column !important;
width: auto !important;
height: 44px * 1.5 !important;
padding: 10px 12px 5px 12px !important;
align-items: flex-start !important;
color: var(--color-main-text) !important;

&:not([href]) {
height: var(--header-menu-item-height) !important;
color: var(--color-text-maxcontrast) !important;
cursor: default !important;

& * {
cursor: default !important;
}

&:hover {
background-color: transparent !important;
}
}

&-content {
display: inline-flex !important;
font-weight: bold !important;
gap: 0 10px !important;
width: auto;

&-displayname {
width: auto;
}

&-placeholder {
width: 16px !important;
height: 24px !important;
margin-right: 10px !important;
visibility: hidden !important;
}
}

span {
color: var(--color-text-maxcontrast) !important;
}
}

&__toggle {

+ 0
- 1
core/css/css-variables.scss View File

@@ -68,4 +68,3 @@

--header-height: #{$header-height};
}


+ 2
- 0
core/css/variables.scss View File

@@ -112,6 +112,8 @@ $sidebar-min-width: 300px;
$sidebar-max-width: 500px;
$list-min-width: 200px;
$list-max-width: 300px;
$header-menu-item-height: 44px;
$header-menu-profile-item-height: 66px;

// mobile. Keep in sync with core/js/js.js
$breakpoint-mobile: 1024px;

+ 3
- 3
core/js/dist/files_client.js
File diff suppressed because it is too large
View File


+ 1
- 1
core/js/dist/files_client.js.map
File diff suppressed because it is too large
View File


+ 1
- 1
core/js/dist/files_fileinfo.js
File diff suppressed because it is too large
View File


+ 1
- 1
core/js/dist/files_fileinfo.js.map
File diff suppressed because it is too large
View File


+ 1
- 1
core/js/dist/files_iedavclient.js
File diff suppressed because it is too large
View File


+ 1
- 1
core/js/dist/files_iedavclient.js.map
File diff suppressed because it is too large
View File


+ 38
- 38
core/js/dist/install.js
File diff suppressed because it is too large
View File


+ 1
- 1
core/js/dist/install.js.map
File diff suppressed because it is too large
View File


+ 25
- 25
core/js/dist/login.js
File diff suppressed because it is too large
View File


+ 1
- 1
core/js/dist/login.js.map
File diff suppressed because it is too large
View File


+ 33
- 33
core/js/dist/main.js
File diff suppressed because it is too large
View File


+ 1
- 1
core/js/dist/main.js.map
File diff suppressed because it is too large
View File


+ 1
- 1
core/js/dist/maintenance.js
File diff suppressed because it is too large
View File


+ 1
- 1
core/js/dist/maintenance.js.map
File diff suppressed because it is too large
View File


+ 389
- 0
core/js/dist/profile.js
File diff suppressed because it is too large
View File


+ 1
- 0
core/js/dist/profile.js.map
File diff suppressed because it is too large
View File


+ 3
- 3
core/js/dist/recommendedapps.js
File diff suppressed because it is too large
View File


+ 1
- 1
core/js/dist/recommendedapps.js.map
File diff suppressed because it is too large
View File


+ 39
- 39
core/js/dist/unified-search.js
File diff suppressed because it is too large
View File


+ 1
- 1
core/js/dist/unified-search.js.map
File diff suppressed because it is too large
View File


+ 33
- 7
core/src/OC/contactsmenu/contact.handlebars View File

@@ -1,13 +1,39 @@
{{#if contact.avatar}}
<img src="{{contact.avatar}}&size=32" class="avatar" srcset="{{contact.avatar}}&size=32 1x, {{contact.avatar}}&size=64 2x, {{contact.avatar}}&size=128 4x" alt="">
{{#if contact.profileUrl}}
{{#if contact.profileTitle}}
<a class="profile-link--avatar" href="{{contact.profileUrl}}">
<img src="{{contact.avatar}}&size=32" class="avatar" srcset="{{contact.avatar}}&size=32 1x, {{contact.avatar}}&size=64 2x, {{contact.avatar}}&size=128 4x" alt="">
</a>
{{/if}}
{{else}}
<img src="{{contact.avatar}}&size=32" class="avatar" srcset="{{contact.avatar}}&size=32 1x, {{contact.avatar}}&size=64 2x, {{contact.avatar}}&size=128 4x" alt="">
{{/if}}
{{else}}
<div class="avatar"></div>
{{#if contact.profileUrl}}
{{#if contact.profileTitle}}
<a class="profile-link--avatar" href="{{contact.profileUrl}}">
<div class="avatar"></div>
</a>
{{/if}}
{{else}}
<div class="avatar"></div>
{{/if}}
{{/if}}
{{#if contact.profileUrl}}
{{#if contact.profileTitle}}
<a class="body profile-link--full-name" href="{{contact.profileUrl}}">
<div class="full-name">{{contact.fullName}}</div>
<div class="last-message">{{contact.lastMessage}}</div>
<div class="email-address">{{contact.emailAddresses}}</div>
</a>
{{/if}}
{{else}}
<div class="body">
<div class="full-name">{{contact.fullName}}</div>
<div class="last-message">{{contact.lastMessage}}</div>
<div class="email-address">{{contact.emailAddresses}}</div>
</div>
{{/if}}
<div class="body">
<div class="full-name">{{contact.fullName}}</div>
<div class="last-message">{{contact.lastMessage}}</div>
<div class="email-address">{{contact.emailAddresses}}</div>
</div>
{{#if contact.topAction}}
<a class="top-action" href="{{contact.topAction.hyperlink}}" title="{{contact.topAction.title}}">
<img src="{{contact.topAction.icon}}" alt="{{contact.topAction.title}}">

+ 103
- 0
core/src/components/Profile/PrimaryActionButton.vue View File

@@ -0,0 +1,103 @@
<!--
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @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>
<a
class="profile__primary-action-button"
:class="{ 'disabled': disabled }"
:href="href"
:target="target"
rel="noopener noreferrer nofollow"
v-on="$listeners">
<img
class="icon"
:class="[icon, { 'icon-invert': colorPrimaryText === '#ffffff' }]"
:src="icon">
<slot />
</a>
</template>

<script>
export default {
name: 'PrimaryActionButton',

props: {
disabled: {
type: Boolean,
default: false,
},
href: {
type: String,
required: true,
},
icon: {
type: String,
required: true,
},
target: {
type: String,
required: true,
validator: (value) => ['_self', '_blank', '_parent', '_top'].includes(value),
},
},

computed: {
colorPrimaryText() {
// For some reason the returned string has prepended whitespace
return getComputedStyle(document.body).getPropertyValue('--color-primary-text').trim()
},
},
}
</script>

<style lang="scss" scoped>
.profile__primary-action-button {
font-size: var(--default-font-size);
font-weight: bold;
width: 188px;
height: 44px;
padding: 0 16px;
line-height: 44px;
text-align: center;
border-radius: var(--border-radius-pill);
color: var(--color-primary-text);
background-color: var(--color-primary-element);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;

.icon {
display: inline-block;
vertical-align: middle;
margin-bottom: 2px;
margin-right: 4px;

&.icon-invert {
filter: invert(1);
}
}

&:hover {
background-color: var(--color-primary-element-light);
}
}
</style>

+ 9
- 3
core/src/components/UserMenu.js View File

@@ -26,6 +26,10 @@ import $ from 'jquery'

export const setUp = () => {
const $menu = $('#header #settings')
// Using page terminoogy as below
const $excludedPageClasses = [
'user-status-menu-item__header',
]

// show loading feedback
$menu.delegate('a', 'click', event => {
@@ -34,9 +38,11 @@ export const setUp = () => {
$page = $page.closest('a')
}
if (event.which === 1 && !event.ctrlKey && !event.metaKey) {
$page.find('img').remove()
$page.find('div').remove() // prevent odd double-clicks
$page.prepend($('<div/>').addClass('icon-loading-small'))
if (!$excludedPageClasses.includes($page.attr('class'))) {
$page.find('img').remove()
$page.find('div').remove() // prevent odd double-clicks
$page.prepend($('<div/>').addClass('icon-loading-small'))
}
} else {
// Close navigation when opening menu entry in
// a new tab

+ 48
- 0
core/src/profile.js View File

@@ -0,0 +1,48 @@
/**
* @copyright 2021, Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
* @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/>.
*
*/

import Vue from 'vue'
import { generateFilePath } from '@nextcloud/router'
import { getRequestToken } from '@nextcloud/auth'
import { translate as t } from '@nextcloud/l10n'
import VTooltip from 'v-tooltip'

import logger from './logger'

import Profile from './views/Profile'

__webpack_nonce__ = btoa(getRequestToken())
__webpack_public_path__ = generateFilePath('core', '', 'js/')

Vue.use(VTooltip)

Vue.mixin({
props: {
logger,
},
methods: {
t,
},
})

const View = Vue.extend(Profile)
new View().$mount('#vue-profile')

+ 531
- 0
core/src/views/Profile.vue View File

@@ -0,0 +1,531 @@
<!--
- @copyright Copyright (c) 2021 Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
- @author Julius Härtl <jus@bitgrid.net>
-
- @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 class="profile">
<div class="profile__header">
<div class="profile__header__container">
<div class="profile__header__container__placeholder" />
<h2 class="profile__header__container__displayname">
{{ displayname || userId }}
<a v-if="isCurrentUser"
class="primary profile__header__container__edit-button"
:href="settingsUrl">
<PencilIcon
class="pencil-icon"
decorative
title=""
:size="16" />
{{ t('core', 'Edit Profile') }}
</a>
</h2>
<div v-if="status.icon || status.message"
class="profile__header__container__status-text"
:class="{ interactive: isCurrentUser }"
@click.prevent.stop="openStatusModal">
{{ status.icon }} {{ status.message }}
</div>
</div>
</div>

<div class="profile__content">
<div class="profile__sidebar">
<Avatar
class="avatar"
:class="{ interactive: isCurrentUser }"
:user="userId"
:size="180"
:show-user-status="true"
:show-user-status-compact="false"
:disable-menu="true"
:disable-tooltip="true"
:is-no-user="!isUserAvatarVisible"
@click.native.prevent.stop="openStatusModal" />

<div class="user-actions">
<!-- When a tel: URL is opened with target="_blank", a blank new tab is opened which is inconsistent with the handling of other URLs so we set target="_self" for the phone action -->
<PrimaryActionButton v-if="primaryAction"
class="user-actions__primary"
:href="primaryAction.target"
:icon="primaryAction.icon"
:target="primaryAction.id === 'phone' ? '_self' :'_blank'">
{{ primaryAction.title }}
</PrimaryActionButton>
<div class="user-actions__other">
<!-- FIXME Remove inline styles after https://github.com/nextcloud/nextcloud-vue/issues/2315 is fixed -->
<Actions v-for="action in middleActions"
:key="action.id"
:default-icon="action.icon"
style="
background-position: 14px center;
background-size: 16px;
background-repeat: no-repeat;"
:style="{
backgroundImage: `url(${action.icon})`,
...(colorMainBackground === '#181818' && { filter: 'invert(1)' })
}">
<ActionLink
:close-after-click="true"
:icon="action.icon"
:href="action.target"
:target="action.id === 'phone' ? '_self' :'_blank'">
{{ action.title }}
</ActionLink>
</Actions>
<template v-if="otherActions">
<Actions v-for="action in otherActions"
:key="action.id"
:force-menu="true">
<ActionLink
:class="{ 'icon-invert': colorMainBackground === '#181818' }"
:close-after-click="true"
:icon="action.icon"
:href="action.target"
:target="action.id === 'phone' ? '_self' :'_blank'">
{{ action.title }}
</ActionLink>
</Actions>
</template>
</div>
</div>
</div>

<div class="profile__blocks">
<div v-if="organisation || role || address" class="profile__blocks-details">
<div v-if="organisation || role" class="detail">
<p>{{ organisation }} <span v-if="organisation && role">•</span> {{ role }}</p>
</div>
<div v-if="address" class="detail">
<p>
<MapMarkerIcon
class="map-icon"
decorative
title=""
:size="16" />
{{ address }}
</p>
</div>
</div>
<template v-if="headline || biography">
<div v-if="headline" class="profile__blocks-headline">
<h3>{{ headline }}</h3>
</div>
<div v-if="biography" class="profile__blocks-biography">
<p>{{ biography }}</p>
</div>
</template>
<template v-else>
<div class="profile__blocks-empty-info">
<AccountIcon
decorative
title=""
fill-color="var(--color-text-maxcontrast)"
:size="60" />
<h3>{{ displayname || userId }} {{ t('core', 'hasn\'t added any info yet') }}</h3>
<p>{{ t('core', 'The headline and about section will show up here') }}</p>
</div>
</template>
</div>
</div>
</div>
</template>

<script>
import { getCurrentUser } from '@nextcloud/auth'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { loadState } from '@nextcloud/initial-state'
import { generateUrl } from '@nextcloud/router'
import { showError } from '@nextcloud/dialogs'

import Avatar from '@nextcloud/vue/dist/Components/Avatar'
import Actions from '@nextcloud/vue/dist/Components/Actions'
import ActionLink from '@nextcloud/vue/dist/Components/ActionLink'
import MapMarkerIcon from 'vue-material-design-icons/MapMarker'
import PencilIcon from 'vue-material-design-icons/Pencil'
import AccountIcon from 'vue-material-design-icons/Account'

import PrimaryActionButton from '../components/Profile/PrimaryActionButton'

const status = loadState('core', 'status', {})
const {
userId,
displayname,
address,
organisation,
role,
headline,
biography,
actions,
isUserAvatarVisible,
} = loadState('core', 'profileParameters', {
userId: null,
displayname: null,
address: null,
organisation: null,
role: null,
headline: null,
biography: null,
actions: [],
isUserAvatarVisible: false,
})

export default {
name: 'Profile',

components: {
AccountIcon,
ActionLink,
Actions,
Avatar,
MapMarkerIcon,
PencilIcon,
PrimaryActionButton,
},

data() {
return {
status,
userId,
displayname,
address,
organisation,
role,
headline,
biography,
actions,
isUserAvatarVisible,
}
},

computed: {
isCurrentUser() {
return getCurrentUser()?.uid === this.userId
},

allActions() {
return this.actions
},

primaryAction() {
if (this.allActions.length) {
return this.allActions[0]
}
return null
},

middleActions() {
if (this.allActions.slice(1, 4).length) {
return this.allActions.slice(1, 4)
}
return null
},

otherActions() {
if (this.allActions.slice(4).length) {
return this.allActions.slice(4)
}
return null
},

settingsUrl() {
return generateUrl('/settings/user')
},

colorMainBackground() {
// For some reason the returned string has prepended whitespace
return getComputedStyle(document.body).getPropertyValue('--color-main-background').trim()
},
},

mounted() {
subscribe('user_status:status.updated', this.handleStatusUpdate)
},

beforeDestroy() {
unsubscribe('user_status:status.updated', this.handleStatusUpdate)
},

methods: {
handleStatusUpdate(status) {
this.status = status
},

openStatusModal() {
const statusMenuItem = document.querySelector('.user-status-menu-item__toggle')
// Changing the user status is only enabled if you are the current user
if (this.isCurrentUser) {
if (statusMenuItem) {
statusMenuItem.click()
} else {
showError(t('core', 'Error opening the user status modal, try hard refreshing the page'))
}
}
},
},
}
</script>

<style lang="scss">
// Override header styles
#header {
background-color: transparent !important;
background-image: none !important;
}

#content {
padding-top: 0px;
}
</style>

<style lang="scss" scoped>
$profile-max-width: 1024px;
$content-max-width: 640px;

.profile {
width: 100%;

&__header {
position: sticky;
height: 190px;
top: -40px;

&__container {
align-self: flex-end;
width: 100%;
max-width: $profile-max-width;
margin: 0 auto;
display: grid;
grid-template-rows: max-content max-content;
grid-template-columns: 240px 1fr;
justify-content: center;

&__placeholder {
grid-row: 1 / 3;
}

&__displayname, &__status-text {
color: var(--color-primary-text);
}

&__displayname {
width: $content-max-width;
height: 45px;
margin-top: 128px;
// Override the global style declaration
margin-bottom: 0;
font-size: 30px;
display: flex;
align-items: center;
cursor: text;

&:not(:last-child) {
margin-top: 100px;
margin-bottom: 4px;
}
}

&__edit-button {
border: none;
margin-left: 18px;
margin-top: 2px;
color: var(--color-primary-element);
background-color: var(--color-primary-text);
box-shadow: 0 0 0 2px var(--color-primary-text);
border-radius: var(--border-radius-pill);
padding: 0 18px;
font-size: var(--default-font-size);
height: 44px;
line-height: 44px;
font-weight: bold;

&:hover {
color: var(--color-primary-text);
background-color: var(--color-primary-element-light);
}

.pencil-icon {
display: inline-block;
vertical-align: middle;
margin-top: 2px;
}
}

&__status-text {
width: max-content;
max-width: $content-max-width;
padding: 5px 10px;
margin-left: -14px;
margin-top: 2px;

&.interactive {
cursor: pointer;

&:hover {
background-color: var(--color-main-background);
color: var(--color-main-text);
border-radius: var(--border-radius-pill);
font-weight: bold;
box-shadow: 0 3px 6px var(--color-box-shadow);
}
}
}
}
}

&__sidebar {
position: sticky;
top: var(--header-height);
align-self: flex-start;
padding-top: 20px;
min-width: 220px;
margin: -150px 20px 0 0;

// Specificity hack is needed to override Avatar component styles
&::v-deep .avatar.avatardiv, h2 {
text-align: center;
margin: auto;
display: block;
padding: 8px;
}

&::v-deep .avatar.avatardiv:not(.avatardiv--unknown) {
background-color: var(--color-main-background) !important;
box-shadow: none;
}

&::v-deep .avatar.avatardiv {
.avatardiv__user-status {
right: 14px;
bottom: 14px;
width: 34px;
height: 34px;
background-size: 28px;
border: none;
// Styles when custom status icon and status text are set
background-color: var(--color-main-background);
line-height: 34px;
font-size: 20px;
}
}

&::v-deep .avatar.interactive.avatardiv {
.avatardiv__user-status {
cursor: pointer;

&:hover {
box-shadow: 0 3px 6px var(--color-box-shadow);
}
}
}
}

&__content {
max-width: $profile-max-width;
margin: 0 auto;
display: flex;
width: 100%;
}

&__blocks {
margin: 18px 0 80px 0;
display: grid;
gap: 16px 0;
width: $content-max-width;

p, h3 {
overflow-wrap: anywhere;
}

&-details {
display: flex;
flex-direction: column;
gap: 2px 0;

.detail {
display: inline-block;
color: var(--color-text-maxcontrast);

p .map-icon {
display: inline-block;
vertical-align: middle;
}
}
}

&-headline {
margin-top: 10px;

h3 {
font-weight: bold;
font-size: 20px;
margin: 0;
}
}

&-biography {
white-space: pre-line;
}

h3, p {
cursor: text;
}

&-empty-info {
margin-top: 80px;
margin-right: 100px;
display: flex;
flex-direction: column;
text-align: center;

h3 {
font-weight: bold;
font-size: 18px;
margin: 8px 0;
}
}
}
}

.user-actions {
display: flex;
flex-direction: column;
gap: 8px 0;
margin-top: 20px;

&__primary {
margin: 0 auto;
}

&__other {
display: flex;
justify-content: center;
gap: 0 4px;
}
}

.icon-invert {
&::v-deep .action-link__icon {
filter: invert(1);
}
}
</style>

+ 1
- 0
core/webpack.js View File

@@ -35,6 +35,7 @@ module.exports = [
install: path.join(__dirname, 'src/install.js'),
login: path.join(__dirname, 'src/login.js'),
main: path.join(__dirname, 'src/main.js'),
profile: path.join(__dirname, 'src/profile.js'),
maintenance: path.join(__dirname, 'src/maintenance.js'),
recommendedapps: path.join(__dirname, 'src/recommendedapps.js'),
'unified-search': path.join(__dirname, 'src/unified-search.js'),

Loading…
Cancel
Save