From bc01814d52ee1c433caa7afef21f8cbd3d8951ac Mon Sep 17 00:00:00 2001 From: Marco Ambrosini Date: Fri, 28 Aug 2026 17:29:52 +0200 Subject: [PATCH 01/22] feat(teams): add team tab order endpoints Signed-off-by: Marco Ambrosini Assisted-by: ClaudeCode:claude-fable-5 --- appinfo/routes.php | 2 + lib/Controller/TeamTabsController.php | 136 ++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 lib/Controller/TeamTabsController.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 12d2ea058..bb4b42848 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -48,6 +48,8 @@ ['name' => 'TeamFolder#getTeamFolder', 'url' => '/teams/{circleId}/folder', 'verb' => 'GET'], ['name' => 'TeamFolder#unlinkTeamFolder', 'url' => '/teams/{circleId}/folder', 'verb' => 'DELETE'], ['name' => 'TeamFolder#upgradeTeamFolder', 'url' => '/teams/{circleId}/folder', 'verb' => 'POST'], + ['name' => 'TeamTabs#getTabOrder', 'url' => '/teams/{circleId}/tab-order', 'verb' => 'GET'], + ['name' => 'TeamTabs#setTabOrder', 'url' => '/teams/{circleId}/tab-order', 'verb' => 'PUT'], // Teams Dashboard widget endpoint ['name' => 'TeamsDashboard#getCompleteTeamsData', 'url' => '/teams/dashboard/widget', 'verb' => 'GET'], diff --git a/lib/Controller/TeamTabsController.php b/lib/Controller/TeamTabsController.php new file mode 100644 index 000000000..1d1db5a39 --- /dev/null +++ b/lib/Controller/TeamTabsController.php @@ -0,0 +1,136 @@ +assertAuthenticatedUserIsMember($circleId); + + try { + $circle = $this->circleRequest->getCircle($circleId); + } catch (CircleNotFoundException) { + throw new OCSNotFoundException('Team not found'); + } + + $order = json_decode($circle->getSettings()[self::SETTING_TAB_ORDER] ?? '[]', true); + + return new DataResponse(['order' => $this->sanitizeOrder(is_array($order) ? $order : [])]); + } + + /** + * @param list $order Tab ids, first to last + */ + #[NoAdminRequired] + public function setTabOrder(string $circleId, array $order): DataResponse { + $member = $this->assertAuthenticatedUserIsMember($circleId); + try { + $this->permissionService->memberMustBeAtLeastAdmin($member); + } catch (InsufficientPermissionException $e) { + throw new OCSException($e->getMessage(), Http::STATUS_FORBIDDEN); + } + + $order = $this->sanitizeOrder($order); + + try { + // CircleService resolves the circle as the acting (federated) user + $this->federatedUserService->setLocalCurrentUser($this->getAuthenticatedUser()); + $this->circleService->updateSetting($circleId, self::SETTING_TAB_ORDER, json_encode($order)); + } catch (\Exception $e) { + throw new OCSException($e->getMessage(), (int)$e->getCode()); + } + + return new DataResponse(['order' => $order]); + } + + /** + * Keep only non-empty strings within the structural bounds, + * deduplicated, reindexed. + * + * @return list + */ + private function sanitizeOrder(array $order): array { + $order = array_filter($order, static fn ($id): bool => is_string($id) + && $id !== '' + && strlen($id) <= self::MAX_ID_LENGTH); + + return array_slice(array_values(array_unique($order)), 0, self::MAX_ENTRIES); + } + + private function assertAuthenticatedUserIsMember(string $circleId): Member { + try { + return $this->permissionService->userMustBeMember($this->getAuthenticatedUser()->getUID(), $circleId); + } catch (InsufficientPermissionException $e) { + throw new OCSException($e->getMessage(), Http::STATUS_FORBIDDEN); + } + } + + private function getAuthenticatedUser(): IUser { + $user = $this->userSession->getUser(); + if ($user === null) { + throw new OCSException('Authentication required', Http::STATUS_UNAUTHORIZED); + } + + return $user; + } +} From 10d10e84475d48944492f5e5be0b82b0eab8e773 Mon Sep 17 00:00:00 2001 From: Marco Ambrosini Date: Fri, 28 Aug 2026 17:29:52 +0200 Subject: [PATCH 02/22] feat(teams): load the Text editor and expose folder provider state Signed-off-by: Marco Ambrosini Assisted-by: ClaudeCode:claude-fable-5 --- lib/Controller/PageController.php | 9 +++++++++ tests/unit/lib/Controller/PageControllerTest.php | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index b0c6c3587..cc12d8b49 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -12,6 +12,7 @@ use OCA\Circles\AppInfo\Application; use OCA\Circles\Service\ConfigService; use OCA\Circles\Service\TeamFolderPolicy; +use OCA\Text\Event\LoadEditor; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\Attribute\FrontpageRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -19,6 +20,7 @@ use OCP\AppFramework\Http\NotFoundResponse; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IRequest; use OCP\Teams\ITeamManager; use OCP\Util; @@ -33,6 +35,7 @@ public function __construct( private IInitialState $initialState, private ITeamManager $teamManager, private TeamFolderPolicy $teamFolderPolicy, + private IEventDispatcher $eventDispatcher, ) { parent::__construct(Application::APP_ID, $request); } @@ -57,6 +60,12 @@ public function index(): TemplateResponse|NotFoundResponse { Util::addScript(Application::APP_ID, 'teams-main'); Util::addStyle(Application::APP_ID, 'teams-main'); + // Load the Text editor so team pages can be edited inline on their + // tabs. The class only resolves while the Text app is enabled. + if (class_exists(LoadEditor::class)) { + $this->eventDispatcher->dispatchTyped(new LoadEditor()); + } + return new TemplateResponse(Application::APP_ID, 'main'); } diff --git a/tests/unit/lib/Controller/PageControllerTest.php b/tests/unit/lib/Controller/PageControllerTest.php index 61d9c3fcf..0309d89a9 100644 --- a/tests/unit/lib/Controller/PageControllerTest.php +++ b/tests/unit/lib/Controller/PageControllerTest.php @@ -15,6 +15,7 @@ use OCP\AppFramework\Http\NotFoundResponse; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IRequest; use OCP\Teams\ITeamFolderProvider; use OCP\Teams\ITeamManager; @@ -27,6 +28,7 @@ final class PageControllerTest extends TestCase { private IInitialState&MockObject $initialState; private ITeamManager&MockObject $teamManager; private TeamFolderPolicy&MockObject $teamFolderPolicy; + private IEventDispatcher&MockObject $eventDispatcher; private PageController $pageController; #[\Override] @@ -38,6 +40,7 @@ protected function setUp(): void { $this->initialState = $this->createMock(IInitialState::class); $this->teamManager = $this->createMock(ITeamManager::class); $this->teamFolderPolicy = $this->createMock(TeamFolderPolicy::class); + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); $this->pageController = new PageController( $this->request, @@ -45,6 +48,7 @@ protected function setUp(): void { $this->initialState, $this->teamManager, $this->teamFolderPolicy, + $this->eventDispatcher, ); } From afeee9211b281eff517c2b9be336fb2d06612a18 Mon Sep 17 00:00:00 2001 From: Marco Ambrosini Date: Fri, 28 Aug 2026 17:29:52 +0200 Subject: [PATCH 03/22] chore(reuse): add the teams illustration Signed-off-by: Marco Ambrosini Assisted-by: ClaudeCode:claude-fable-5 --- LICENSES/LicenseRef-NextcloudTrademarks.txt | 9 +++++++++ img/teams-illustration.svg | 5 +++++ 2 files changed, 14 insertions(+) create mode 100644 LICENSES/LicenseRef-NextcloudTrademarks.txt create mode 100644 img/teams-illustration.svg diff --git a/LICENSES/LicenseRef-NextcloudTrademarks.txt b/LICENSES/LicenseRef-NextcloudTrademarks.txt new file mode 100644 index 000000000..464a30b58 --- /dev/null +++ b/LICENSES/LicenseRef-NextcloudTrademarks.txt @@ -0,0 +1,9 @@ +The Nextcloud marks +Nextcloud and the Nextcloud logo is a registered trademark of Nextcloud GmbH in Germany and/or other countries. +These guidelines cover the following marks pertaining both to the product names and the logo: “Nextcloud” +and the blue/white cloud logo with or without the word Nextcloud; the service “Nextcloud Enterprise”; +and our products: “Nextcloud Files”; “Nextcloud Groupware” and “Nextcloud Talk”. +This set of marks is collectively referred to as the “Nextcloud marks.” + +Use of Nextcloud logos and other marks is only permitted under the guidelines provided by the Nextcloud GmbH. +A copy can be found at https://nextcloud.com/trademarks/ diff --git a/img/teams-illustration.svg b/img/teams-illustration.svg new file mode 100644 index 000000000..aff0ba4e5 --- /dev/null +++ b/img/teams-illustration.svg @@ -0,0 +1,5 @@ + + \ No newline at end of file From 183fbf28b91493a5fdc9beabe1311e64148f3793 Mon Sep 17 00:00:00 2001 From: Marco Ambrosini Date: Fri, 28 Aug 2026 17:29:52 +0200 Subject: [PATCH 04/22] feat(teams): add team pages and tab order API Signed-off-by: Marco Ambrosini Assisted-by: ClaudeCode:claude-fable-5 --- src/teams/api.spec.ts | 2 - src/teams/api.ts | 193 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 170 insertions(+), 25 deletions(-) diff --git a/src/teams/api.spec.ts b/src/teams/api.spec.ts index 10d5f6d0c..6111f37c1 100644 --- a/src/teams/api.spec.ts +++ b/src/teams/api.spec.ts @@ -47,7 +47,6 @@ describe('createTeam', () => { expect(axios.post).toHaveBeenCalledWith( '/ocs/apps/circles/circles', { name: 'Design', createTeamFolder: true }, - { headers: { 'OCS-APIRequest': 'true' } }, ) }) @@ -57,7 +56,6 @@ describe('createTeam', () => { expect(axios.post).toHaveBeenCalledWith( '/ocs/apps/circles/circles', { name: 'Design', createTeamFolder: false }, - { headers: { 'OCS-APIRequest': 'true' } }, ) }) }) diff --git a/src/teams/api.ts b/src/teams/api.ts index aadc5722c..5757ada64 100644 --- a/src/teams/api.ts +++ b/src/teams/api.ts @@ -3,9 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Member, MemberCandidate, Resource, Team, TeamRole } from './types.ts' +import type { Member, MemberCandidate, Resource, SharedResource, Team, TeamRole } from './types.ts' import axios from '@nextcloud/axios' +import { FileType } from '@nextcloud/files' +import { defaultRootPath, getClient, getDefaultPropfind, resultToNode } from '@nextcloud/files/dav' import { generateOcsUrl } from '@nextcloud/router' import { logger } from '../logger.ts' import { SHARES_TYPES_MEMBER_MAP } from './team-page/models/constants.ts' @@ -14,9 +16,6 @@ import { getRecommendations, getSuggestions } from './team-page/services/collabo /** `SHARES_TYPES_MEMBER_MAP` is built dynamically, so type its shape explicitly. */ const shareTypeToMemberType = SHARES_TYPES_MEMBER_MAP as Record -/** OCS endpoints require this header. */ -const HEADERS = { 'OCS-APIRequest': 'true' } - /** Minimal shape of an OCS response envelope. */ interface OcsResponse { ocs: { data: T } @@ -141,8 +140,8 @@ function mapFullMember(raw: RawMember): Member { */ export async function fetchTeams(): Promise { const [circlesRes, dashRes] = await Promise.allSettled([ - axios.get>(generateOcsUrl('apps/circles/circles') + '?limit=-1', { headers: HEADERS }), - axios.get>(generateOcsUrl('apps/circles/teams/dashboard/widget') + '?limit=200&offset=0', { headers: HEADERS }), + axios.get>(generateOcsUrl('apps/circles/circles') + '?limit=-1'), + axios.get>(generateOcsUrl('apps/circles/teams/dashboard/widget') + '?limit=200&offset=0'), ]) // The team list is required; without it we have nothing to show. @@ -181,10 +180,7 @@ export async function fetchTeams(): Promise { * @param teamId - The team single id */ export async function fetchTeamMembers(teamId: string): Promise { - const res = await axios.get>( - generateOcsUrl('apps/circles/circles/{circleId}/members', { circleId: teamId }), - { headers: HEADERS }, - ) + const res = await axios.get>(generateOcsUrl('apps/circles/circles/{circleId}/members', { circleId: teamId })) return (res.data.ocs.data ?? []).map(mapFullMember) } @@ -199,7 +195,6 @@ export async function createTeam(name: string, createTeamFolder = true): Promise const res = await axios.post>( generateOcsUrl('apps/circles/circles'), { name, createTeamFolder }, - { headers: HEADERS }, ) return res.data.ocs.data.id } @@ -214,7 +209,6 @@ export async function setTeamDescription(teamId: string, description: string): P await axios.put( generateOcsUrl('apps/circles/circles/{circleId}/description', { circleId: teamId }), { value: description }, - { headers: HEADERS }, ) } @@ -227,7 +221,6 @@ export async function leaveTeam(teamId: string): Promise { await axios.put( generateOcsUrl('apps/circles/circles/{circleId}/leave', { circleId: teamId }), {}, - { headers: HEADERS }, ) } @@ -237,10 +230,7 @@ export async function leaveTeam(teamId: string): Promise { * @param teamId - The team single id */ export async function deleteTeam(teamId: string): Promise { - await axios.delete( - generateOcsUrl('apps/circles/circles/{circleId}', { circleId: teamId }), - { headers: HEADERS }, - ) + await axios.delete(generateOcsUrl('apps/circles/circles/{circleId}', { circleId: teamId })) } /** @@ -262,10 +252,7 @@ export interface TeamFolder { */ export async function getTeamFolder(teamId: string): Promise { try { - const { data } = await axios.get>( - generateOcsUrl('apps/circles/teams/{circleId}/folder', { circleId: teamId }), - { headers: HEADERS }, - ) + const { data } = await axios.get>(generateOcsUrl('apps/circles/teams/{circleId}/folder', { circleId: teamId })) return data.ocs.data } catch (error) { if (error && typeof error === 'object' @@ -290,11 +277,172 @@ export async function upgradeTeamFolder(teamId: string): Promise { const { data } = await axios.post>( generateOcsUrl('apps/circles/teams/{circleId}/folder', { circleId: teamId }), {}, - { headers: HEADERS }, ) return data.ocs.data.folder } +/** + * Subfolder of the team folder holding the page files, inside the app's + * own namespace of the hidden `.system` folder: the pages neither clutter + * the files the team actually shares nor the `.system` root itself. + */ +const PAGES_FOLDER = '.system/teams/pages' + +/** + * A team page: a markdown file stored in the team folder's hidden pages + * subfolder, surfaced as a tab on the team. + */ +export interface TeamPage { + fileId: number + /** Page title: the file name without the .md extension. */ + title: string + /** Path relative to the user's files root, as the Text editor expects. */ + filePath: string +} + +/** + * List the team pages: the markdown files in the team folder's hidden + * pages subfolder. + * + * @param mountPoint - The team folder mount point of the current user + */ +export async function fetchTeamPages(mountPoint: string): Promise { + let response + try { + response = await getClient().getDirectoryContents(`${defaultRootPath}/${mountPoint}/${PAGES_FOLDER}`, { + details: true, + data: getDefaultPropfind(), + }) + } catch (error) { + // 404 means the pages subfolder was not created yet (it appears with + // the first page), or the team folder itself has not been physically + // created — either way "no pages". + if ((error as { status?: number })?.status === 404) { + return [] + } + throw error + } + const data = Array.isArray(response) ? response : response.data + + return data + .map((entry) => resultToNode(entry, defaultRootPath)) + .filter((node) => node.type === FileType.File + && node.extension?.toLowerCase() === '.md' + && node.fileid !== undefined) + .map((node) => ({ + fileId: node.fileid!, + title: node.basename.slice(0, -node.extension!.length), + filePath: `/${mountPoint}/${PAGES_FOLDER}/${node.basename}`, + })) + .sort((a, b) => a.title.localeCompare(b.title)) +} + +/** + * Create a team page: an empty markdown file in the team folder's hidden + * pages subfolder, which appears with the first page. + * + * @param mountPoint - The team folder mount point of the current user + * @param name - The page name (without extension) + */ +export async function createTeamPage(mountPoint: string, name: string): Promise { + const pagesFolder = `${defaultRootPath}/${mountPoint}/${PAGES_FOLDER}` + try { + // Recursive: `.system` and the pages folder inside it appear with + // the first page. + await getClient().createDirectory(pagesFolder, { recursive: true }) + } catch (error) { + // 405: the folder appeared between the existence probe and the MKCOL. + if ((error as { status?: number })?.status !== 405) { + throw error + } + } + const written = await getClient().putFileContents(`${pagesFolder}/${name}.md`, '', { + // Refuse to overwrite an existing page of the same name + overwrite: false, + }) + // The webdav client returns false instead of throwing on the 412 an + // existing page produces with overwrite disabled. + if (written === false) { + throw Object.assign(new Error('A page with this name already exists'), { status: 412 }) + } +} + +/** + * Delete a team page: remove its markdown file from the team folder. + * + * @param page - The team page to delete + */ +export async function deleteTeamPage(page: TeamPage): Promise { + await getClient().deleteFile(`${defaultRootPath}${page.filePath}`) +} + +/** + * Rename a team page: move its markdown file to the new name within the + * team folder. Refuses to overwrite an existing page of the same name. + * + * @param page - The team page to rename + * @param name - The new page name (without extension) + */ +export async function renameTeamPage(page: TeamPage, name: string): Promise { + const directory = page.filePath.slice(0, page.filePath.lastIndexOf('/')) + await getClient().moveFile( + `${defaultRootPath}${page.filePath}`, + `${defaultRootPath}${directory}/${name}.md`, + { overwrite: false }, + ) +} + +/** + * Fetch the team-level tab order (readable by every member). + * + * @param teamId - The team single id + * @return Tab ids, first to last. Empty when no order has been saved. + */ +export async function fetchTabOrder(teamId: string): Promise { + const { data } = await axios.get>(generateOcsUrl('apps/circles/teams/{circleId}/tab-order', { circleId: teamId })) + return data.ocs.data.order ?? [] +} + +/** + * Save the team-level tab order. Requires team admin or above. + * + * @param teamId - The team single id + * @param order - Tab ids, first to last + */ +export async function saveTabOrder(teamId: string, order: string[]): Promise { + await axios.put( + generateOcsUrl('apps/circles/teams/{circleId}/tab-order', { circleId: teamId }), + { order }, + ) +} + +/** + * Fetch the resources shared to a team from the core teams resource + * providers (Talk rooms, calendars, collectives, …). + * + * @param teamId - The team single id + */ +export async function fetchTeamResources(teamId: string): Promise { + const res = await axios.get>(generateOcsUrl('teams/{teamId}/resources', { teamId })) + return res.data.ocs.data.resources ?? [] +} + +/** + * Create a collective named after a team. The collectives app links the + * collective to the team by name, so no separate share step is needed. + * + * TODO: calls the collectives API directly; should eventually go through + * a teams extension point instead of hardcoding another app's route. + * + * @param name - The collective name (the team's name) + */ +export async function createCollective(name: string): Promise { + await axios.post( + generateOcsUrl('apps/collectives/api/v1.0/collectives'), + { name }, + ) +} + /** * Search for potential new members (users, groups, emails, contacts, other * teams…) using the same sharee autocompletion endpoint as file sharing. @@ -333,7 +481,6 @@ export async function addTeamMembers(teamId: string, candidates: MemberCandidate const res = await axios.post>>( generateOcsUrl('apps/circles/circles/{circleId}/members/multi', { circleId: teamId }), { members }, - { headers: HEADERS }, ) return Object.keys(res.data.ocs.data ?? {}).length } From 23eda59557aa5905a609b1a5c5d1971f61f0507c Mon Sep 17 00:00:00 2001 From: Marco Ambrosini Date: Fri, 28 Aug 2026 17:29:52 +0200 Subject: [PATCH 05/22] feat(teams): add a per-team resources store Signed-off-by: Marco Ambrosini Assisted-by: ClaudeCode:claude-fable-5 --- src/teams/resourcesStore.ts | 233 ++++++++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 src/teams/resourcesStore.ts diff --git a/src/teams/resourcesStore.ts b/src/teams/resourcesStore.ts new file mode 100644 index 000000000..885750b3f --- /dev/null +++ b/src/teams/resourcesStore.ts @@ -0,0 +1,233 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { TeamFolder, TeamPage } from './api.ts' +import type { SharedResource } from './types.ts' + +import { defineStore } from 'pinia' +import { logger } from '../logger.ts' +import { fetchTabOrder, fetchTeamPages, fetchTeamResources, getTeamFolder, saveTabOrder, upgradeTeamFolder } from './api.ts' + +/** + * The shared resource state of one team: its team folder, the resources + * shared to it, its pages and the navigation tab order. + */ +export interface TeamResources { + folder: TeamFolder | null + /** The folder probe finished; `folder` is authoritative (may be null). */ + folderChecked: boolean + /** The folder probe failed; `folder` is unknown, not absent. */ + folderError: boolean + resources: SharedResource[] + resourcesChecked: boolean + pages: TeamPage[] + pagesChecked: boolean + pagesError: boolean + tabOrder: string[] + orderChecked: boolean + /** The order fetch failed; `tabOrder` is unknown, not empty. */ + orderError: boolean +} + +const EMPTY_SLOT: TeamResources = Object.freeze({ + folder: null, + folderChecked: false, + folderError: false, + resources: [], + resourcesChecked: false, + pages: [], + pagesChecked: false, + pagesError: false, + tabOrder: [], + orderChecked: false, + orderError: false, +}) + +/** + * Store holding the per-team resource state, keyed by team id. Writes always + * land in the slot of the team they were requested for, so late responses + * from a previous team can never corrupt the currently shown team. + * + * The initial load is owned by the team page (the route component of the + * team scope); the sidebar and the views only read the store. The ensure* + * actions are also called for targeted refreshes after mutations. + * + * The ensure* actions never throw: consumers read the error flags off the + * slot. The mutating actions (createFolder, saveOrder) throw so the calling + * component can toast. + */ +export const useTeamResourcesStore = defineStore('teamResources', { + state: () => ({ + slots: {} as Record, + }), + + getters: { + /** + * The (possibly not yet loaded) resource state of a team. + * + * @param state - The store state + */ + forTeam(state) { + return (teamId: string): TeamResources => state.slots[teamId] ?? EMPTY_SLOT + }, + }, + + actions: { + /** + * The mutable slot of a team, created on first access. + * + * @param teamId - The team the slot belongs to + */ + slot(teamId: string): TeamResources { + if (!this.slots[teamId]) { + this.slots[teamId] = { ...EMPTY_SLOT } + } + return this.slots[teamId] + }, + + /** + * Load everything the team-scoped pages need. + * + * @param teamId - The team to load + * @param refresh - Refetch data already in the cache + */ + async loadTeam(teamId: string, refresh = false): Promise { + await Promise.all([ + // Pages live in the folder, so they load once it is known. + this.ensureFolder(teamId, refresh).then(() => this.ensurePages(teamId, refresh)), + this.ensureResources(teamId, refresh), + this.ensureOrder(teamId, refresh), + ]) + }, + + /** + * Probe for the team folder unless already known. + * + * @param teamId - The team to probe + * @param refresh - Refetch even when cached + */ + async ensureFolder(teamId: string, refresh = false): Promise { + const slot = this.slot(teamId) + if (slot.folderChecked && !refresh) { + return + } + try { + slot.folder = await getTeamFolder(teamId) + slot.folderChecked = true + slot.folderError = false + } catch (error) { + logger.error('Could not load the team folder', { error, teamId }) + slot.folderError = true + } + }, + + /** + * Load the resources shared to the team unless already known. + * + * @param teamId - The team to load resources for + * @param refresh - Refetch even when cached + */ + async ensureResources(teamId: string, refresh = false): Promise { + const slot = this.slot(teamId) + if (slot.resourcesChecked && !refresh) { + return + } + try { + slot.resources = await fetchTeamResources(teamId) + slot.resourcesChecked = true + } catch (error) { + logger.error('Could not load the team resources', { error, teamId }) + } + }, + + /** + * Load the team pages unless already known. Probes the folder first + * when needed — pages are the markdown files in the team folder. + * + * @param teamId - The team to load pages for + * @param refresh - Refetch the pages even when cached + */ + async ensurePages(teamId: string, refresh = false): Promise { + const slot = this.slot(teamId) + if (slot.pagesChecked && !refresh) { + return + } + await this.ensureFolder(teamId) + if (!slot.folder) { + slot.pages = [] + slot.pagesChecked = slot.folderChecked + slot.pagesError = slot.folderError + return + } + try { + slot.pages = await fetchTeamPages(slot.folder.mountPoint) + slot.pagesChecked = true + slot.pagesError = false + } catch (error) { + logger.error('Could not load the team pages', { error, teamId }) + slot.pages = [] + slot.pagesError = true + } + }, + + /** + * Load the team-level navigation order unless already known. + * + * @param teamId - The team to load the order for + * @param refresh - Refetch even when cached + */ + async ensureOrder(teamId: string, refresh = false): Promise { + const slot = this.slot(teamId) + if (slot.orderChecked && !refresh) { + return + } + try { + slot.tabOrder = await fetchTabOrder(teamId) + slot.orderChecked = true + slot.orderError = false + } catch (error) { + logger.error('Could not load the navigation order', { error, teamId }) + slot.orderError = true + } + }, + + /** + * Create the team folder and record it. Throws on failure. + * + * @param teamId - The team to create the folder for + */ + async createFolder(teamId: string): Promise { + const folder = await upgradeTeamFolder(teamId) + const slot = this.slot(teamId) + slot.folder = folder + slot.folderChecked = true + slot.folderError = false + // A fresh folder has no pages yet. + slot.pages = [] + slot.pagesChecked = true + slot.pagesError = false + return folder + }, + + /** + * Persist a new navigation order, applying it optimistically and + * rolling back on failure. Throws on failure. + * + * @param teamId - The team the order belongs to + * @param order - The entry ids in their new order + */ + async saveOrder(teamId: string, order: string[]): Promise { + const slot = this.slot(teamId) + const previous = slot.tabOrder + slot.tabOrder = order + try { + await saveTabOrder(teamId, order) + } catch (error) { + slot.tabOrder = previous + throw error + } + }, + }, +}) From af1de253148133b5207421b597043b34dbd11bbf Mon Sep 17 00:00:00 2001 From: Marco Ambrosini Date: Fri, 28 Aug 2026 17:29:52 +0200 Subject: [PATCH 06/22] perf(teams): let the browser cache team avatars The avatar endpoint already serves 24h cache headers; a per-circle version busts them after an update. No CSRF check on the endpoint so plain image elements can load it, as Talk does for conversations. Signed-off-by: Marco Ambrosini Assisted-by: ClaudeCode:claude-fable-5 --- lib/Controller/LocalController.php | 6 +++ src/teams/components/TeamAvatar.vue | 84 +++++++++++------------------ 2 files changed, 36 insertions(+), 54 deletions(-) diff --git a/lib/Controller/LocalController.php b/lib/Controller/LocalController.php index 062023e04..5c2125f9c 100644 --- a/lib/Controller/LocalController.php +++ b/lib/Controller/LocalController.php @@ -36,6 +36,7 @@ use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\BruteForceProtection; use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\UserRateLimit; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\FileDisplayResponse; @@ -509,7 +510,12 @@ public function editConfig(string $circleId, int $value): DataResponse { } } + /** + * No CSRF check so the avatar can be loaded by plain image elements; + * the endpoint only reads the picture and checks membership itself. + */ #[NoAdminRequired] + #[NoCSRFRequired] #[BruteForceProtection(action: 'circleAvatar')] public function circleAvatar(string $circleId): FileDisplayResponse|DataResponse { try { diff --git a/src/teams/components/TeamAvatar.vue b/src/teams/components/TeamAvatar.vue index 7a044c9b8..9e549999a 100644 --- a/src/teams/components/TeamAvatar.vue +++ b/src/teams/components/TeamAvatar.vue @@ -3,74 +3,50 @@ - SPDX-License-Identifier: AGPL-3.0-or-later --> - -watch(() => props.circleId, () => { - loadAvatarUrl() -}) +