From 6deb8776d4ffd3ff793941b959d07731e1f249a3 Mon Sep 17 00:00:00 2001 From: Andrey Borysenko Date: Tue, 27 Jun 2023 13:33:10 +0300 Subject: [PATCH 1/8] Moved users_picker profile custom picker to contacts --- img/profile-dark.svg | 1 + img/profile.svg | 1 + lib/AppInfo/Application.php | 6 + .../ProfilePickerReferenceListener.php | 43 ++++ .../ProfilePickerReferenceProvider.php | 193 +++++++++++++++++ .../ProfilePickerReferenceWidget.vue | 186 ++++++++++++++++ .../ProfilePicker/ProfilesCustomPicker.vue | 202 ++++++++++++++++++ .../ProfilePicker/icons/UserIcon.vue | 39 ++++ src/reference.js | 36 ++++ webpack.config.js | 1 + 10 files changed, 708 insertions(+) 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/components/ProfilePicker/icons/UserIcon.vue create mode 100644 src/reference.js 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..f279dd6ed --- /dev/null +++ b/lib/Reference/ProfilePickerReferenceProvider.php @@ -0,0 +1,193 @@ + + * + * @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 OC\Collaboration\Reference\LinkReferenceProvider; +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 { + +// private const RICH_OBJECT_TYPE = Application::APP_ID . '_profile_picker'; + private const RICH_OBJECT_TYPE = 'users_picker_profile'; + + private ?string $userId; + private IL10N $l10n; + private IURLGenerator $urlGenerator; + private LinkReferenceProvider $linkReferenceProvider; + private IUserManager $userManager; + private IAccountManager $accountManager; + + public function __construct( + IL10N $l10n, + IURLGenerator $urlGenerator, + LinkReferenceProvider $linkReferenceProvider, + IUserManager $userManager, + IAccountManager $accountManager, + ?string $userId + ) { + $this->userId = $userId; + $this->l10n = $l10n; + $this->urlGenerator = $urlGenerator; + $this->linkReferenceProvider = $linkReferenceProvider; + $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)) { + $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 $this->linkReferenceProvider->resolveReference($referenceText); + } + return null; + } + + private 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; + } + + private function getOpenStreetLocationUrl($location) { + 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..e931c43e1 --- /dev/null +++ b/src/components/ProfilePicker/ProfilePickerReferenceWidget.vue @@ -0,0 +1,186 @@ + + + + + + + diff --git a/src/components/ProfilePicker/ProfilesCustomPicker.vue b/src/components/ProfilePicker/ProfilesCustomPicker.vue new file mode 100644 index 000000000..0d9441c44 --- /dev/null +++ b/src/components/ProfilePicker/ProfilesCustomPicker.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/src/components/ProfilePicker/icons/UserIcon.vue b/src/components/ProfilePicker/icons/UserIcon.vue new file mode 100644 index 000000000..fa8161c60 --- /dev/null +++ b/src/components/ProfilePicker/icons/UserIcon.vue @@ -0,0 +1,39 @@ + + + diff --git a/src/reference.js b/src/reference.js new file mode 100644 index 000000000..899a2d986 --- /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/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$/, From df4298d532a2526c0778aa656152313de0129df4 Mon Sep 17 00:00:00 2001 From: Andrey Borysenko Date: Fri, 30 Jun 2023 14:14:40 +0300 Subject: [PATCH 2/8] Update src/components/ProfilePicker/ProfilesCustomPicker.vue Co-authored-by: Christoph Wurst Signed-off-by: Andrey Borysenko --- src/components/ProfilePicker/ProfilesCustomPicker.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ProfilePicker/ProfilesCustomPicker.vue b/src/components/ProfilePicker/ProfilesCustomPicker.vue index 0d9441c44..abcbfae35 100644 --- a/src/components/ProfilePicker/ProfilesCustomPicker.vue +++ b/src/components/ProfilePicker/ProfilesCustomPicker.vue @@ -85,7 +85,7 @@ export default { return [] }, noResultText() { - return this.loading ? t('contacts', 'Searching...') : t('contacts', 'Not found') + return this.loading ? t('contacts', 'Searching …') : t('contacts', 'Not found') }, }, From cf6a674084549e4dcdaa685e0cf03b86b3d44b60 Mon Sep 17 00:00:00 2001 From: Andrey Borysenko Date: Fri, 30 Jun 2023 14:27:09 +0300 Subject: [PATCH 3/8] Polish. Use try-catch-finally. Some restructure Signed-off-by: Andrey Borysenko --- .../ProfilePickerReferenceProvider.php | 97 +++++++++---------- .../ProfilePickerReferenceWidget.vue | 90 +++++++---------- .../ProfilePicker/ProfilesCustomPicker.vue | 88 +++++++++-------- .../ProfilePicker/icons/UserIcon.vue | 39 -------- src/reference.js | 2 +- 5 files changed, 132 insertions(+), 184 deletions(-) delete mode 100644 src/components/ProfilePicker/icons/UserIcon.vue diff --git a/lib/Reference/ProfilePickerReferenceProvider.php b/lib/Reference/ProfilePickerReferenceProvider.php index f279dd6ed..6644bbe9b 100644 --- a/lib/Reference/ProfilePickerReferenceProvider.php +++ b/lib/Reference/ProfilePickerReferenceProvider.php @@ -26,6 +26,7 @@ namespace OCA\Contacts\Reference; + use OC\Collaboration\Reference\LinkReferenceProvider; use OCP\Collaboration\Reference\ADiscoverableReferenceProvider; use OCP\Collaboration\Reference\Reference; @@ -39,10 +40,7 @@ use OCP\IUserManager; class ProfilePickerReferenceProvider extends ADiscoverableReferenceProvider { - -// private const RICH_OBJECT_TYPE = Application::APP_ID . '_profile_picker'; private const RICH_OBJECT_TYPE = 'users_picker_profile'; - private ?string $userId; private IL10N $l10n; private IURLGenerator $urlGenerator; @@ -105,52 +103,53 @@ public function matchReference(string $referenceText): bool { * @inheritDoc */ public function resolveReference(string $referenceText): ?IReference { - if ($this->matchReference($referenceText)) { - $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 $this->linkReferenceProvider->resolveReference($referenceText); + if (!$this->matchReference($referenceText)) { + return null; } - 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 $this->linkReferenceProvider->resolveReference($referenceText); } private function getObjectId(string $url): ?string { @@ -169,7 +168,7 @@ private function getObjectId(string $url): ?string { return null; } - private function getOpenStreetLocationUrl($location) { + private function getOpenStreetLocationUrl($location): string { return 'https://www.openstreetmap.org/search?query=' . urlencode($location); } diff --git a/src/components/ProfilePicker/ProfilePickerReferenceWidget.vue b/src/components/ProfilePicker/ProfilePickerReferenceWidget.vue index e931c43e1..42352ae73 100644 --- a/src/components/ProfilePicker/ProfilePickerReferenceWidget.vue +++ b/src/components/ProfilePicker/ProfilePickerReferenceWidget.vue @@ -1,33 +1,11 @@ - -