Skip to content

Design pass and team pages - #2846

Open
marcoambrosini wants to merge 19 commits into
masterfrom
vertical-navigation
Open

Design pass and team pages#2846
marcoambrosini wants to merge 19 commits into
masterfrom
vertical-navigation

Conversation

@marcoambrosini

@marcoambrosini marcoambrosini commented Aug 28, 2026

Copy link
Copy Markdown
Member

Re-organize the existing features, polishing and add a new team pages feature.

Screenshot 2026-08-28 at 17 23 16 Screenshot 2026-08-28 at 17 23 34 Screenshot 2026-08-28 at 17 23 45

Marco Ambrosini added 19 commits August 28, 2026 17:29
Signed-off-by: Marco Ambrosini <marco.ambrosini@nextcloud.com>
Assisted-by: ClaudeCode:claude-fable-5
Signed-off-by: Marco Ambrosini <marco.ambrosini@nextcloud.com>
Assisted-by: ClaudeCode:claude-fable-5
Signed-off-by: Marco Ambrosini <marco.ambrosini@nextcloud.com>
Assisted-by: ClaudeCode:claude-fable-5
Signed-off-by: Marco Ambrosini <marco.ambrosini@nextcloud.com>
Assisted-by: ClaudeCode:claude-fable-5
Signed-off-by: Marco Ambrosini <marco.ambrosini@nextcloud.com>
Assisted-by: ClaudeCode:claude-fable-5
Signed-off-by: Marco Ambrosini <marco.ambrosini@nextcloud.com>
Assisted-by: ClaudeCode:claude-fable-5
Signed-off-by: Marco Ambrosini <marco.ambrosini@nextcloud.com>
Assisted-by: ClaudeCode:claude-fable-5
Signed-off-by: Marco Ambrosini <marco.ambrosini@nextcloud.com>
Assisted-by: ClaudeCode:claude-fable-5
Signed-off-by: Marco Ambrosini <marco.ambrosini@nextcloud.com>
Assisted-by: ClaudeCode:claude-fable-5
Signed-off-by: Marco Ambrosini <marco.ambrosini@nextcloud.com>
Assisted-by: ClaudeCode:claude-fable-5
Signed-off-by: Marco Ambrosini <marco.ambrosini@nextcloud.com>
Assisted-by: ClaudeCode:claude-fable-5
Signed-off-by: Marco Ambrosini <marco.ambrosini@nextcloud.com>
Assisted-by: ClaudeCode:claude-fable-5
Signed-off-by: Marco Ambrosini <marco.ambrosini@nextcloud.com>
Assisted-by: ClaudeCode:claude-fable-5
Signed-off-by: Marco Ambrosini <marco.ambrosini@nextcloud.com>
Assisted-by: ClaudeCode:claude-fable-5
Signed-off-by: Marco Ambrosini <marco.ambrosini@nextcloud.com>
Assisted-by: ClaudeCode:claude-fable-5
Signed-off-by: Marco Ambrosini <marco.ambrosini@nextcloud.com>
Assisted-by: ClaudeCode:claude-fable-5
Signed-off-by: Marco Ambrosini <marco.ambrosini@nextcloud.com>
Assisted-by: ClaudeCode:claude-fable-5
Signed-off-by: Marco Ambrosini <marco.ambrosini@nextcloud.com>
Assisted-by: ClaudeCode:claude-fable-5
Signed-off-by: Marco Ambrosini <marco.ambrosini@nextcloud.com>
Assisted-by: ClaudeCode:claude-fable-5
@marcoambrosini
marcoambrosini requested a review from artonge August 28, 2026 15:39
@github-project-automation github-project-automation Bot moved this to Backlog in 👥 Teams Aug 28, 2026
@marcoambrosini marcoambrosini moved this from Backlog to In review in 👥 Teams Aug 28, 2026
@marcoambrosini marcoambrosini self-assigned this Aug 28, 2026
@alimmroth alimmroth added this to the Nextcloud 35 milestone Aug 28, 2026

@artonge artonge left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good overall, still some comments.
I also would like to the opinion of a frontender before merging.

* needs limiting — the setting is loaded on every fetch of the circle
* and replicated to federated instances.
*/
private const MAX_ID_LENGTH = 64;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason for this limit?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a team admin drags the sidebar entries into a new order, the client sends the server a list of ids like ["team-folder", "page-42", "home"], and the server saves that list in the team's settings.
The problem the limits solve: the server just trusts whatever list it receives. Nothing in the browser UI stops someone from skipping the UI entirely and sending the request by hand, with a list of 10 million entries, or ids that are each a megabyte of garbage text. The server would happily save all of it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then let's rephrase the comment to make it understandable by the lay person.

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://discord.com/branding

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discord.com ?

