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,
 })