Design pass and team pages - #2846
Conversation
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
d1a2ffa to
88ae97e
Compare
artonge
left a comment
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
Any reason for this limit?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
discord.com ?
I don't think we need this file.
| // 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 | ||
| } |
There was a problem hiding this comment.
Let's drop caching avatar, this should be done by the browser.
| path = ["img/dashboard/**.webp"] | ||
| precedence = "aggregate" | ||
| SPDX-FileCopyrightText = "Nextcloud GmbH <https://nextcloud.com/trademarks/>" | ||
| SPDX-License-Identifier = "LicenseRef-NextcloudTrademarks" |
There was a problem hiding this comment.
Which licence are we usualy using for our assets?
|
|
||
| /** | ||
| * 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 |
There was a problem hiding this comment.
Maybe we want to drop the credit here ^^
| 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 }, |
There was a problem hiding this comment.
Headers are probably not needed here. They were dropped in a recently merged PR, a rebase should remove the variable anyway.
| 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 | ||
| } |
There was a problem hiding this comment.
| 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
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
@pringelmann, this is a view injected by te router, is the reuse inevitable? Because it makes the code more complex than needed I think.
There was a problem hiding this comment.
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.
20e1b7c to
88ae97e
Compare
| :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" /> |
There was a problem hiding this comment.
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)"> |
There was a problem hiding this comment.
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?
| * 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 |
There was a problem hiding this comment.
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.
|
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: |
pringelmann
left a comment
There was a problem hiding this comment.
No blocking comments
Re-organize the existing features, polishing and add a new team pages feature.