I don't think we need this file.

Comment on lines +10 to +117
// Session-wide avatar cache shared by every TeamAvatar instance, so avatars
// render instantly wherever a circle appears again (header, switcher list…)
// and are only refetched when an avatar update is broadcast.
const avatarUrls = new Map<string, string | undefined>()
const avatarRequests = new Map<string, Promise<string | undefined>>()

// The avatar endpoint has to be probed per team and answers 404 when no
// picture was uploaded, so a "none" result is remembered across page loads
// to keep reloads from re-probing every team. Own uploads refresh the entry
// through the avatar-updated event; another member's upload shows up once
// the entry expires.
const NO_AVATAR_STORAGE_PREFIX = 'circles.no-avatar.'
const NO_AVATAR_TTL = 60 * 60 * 1000

/**
* Whether a recent probe found no avatar for this circle.
*
* @param circleId - The circle the avatar belongs to
*/
function hasCachedNoAvatar(circleId: string): boolean {
try {
const probedAt = window.localStorage.getItem(NO_AVATAR_STORAGE_PREFIX + circleId)
if (probedAt === null) {
return false
}
if (Date.now() - Number(probedAt) > NO_AVATAR_TTL) {
window.localStorage.removeItem(NO_AVATAR_STORAGE_PREFIX + circleId)
return false
}
return true
} catch {
return false
}
}

/**
* Remember (or forget) that a circle has no avatar.
*
* @param circleId - The circle the avatar belongs to
* @param none - True when the probe returned 404, false when one exists
*/
function setCachedNoAvatar(circleId: string, none: boolean): void {
try {
if (none) {
window.localStorage.setItem(NO_AVATAR_STORAGE_PREFIX + circleId, String(Date.now()))
} else {
window.localStorage.removeItem(NO_AVATAR_STORAGE_PREFIX + circleId)
}
} catch {
// Full or unavailable storage only costs the probe again next load.
}
}

/**
* Fetch a circle's avatar into an object URL and cache it. The previous
* object URL is deliberately not revoked: other live consumers may still
* render it, and the handful of stale blobs per session is negligible.
*
* @param circleId - The circle to fetch the avatar for
* @param refresh - Bypass the endpoint's 24h HTTP cache (after an update)
*/
async function fetchAvatarUrl(circleId: string, refresh = false): Promise<string | undefined> {
try {
const response = await axios.get(
generateOcsUrl(`/apps/circles/circles/${circleId}/avatar`) + (refresh ? `?v=${Date.now()}` : ''),
{ responseType: 'blob' },
)
const url = URL.createObjectURL(response.data)
avatarUrls.set(circleId, url)
setCachedNoAvatar(circleId, false)
return url
} catch (error) {
// Only a definitive "no avatar" is remembered; transient errors
// (network, 5xx) leave no cache entry so the next consumer retries.
const notFound = (error as { response?: { status?: number } })?.response?.status === 404
if (notFound) {
avatarUrls.set(circleId, undefined)
}
setCachedNoAvatar(circleId, notFound)
return undefined
}
}

