From 23fc538eb551cbc32e354f53deb535b9c5bbcc9f Mon Sep 17 00:00:00 2001 From: Andrey Borysenko <andrey18106x@gmail.com> Date: Tue, 27 Jun 2023 13:33:10 +0300 Subject: [PATCH 1/4] feat(picker): Move users_picker profile custom picker to contacts Signed-off-by: Julien Veyssier <julien-nc@posteo.net> --- img/LICENSES.md | 8 + img/profile-dark.svg | 1 + img/profile.svg | 1 + lib/AppInfo/Application.php | 6 + .../ProfilePickerReferenceListener.php | 43 +++ .../ProfilePickerReferenceProvider.php | 188 +++++++++++ .../ProfilePickerReferenceWidget.vue | 151 +++++++++ .../ProfilePicker/ProfilesCustomPicker.vue | 212 ++++++++++++ src/reference.js | 32 ++ .../ProfilePickerReferenceProviderTest.php | 314 ++++++++++++++++++ vite.config.js | 1 + 11 files changed, 957 insertions(+) create mode 100644 img/LICENSES.md create mode 100644 img/profile-dark.svg create mode 100644 img/profile.svg create mode 100644 lib/Listener/ProfilePickerReferenceListener.php create mode 100644 lib/Reference/ProfilePickerReferenceProvider.php create mode 100644 src/components/ProfilePicker/ProfilePickerReferenceWidget.vue create mode 100644 src/components/ProfilePicker/ProfilesCustomPicker.vue create mode 100644 src/reference.js create mode 100644 tests/unit/Reference/ProfilePickerReferenceProviderTest.php diff --git a/img/LICENSES.md b/img/LICENSES.md new file mode 100644 index 000000000..b08286924 --- /dev/null +++ b/img/LICENSES.md @@ -0,0 +1,8 @@ +# Licenses + +## profile.svg, profile-dark.svg + +* Created by: Google +* License: Apache License version 2.0 +* Link: https://pictogrammers.com/library/mdi/icon/account/ +* \ No newline at end of file diff --git a/img/profile-dark.svg b/img/profile-dark.svg new file mode 100644 index 000000000..2aa865d2c --- /dev/null +++ b/img/profile-dark.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z" /></svg> \ No newline at end of file diff --git a/img/profile.svg b/img/profile.svg new file mode 100644 index 000000000..ed094bacb --- /dev/null +++ b/img/profile.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#fff"><path d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z" /></svg> \ No newline at end of file diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 31f0f19a4..b55953612 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -7,11 +7,14 @@ use OCA\Contacts\Dav\PatchPlugin; use OCA\Contacts\Listener\LoadContactsFilesActions; +use OCA\Contacts\Listener\ProfilePickerReferenceListener; +use OCA\Contacts\Reference\ProfilePickerReferenceProvider; use OCA\Files\Event\LoadAdditionalScriptsEvent; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\Collaboration\Reference\RenderReferenceEvent; use OCP\EventDispatcher\IEventDispatcher; use OCP\SabrePluginEvent; @@ -28,6 +31,9 @@ public function __construct() { public function register(IRegistrationContext $context): void { $context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadContactsFilesActions::class); + + $context->registerEventListener(RenderReferenceEvent::class, ProfilePickerReferenceListener::class); + $context->registerReferenceProvider(ProfilePickerReferenceProvider::class); } public function boot(IBootContext $context): void { diff --git a/lib/Listener/ProfilePickerReferenceListener.php b/lib/Listener/ProfilePickerReferenceListener.php new file mode 100644 index 000000000..60112471d --- /dev/null +++ b/lib/Listener/ProfilePickerReferenceListener.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2023 Andrey Borysenko <andrey18106x@gmail.com> + * + * @author 2023 Andrey Borysenko <andrey18106x@gmail.com> + * + * @license AGPL-3.0-or-later + * + * 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/>. + * + */ + +namespace OCA\Contacts\Listener; + +use OCA\Contacts\AppInfo\Application; +use OCP\Collaboration\Reference\RenderReferenceEvent; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Util; + +class ProfilePickerReferenceListener implements IEventListener { + public function handle(Event $event): void { + if (!$event instanceof RenderReferenceEvent) { + return; + } + + Util::addScript(Application::APP_ID, Application::APP_ID . '-reference'); + } +} diff --git a/lib/Reference/ProfilePickerReferenceProvider.php b/lib/Reference/ProfilePickerReferenceProvider.php new file mode 100644 index 000000000..e0d354e79 --- /dev/null +++ b/lib/Reference/ProfilePickerReferenceProvider.php @@ -0,0 +1,188 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2023 Andrey Borysenko <andrey18106x@gmail.com> + * + * @author 2023 Andrey Borysenko <andrey18106x@gmail.com> + * + * @license AGPL-3.0-or-later + * + * 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/>. + * + */ + +namespace OCA\Contacts\Reference; + +use OCA\Contacts\AppInfo\Application; +use OCP\Accounts\IAccountManager; + +use OCP\Collaboration\Reference\ADiscoverableReferenceProvider; +use OCP\Collaboration\Reference\IReference; +use OCP\Collaboration\Reference\Reference; + +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\IUserManager; + +class ProfilePickerReferenceProvider extends ADiscoverableReferenceProvider { + public const RICH_OBJECT_TYPE = 'users_picker_profile'; + + public function __construct( + private IL10N $l10n, + private IURLGenerator $urlGenerator, + private IUserManager $userManager, + private IAccountManager $accountManager, + private ?string $userId, + ) { + } + + /** + * @inheritDoc + */ + public function getId(): string { + return 'profile_picker'; + } + + /** + * @inheritDoc + */ + public function getTitle(): string { + return $this->l10n->t('Profile picker'); + } + + /** + * @inheritDoc + */ + public function getOrder(): int { + return 10; + } + + /** + * @inheritDoc + */ + public function getIconUrl(): string { + return $this->urlGenerator->imagePath(Application::APP_ID, 'profile-dark.svg'); + } + + /** + * @inheritDoc + */ + public function matchReference(string $referenceText): bool { + return $this->getObjectId($referenceText) !== null; + } + + /** + * @inheritDoc + */ + public function resolveReference(string $referenceText): ?IReference { + if (!$this->matchReference($referenceText)) { + return null; + } + + $userId = $this->getObjectId($referenceText); + $user = $this->userManager->get($userId); + if ($user === null) { + return null; + } + $account = $this->accountManager->getAccount($user); + $profileEnabled = $account->getProperty(IAccountManager::PROPERTY_PROFILE_ENABLED)->getValue() === '1'; + if (!$profileEnabled) { + return null; + } + + $reference = new Reference($referenceText); + + $userDisplayName = $user->getDisplayName(); + $userEmail = $user->getEMailAddress(); + $userAvatarUrl = $this->urlGenerator->linkToRouteAbsolute('core.avatar.getAvatar', ['userId' => $userId, 'size' => '64']); + + $bio = $account->getProperty(IAccountManager::PROPERTY_BIOGRAPHY); + $bio = $bio->getScope() !== IAccountManager::SCOPE_PRIVATE ? $bio->getValue() : null; + $headline = $account->getProperty(IAccountManager::PROPERTY_HEADLINE); + $location = $account->getProperty(IAccountManager::PROPERTY_ADDRESS); + $website = $account->getProperty(IAccountManager::PROPERTY_WEBSITE); + $organisation = $account->getProperty(IAccountManager::PROPERTY_ORGANISATION); + $role = $account->getProperty(IAccountManager::PROPERTY_ROLE); + + // for clients who can't render the reference widgets + $reference->setTitle($userDisplayName); + $reference->setDescription($userEmail ?? $userDisplayName); + $reference->setImageUrl($userAvatarUrl); + + // for the Vue reference widget + $reference->setRichObject( + self::RICH_OBJECT_TYPE, + [ + 'user_id' => $userId, + 'title' => $userDisplayName, + 'subline' => $userEmail ?? $userDisplayName, + 'email' => $userEmail, + 'bio' => isset($bio) && $bio !== '' + ? (mb_strlen($bio) > 80 + ? (mb_substr($bio, 0, 80) . '...') + : $bio) + : null, + 'full_bio' => $bio, + 'headline' => $headline->getScope() !== IAccountManager::SCOPE_PRIVATE ? $headline->getValue() : null, + 'location' => $location->getScope() !== IAccountManager::SCOPE_PRIVATE ? $location->getValue() : null, + 'location_url' => $location->getScope() !== IAccountManager::SCOPE_PRIVATE ? $this->getOpenStreetLocationUrl($location->getValue()) : null, + 'website' => $website->getScope() !== IAccountManager::SCOPE_PRIVATE ? $website->getValue() : null, + 'organisation' => $organisation->getScope() !== IAccountManager::SCOPE_PRIVATE ? $organisation->getValue() : null, + 'role' => $role->getScope() !== IAccountManager::SCOPE_PRIVATE ? $role->getValue() : null, + 'url' => $referenceText, + ] + ); + return $reference; + } + + public function getObjectId(string $url): ?string { + $baseUrl = $this->urlGenerator->getBaseUrl(); + $baseWithIndex = $baseUrl . '/index.php'; + + preg_match('/^' . preg_quote($baseUrl, '/') . '\/u\/(\w+)$/', $url, $matches); + if (count($matches) > 1) { + return $matches[1]; + } + preg_match('/^' . preg_quote($baseWithIndex, '/') . '\/u\/(\w+)$/', $url, $matches); + if (count($matches) > 1) { + return $matches[1]; + } + + return null; + } + + public function getOpenStreetLocationUrl($location): string { + return 'https://www.openstreetmap.org/search?query=' . urlencode($location); + } + + /** + * @inheritDoc + */ + public function getCachePrefix(string $referenceId): string { + return $this->userId ?? ''; + } + + /** + * @inheritDoc + */ + public function getCacheKey(string $referenceId): ?string { + $objectId = $this->getObjectId($referenceId); + if ($objectId !== null) { + return $objectId; + } + return $referenceId; + } +} diff --git a/src/components/ProfilePicker/ProfilePickerReferenceWidget.vue b/src/components/ProfilePicker/ProfilePickerReferenceWidget.vue new file mode 100644 index 000000000..2f63a5055 --- /dev/null +++ b/src/components/ProfilePicker/ProfilePickerReferenceWidget.vue @@ -0,0 +1,151 @@ +<template> + <div class="profile-reference"> + <div class="profile-reference__wrapper"> + <div class="profile-reference__wrapper__header"> + <NcAvatar :user="richObject.user_id" :size="48" class="profile-card__avatar" /> + <div class="profile-card__title"> + <a :href="richObject.url" target="_blank"> + <Account :size="20" /> + <strong> + {{ richObject.email !== null ? richObject.title + ' - ' + richObject.email : richObject.title }} + </strong> + </a> + </div> + </div> + <div class="profile-content"> + <p class="profile-content__subline"> + <span v-if="richObject.headline" class="headline"> + {{ richObject.headline }} + </span> + <span v-if="richObject.location" class="location"> + <MapMarker :size="20" /> + <template v-if="richObject.location_url"> + <a :href="richObject.location_url" class="external" target="_blank">{{ richObject.location }}</a> + </template> + <template v-else> + {{ richObject.location }} + </template> + </span> + <span v-if="richObject.website" class="website"> + <Web :size="20" /> + <a :href="richObject.website" class="external" target="_blank">{{ richObject.website }}</a> + </span> + <span v-if="richObject.organisation" class="organisation"> + <Domain :size="20" /> + {{ richObject.organisation }} + </span> + <span v-if="richObject.role" class="role"> + <Handshake :size="20" /> + {{ richObject.role }} + </span> + <span v-if="richObject.bio" + class="bio" + :title="richObject.full_bio"> + <TextAccount :size="20" /> + {{ richObject.bio }} + </span> + </p> + </div> + </div> + </div> +</template> + +<script> +import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js' + +import Account from 'vue-material-design-icons/Account.vue' +import MapMarker from 'vue-material-design-icons/MapMarker.vue' +import Web from 'vue-material-design-icons/Web.vue' +import Domain from 'vue-material-design-icons/Domain.vue' +import Handshake from 'vue-material-design-icons/Handshake.vue' +import TextAccount from 'vue-material-design-icons/TextAccount.vue' + +export default { + name: 'ProfilePickerReferenceWidget', + components: { + NcAvatar, + Account, + MapMarker, + Web, + Domain, + Handshake, + TextAccount, + }, + props: { + richObjectType: { + type: String, + default: '', + }, + richObject: { + type: Object, + default: null, + }, + accessible: { + type: Boolean, + default: true, + }, + }, +} +</script> + +<style scoped lang="scss"> +.profile-reference { + width: 100%; + white-space: normal; + display: flex; + + &__wrapper { + width: 100%; + display: flex; + align-items: center; + flex-direction: column; + + &__header { + width: 100%; + min-height: 70px; + padding-left: 12px; + background-color: var(--color-primary); + background-image: var(--gradient-primary-background); + position: relative; + display: flex; + align-items: center; + gap: 10px; + } + + .profile-card__title a { + display: flex; + align-items: center; + gap: 5px; + color: var(--color-primary-element-text); + } + + .profile-content { + display: flex; + flex-direction: column; + justify-content: center; + min-height: 46px; + padding: 10px 10px 10px 60px; + width: 100%; + } + + .headline { + font-style: italic; + padding-left: 5px; + } + + .profile-content__subline { + padding: 0 0 0 10px; + + & span.material-design-icon { + margin-right: 5px; + } + + & > span { + display: flex; + align-items: center; + margin-bottom: 5px; + } + } + } +} +</style> diff --git a/src/components/ProfilePicker/ProfilesCustomPicker.vue b/src/components/ProfilePicker/ProfilesCustomPicker.vue new file mode 100644 index 000000000..80074641a --- /dev/null +++ b/src/components/ProfilePicker/ProfilesCustomPicker.vue @@ -0,0 +1,212 @@ +<template> + <div class="profile-picker"> + <div class="profile-picker__heading"> + <h2> + {{ t('contacts', 'Profile picker') }} + </h2> + <div class="input-wrapper"> + <NcSelect ref="profiles-search-input" + v-model="selectedProfile" + input-id="profiles-search" + :loading="loading" + :filterable="false" + :placeholder="t('contacts', 'Search for a user profile')" + :clear-search-on-blur="() => false" + :user-select="true" + :multiple="false" + :options="options" + @search="searchForProfile" + @option:selecting="resolveResult"> + <template #no-options="{ search }"> + {{ search ? noResultText : t('contacts', 'Search for a user profile. Start typing') }} + </template> + </NcSelect> + </div> + <NcEmptyContent class="empty-content"> + <template #icon> + <Account :size="20" /> + </template> + </NcEmptyContent> + </div> + <div class="profile-picker__footer"> + <NcButton v-if="selectedProfile !== null" + type="primary" + :aria-label="t('contacts', 'Insert selected user profile link')" + :disabled="loading || selectedProfile === null" + @click="submit"> + {{ t('contacts', 'Insert') }} + <template #icon> + <ArrowRightIcon /> + </template> + </NcButton> + </div> + </div> +</template> + +<script> +import axios from '@nextcloud/axios' +import { generateOcsUrl, generateUrl } from '@nextcloud/router' + +import debounce from 'debounce' + +import { + NcSelect, + NcButton, + NcEmptyContent, +} from '@nextcloud/vue' + +import Account from 'vue-material-design-icons/Account.vue' +import ArrowRightIcon from 'vue-material-design-icons/ArrowRight.vue' + +export default { + name: 'ProfilesCustomPicker', + + components: { + NcSelect, + NcButton, + NcEmptyContent, + Account, + ArrowRightIcon, + }, + + props: { + providerId: { + type: String, + required: true, + }, + accessible: { + type: Boolean, + default: false, + }, + }, + + data() { + return { + searchQuery: '', + loading: false, + resultUrl: null, + reference: null, + profiles: [], + selectedProfile: null, + abortController: null, + } + }, + + computed: { + options() { + if (this.searchQuery !== '') { + return this.profiles + } + return [] + }, + noResultText() { + return this.loading ? t('contacts', 'Searching …') : t('contacts', 'Not found') + }, + }, + + mounted() { + this.focusOnInput() + }, + + methods: { + focusOnInput() { + this.$nextTick(() => { + this.$refs['profiles-search-input'].$el.getElementsByTagName('input')[0]?.focus() + }) + }, + + async searchForProfile(query) { + if (query.trim() === '' || query.trim().length < 3) { + return + } + this.searchQuery = query.trim() + this.loading = true + await this.debounceFindProfiles(query) + }, + + debounceFindProfiles: debounce(function(...args) { + this.findProfiles(...args) + }, 300), + + async findProfiles(query) { + const url = generateOcsUrl('core/autocomplete/get?search={searchQuery}&itemType=%20&itemId=%20&shareTypes[]=0&limit=20', { searchQuery: query }) + try { + const res = await axios.get(url) + this.profiles = res.data.ocs.data.map(userAutocomplete => { + return { + user: userAutocomplete.id, + displayName: userAutocomplete.label, + icon: userAutocomplete.icon, + subtitle: userAutocomplete.subline, + isNoUser: userAutocomplete.source.startsWith('users'), + } + }) + } catch (err) { + console.error(err) + } finally { + this.loading = false + } + }, + + submit() { + this.resultUrl = window.location.origin + generateUrl(`/u/${this.selectedProfile.user.trim().toLowerCase()}`, null, { noRewrite: true }) + this.$emit('submit', this.resultUrl) + }, + + resolveResult(selectedItem) { + this.loading = true + this.abortController = new AbortController() + this.selectedProfile = selectedItem + this.resultUrl = window.location.origin + generateUrl(`/u/${this.selectedProfile.user.trim().toLowerCase()}`, null, { noRewrite: true }) + try { + const res = axios.get(generateOcsUrl('references/resolve', 2) + '?reference=' + encodeURIComponent(this.resultUrl), { + signal: this.abortController.signal, + }) + this.reference = res.data.ocs.data.references[this.resultUrl] + } catch (err) { + console.error(err) + } finally { + this.loading = false + } + }, + + clearSelection() { + this.selectedProfile = null + this.resultUrl = null + this.reference = null + }, + }, +} +</script> + +<style scoped lang="scss"> +.profile-picker { + width: 100%; + min-height: 450px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + padding: 12px 16px 16px 16px; + + &__heading, .select { + width: 100%; + + h2 { + text-align: center; + } + } + + &__footer { + width: 100%; + display: flex; + align-items: center; + justify-content: end; + margin-top: 12px; + + > * { + margin-left: 4px; + } + } +} +</style> diff --git a/src/reference.js b/src/reference.js new file mode 100644 index 000000000..d10d73152 --- /dev/null +++ b/src/reference.js @@ -0,0 +1,32 @@ +import { registerWidget, registerCustomPickerElement, NcCustomPickerRenderResult } from '@nextcloud/vue/dist/Components/NcRichText.js' + +registerWidget('users_picker_profile', async (el, { richObjectType, richObject, accessible }) => { + const { default: Vue } = await import('vue') + const { default: ProfilePickerReferenceWidget } = await import('./components/ProfilePicker/ProfilePickerReferenceWidget.vue') + Vue.mixin({ methods: { t, n } }) + const Widget = Vue.extend(ProfilePickerReferenceWidget) + new Widget({ + propsData: { + richObjectType, + richObject, + accessible, + }, + }).$mount(el) +}, () => {}, { hasInteractiveView: false }) + +registerCustomPickerElement('profile_picker', async (el, { providerId, accessible }) => { + const { default: Vue } = await import(/* webpackChunkName: "vue-lazy" */'vue') + Vue.mixin({ methods: { t, n } }) + const { default: ProfilesCustomPicker } = await import('./components/ProfilePicker/ProfilesCustomPicker.vue') + const Element = Vue.extend(ProfilesCustomPicker) + const vueElement = new Element({ + propsData: { + providerId, + accessible, + }, + }).$mount(el) + return new NcCustomPickerRenderResult(vueElement.$el, vueElement) +}, (el, renderResult) => { + console.debug('Profile custom picker destroy callback. el', el, 'renderResult:', renderResult) + renderResult.object.$destroy() +}, 'normal') diff --git a/tests/unit/Reference/ProfilePickerReferenceProviderTest.php b/tests/unit/Reference/ProfilePickerReferenceProviderTest.php new file mode 100644 index 000000000..6a5bf954d --- /dev/null +++ b/tests/unit/Reference/ProfilePickerReferenceProviderTest.php @@ -0,0 +1,314 @@ +<?php + +namespace OCA\Contacts\Reference; + +use ChristophWurst\Nextcloud\Testing\TestCase; +use OCP\Accounts\IAccount; +use OCP\Accounts\IAccountManager; +use OCP\Accounts\IAccountProperty; +use OCP\Collaboration\Reference\IReference; +use OCP\Collaboration\Reference\Reference; +use OCP\IL10N; + +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserManager; +use PHPUnit\Framework\MockObject\MockObject; + +class ProfilePickerReferenceProviderTest extends TestCase { + private string $userId = 'admin'; + private IL10N|MockObject $l10n; + private IURLGenerator|MockObject $urlGenerator; + private IUserManager|MockObject $userManager; + private IAccountManager|MockObject $accountManager; + private ProfilePickerReferenceProvider $referenceProvider; + + private array $testUsersData = [ + 'user1' => [ + 'user_id' => 'user1', + 'displayname' => 'First User', + 'email' => 'user1@domain.co', + 'avatarurl' => 'https://nextcloud.local/index.php/avatar/user1/64', + ], + 'user2' => [ + 'user_id' => 'user2', + 'displayname' => 'Second User', + 'email' => 'user2@domain.co', + 'avatarurl' => 'https://nextcloud.local/index.php/avatar/user2/64', + ], + 'user3' => null, + ]; + private array $testAccountsData = [ + 'user1' => [ + IAccountManager::PROPERTY_BIOGRAPHY => [ + 'scope' => IAccountManager::SCOPE_PRIVATE, + 'value' => 'This is a first test user', + ], + IAccountManager::PROPERTY_HEADLINE => [ + 'scope' => IAccountManager::SCOPE_LOCAL, + 'value' => 'I\'m a first test user', + ], + IAccountManager::PROPERTY_ADDRESS => [ + 'scope' => IAccountManager::SCOPE_LOCAL, + 'value' => 'Odessa', + ], + IAccountManager::PROPERTY_WEBSITE => [ + 'scope' => IAccountManager::SCOPE_LOCAL, + 'value' => 'https://domain.co/testuser1', + ], + IAccountManager::PROPERTY_ORGANISATION => [ + 'scope' => IAccountManager::SCOPE_PRIVATE, + 'value' => 'Nextcloud GmbH', + ], + IAccountManager::PROPERTY_ROLE => [ + 'scope' => IAccountManager::SCOPE_LOCAL, + 'value' => 'Non-existing user', + ], + ], + 'user2' => [ + IAccountManager::PROPERTY_BIOGRAPHY => [ + 'scope' => IAccountManager::SCOPE_LOCAL, + 'value' => 'This is a test user', + ], + IAccountManager::PROPERTY_HEADLINE => [ + 'scope' => IAccountManager::SCOPE_LOCAL, + 'value' => 'Second test user', + ], + IAccountManager::PROPERTY_ADDRESS => [ + 'scope' => IAccountManager::SCOPE_LOCAL, + 'value' => 'Berlin', + ], + IAccountManager::PROPERTY_WEBSITE => [ + 'scope' => IAccountManager::SCOPE_LOCAL, + 'value' => 'https://domain.co/testuser2', + ], + IAccountManager::PROPERTY_ORGANISATION => [ + 'scope' => IAccountManager::SCOPE_PRIVATE, + 'value' => 'Nextcloud GmbH', + ], + IAccountManager::PROPERTY_ROLE => [ + 'scope' => IAccountManager::SCOPE_LOCAL, + 'value' => 'Non-existing user', + ], + ], + 'user3' => null, + ]; + private string $baseUrl = 'https://nextcloud.local'; + private string $testLink = 'https://nextcloud.local/index.php/u/user'; + private array $testLinks = [ + 'user1' => 'https://nextcloud.local/index.php/u/user1', + 'user2' => 'https://nextcloud.local/index.php/u/user2', + 'user4' => 'https://nextcloud.local/index.php/u/user4', + ]; + + public function setUp(): void { + parent::setUp(); + + $this->l10n = $this->createMock(IL10N::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->accountManager = $this->createMock(IAccountManager::class); + + $this->referenceProvider = new ProfilePickerReferenceProvider( + $this->l10n, + $this->urlGenerator, + $this->userManager, + $this->accountManager, + $this->userId + ); + + $this->urlGenerator->expects($this->any()) + ->method('getBaseUrl') + ->willReturn($this->baseUrl); + } + + private function getTestAccountPropertyValue(string $testUserId, string $property): mixed { + if ($this->testAccountsData[$testUserId][$property]['scope'] === IAccountManager::SCOPE_PRIVATE) { + return null; + } + return $this->testAccountsData[$testUserId][$property]['value']; + } + + /** + * @param string $userId + * @return IReference|null + */ + private function setupUserAccountReferenceExpectation(string $userId): ?IReference { + $user = $this->createMock(IUser::class); + + if (isset($this->testUsersData[$userId])) { + + // setup user expectations + $user->expects($this->any()) + ->method('getUID') + ->willReturn($this->testUsersData[$userId]['user_id']); + $user->expects($this->any()) + ->method('getDisplayName') + ->willReturn($this->testUsersData[$userId]['displayname']); + $user->expects($this->any()) + ->method('getEMailAddress') + ->willReturn($this->testUsersData[$userId]['email']); + + $this->userManager->expects($this->any()) + ->method('get') + ->with($userId) + ->willReturn($user); + + // setup account expectations + $account = $this->createMock(IAccount::class); + $account->expects($this->any()) + ->method('getProperty') + ->willReturnCallback(function ($property) use ($userId) { + $propertyMock = $this->createMock(IAccountProperty::class); + $propertyMock->expects($this->any()) + ->method('getValue') + ->willReturn($this->testAccountsData[$userId][$property]['value']); + $propertyMock->expects($this->any()) + ->method('getScope') + ->willReturn($this->testAccountsData[$userId][$property]['scope']); + return $propertyMock; + }); + + $this->accountManager->expects($this->any()) + ->method('getAccount') + ->with($user) + ->willReturn($account); + + // setup reference + if ($this->testUsersData[$userId] === null) { + $expectedReference = null; + } else { + $expectedReference = new Reference($this->testLinks[$userId]); + $expectedReference->setTitle($this->testUsersData[$userId]['displayname']); + $expectedReference->setDescription($this->testUsersData[$userId]['email']); + $expectedReference->setImageUrl($this->testUsersData[$userId]['avatarurl']); + $bio = $this->getTestAccountPropertyValue($userId, IAccountManager::PROPERTY_BIOGRAPHY); + $location = $this->getTestAccountPropertyValue($userId, IAccountManager::PROPERTY_ADDRESS); + + $expectedReference->setRichObject(ProfilePickerReferenceProvider::RICH_OBJECT_TYPE, [ + 'user_id' => $this->testUsersData[$userId]['user_id'], + 'title' => $this->testUsersData[$userId]['displayname'], + 'subline' => $this->testUsersData[$userId]['email'] ?? $this->testUsersData[$userId]['displayname'], + 'email' => $this->testUsersData[$userId]['email'], + 'bio' => $bio !== null ? substr_replace($bio, '...', 80, strlen($bio)) : null, + 'headline' => $this->getTestAccountPropertyValue($userId, IAccountManager::PROPERTY_HEADLINE), + 'location' => $location, + 'location_url' => $location !== null ? 'https://www.openstreetmap.org/search?query=' . urlencode($location) : null, + 'website' => $this->getTestAccountPropertyValue($userId, IAccountManager::PROPERTY_WEBSITE), + 'organisation' => $this->getTestAccountPropertyValue($userId, IAccountManager::PROPERTY_ORGANISATION), + 'role' => $this->getTestAccountPropertyValue($userId, IAccountManager::PROPERTY_ROLE), + 'url' => $this->testLinks[$userId], + ]); + } + + $this->urlGenerator->expects($this->any()) + ->method('linkToRouteAbsolute') + ->with('core.avatar.getAvatar', ['userId' => $userId, 'size' => 64]) + ->willReturn($this->testUsersData[$userId]['avatarurl']); + } + + return $expectedReference ?? null; + } + + /** + * Resolved reference should contain the expected reference fields according to account property scope + * + * @dataProvider resolveReferenceDataProvider + */ + public function testResolveReference($expected, $reference, $userId) { + if (isset($userId)) { + $expectedReference = $this->setupUserAccountReferenceExpectation($userId); + } + + $resultReference = $this->referenceProvider->resolveReference($reference); + $this->assertEquals($expected, isset($resultReference)); + $this->assertEquals($expectedReference ?? null, $resultReference); + } + + public function testGetId() { + $this->assertEquals('profile_picker', $this->referenceProvider->getId()); + } + + /** + * @dataProvider referenceDataProvider + */ + public function testMatchReference($expected, $reference) { + $this->assertEquals($expected, $this->referenceProvider->matchReference($reference)); + } + + /** + * @dataProvider cacheKeyDataProvider + */ + public function testGetCacheKey($expected, $reference) { + $this->assertEquals($expected, $this->referenceProvider->getCacheKey($reference)); + } + + public function testGetCachePrefix() { + $this->assertEquals($this->userId, $this->referenceProvider->getCachePrefix($this->testLink)); + } + + public function testGetTitle() { + $this->l10n->expects($this->once()) + ->method('t') + ->with('Profile picker') + ->willReturn('Profile picker'); + $this->assertEquals('Profile picker', $this->referenceProvider->getTitle()); + } + + /** + * Test getObjectId method. + * It should return the userid extracted from the link (http(s)://domain.com/(index.php)/u/{userid}). + * + * @dataProvider objectIdDataProvider + */ + public function testGetObjectId($expected, $reference) { + $this->assertEquals($expected, $this->referenceProvider->getObjectId($reference)); + } + + /** + * @dataProvider locationDataProvider + */ + public function testGetOpenStreetLocationUrl($expected, $location) { + $this->assertEquals($expected, $this->referenceProvider->getOpenStreetLocationUrl($location)); + } + + public function referenceDataProvider(): array { + return [ + 'not a link' => [false, 'profile_picker'], + 'valid link to test user' => [true, 'https://nextcloud.local/index.php/u/user1'], + 'pretty link to test user' => [true, 'https://nextcloud.local/u/user1'], + 'not valid link' => [false, 'https://nextcloud.local'], + ]; + } + + public function objectIdDataProvider(): array { + return [ + 'valid link to test user' => ['user1', 'https://nextcloud.local/index.php/u/user1'], + 'not valid link' => [null, 'https://nextcloud.local'], + ]; + } + + public function cacheKeyDataProvider(): array { + return [ + 'valid link to test user' => ['user1', 'https://nextcloud.local/index.php/u/user1'], + 'not valid link' => ['https://nextcloud.local', 'https://nextcloud.local'], + ]; + } + + public function locationDataProvider(): array { + return [ + 'link to location' => ['https://www.openstreetmap.org/search?query=location', 'location'], + 'link to Odessa' => ['https://www.openstreetmap.org/search?query=Odessa', 'Odessa'], + 'link to Frankfurt am Main' => ['https://www.openstreetmap.org/search?query=Frankfurt+am+Main', 'Frankfurt am Main'], + ]; + } + + public function resolveReferenceDataProvider(): array { + return [ + 'test reference for user1' => [true, 'https://nextcloud.local/index.php/u/user1', 'user1'], + 'test reference for user2' => [true, 'https://nextcloud.local/index.php/u/user2', 'user2'], + 'test reference for non-existing user' => [false, 'https://nextcloud.local/index.php/u/user4', 'user4'], + 'test reference for not valid link' => [null, 'https://nextcloud.local', null], + ]; + } +} diff --git a/vite.config.js b/vite.config.js index 69a048f12..1fea2e6e4 100644 --- a/vite.config.js +++ b/vite.config.js @@ -10,6 +10,7 @@ export default createAppConfig({ 'main': path.join(__dirname, 'src', 'main.js'), 'files-action': path.join(__dirname, 'src', 'files-action.js'), 'admin-settings': path.join(__dirname, 'src', 'admin-settings.js'), + 'reference': path.join(__dirname, 'src', 'reference.js'), }, { inlineCSS: false, }) From 69b73de2874a9d49504f5623b5ec362061f31ad2 Mon Sep 17 00:00:00 2001 From: Julien Veyssier <julien-nc@posteo.net> Date: Wed, 4 Dec 2024 14:49:09 +0100 Subject: [PATCH 2/4] chore: fix licenses Signed-off-by: Julien Veyssier <julien-nc@posteo.net> --- REUSE.toml | 2 +- img/LICENSES.md | 8 ------ .../ProfilePickerReferenceListener.php | 26 +++---------------- .../ProfilePickerReferenceProvider.php | 26 +++---------------- .../ProfilePickerReferenceWidget.vue | 5 ++++ .../ProfilePicker/ProfilesCustomPicker.vue | 5 ++++ src/reference.js | 4 +++ .../ProfilePickerReferenceProviderTest.php | 4 +++ 8 files changed, 27 insertions(+), 53 deletions(-) delete mode 100644 img/LICENSES.md diff --git a/REUSE.toml b/REUSE.toml index b0f64241a..e6e218ba8 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -48,7 +48,7 @@ SPDX-FileCopyrightText = "2024 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "AGPL-3.0-or-later" [[annotations]] -path = ["img/address-book.svg", "img/circles.svg", "img/clone.svg", "img/eye.svg", "img/language.svg", "img/phone.svg", "img/qrcode.svg", "img/recent-actors.svg", "img/social.svg", "img/sync.svg", "img/up.svg", "img/contacts.svg", "img/app.svg", "img/group.svg"] +path = ["img/address-book.svg", "img/circles.svg", "img/clone.svg", "img/eye.svg", "img/language.svg", "img/phone.svg", "img/qrcode.svg", "img/recent-actors.svg", "img/social.svg", "img/sync.svg", "img/up.svg", "img/contacts.svg", "img/app.svg", "img/group.svg", "img/profile.svg", "img/profile-dark.svg"] precedence = "aggregate" SPDX-FileCopyrightText = "2018-2024 Google LLC" SPDX-License-Identifier = "Apache-2.0" diff --git a/img/LICENSES.md b/img/LICENSES.md deleted file mode 100644 index b08286924..000000000 --- a/img/LICENSES.md +++ /dev/null @@ -1,8 +0,0 @@ -# Licenses - -## profile.svg, profile-dark.svg - -* Created by: Google -* License: Apache License version 2.0 -* Link: https://pictogrammers.com/library/mdi/icon/account/ -* \ No newline at end of file diff --git a/lib/Listener/ProfilePickerReferenceListener.php b/lib/Listener/ProfilePickerReferenceListener.php index 60112471d..83e8e3962 100644 --- a/lib/Listener/ProfilePickerReferenceListener.php +++ b/lib/Listener/ProfilePickerReferenceListener.php @@ -1,29 +1,11 @@ <?php - -declare(strict_types=1); - /** - * @copyright Copyright (c) 2023 Andrey Borysenko <andrey18106x@gmail.com> - * - * @author 2023 Andrey Borysenko <andrey18106x@gmail.com> - * - * @license AGPL-3.0-or-later - * - * 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/>. - * + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ +declare(strict_types=1); + namespace OCA\Contacts\Listener; use OCA\Contacts\AppInfo\Application; diff --git a/lib/Reference/ProfilePickerReferenceProvider.php b/lib/Reference/ProfilePickerReferenceProvider.php index e0d354e79..ad84b9a0f 100644 --- a/lib/Reference/ProfilePickerReferenceProvider.php +++ b/lib/Reference/ProfilePickerReferenceProvider.php @@ -1,29 +1,11 @@ <?php - -declare(strict_types=1); - /** - * @copyright Copyright (c) 2023 Andrey Borysenko <andrey18106x@gmail.com> - * - * @author 2023 Andrey Borysenko <andrey18106x@gmail.com> - * - * @license AGPL-3.0-or-later - * - * 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/>. - * + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ +declare(strict_types=1); + namespace OCA\Contacts\Reference; use OCA\Contacts\AppInfo\Application; diff --git a/src/components/ProfilePicker/ProfilePickerReferenceWidget.vue b/src/components/ProfilePicker/ProfilePickerReferenceWidget.vue index 2f63a5055..51d60aad4 100644 --- a/src/components/ProfilePicker/ProfilePickerReferenceWidget.vue +++ b/src/components/ProfilePicker/ProfilePickerReferenceWidget.vue @@ -1,3 +1,8 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + <template> <div class="profile-reference"> <div class="profile-reference__wrapper"> diff --git a/src/components/ProfilePicker/ProfilesCustomPicker.vue b/src/components/ProfilePicker/ProfilesCustomPicker.vue index 80074641a..f07a935e9 100644 --- a/src/components/ProfilePicker/ProfilesCustomPicker.vue +++ b/src/components/ProfilePicker/ProfilesCustomPicker.vue @@ -1,3 +1,8 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + <template> <div class="profile-picker"> <div class="profile-picker__heading"> diff --git a/src/reference.js b/src/reference.js index d10d73152..1a14e76b6 100644 --- a/src/reference.js +++ b/src/reference.js @@ -1,3 +1,7 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ import { registerWidget, registerCustomPickerElement, NcCustomPickerRenderResult } from '@nextcloud/vue/dist/Components/NcRichText.js' registerWidget('users_picker_profile', async (el, { richObjectType, richObject, accessible }) => { diff --git a/tests/unit/Reference/ProfilePickerReferenceProviderTest.php b/tests/unit/Reference/ProfilePickerReferenceProviderTest.php index 6a5bf954d..47bb5c0b7 100644 --- a/tests/unit/Reference/ProfilePickerReferenceProviderTest.php +++ b/tests/unit/Reference/ProfilePickerReferenceProviderTest.php @@ -1,4 +1,8 @@ <?php +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ namespace OCA\Contacts\Reference; From 4fb561f307a8e3c11d954ebb188a3a5386fcf191 Mon Sep 17 00:00:00 2001 From: Julien Veyssier <julien-nc@posteo.net> Date: Wed, 4 Dec 2024 15:02:48 +0100 Subject: [PATCH 3/4] fix: profile picker tests Signed-off-by: Julien Veyssier <julien-nc@posteo.net> --- .../ProfilePickerReferenceProviderTest.php | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/unit/Reference/ProfilePickerReferenceProviderTest.php b/tests/unit/Reference/ProfilePickerReferenceProviderTest.php index 47bb5c0b7..13a0bfcae 100644 --- a/tests/unit/Reference/ProfilePickerReferenceProviderTest.php +++ b/tests/unit/Reference/ProfilePickerReferenceProviderTest.php @@ -68,6 +68,10 @@ class ProfilePickerReferenceProviderTest extends TestCase { 'scope' => IAccountManager::SCOPE_LOCAL, 'value' => 'Non-existing user', ], + IAccountManager::PROPERTY_PROFILE_ENABLED => [ + 'scope' => IAccountManager::SCOPE_LOCAL, + 'value' => '1', + ], ], 'user2' => [ IAccountManager::PROPERTY_BIOGRAPHY => [ @@ -94,6 +98,10 @@ class ProfilePickerReferenceProviderTest extends TestCase { 'scope' => IAccountManager::SCOPE_LOCAL, 'value' => 'Non-existing user', ], + IAccountManager::PROPERTY_PROFILE_ENABLED => [ + 'scope' => IAccountManager::SCOPE_LOCAL, + 'value' => '1', + ], ], 'user3' => null, ]; @@ -166,10 +174,10 @@ private function setupUserAccountReferenceExpectation(string $userId): ?IReferen $propertyMock = $this->createMock(IAccountProperty::class); $propertyMock->expects($this->any()) ->method('getValue') - ->willReturn($this->testAccountsData[$userId][$property]['value']); + ->willReturn($this->testAccountsData[$userId][$property]['value'] ?? ''); $propertyMock->expects($this->any()) ->method('getScope') - ->willReturn($this->testAccountsData[$userId][$property]['scope']); + ->willReturn($this->testAccountsData[$userId][$property]['scope'] ?? ''); return $propertyMock; }); @@ -194,7 +202,12 @@ private function setupUserAccountReferenceExpectation(string $userId): ?IReferen 'title' => $this->testUsersData[$userId]['displayname'], 'subline' => $this->testUsersData[$userId]['email'] ?? $this->testUsersData[$userId]['displayname'], 'email' => $this->testUsersData[$userId]['email'], - 'bio' => $bio !== null ? substr_replace($bio, '...', 80, strlen($bio)) : null, + 'bio' => isset($bio) && $bio !== '' + ? (mb_strlen($bio) > 80 + ? (mb_substr($bio, 0, 80) . '...') + : $bio) + : null, + 'full_bio' => $bio, 'headline' => $this->getTestAccountPropertyValue($userId, IAccountManager::PROPERTY_HEADLINE), 'location' => $location, 'location_url' => $location !== null ? 'https://www.openstreetmap.org/search?query=' . urlencode($location) : null, From a7cc035f079590c2b515c2ce4259f303ffb07660 Mon Sep 17 00:00:00 2001 From: Julien Veyssier <julien-nc@posteo.net> Date: Thu, 5 Dec 2024 17:07:28 +0100 Subject: [PATCH 4/4] feat(profilepicker): check fields visibility in reference provider Signed-off-by: Julien Veyssier <julien-nc@posteo.net> --- .../ProfilePickerReferenceProvider.php | 44 +++++++++------- .../ProfilePickerReferenceProviderTest.php | 51 +++++++++++++++++-- 2 files changed, 75 insertions(+), 20 deletions(-) diff --git a/lib/Reference/ProfilePickerReferenceProvider.php b/lib/Reference/ProfilePickerReferenceProvider.php index ad84b9a0f..217615014 100644 --- a/lib/Reference/ProfilePickerReferenceProvider.php +++ b/lib/Reference/ProfilePickerReferenceProvider.php @@ -18,6 +18,7 @@ use OCP\IL10N; use OCP\IURLGenerator; use OCP\IUserManager; +use OCP\Profile\IProfileManager; class ProfilePickerReferenceProvider extends ADiscoverableReferenceProvider { public const RICH_OBJECT_TYPE = 'users_picker_profile'; @@ -27,6 +28,7 @@ public function __construct( private IURLGenerator $urlGenerator, private IUserManager $userManager, private IAccountManager $accountManager, + private IProfileManager $profileManager, private ?string $userId, ) { } @@ -79,11 +81,12 @@ public function resolveReference(string $referenceText): ?IReference { if ($user === null) { return null; } - $account = $this->accountManager->getAccount($user); - $profileEnabled = $account->getProperty(IAccountManager::PROPERTY_PROFILE_ENABLED)->getValue() === '1'; - if (!$profileEnabled) { + if (!$this->profileManager->isProfileEnabled($user)) { return null; } + $account = $this->accountManager->getAccount($user); + + $currentUser = $this->userManager->get($this->userId); $reference = new Reference($referenceText); @@ -91,8 +94,17 @@ public function resolveReference(string $referenceText): ?IReference { $userEmail = $user->getEMailAddress(); $userAvatarUrl = $this->urlGenerator->linkToRouteAbsolute('core.avatar.getAvatar', ['userId' => $userId, 'size' => '64']); - $bio = $account->getProperty(IAccountManager::PROPERTY_BIOGRAPHY); - $bio = $bio->getScope() !== IAccountManager::SCOPE_PRIVATE ? $bio->getValue() : null; + $bioProperty = $account->getProperty(IAccountManager::PROPERTY_BIOGRAPHY); + $bio = null; + $fullBio = null; + if ($this->profileManager->isProfileFieldVisible(IAccountManager::PROPERTY_BIOGRAPHY, $user, $currentUser)) { + $fullBio = $bioProperty->getValue(); + $bio = $fullBio !== '' + ? (mb_strlen($fullBio) > 80 + ? (mb_substr($fullBio, 0, 80) . '...') + : $fullBio) + : null; + } $headline = $account->getProperty(IAccountManager::PROPERTY_HEADLINE); $location = $account->getProperty(IAccountManager::PROPERTY_ADDRESS); $website = $account->getProperty(IAccountManager::PROPERTY_WEBSITE); @@ -104,6 +116,8 @@ public function resolveReference(string $referenceText): ?IReference { $reference->setDescription($userEmail ?? $userDisplayName); $reference->setImageUrl($userAvatarUrl); + $isLocationVisible = $this->profileManager->isProfileFieldVisible(IAccountManager::PROPERTY_ADDRESS, $user, $currentUser); + // for the Vue reference widget $reference->setRichObject( self::RICH_OBJECT_TYPE, @@ -112,18 +126,14 @@ public function resolveReference(string $referenceText): ?IReference { 'title' => $userDisplayName, 'subline' => $userEmail ?? $userDisplayName, 'email' => $userEmail, - 'bio' => isset($bio) && $bio !== '' - ? (mb_strlen($bio) > 80 - ? (mb_substr($bio, 0, 80) . '...') - : $bio) - : null, - 'full_bio' => $bio, - 'headline' => $headline->getScope() !== IAccountManager::SCOPE_PRIVATE ? $headline->getValue() : null, - 'location' => $location->getScope() !== IAccountManager::SCOPE_PRIVATE ? $location->getValue() : null, - 'location_url' => $location->getScope() !== IAccountManager::SCOPE_PRIVATE ? $this->getOpenStreetLocationUrl($location->getValue()) : null, - 'website' => $website->getScope() !== IAccountManager::SCOPE_PRIVATE ? $website->getValue() : null, - 'organisation' => $organisation->getScope() !== IAccountManager::SCOPE_PRIVATE ? $organisation->getValue() : null, - 'role' => $role->getScope() !== IAccountManager::SCOPE_PRIVATE ? $role->getValue() : null, + 'bio' => $bio, + 'full_bio' => $fullBio, + 'headline' => $this->profileManager->isProfileFieldVisible(IAccountManager::PROPERTY_HEADLINE, $user, $currentUser) ? $headline->getValue() : null, + 'location' => $isLocationVisible ? $location->getValue() : null, + 'location_url' => $isLocationVisible ? $this->getOpenStreetLocationUrl($location->getValue()) : null, + 'website' => $this->profileManager->isProfileFieldVisible(IAccountManager::PROPERTY_WEBSITE, $user, $currentUser) ? $website->getValue() : null, + 'organisation' => $this->profileManager->isProfileFieldVisible(IAccountManager::PROPERTY_ORGANISATION, $user, $currentUser) ? $organisation->getValue() : null, + 'role' => $this->profileManager->isProfileFieldVisible(IAccountManager::PROPERTY_ROLE, $user, $currentUser) ? $role->getValue() : null, 'url' => $referenceText, ] ); diff --git a/tests/unit/Reference/ProfilePickerReferenceProviderTest.php b/tests/unit/Reference/ProfilePickerReferenceProviderTest.php index 13a0bfcae..df3b2147e 100644 --- a/tests/unit/Reference/ProfilePickerReferenceProviderTest.php +++ b/tests/unit/Reference/ProfilePickerReferenceProviderTest.php @@ -17,14 +17,17 @@ use OCP\IURLGenerator; use OCP\IUser; use OCP\IUserManager; +use OCP\Profile\IProfileManager; use PHPUnit\Framework\MockObject\MockObject; class ProfilePickerReferenceProviderTest extends TestCase { private string $userId = 'admin'; + private IUser|MockObject $adminUser; private IL10N|MockObject $l10n; private IURLGenerator|MockObject $urlGenerator; private IUserManager|MockObject $userManager; private IAccountManager|MockObject $accountManager; + private IProfileManager|MockObject $profileManager; private ProfilePickerReferenceProvider $referenceProvider; private array $testUsersData = [ @@ -46,60 +49,74 @@ class ProfilePickerReferenceProviderTest extends TestCase { 'user1' => [ IAccountManager::PROPERTY_BIOGRAPHY => [ 'scope' => IAccountManager::SCOPE_PRIVATE, + 'visible' => true, 'value' => 'This is a first test user', ], IAccountManager::PROPERTY_HEADLINE => [ 'scope' => IAccountManager::SCOPE_LOCAL, + 'visible' => false, 'value' => 'I\'m a first test user', ], IAccountManager::PROPERTY_ADDRESS => [ 'scope' => IAccountManager::SCOPE_LOCAL, + 'visible' => true, 'value' => 'Odessa', ], IAccountManager::PROPERTY_WEBSITE => [ 'scope' => IAccountManager::SCOPE_LOCAL, + 'visible' => true, 'value' => 'https://domain.co/testuser1', ], IAccountManager::PROPERTY_ORGANISATION => [ 'scope' => IAccountManager::SCOPE_PRIVATE, + 'visible' => true, 'value' => 'Nextcloud GmbH', ], IAccountManager::PROPERTY_ROLE => [ 'scope' => IAccountManager::SCOPE_LOCAL, + 'visible' => true, 'value' => 'Non-existing user', ], IAccountManager::PROPERTY_PROFILE_ENABLED => [ 'scope' => IAccountManager::SCOPE_LOCAL, + 'visible' => true, 'value' => '1', ], ], 'user2' => [ IAccountManager::PROPERTY_BIOGRAPHY => [ 'scope' => IAccountManager::SCOPE_LOCAL, + 'visible' => true, 'value' => 'This is a test user', ], IAccountManager::PROPERTY_HEADLINE => [ 'scope' => IAccountManager::SCOPE_LOCAL, + 'visible' => true, 'value' => 'Second test user', ], IAccountManager::PROPERTY_ADDRESS => [ 'scope' => IAccountManager::SCOPE_LOCAL, + 'visible' => true, 'value' => 'Berlin', ], IAccountManager::PROPERTY_WEBSITE => [ 'scope' => IAccountManager::SCOPE_LOCAL, + 'visible' => true, 'value' => 'https://domain.co/testuser2', ], IAccountManager::PROPERTY_ORGANISATION => [ 'scope' => IAccountManager::SCOPE_PRIVATE, + 'visible' => true, 'value' => 'Nextcloud GmbH', ], IAccountManager::PROPERTY_ROLE => [ 'scope' => IAccountManager::SCOPE_LOCAL, + 'visible' => true, 'value' => 'Non-existing user', ], IAccountManager::PROPERTY_PROFILE_ENABLED => [ 'scope' => IAccountManager::SCOPE_LOCAL, + 'visible' => true, 'value' => '1', ], ], @@ -120,22 +137,44 @@ public function setUp(): void { $this->urlGenerator = $this->createMock(IURLGenerator::class); $this->userManager = $this->createMock(IUserManager::class); $this->accountManager = $this->createMock(IAccountManager::class); + $this->profileManager = $this->createMock(IProfileManager::class); $this->referenceProvider = new ProfilePickerReferenceProvider( $this->l10n, $this->urlGenerator, $this->userManager, $this->accountManager, + $this->profileManager, $this->userId ); $this->urlGenerator->expects($this->any()) ->method('getBaseUrl') ->willReturn($this->baseUrl); + + $this->profileManager->expects($this->any()) + ->method('isProfileEnabled') + ->willReturn(true); + + $this->profileManager->expects($this->any()) + ->method('isProfileFieldVisible') + ->willReturnCallback(function (string $profileField, IUser $targetUser, ?IUser $visitingUser) { + return $this->testAccountsData[$targetUser->getUID()][$profileField]['visible'] + && $this->testAccountsData[$targetUser->getUID()][$profileField]['scope'] !== IAccountManager::SCOPE_PRIVATE; + }); + + $this->adminUser = $this->createMock(IUser::class); + $this->adminUser->expects($this->any()) + ->method('getUID') + ->willReturn('admin'); + $this->adminUser->expects($this->any()) + ->method('getDisplayName') + ->willReturn('admin'); } private function getTestAccountPropertyValue(string $testUserId, string $property): mixed { - if ($this->testAccountsData[$testUserId][$property]['scope'] === IAccountManager::SCOPE_PRIVATE) { + if (!$this->testAccountsData[$testUserId][$property]['visible'] + || $this->testAccountsData[$testUserId][$property]['scope'] === IAccountManager::SCOPE_PRIVATE) { return null; } return $this->testAccountsData[$testUserId][$property]['value']; @@ -163,8 +202,14 @@ private function setupUserAccountReferenceExpectation(string $userId): ?IReferen $this->userManager->expects($this->any()) ->method('get') - ->with($userId) - ->willReturn($user); + ->willReturnCallback(function (string $uid) use ($user, $userId) { + if ($uid === $userId) { + return $user; + } elseif ($uid === 'admin') { + return $this->adminUser; + } + return null; + }); // setup account expectations $account = $this->createMock(IAccount::class);