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/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..83e8e3962 --- /dev/null +++ b/lib/Listener/ProfilePickerReferenceListener.php @@ -0,0 +1,25 @@ +<?php +/** + * 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; +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..217615014 --- /dev/null +++ b/lib/Reference/ProfilePickerReferenceProvider.php @@ -0,0 +1,180 @@ +<?php +/** + * 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; +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; +use OCP\Profile\IProfileManager; + +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 IProfileManager $profileManager, + 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; + } + if (!$this->profileManager->isProfileEnabled($user)) { + return null; + } + $account = $this->accountManager->getAccount($user); + + $currentUser = $this->userManager->get($this->userId); + + $reference = new Reference($referenceText); + + $userDisplayName = $user->getDisplayName(); + $userEmail = $user->getEMailAddress(); + $userAvatarUrl = $this->urlGenerator->linkToRouteAbsolute('core.avatar.getAvatar', ['userId' => $userId, 'size' => '64']); + + $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); + $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); + + $isLocationVisible = $this->profileManager->isProfileFieldVisible(IAccountManager::PROPERTY_ADDRESS, $user, $currentUser); + + // for the Vue reference widget + $reference->setRichObject( + self::RICH_OBJECT_TYPE, + [ + 'user_id' => $userId, + 'title' => $userDisplayName, + 'subline' => $userEmail ?? $userDisplayName, + 'email' => $userEmail, + '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, + ] + ); + 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..51d60aad4 --- /dev/null +++ b/src/components/ProfilePicker/ProfilePickerReferenceWidget.vue @@ -0,0 +1,156 @@ +<!-- + - 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"> + <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..f07a935e9 --- /dev/null +++ b/src/components/ProfilePicker/ProfilesCustomPicker.vue @@ -0,0 +1,217 @@ +<!-- + - 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"> + <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..1a14e76b6 --- /dev/null +++ b/src/reference.js @@ -0,0 +1,36 @@ +/** + * 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 }) => { + 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..df3b2147e --- /dev/null +++ b/tests/unit/Reference/ProfilePickerReferenceProviderTest.php @@ -0,0 +1,376 @@ +<?php +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +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 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 = [ + '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, + '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', + ], + ], + '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->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]['visible'] + || $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') + ->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); + $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' => 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, + '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, })