diff --git a/lib/Activity/Provider.php b/lib/Activity/Provider.php index 1a796846c..ae4d6b0c4 100644 --- a/lib/Activity/Provider.php +++ b/lib/Activity/Provider.php @@ -112,6 +112,8 @@ private function parseAsMember( $this->parserCircle->parseSubjectCircleCreate($event, $params); $this->parserCircle->parseSubjectCircleDelete($event, $params); + $this->parserCircle->parseSubjectCircleEdit($event, $params); + $this->parserCircle->parseSubjectCircleSettingChanged($event, $params); $this->parseMemberAsMember($event, $params); $this->parseCircleMemberAsMember($event, $params); } diff --git a/lib/Activity/ProviderParser.php b/lib/Activity/ProviderParser.php index 1e60ae41a..55c93a220 100644 --- a/lib/Activity/ProviderParser.php +++ b/lib/Activity/ProviderParser.php @@ -28,11 +28,12 @@ protected function parseCircleEvent( array $params, string $ownEvent, string $othersEvent, + array $additionalData = [], ): void { $data = [ 'author' => $this->generateUserParameter($params['initiator'] ?? []), 'circle' => $this->generateCircleParameter($params['circle']) - ]; + ] + $additionalData; if ($this->isViewerTheAuthor($params['initiator'] ?? [], $this->activityManager->getCurrentUserId())) { $this->setSubject($event, $ownEvent, $data); @@ -265,4 +266,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..f1d474b63 100644 --- a/lib/Activity/ProviderSubjectCircle.php +++ b/lib/Activity/ProviderSubjectCircle.php @@ -10,9 +10,31 @@ namespace OCA\Circles\Activity; use OCA\Circles\Exceptions\FakeException; +use OCA\Circles\Model\Circle; +use OCA\Circles\Service\ConfigService; use OCP\Activity\IEvent; class ProviderSubjectCircle extends ProviderParser { + private const SETTING_TEAM_TAB_ORDER = 'teamsTabOrder'; + private const SETTING_PASSWORD_SINGLE = 'password_single'; + private const SETTING_PASSWORD_SINGLE_ENABLED = 'password_single_enabled'; + private const CONFIG_ACTIVITY_SUBJECTS = [ + 'config_open_enabled' => ['enabled', 'Anyone can request membership'], + 'config_open_disabled' => ['disabled', 'Anyone can request membership'], + 'config_invite_enabled' => ['enabled', 'Members need to accept invitation'], + 'config_invite_disabled' => ['disabled', 'Members need to accept invitation'], + 'config_request_enabled' => ['enabled', 'Memberships must be confirmed or accepted by a moderator'], + 'config_request_disabled' => ['disabled', 'Memberships must be confirmed or accepted by a moderator'], + 'config_friend_enabled' => ['enabled', 'Members can also invite'], + 'config_friend_disabled' => ['disabled', 'Members can also invite'], + 'config_root_enabled' => ['enabled', 'Prevent teams from being a member of another team'], + 'config_root_disabled' => ['disabled', 'Prevent teams from being a member of another team'], + 'config_federated_enabled' => ['enabled', 'Allow federated members'], + 'config_federated_disabled' => ['disabled', 'Allow federated members'], + 'config_visible_enabled' => ['enabled', 'Visible to everyone'], + 'config_visible_disabled' => ['disabled', 'Visible to everyone'], + ]; + public function parseSubjectCircleCreate(IEvent $event, array $params): void { if ($event->getSubject() !== 'circle_create') { return; @@ -46,4 +68,108 @@ 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(); + } + + public function parseSubjectCircleSettingChanged(IEvent $event, array $params): void { + if ($event->getSubject() !== 'circle_setting_changed') { + return; + } + + $setting = (string)($params['setting'] ?? ''); + if (isset(self::CONFIG_ACTIVITY_SUBJECTS[$setting])) { + [$state, $label] = self::CONFIG_ACTIVITY_SUBJECTS[$setting]; + $data = [ + 'author' => $this->generateUserParameter($params['initiator'] ?? []), + 'circle' => $this->generateCircleParameter($params['circle']), + 'state' => $this->generateHighlightParameter($state), + 'setting' => $this->generateHighlightParameter($label), + ]; + if ($this->isViewerTheAuthor($params['initiator'] ?? [], $this->activityManager->getCurrentUserId())) { + $this->setSubject($event, $this->l10n->t('You {state} "{setting}" for {circle}'), $data); + } else { + $this->setSubject($event, $this->l10n->t('{author} {state} "{setting}" for {circle}'), $data); + } + + throw new FakeException(); + } + + [$ownEvent, $othersEvent] = $this->getSettingActivitySubjects($setting); + + $this->parseCircleEvent( + $event, $params, + $ownEvent, + $othersEvent, + ); + + throw new FakeException(); + } + + /** @return array{string, string} */ + private function getSettingActivitySubjects(string $setting): array { + return match ($setting) { + Circle::SETTING_TEAM_FOLDER_QUOTA => [ + $this->l10n->t('You changed the Team Folder quota of {circle}'), + $this->l10n->t('{author} changed the Team Folder quota of {circle}'), + ], + self::SETTING_TEAM_TAB_ORDER => [ + $this->l10n->t('You changed the navigation order of {circle}'), + $this->l10n->t('{author} changed the navigation order of {circle}'), + ], + ConfigService::MEMBERS_LIMIT => [ + $this->l10n->t('You changed the member limit of {circle}'), + $this->l10n->t('{author} changed the member limit of {circle}'), + ], + ConfigService::ENFORCE_PASSWORD, self::SETTING_PASSWORD_SINGLE_ENABLED => [ + $this->l10n->t('You changed the password protection of {circle}'), + $this->l10n->t('{author} changed the password protection of {circle}'), + ], + self::SETTING_PASSWORD_SINGLE => [ + $this->l10n->t('You changed the password of {circle}'), + $this->l10n->t('{author} changed the password of {circle}'), + ], + default => [ + $this->l10n->t('You changed the settings of {circle}'), + $this->l10n->t('{author} changed the settings of {circle}'), + ], + }; + } } 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/CircleConfig.php b/lib/FederatedItems/CircleConfig.php index 3f0115193..9eddec14f 100644 --- a/lib/FederatedItems/CircleConfig.php +++ b/lib/FederatedItems/CircleConfig.php @@ -18,6 +18,7 @@ use OCA\Circles\Model\Federated\FederatedEvent; use OCA\Circles\Model\Helpers\MemberHelper; use OCA\Circles\Service\ConfigService; +use OCA\Circles\Service\EventService; use OCA\Circles\Service\PermissionService; use OCA\Circles\Tools\Traits\TDeserialize; @@ -41,6 +42,7 @@ public function __construct( private CircleRequest $circleRequest, private PermissionService $permissionService, private ConfigService $configService, + private EventService $eventService, ) { } @@ -146,6 +148,11 @@ public function manage(FederatedEvent $event): void { // TODO: sync if it is to broadcast to Trusted RemoteInstance $this->circleRequest->updateConfig($circle); + $this->eventService->circleConfigChanged( + $circle, + $event->getCircle()->getConfig(), + $config, + ); } /** 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/FederatedItems/CircleSetting.php b/lib/FederatedItems/CircleSetting.php index a20f02c79..b9360647f 100644 --- a/lib/FederatedItems/CircleSetting.php +++ b/lib/FederatedItems/CircleSetting.php @@ -16,6 +16,7 @@ use OCA\Circles\Model\Federated\FederatedEvent; use OCA\Circles\Model\Helpers\MemberHelper; use OCA\Circles\Service\ConfigService; +use OCA\Circles\Service\EventService; use OCA\Circles\Service\ShareTokenService; use OCA\Circles\Tools\Traits\TDeserialize; @@ -29,6 +30,7 @@ public function __construct( private CircleRequest $circleRequest, private ShareTokenService $shareTokenService, private ConfigService $configService, + private EventService $eventService, ) { } @@ -91,6 +93,7 @@ public function manage(FederatedEvent $event): void { // TODO: sync if it is to broadcast to Trusted RemoteInstance $this->circleRequest->updateSettings($circle); + $this->eventService->circleSettingChanged($circle, $event->getParams()->g('setting')); // refresh share password (in case of static password changes) // only is password are enforced for this Circle diff --git a/lib/Service/ActivityService.php b/lib/Service/ActivityService.php index f1bcf2364..436404a65 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; @@ -23,10 +24,40 @@ use UnhandledMatchError; class ActivityService { + private const SETTING_TEAM_TAB_ORDER = 'teamsTabOrder'; + private const SETTING_PASSWORD_SINGLE = 'password_single'; + private const SETTING_PASSWORD_SINGLE_ENABLED = 'password_single_enabled'; + private const ACTIVITY_CONFIGS = [ + Circle::CFG_OPEN => 'open', + Circle::CFG_INVITE => 'invite', + Circle::CFG_REQUEST => 'request', + Circle::CFG_FRIEND => 'friend', + Circle::CFG_ROOT => 'root', + Circle::CFG_FEDERATED => 'federated', + Circle::CFG_VISIBLE => 'visible', + ]; + private const ACTIVITY_CONFIG_SETTINGS = [ + 'config_open_enabled', + 'config_open_disabled', + 'config_invite_enabled', + 'config_invite_disabled', + 'config_request_enabled', + 'config_request_disabled', + 'config_friend_enabled', + 'config_friend_disabled', + 'config_root_enabled', + 'config_root_disabled', + 'config_federated_enabled', + 'config_federated_disabled', + 'config_visible_enabled', + 'config_visible_disabled', + ]; + 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 +71,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 +97,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 +112,69 @@ 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) + ); + } + + public function onCircleSetting(Circle $circle, string $setting): void { + if ($circle->isConfig(Circle::CFG_PERSONAL)) { + return; + } + + $event = $this->generateEvent('circles_as_member', $circle); + $event->setSubject( + 'circle_setting_changed', + [ + 'ver' => 2, + 'circle' => $this->shortenCircleData($circle), + 'initiator' => ($circle->hasInitiator() ? $this->shortenMemberData($circle->getInitiator()) : null), + 'setting' => $this->getActivitySetting($setting), + ] + ); + + $this->publishEvent( + $event, + $this->memberRequest->getInheritedMembers($circle->getSingleId(), false, Member::LEVEL_MEMBER) + ); + } + + public function onCircleConfigChanged(Circle $circle, int $previousConfig, int $newConfig): void { + $changedConfig = $previousConfig ^ $newConfig; + foreach (self::ACTIVITY_CONFIGS as $config => $setting) { + if (($changedConfig & $config) === 0) { + continue; + } + + $this->onCircleSetting( + $circle, + 'config_' . $setting . (($newConfig & $config) === 0 ? '_disabled' : '_enabled'), + ); + } + } + /** * @param Circle $circle * @param Member $member @@ -127,7 +221,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 +262,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 +303,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 +363,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 +399,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 +436,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 +467,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,17 +492,40 @@ 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; } + private function getActivitySetting(string $setting): string { + return in_array( + $setting, + [ + Circle::SETTING_TEAM_FOLDER_QUOTA, + self::SETTING_TEAM_TAB_ORDER, + ConfigService::MEMBERS_LIMIT, + ConfigService::ENFORCE_PASSWORD, + self::SETTING_PASSWORD_SINGLE, + self::SETTING_PASSWORD_SINGLE_ENABLED, + ...self::ACTIVITY_CONFIG_SETTINGS, + ], + true, + ) ? $setting : 'generic'; + } + /** * Publish the event to the users. * - if user is IUser, we get userId, diff --git a/lib/Service/EventService.php b/lib/Service/EventService.php index b2b806743..323ea3a46 100644 --- a/lib/Service/EventService.php +++ b/lib/Service/EventService.php @@ -30,6 +30,7 @@ use OCA\Circles\Events\PreparingCircleMemberEvent; use OCA\Circles\Events\RemovingCircleMemberEvent; use OCA\Circles\Events\RequestingCircleMemberEvent; +use OCA\Circles\Model\Circle; use OCA\Circles\Model\Federated\FederatedEvent; use OCA\Circles\Model\Membership; use OCA\Circles\Model\Mount; @@ -68,6 +69,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); } /** @@ -97,6 +105,14 @@ public function circleDestroyed(FederatedEvent $federatedEvent, array $results): $this->eventDispatcher->dispatchTyped($event); } + public function circleSettingChanged(Circle $circle, string $setting): void { + $this->activityService->onCircleSetting($circle, $setting); + } + + public function circleConfigChanged(Circle $circle, int $previousConfig, int $newConfig): void { + $this->activityService->onCircleConfigChanged($circle, $previousConfig, $newConfig); + } + /** * @param FederatedEvent $federatedEvent */ 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 {