/**
* Load a circle's avatar object URL through the session-wide cache:
* concurrent consumers share one request, a definitive "no avatar" is
* remembered across page loads, and a refresh always fetches anew.
*
* @param circleId - The circle to load the avatar for
* @param refresh - Skip the caches and refetch (after an avatar update)
*/
export async function loadCachedAvatarUrl(circleId: string, refresh = false): Promise<string | undefined> {
if (!refresh && avatarUrls.has(circleId)) {
return avatarUrls.get(circleId)
}
if (!refresh && hasCachedNoAvatar(circleId)) {
avatarUrls.set(circleId, undefined)
return undefined
}
// Concurrent consumers of the same circle share one request; a refresh
// always starts a fresh one to skip an in-flight stale response.
let request = refresh ? undefined : avatarRequests.get(circleId)
if (!request) {
request = fetchAvatarUrl(circleId, refresh).finally(() => avatarRequests.delete(circleId))
avatarRequests.set(circleId, request)
}
return request
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's drop caching avatar, this should be done by the browser.

Comment thread vite.config.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uneeded, let's revert.

Comment thread REUSE.toml
path = ["img/dashboard/**.webp"]
precedence = "aggregate"
SPDX-FileCopyrightText = "Nextcloud GmbH <https://nextcloud.com/trademarks/>"
SPDX-License-Identifier = "LicenseRef-NextcloudTrademarks"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which licence are we usualy using for our assets?

Comment thread src/teams/api.ts

/**
* A team page: a markdown file stored in the root of the team folder,
* surfaced as a tab on the team (like the pinned tabs of a Microsoft

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we want to drop the credit here ^^

Comment thread src/teams/api.ts
export async function fetchTabOrder(teamId: string): Promise<string[]> {
const { data } = await axios.get<OcsResponse<{ order: string[] }>>(
generateOcsUrl('apps/circles/teams/{circleId}/tab-order', { circleId: teamId }),
{ headers: HEADERS },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Headers are probably not needed here. They were dropped in a recently merged PR, a rebase should remove the variable anyway.

Comment thread src/teams/api.ts
Comment on lines +67 to +72
const textApi = (window as unknown as { OCA?: { Text?: TextApi } }).OCA?.Text
if (!textApi?.createCollaborativeEditor) {
error.value = t('circles', 'The Text app is required to show team pages')
loading.value = false
return
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const textApi = (window as unknown as { OCA?: { Text?: TextApi } }).OCA?.Text
if (!textApi?.createCollaborativeEditor) {
error.value = t('circles', 'The Text app is required to show team pages')
loading.value = false
return
}
if (window.OCA?.Text === undefined) {
error.value = t('circles', 'The Text app is required to show team pages')
loading.value = false
return
}

Then directly use OCA.Text instead of textAPI

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick only to allow greping for OCA.Text.


let editor: TextEditorEmbed | null = null

// The component instance is reused across page tabs, so loadPage runs can

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pringelmann, this is a view injected by te router, is the reuse inevitable? Because it makes the code more complex than needed I think.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not inevitable. It's the router's default: page/:fileId is the same component at the same depth, so page/42 -> page/43 swaps props and keeps the instance.

Keying the child router-view in TeamPage.vue fixes it:

-<router-view />
+<router-view :key="$route.path" />

Fresh instance per page, so the counter and all the seq !== loadSeq checks go, the watcher becomes onMounted(loadPage), and the destroyEditor() at the top of loadPage is dead code. Sibling tabs are unaffected, they remount either way.

One guard still has to stay. Navigate away while createCollaborativeEditor is still running and onBeforeUnmount fires first, while editor is still null. The editor shows up afterwards and nothing destroys it.

One flag covers that: set unmounted = true in onBeforeUnmount, then created.destroy() instead of assigning it.

Comment thread src/teams/resourcesStore.ts
@artonge
artonge requested a review from pringelmann August 28, 2026 20:29
:name="resource.label">
<template #icon>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-if="resource.iconSvg" class="resource__icon" v-html="resource.iconSvg" />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even if an exploit path is narrow we should try to avoid XSS vectors like this if the input cannot be fully trusted.

NcIconSvgWrapper is already imported here and sanitises properly:

<NcIconSvgWrapper v-if="resource.iconSvg" :svg="resource.iconSvg" class="resource__icon" />

Kills the eslint-disable too

</template>

<template v-if="entry.page && isTeamAdmin" #actions>
<NcActionButton closeAfterClick @click="onDeletePage(entry.page)">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deletes on click, right under Rename in the same menu, on a doc the whole team shares. Trash makes it recoverable, but useTeamActions already does showConfirmation for Leave and Delete team. Maybe we do the same here?

Comment thread src/teams/api.ts
* surfaced as a tab on the team (like the pinned tabs of a Microsoft
* Teams channel).
*
* TODO: store the pages in the team folder's .system folder instead of

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we decide before this ships? Once people start making pages the files are in the folder root, and moving them to .system later becomes a migration.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker :)

@pringelmann

pringelmann commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Really nice work, this is a big step up from what we had!

One process thing, not a blocker: this is quite a lot to review all at once. ~3.5k new lines over 40+ files with major changes covering four different code sites.

Not asking you to split it now, that rebase would be miserable. But maybe next time start with something more self-contained like the tab ordering (new controller, new route, one store field). This is easier for everyone as a standalone PR to review.

Github also just made it simpler to stack your PRs so you aren't blocked by reviews:
https://github.github.com/gh-stack/
https://docs.github.com/en/pull-requests/how-tos/stacked-pull-requests

@pringelmann pringelmann left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking comments

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: In review

Development

Successfully merging this pull request may close these issues.

4 participants