From 704fbab6889ebe84c22d9c0ec0ed4d10e4d9d37f Mon Sep 17 00:00:00 2001 From: Stefan Lender Date: Mon, 12 Oct 2026 09:29:33 +0200 Subject: [PATCH 1/2] feat(teams): add activity and resource integration Co-authored-by: Copilot GPT-5.6 Terra Signed-off-by: Stefan Lender --- lib/Activity/Provider.php | 1 + lib/Activity/ProviderParser.php | 10 + lib/Activity/ProviderSubjectCircle.php | 41 + lib/Db/CircleRequest.php | 9 + lib/FederatedItems/CircleEdit.php | 1 + lib/Service/ActivityService.php | 58 +- lib/Service/EventService.php | 7 + src/teams/api.spec.ts | 41 +- src/teams/api.ts | 48 +- src/teams/components/TeamNavigation.vue | 24 +- src/teams/router.ts | 7 + src/teams/types.ts | 40 + src/teams/views/TeamActivityView.spec.ts | 185 +++++ src/teams/views/TeamActivityView.vue | 752 ++++++++++++++++++ .../lib/FederatedItems/MemberLevelTest.php | 49 ++ .../unit/lib/Service/ActivityServiceTest.php | 186 +++++ tests/unit/lib/Service/EventServiceTest.php | 36 + 17 files changed, 1481 insertions(+), 14 deletions(-) create mode 100644 src/teams/views/TeamActivityView.spec.ts create mode 100644 src/teams/views/TeamActivityView.vue create mode 100644 tests/unit/lib/FederatedItems/MemberLevelTest.php create mode 100644 tests/unit/lib/Service/ActivityServiceTest.php create mode 100644 tests/unit/lib/Service/EventServiceTest.php diff --git a/lib/Activity/Provider.php b/lib/Activity/Provider.php index 1a796846c..0535e9724 100644 --- a/lib/Activity/Provider.php +++ b/lib/Activity/Provider.php @@ -112,6 +112,7 @@ private function parseAsMember( $this->parserCircle->parseSubjectCircleCreate($event, $params); $this->parserCircle->parseSubjectCircleDelete($event, $params); + $this->parserCircle->parseSubjectCircleEdit($event, $params); $this->parseMemberAsMember($event, $params); $this->parseCircleMemberAsMember($event, $params); } diff --git a/lib/Activity/ProviderParser.php b/lib/Activity/ProviderParser.php index 1e60ae41a..404881a4f 100644 --- a/lib/Activity/ProviderParser.php +++ b/lib/Activity/ProviderParser.php @@ -265,4 +265,14 @@ protected function generateGroupParameter(array $group): array { '_parsed' => $group['userId'] ]; } + + /** @return array */ + protected function generateHighlightParameter(string $value): array { + return [ + 'type' => 'highlight', + 'id' => $value, + 'name' => $value, + '_parsed' => $value, + ]; + } } diff --git a/lib/Activity/ProviderSubjectCircle.php b/lib/Activity/ProviderSubjectCircle.php index f591929cd..719872a25 100644 --- a/lib/Activity/ProviderSubjectCircle.php +++ b/lib/Activity/ProviderSubjectCircle.php @@ -46,4 +46,45 @@ public function parseSubjectCircleDelete(IEvent $event, array $params): void { throw new FakeException(); } + + public function parseSubjectCircleEdit(IEvent $event, array $params): void { + if ($event->getSubject() !== 'circle_edit') { + return; + } + + $change = (string)($params['change'] ?? 'details'); + if ($change === 'name' || $change === 'displayName') { + $data = [ + 'author' => $this->generateUserParameter($params['initiator'] ?? []), + 'title' => $this->generateHighlightParameter((string)($params['title'] ?? '')), + ]; + if ($this->isViewerTheAuthor($params['initiator'] ?? [], $this->activityManager->getCurrentUserId())) { + $this->setSubject($event, $this->l10n->t('You changed the team title to {title}'), $data); + } else { + $this->setSubject($event, $this->l10n->t('{author} changed the team title to {title}'), $data); + } + + throw new FakeException(); + } + + if ($change === 'description') { + $this->parseCircleEvent( + $event, + $params, + $this->l10n->t('You changed the description of {circle}'), + $this->l10n->t('{author} changed the description of {circle}'), + ); + + throw new FakeException(); + } + + $this->parseCircleEvent( + $event, $params, + $this->l10n->t('You edited the team {circle}'), + $this->l10n->t('{author} edited the team {circle}') + ); + + throw new FakeException(); + } + } diff --git a/lib/Db/CircleRequest.php b/lib/Db/CircleRequest.php index 93ff586c2..0589b0519 100644 --- a/lib/Db/CircleRequest.php +++ b/lib/Db/CircleRequest.php @@ -40,6 +40,15 @@ public function __construct( parent::__construct($timezoneService, $configService); } + public function getActivityObjectId(string $singleId): int { + $qb = $this->getQueryBuilder(); + $qb->select('id') + ->from(self::TABLE_CIRCLE) + ->where($qb->expr()->eq('unique_id', $qb->createNamedParameter($singleId))); + + return (int)$qb->executeQuery()->fetchOne(); + } + /** * @param Circle $circle * diff --git a/lib/FederatedItems/CircleEdit.php b/lib/FederatedItems/CircleEdit.php index d6c43e143..85b5537ae 100644 --- a/lib/FederatedItems/CircleEdit.php +++ b/lib/FederatedItems/CircleEdit.php @@ -112,6 +112,7 @@ public function manage(FederatedEvent $event): void { } $this->circleRequest->edit($circle); + $event->setCircle($circle); $this->memberRequest->updateDisplayName($circle->getSingleId(), $circle->getDisplayName()); $this->eventService->circleEditing($event); } diff --git a/lib/Service/ActivityService.php b/lib/Service/ActivityService.php index f1bcf2364..d41a663af 100644 --- a/lib/Service/ActivityService.php +++ b/lib/Service/ActivityService.php @@ -10,6 +10,7 @@ namespace OCA\Circles\Service; use OCA\Circles\AppInfo\Application; +use OCA\Circles\Db\CircleRequest; use OCA\Circles\Db\MemberRequest; use OCA\Circles\Events\CircleGenericEvent; use OCA\Circles\IFederatedUser; @@ -27,6 +28,7 @@ public function __construct( private readonly IActivityManager $activityManager, private readonly IUserManager $userManager, private readonly MemberRequest $memberRequest, + private readonly CircleRequest $circleRequest, private readonly ConfigService $configService, ) { } @@ -40,7 +42,7 @@ public function onCircleCreation(Circle $circle): void { return; } - $event = $this->generateEvent('circles_as_non_member'); + $event = $this->generateEvent('circles_as_non_member', $circle); $event->setSubject( 'circle_create', [ @@ -66,7 +68,7 @@ public function onCircleDestruction(Circle $circle): void { return; } - $event = $this->generateEvent('circles_as_member'); + $event = $this->generateEvent('circles_as_member', $circle); $event->setSubject( 'circle_delete', [ @@ -81,6 +83,33 @@ public function onCircleDestruction(Circle $circle): void { ); } + public function onCircleEdited(Circle $circle, string $change): void { + if ($circle->isConfig(Circle::CFG_PERSONAL)) { + return; + } + + $event = $this->generateEvent('circles_as_member', $circle); + $event->setSubject( + 'circle_edit', + [ + 'ver' => 2, + 'circle' => $this->shortenCircleData($circle), + 'initiator' => ($circle->hasInitiator() ? $this->shortenMemberData($circle->getInitiator()) : null), + 'change' => $change, + 'title' => match ($change) { + 'name' => $circle->getName(), + 'displayName' => $circle->getDisplayName(), + default => null, + }, + ] + ); + + $this->publishEvent( + $event, + $this->memberRequest->getInheritedMembers($circle->getSingleId(), false, Member::LEVEL_MEMBER) + ); + } + /** * @param Circle $circle * @param Member $member @@ -127,7 +156,7 @@ private function onMemberNewAccount( Member $member, int $eventType, ): void { - $event = $this->generateEvent('circles_as_member'); + $event = $this->generateEvent('circles_as_member', $circle); try { $event->setSubject( @@ -168,7 +197,7 @@ private function onMemberNewCircle( Member $member, int $eventType = CircleGenericEvent::JOINED, ): void { - $event = $this->generateEvent('circles_as_member'); + $event = $this->generateEvent('circles_as_member', $circle); try { $event->setSubject( @@ -209,7 +238,7 @@ private function onMemberAlmost( return; // only if almost-member is a local account } - $event = $this->generateEvent('circles_as_moderator'); + $event = $this->generateEvent('circles_as_moderator', $circle); try { $event->setSubject( @@ -269,7 +298,7 @@ private function onMemberRemoveAccount( Member $member, int $eventType, ): void { - $event = $this->generateEvent('circles_as_member'); + $event = $this->generateEvent('circles_as_member', $circle); try { $event->setSubject( @@ -305,7 +334,7 @@ private function onMemberRemoveCircle( Member $member, int $eventType = CircleGenericEvent::JOINED, ): void { - $event = $this->generateEvent('circles_as_member'); + $event = $this->generateEvent('circles_as_member', $circle); try { $event->setSubject( @@ -342,13 +371,13 @@ public function onMemberLevel( Member $member, int $level, ): void { - if ($member->getLevel() === Member::LEVEL_OWNER) { + if ($level === Member::LEVEL_OWNER) { $this->onMemberOwner($circle, $member); return; } - $event = $this->generateEvent('circles_as_moderator'); + $event = $this->generateEvent('circles_as_moderator', $circle); $event->setSubject( 'member_level', [ @@ -373,7 +402,7 @@ public function onMemberLevel( * @param Member $member */ public function onMemberOwner(Circle $circle, Member $member): void { - $event = $this->generateEvent('circles_as_moderator'); + $event = $this->generateEvent('circles_as_moderator', $circle); $event->setSubject( 'member_owner', [ @@ -398,13 +427,20 @@ public function onShareNew(Circle $getCircle, FederatedEvent $federatedEvent): v * Create an Activity Event with the basic settings for the app. * * @param string $type + * @param Circle|null $circle * * @return IEvent */ - private function generateEvent(string $type): IEvent { + private function generateEvent(string $type, ?Circle $circle = null): IEvent { $event = $this->activityManager->generateEvent(); $event->setApp(Application::APP_ID) ->setType($type); + if ($circle !== null) { + $event->setObject(Application::APP_ID, $this->circleRequest->getActivityObjectId($circle->getSingleId()), $circle->getName()); + if ($circle->hasInitiator() && $circle->getInitiator()->getUserType() === Member::TYPE_USER) { + $event->setAuthor($circle->getInitiator()->getUserId()); + } + } return $event; } diff --git a/lib/Service/EventService.php b/lib/Service/EventService.php index b2b806743..f8858e9a7 100644 --- a/lib/Service/EventService.php +++ b/lib/Service/EventService.php @@ -68,6 +68,13 @@ public function circleCreated(FederatedEvent $federatedEvent, array $results): v public function circleEditing(FederatedEvent $federatedEvent): void { $event = new EditingCircleEvent($federatedEvent); $this->eventDispatcher->dispatchTyped($event); + $change = match (true) { + $federatedEvent->getData()->hasKey('name') => 'name', + $federatedEvent->getData()->hasKey('displayName') => 'displayName', + $federatedEvent->getData()->hasKey('description') => 'description', + default => 'details', + }; + $this->activityService->onCircleEdited($event->getCircle(), $change); } /** diff --git a/src/teams/api.spec.ts b/src/teams/api.spec.ts index e85ec1408..ed5880441 100644 --- a/src/teams/api.spec.ts +++ b/src/teams/api.spec.ts @@ -32,7 +32,7 @@ vi.mock('./team-page/services/collaborationAutocompletion.js', () => ({ getSuggestions: vi.fn(), })) -const { createTeam, fetchTeams, setTeamFavorite, reorderFavoriteTeams } = await import('./api.ts') +const { createTeam, fetchTeamActivities, fetchTeams, setTeamFavorite, reorderFavoriteTeams } = await import('./api.ts') describe('fetchTeams', () => { it('drops circles the current user is not a member of', async () => { @@ -62,6 +62,45 @@ describe('fetchTeams', () => { }) }) +describe('fetchTeamActivities', () => { + it('requests the Activity endpoint with the stable team id', async () => { + vi.mocked(axios.get).mockResolvedValueOnce({ + data: { + ocs: { + data: [{ activity_id: 1, subject: 'Alice joined Design', datetime: '2026-09-30T12:00:00+00:00', user: 'alice' }], + }, + }, + headers: { + link: '; rel="next"', + }, + }) + + await expect(fetchTeamActivities('team-1', { actor: 'alice', limit: 25 })).resolves.toEqual({ + activities: [{ activity_id: 1, subject: 'Alice joined Design', datetime: '2026-09-30T12:00:00+00:00', user: 'alice' }], + nextSince: 1, + }) + expect(axios.get).toHaveBeenCalledWith( + '/ocs/apps/activity/api/v2/activity/team/{teamId}', + { params: { actor: 'alice', limit: 25 } }, + ) + }) + + it('treats Activity’s not-modified response as an empty page', async () => { + vi.mocked(axios.get).mockRejectedValueOnce({ response: { status: 304 } }) + + await expect(fetchTeamActivities('team-1')).resolves.toEqual({ activities: [] }) + }) + + it('defaults missing Activity data and rethrows other request failures', async () => { + vi.mocked(axios.get).mockResolvedValueOnce({ data: { ocs: {} }, headers: {} }) + await expect(fetchTeamActivities('team-1')).resolves.toEqual({ activities: [], nextSince: undefined }) + + const error = new Error('Activity unavailable') + vi.mocked(axios.get).mockRejectedValueOnce(error) + await expect(fetchTeamActivities('team-1')).rejects.toThrow(error) + }) +}) + describe('createTeam', () => { beforeEach(() => { vi.mocked(axios.post).mockResolvedValue({ diff --git a/src/teams/api.ts b/src/teams/api.ts index f5d66fe7d..05191c451 100644 --- a/src/teams/api.ts +++ b/src/teams/api.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Member, MemberCandidate, Resource, SharedResource, Team, TeamRole } from './types.ts' +import type { Member, MemberCandidate, Resource, SharedResource, Team, TeamActivity, TeamActivityPage, TeamActivityQuery, TeamRole } from './types.ts' import axios from '@nextcloud/axios' import { FileType } from '@nextcloud/files' @@ -21,6 +21,28 @@ interface OcsResponse { ocs: { data: T } } +/** + * Extract the Activity cursor from its RFC 5988 next-page link. + * + * @param link - The response's pagination Link header + */ +function getNextActivityCursor(link: string | undefined): number | undefined { + const match = link?.match(/[?&]since=(\d+)[^>]*>;\s*rel="next"/) + return match === null || match === undefined ? undefined : Number(match[1]) +} + +/** + * Whether Activity had no newer response for the current representation. + * + * @param error - A failed Activity request + */ +function isNotModifiedResponse(error: unknown): boolean { + return typeof error === 'object' + && error !== null + && 'response' in error + && (error as { response?: { status?: number } }).response?.status === 304 +} + /** Raw member as returned by the circles API. */ interface RawMember { singleId: string @@ -217,6 +239,30 @@ export async function fetchTeamMembers(teamId: string): Promise { return (res.data.ocs.data ?? []).map(mapFullMember) } +/** + * Fetch Activity events that are visible for a team member. + * + * @param teamId - The stable team single id + * @param query - Cursor and server-side filters for the stream + */ +export async function fetchTeamActivities(teamId: string, query: TeamActivityQuery = {}): Promise { + try { + const res = await axios.get>( + generateOcsUrl('apps/activity/api/v2/activity/team/{teamId}', { teamId }), + { params: query }, + ) + return { + activities: res.data.ocs.data ?? [], + nextSince: getNextActivityCursor(res.headers?.link), + } + } catch (error) { + if (isNotModifiedResponse(error)) { + return { activities: [] } + } + throw error + } +} + /** * Create a team and return its single id. * diff --git a/src/teams/components/TeamNavigation.vue b/src/teams/components/TeamNavigation.vue index 133f55c48..fff555bab 100644 --- a/src/teams/components/TeamNavigation.vue +++ b/src/teams/components/TeamNavigation.vue @@ -7,7 +7,7 @@ import type { RouteLocationRaw } from 'vue-router' import type { TeamPage } from '../api.ts' -import { mdiAccountMultiple, mdiAccountMultipleOutline, mdiArrowTopRight, mdiBookOpenPageVariantOutline, mdiCogOutline, mdiFileDocument, mdiFileDocumentOutline, mdiFolder, mdiFolderOutline, mdiFolderPlusOutline, mdiPlus, mdiShareVariant, mdiShareVariantOutline, mdiTextBoxPlusOutline, mdiTrashCanOutline, mdiViewDashboardOutline } from '@mdi/js' +import { mdiAccountMultiple, mdiAccountMultipleOutline, mdiArrowTopRight, mdiBookOpenPageVariantOutline, mdiCogOutline, mdiFileDocument, mdiFileDocumentOutline, mdiFolder, mdiFolderOutline, mdiFolderPlusOutline, mdiHistory, mdiPlus, mdiShareVariant, mdiShareVariantOutline, mdiTextBoxPlusOutline, mdiTrashCanOutline, mdiViewDashboardOutline } from '@mdi/js' import { showConfirmation, showError, showSuccess } from '@nextcloud/dialogs' import { t } from '@nextcloud/l10n' import { computed, nextTick, ref } from 'vue' @@ -65,6 +65,7 @@ const { canManage, isTeamAdmin } = useTeamActions(() => team.value) const settingsOpen = ref(false) const enabledApps = (window as unknown as { OC?: { appswebroots?: Record } }).OC?.appswebroots ?? {} +const activityAppEnabled = computed(() => enabledApps.activity !== undefined) // Per-team resource state (folder, resources, pages, order) lives in the // shared store, loaded by the team page (the owner of the team scope) and @@ -160,6 +161,18 @@ const orderedEntries = computed(() => { return [...known, ...unknown] }) +const activityEntry = computed(() => { + if (!activityAppEnabled.value) { + return null + } + return { + id: 'activity', + label: t('circles', 'Activity'), + icon: mdiHistory, + to: { name: 'team-activity', params: { teamId: teamId.value } }, + } +}) + // Reordering entries changes the team-level order, so team admins and above // only — the server enforces the same level on the save endpoint. const canReorder = computed(() => isTeamAdmin.value) @@ -612,6 +625,15 @@ async function onEntryDragEnd(): Promise {