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 @@ + \ 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 @@ + \ No newline at end of file diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index e58cab05d..3d8dcee30 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -24,11 +24,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; @@ -45,6 +48,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 @@ + + * + * @author 2023 Andrey Borysenko + * + * @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 . + * + */ + +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..6bf07287b --- /dev/null +++ b/lib/Reference/ProfilePickerReferenceProvider.php @@ -0,0 +1,188 @@ + + * + * @author 2023 Andrey Borysenko + * + * @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 . + * + */ + +namespace OCA\Contacts\Reference; + + +use OCP\Collaboration\Reference\ADiscoverableReferenceProvider; +use OCP\Collaboration\Reference\Reference; + +use OCP\Collaboration\Reference\IReference; +use OCP\IL10N; +use OCP\IURLGenerator; + +use OCA\Contacts\AppInfo\Application; +use OCP\Accounts\IAccountManager; +use OCP\IUserManager; + +class ProfilePickerReferenceProvider extends ADiscoverableReferenceProvider { + public const RICH_OBJECT_TYPE = 'users_picker_profile'; + private ?string $userId; + private IL10N $l10n; + private IURLGenerator $urlGenerator; + private IUserManager $userManager; + private IAccountManager $accountManager; + + public function __construct( + IL10N $l10n, + IURLGenerator $urlGenerator, + IUserManager $userManager, + IAccountManager $accountManager, + ?string $userId + ) { + $this->userId = $userId; + $this->l10n = $l10n; + $this->urlGenerator = $urlGenerator; + $this->userManager = $userManager; + $this->accountManager = $accountManager; + } + + /** + * @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) { + $reference = new Reference($referenceText); + + $userDisplayName = $user->getDisplayName(); + $userEmail = $user->getEMailAddress(); + $userAvatarUrl = $this->urlGenerator->linkToRouteAbsolute('core.avatar.getAvatar', ['userId' => $userId, 'size' => '64']); + + $bio = $this->accountManager->getAccount($user)->getProperty(IAccountManager::PROPERTY_BIOGRAPHY); + $bio = $bio->getScope() !== IAccountManager::SCOPE_PRIVATE ? $bio->getValue() : null; + $headline = $this->accountManager->getAccount($user)->getProperty(IAccountManager::PROPERTY_HEADLINE); + $location = $this->accountManager->getAccount($user)->getProperty(IAccountManager::PROPERTY_ADDRESS); + $website = $this->accountManager->getAccount($user)->getProperty(IAccountManager::PROPERTY_WEBSITE); + $organisation = $this->accountManager->getAccount($user)->getProperty(IAccountManager::PROPERTY_ORGANISATION); + $role = $this->accountManager->getAccount($user)->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 !== '' ? substr_replace($bio, '...', 80, strlen($bio)) : null, + '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; + } + return null; + } + + 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..7efdd5fb8 --- /dev/null +++ b/src/components/ProfilePicker/ProfilePickerReferenceWidget.vue @@ -0,0 +1,164 @@ + + + + + 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 @@ + + + + + diff --git a/src/reference.js b/src/reference.js new file mode 100644 index 000000000..c32f2d4a6 --- /dev/null +++ b/src/reference.js @@ -0,0 +1,36 @@ +import { registerWidget, registerCustomPickerElement, NcCustomPickerRenderResult } from '@nextcloud/vue/dist/Components/NcRichText.js' +import { getRequestToken } from '@nextcloud/auth' + +__webpack_nonce__ = btoa(getRequestToken()) // eslint-disable-line +__webpack_public_path__ = OC.linkTo('contacts', 'js/') // eslint-disable-line + +registerWidget('users_picker_profile', async (el, { richObjectType, richObject, accessible }) => { + const { default: Vue } = await import(/* webpackChunkName: "reference-issue-lazy" */'vue') + const { default: ProfilePickerReferenceWidget } = await import(/* webpackChunkName: "reference-issue-lazy" */'./components/ProfilePicker/ProfilePickerReferenceWidget.vue') + Vue.mixin({ methods: { t, n } }) + const Widget = Vue.extend(ProfilePickerReferenceWidget) + new Widget({ + propsData: { + richObjectType, + richObject, + accessible, + }, + }).$mount(el) +}) + +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(/* webpackChunkName: "image-picker-lazy" */'./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..1d0eb7287 --- /dev/null +++ b/tests/unit/Reference/ProfilePickerReferenceProviderTest.php @@ -0,0 +1,314 @@ + [ + '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/webpack.config.js b/webpack.config.js index b1b34c3b5..0ebc46880 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -4,6 +4,7 @@ const webpackConfig = require('@nextcloud/webpack-vue-config') webpackConfig.entry['files-action'] = path.join(__dirname, 'src', 'files-action.js') webpackConfig.entry['admin-settings'] = path.join(__dirname, 'src', 'admin-settings.js') +webpackConfig.entry['reference'] = path.join(__dirname, 'src', 'reference.js') webpackConfig.plugins.push(...[ new webpack.IgnorePlugin({ resourceRegExp: /^\.\/locale$/,