From 235053fa1d8afc3e5c4ece7be7e84c7334a13539 Mon Sep 17 00:00:00 2001 From: Kristian Zendato Date: Fri, 7 Aug 2026 16:08:31 +0800 Subject: [PATCH] feat: add shared resource section to profile Signed-off-by: Kristian Zendato --- apps/profile/.noopenapi | 0 .../composer/composer/autoload_classmap.php | 1 + .../composer/composer/autoload_static.php | 1 + .../lib/Controller/ProfileApiController.php | 185 ++++++++++++++++ apps/profile/lib/ResponseDefinitions.php | 21 ++ apps/profile/openapi.json | 204 ++++++++++++++++++ apps/profile/openapi.json.license | 2 + .../components/SharedResourcesSection.spec.ts | 48 +++++ .../src/components/SharedResourcesSection.vue | 94 ++++++++ .../src/services/sharedResources.spec.ts | 46 ++++ apps/profile/src/services/sharedResources.ts | 33 +++ apps/profile/src/views/ProfileApp.vue | 36 +++- openapi.json | 152 +++++++++++++ 13 files changed, 822 insertions(+), 1 deletion(-) delete mode 100644 apps/profile/.noopenapi create mode 100644 apps/profile/lib/Controller/ProfileApiController.php create mode 100644 apps/profile/lib/ResponseDefinitions.php create mode 100644 apps/profile/openapi.json create mode 100644 apps/profile/openapi.json.license create mode 100644 apps/profile/src/components/SharedResourcesSection.spec.ts create mode 100644 apps/profile/src/components/SharedResourcesSection.vue create mode 100644 apps/profile/src/services/sharedResources.spec.ts create mode 100644 apps/profile/src/services/sharedResources.ts diff --git a/apps/profile/.noopenapi b/apps/profile/.noopenapi deleted file mode 100644 index e69de29bb2d1d..0000000000000 diff --git a/apps/profile/composer/composer/autoload_classmap.php b/apps/profile/composer/composer/autoload_classmap.php index 623dce51d5393..d2733b2fd2f8c 100644 --- a/apps/profile/composer/composer/autoload_classmap.php +++ b/apps/profile/composer/composer/autoload_classmap.php @@ -8,6 +8,7 @@ return array( 'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php', 'OCA\\Profile\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php', + 'OCA\\Profile\\Controller\\ProfileApiController' => $baseDir . '/../lib/Controller/ProfileApiController.php', 'OCA\\Profile\\Controller\\ProfilePageController' => $baseDir . '/../lib/Controller/ProfilePageController.php', 'OCA\\Profile\\Listener\\LoadAdditionalEntriesListener' => $baseDir . '/../lib/Listener/LoadAdditionalEntriesListener.php', 'OCA\\Profile\\Listener\\ProfilePickerReferenceListener' => $baseDir . '/../lib/Listener/ProfilePickerReferenceListener.php', diff --git a/apps/profile/composer/composer/autoload_static.php b/apps/profile/composer/composer/autoload_static.php index 41efca598c418..472d0115a3017 100644 --- a/apps/profile/composer/composer/autoload_static.php +++ b/apps/profile/composer/composer/autoload_static.php @@ -23,6 +23,7 @@ class ComposerStaticInitProfile public static $classMap = array ( 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', 'OCA\\Profile\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php', + 'OCA\\Profile\\Controller\\ProfileApiController' => __DIR__ . '/..' . '/../lib/Controller/ProfileApiController.php', 'OCA\\Profile\\Controller\\ProfilePageController' => __DIR__ . '/..' . '/../lib/Controller/ProfilePageController.php', 'OCA\\Profile\\Listener\\LoadAdditionalEntriesListener' => __DIR__ . '/..' . '/../lib/Listener/LoadAdditionalEntriesListener.php', 'OCA\\Profile\\Listener\\ProfilePickerReferenceListener' => __DIR__ . '/..' . '/../lib/Listener/ProfilePickerReferenceListener.php', diff --git a/apps/profile/lib/Controller/ProfileApiController.php b/apps/profile/lib/Controller/ProfileApiController.php new file mode 100644 index 0000000000000..251da0da38caa --- /dev/null +++ b/apps/profile/lib/Controller/ProfileApiController.php @@ -0,0 +1,185 @@ +, array{}> + * @throws OCSNotFoundException - The specified user does not exist. + * + * 200: The shared resources between the current user and the specified user. + * 404: The specified user does not exist. + */ + #[NoCSRFRequired] + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/api/v1/resources/{userId}')] + public function getResources(string $userId): DataResponse { + $user = $this->userManager->get($userId); + if (!$user) { + throw new OCSNotFoundException(); + } + + $files = $this->getSharedNodes($userId); + $events = $this->getSharedCalendarEvents($userId); + + $entries = array_values(array_merge($files, $events)); + return new DataResponse($entries); + } + + /** + * Get all upcoming events shared between a user and the current user. + * + * If the calendar app is disabled for the current user no events will be returned. + * + * @param string $userId - The user ID of the user to get shared events with. + * @return list + */ + private function getSharedCalendarEvents(string $userId) { + if (!$this->appManager->isEnabledForUser('calendar', $this->userSession->getUser())) { + return []; + } + + $mePrincipal = 'principals/users/' . $this->userSession->getUser()->getUID(); + + $query = $this->calendarManager->newQuery($mePrincipal); + $query->setSearchPattern($userId); + $query->addType('VEVENT'); + $query->addSearchProperty(ICalendarQuery::SEARCH_PROPERTY_ATTENDEE); + $query->addSearchProperty(ICalendarQuery::SEARCH_PROPERTY_ORGANIZER); + $now = new \DateTimeImmutable('now'); + $query->setTimerangeStart($now->modify('-1 hour')); + $query->setLimit(9); + + $events = $this->calendarManager->searchForPrincipal($query); + $result = []; + foreach ($events as $event) { + if (isset($event['objects'][0]['STATUS']) && $event['objects'][0]['STATUS'][0] === 'CANCELLED') { + continue; + } + + $end = $event['objects'][0]['DTEND'][0]; + if ($now->diff($end)->invert === 1) { + // already ended, skip + continue; + } + + $calendarUid = $event['objects'][0]['UID'][0]; + if (isset($event['RECURRENCE-ID'])) { + $recurrenceId = $event['RECURRENCE-ID'][0]; + $href = $this->urlGenerator->linkToRouteAbsolute('calendar.object.indexuid.recurrenceId', ['uid' => $calendarUid, 'recurrenceId' => $recurrenceId]); + } else { + $href = $this->urlGenerator->linkToRouteAbsolute('calendar.object.indexuid', ['uid' => $calendarUid]); + } + + $start = \DateTime::createFromImmutable($event['objects'][0]['DTSTART'][0]); + $result[] = [ + 'label' => $event['objects'][0]['SUMMARY'][0], + 'text' => $this->formatter->formatTimeSpan($start, \DateTime::createFromImmutable($now)), + 'href' => $href, + 'img' => $this->urlGenerator->getAbsoluteURL($this->appManager->getAppIcon('calendar')), + ]; + } + return $result; + } + + /** + * @return ProfileSharedResource[] + */ + private function getSharedNodes(string $userId): array { + $outgoingShares = []; + $offset = 0; + while (count($outgoingShares) < 5) { + $shares = $this->shareManager->getSharesBy($userId, IShare::TYPE_USER, limit: 50, offset: $offset); + $outgoingShares = array_merge($outgoingShares, array_filter($shares, fn ($share) => $share->getSharedWith() === $this->userSession->getUser()->getUID())); + $offset += 50; + if (count($shares) < 50) { + break; + } + } + + $incomingShares = []; + $offset = 0; + while (count($incomingShares) < 5) { + $shares = $this->shareManager->getSharesBy($this->userSession->getUser()->getUID(), IShare::TYPE_USER, limit: 50, offset: $offset); + $incomingShares = array_merge($incomingShares, array_filter($shares, fn ($share) => $share->getSharedWith() === $userId)); + $offset += 50; + if (count($shares) < 50) { + break; + } + } + + $shares = array_slice(array_merge($outgoingShares, $incomingShares), 0, 5); + usort($shares, fn ($a, $b) => $a->getNode()->getMTime() <=> $b->getNode()->getMTime()); + $files = []; + foreach ($shares as $share) { + $node = $share->getNode(); + // Preview endpoint only serves files; folders need the mime icon directly. + if ($node instanceof File) { + $img = $this->urlGenerator->linkToRouteAbsolute('core.Preview.getPreviewByFileId', [ + 'fileId' => $node->getId(), + 'mimeFallback' => true, + ]); + } else { + $img = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('core', 'filetypes/folder.svg')); + } + $files[] = [ + 'label' => $node->getName(), + 'text' => $this->formatter->formatTimeSpan($node->getMTime()), + 'href' => $this->urlGenerator->linkToRouteAbsolute('files.view.index', [ + 'dir' => $node->getParent()->getPath(), + 'fileid' => $node->getId(), + ]), + 'img' => $img, + ]; + } + return $files; + } +} diff --git a/apps/profile/lib/ResponseDefinitions.php b/apps/profile/lib/ResponseDefinitions.php new file mode 100644 index 0000000000000..5af937930f62f --- /dev/null +++ b/apps/profile/lib/ResponseDefinitions.php @@ -0,0 +1,21 @@ + { + cleanup() +}) + +test('renders You & user title and shared resources', async () => { + const { findByRole, findByText } = render(SharedResourcesSection, { + props: { + displayName: 'Alice', + resources: [ + { + label: 'Team notes', + text: 'yesterday', + href: 'https://example.com/f/1', + img: 'https://example.com/preview/1', + }, + ], + }, + }) + + expect(await findByRole('heading', { name: 'You & Alice' })).toBeTruthy() + expect(await findByText('Team notes')).toBeTruthy() + expect(await findByText('yesterday')).toBeTruthy() + + const link = await findByRole('link', { name: /Team notes/i }) + expect(link.getAttribute('href')).toBe('https://example.com/f/1') + expect(link.getAttribute('target')).toBe('_self') +}) + +test('shows No shared resource when empty', async () => { + const { findByRole, findByText } = render(SharedResourcesSection, { + props: { + displayName: 'Alice', + resources: [], + }, + }) + + expect(await findByText('No shared resource')).toBeTruthy() + expect(await findByRole('heading', { name: 'You & Alice' })).toBeTruthy() +}) diff --git a/apps/profile/src/components/SharedResourcesSection.vue b/apps/profile/src/components/SharedResourcesSection.vue new file mode 100644 index 0000000000000..f054410e8e23d --- /dev/null +++ b/apps/profile/src/components/SharedResourcesSection.vue @@ -0,0 +1,94 @@ + + + + + + + diff --git a/apps/profile/src/services/sharedResources.spec.ts b/apps/profile/src/services/sharedResources.spec.ts new file mode 100644 index 0000000000000..c1e1d46cabe3b --- /dev/null +++ b/apps/profile/src/services/sharedResources.spec.ts @@ -0,0 +1,46 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import axios from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' +import { beforeEach, expect, test, vi } from 'vitest' +import { getSharedResources } from './sharedResources.ts' + +vi.mock('@nextcloud/axios', () => ({ + default: { + get: vi.fn(), + }, +})) + +vi.mock('@nextcloud/router', () => ({ + generateOcsUrl: vi.fn(() => '/ocs/v2.php/apps/profile/api/v1/resources/alice'), +})) + +beforeEach(() => { + vi.clearAllMocks() +}) + +test('getSharedResources fetches and returns shared resources', async () => { + const resources = [ + { + label: 'Shared folder', + text: '2 days ago', + href: 'https://example.com/f/1', + img: 'https://example.com/preview/1', + }, + ] + + vi.mocked(axios.get).mockResolvedValue({ + data: { + ocs: { + data: resources, + }, + }, + }) + + await expect(getSharedResources('alice')).resolves.toEqual(resources) + expect(generateOcsUrl).toHaveBeenCalledWith('/apps/profile/api/v1/resources/{userId}', { userId: 'alice' }) + expect(axios.get).toHaveBeenCalledWith('/ocs/v2.php/apps/profile/api/v1/resources/alice') +}) diff --git a/apps/profile/src/services/sharedResources.ts b/apps/profile/src/services/sharedResources.ts new file mode 100644 index 0000000000000..24d7d72cf2171 --- /dev/null +++ b/apps/profile/src/services/sharedResources.ts @@ -0,0 +1,33 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { AxiosResponse } from '@nextcloud/axios' + +import axios from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' + +export interface SharedResource { + label: string + text: string + href: string + img: string +} + +interface OcsResponse { + ocs: { + data: T + } +} + +/** + * Fetch resources shared between the current user and the given user. + * + * @param userId - The user ID of the profile being viewed + */ +export async function getSharedResources(userId: string): Promise { + const url = generateOcsUrl('/apps/profile/api/v1/resources/{userId}', { userId }) + const response = await axios.get(url) as AxiosResponse> + return response.data.ocs.data +} diff --git a/apps/profile/src/views/ProfileApp.vue b/apps/profile/src/views/ProfileApp.vue index 33d169f31d23e..6b31d13e22d0a 100644 --- a/apps/profile/src/views/ProfileApp.vue +++ b/apps/profile/src/views/ProfileApp.vue @@ -5,6 +5,7 @@