From cdd13ca24ebde1c1ee46565ba2899d68f0d66b21 Mon Sep 17 00:00:00 2001 From: amblued Date: Wed, 26 Aug 2026 09:16:28 +0800 Subject: [PATCH] feat(spaces): unify collection scope across resource pages - Treat All as an unfiltered Space collection while preserving exact Space filters. - Keep global resources outside remembered Space context and preserve memo navigation origins. - Surface Space placement consistently across memo, attachment, and visibility UI. --- CONTEXT.md | 4 + web/src/components/AppSidebar/AppSidebar.tsx | 94 ++++++++--- .../components/AppSidebar/QuickFindDialog.tsx | 60 +++++-- web/src/components/AppSidebar/routes.ts | 70 ++++++++- .../AttachmentLibraryPrimitives.tsx | 5 + .../components/Inbox/MemoCommentMessage.tsx | 7 +- .../components/Inbox/MemoMentionMessage.tsx | 7 +- .../MemoActionMenu/MemoActionMenu.tsx | 1 + web/src/components/MemoActionMenu/hooks.ts | 29 +++- web/src/components/MemoActionMenu/types.ts | 2 + web/src/components/MemoCommentSection.tsx | 14 +- .../MemoContent/MemoMarkdownRenderer.tsx | 10 +- web/src/components/MemoContent/Tag.tsx | 16 +- web/src/components/MemoContent/index.tsx | 2 + .../MemoContent/markdown/AnchorLink.tsx | 24 ++- web/src/components/MemoContent/types.ts | 4 + .../MemoDetailSidebar/MemoDetailSidebar.tsx | 28 +++- .../MemoEditor/Toolbar/EditorToolbar.tsx | 3 +- .../MemoEditor/Toolbar/VisibilitySelector.tsx | 4 +- web/src/components/MemoEditor/index.tsx | 4 + .../components/MemoEditor/types/components.ts | 4 + .../MemoMetadata/Relation/RelationCard.tsx | 12 +- .../Relation/RelationListView.tsx | 11 +- .../components/MemoPreview/MemoPreview.tsx | 1 + web/src/components/MemoView/MemoView.tsx | 29 +++- .../components/MemoView/MemoViewContext.tsx | 4 +- .../MemoView/components/MemoBody.tsx | 7 +- .../components/MemoCommentListView.tsx | 5 +- .../MemoView/components/MemoHeader.tsx | 64 +++++--- .../MemoView/components/MemoSpaceBadge.tsx | 37 +++++ web/src/components/MemoView/navigation.ts | 119 ++++++++++++++ web/src/components/MemoView/types.ts | 4 + .../components/UserMemoMap/UserMemoMap.tsx | 16 +- web/src/contexts/AppSidebarContext.tsx | 2 + web/src/contexts/GlobalMemoEditorContext.tsx | 9 +- web/src/contexts/SpaceContext.tsx | 60 +++++-- web/src/hooks/useFilteredMemoStats.ts | 10 +- web/src/lib/cel-filter.ts | 5 +- web/src/lib/memo-views.ts | 26 +-- web/src/pages/Archived.tsx | 6 +- web/src/pages/Explore.tsx | 10 +- web/src/pages/Home.tsx | 2 +- web/src/pages/MemoDetail.tsx | 25 ++- web/src/pages/UserProfile.tsx | 2 +- web/src/utils/memo.ts | 14 +- web/tests/app-sidebar-logo.test.tsx | 148 +++++++++++++++--- web/tests/app-sidebar-routes.test.ts | 87 +++++++++- .../attachment-library-source-link.test.tsx | 11 +- web/tests/attachment-library-stats.test.ts | 16 +- web/tests/filtered-memo-stats.test.ts | 23 ++- web/tests/global-memo-editor.test.tsx | 40 +++++ web/tests/home-loading-boundary.test.tsx | 5 +- web/tests/memo-action-navigation.test.tsx | 115 ++++++++++++++ web/tests/memo-content-footnotes.test.tsx | 14 +- web/tests/memo-header-navigation.test.tsx | 127 +++++++++++++++ web/tests/memo-space-badge.test.tsx | 49 ++++++ web/tests/memo-tag-navigation.test.tsx | 76 +++++++++ web/tests/memo-view-navigation.test.ts | 127 +++++++++++++++ web/tests/quick-find-navigation.test.tsx | 143 +++++++++++++++++ web/tests/quick-find.test.ts | 69 +++++++- web/tests/space-context.test.tsx | 71 +++++++-- web/tests/space-feed-pages.test.tsx | 27 +++- web/tests/user-stats-filter-queries.test.tsx | 2 +- web/tests/visibility-selector.test.tsx | 27 +--- web/tests/visibility-space.test.tsx | 18 ++- 65 files changed, 1818 insertions(+), 249 deletions(-) create mode 100644 web/src/components/MemoView/components/MemoSpaceBadge.tsx create mode 100644 web/src/components/MemoView/navigation.ts create mode 100644 web/tests/memo-action-navigation.test.tsx create mode 100644 web/tests/memo-header-navigation.test.tsx create mode 100644 web/tests/memo-space-badge.test.tsx create mode 100644 web/tests/memo-tag-navigation.test.tsx create mode 100644 web/tests/memo-view-navigation.test.ts create mode 100644 web/tests/quick-find-navigation.test.tsx diff --git a/CONTEXT.md b/CONTEXT.md index 76c56c0c..94c4f5a4 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -31,3 +31,7 @@ _Avoid_: Pending membership, direct add **Space membership**: An accepted relationship between an active Memos user and a Space, carrying either the `ADMIN` or `USER` Space role. _Avoid_: Invitation, application role + +**Memo collection scope**: +The Space-placement dimension applied to memo and derived-resource collections. It is either `all`, which adds no Space predicate, or one exact Space. An unassigned Memo remains part of `all`; unassigned is a placement, not a collection scope. +_Avoid_: Unassigned scope, no-Space collection diff --git a/web/src/components/AppSidebar/AppSidebar.tsx b/web/src/components/AppSidebar/AppSidebar.tsx index c91e8a45..83d9f3e0 100644 --- a/web/src/components/AppSidebar/AppSidebar.tsx +++ b/web/src/components/AppSidebar/AppSidebar.tsx @@ -65,7 +65,7 @@ import type { MemoView } from "@/types/proto/api/v1/memo_view_service_pb"; import { User_Role, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb"; import { useTranslate } from "@/utils/i18n"; import MemosLogo from "../MemosLogo"; -import { getSidebarRouteKind } from "./routes"; +import { getSidebarRouteKind, routeSupportsCollectionScope } from "./routes"; import SidebarRow, { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_FOCUS_CLASSES, SIDEBAR_ROW_ICON_CLASSES, sidebarRowStateClasses } from "./SidebarRow"; import SidebarSection, { SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, @@ -263,7 +263,6 @@ const CollectionSidebarContent = ({ context }: { context: MemoStatsContext }) => context, userName: statsUserName, filter: statsFilter, - includeSpaceVisibility: !!selectedSpaceName, enabled: authInitialized && instanceInitialized && (md || mobileOpen), }); @@ -273,7 +272,8 @@ const CollectionSidebarContent = ({ context }: { context: MemoStatsContext }) => // clicks must land somewhere that renders the filtered feed. const onCollectionRoute = isMemoScopeRoute(location.pathname) || !!profileMatch; const filterTarget = onCollectionRoute ? undefined : context === "explore" ? ROUTES.EXPLORE : ROUTES.HOME; - const tagStateScope = `${statsUserName ?? context}${selectedSpaceName ? `:${selectedSpaceName}` : ""}`; + const tagStateScope = + context === "profile" ? (statsUserName ?? context) : `${statsUserName ?? context}${selectedSpaceName ? `:${selectedSpaceName}` : ""}`; return (
@@ -405,6 +405,8 @@ const MemoDetailSidebarContent = () => { return ( { const RouteSidebarContent = () => { const location = useLocation(); - const currentUser = useCurrentUser(); - const { memoDetail } = useAppSidebar(); const kind = getSidebarRouteKind(location.pathname); if (kind === "home" || kind === "archived" || kind === "explore" || kind === "profile") { return ; @@ -424,10 +424,8 @@ const RouteSidebarContent = () => { if (kind === "attachments") return ; if (kind === "inbox") return ; if (kind === "settings") return ; - if (kind === "memo" && memoDetail) return ; - // Routes without a specific tenant (about, error pages, unknown paths, memo detail - // before the page publishes its descriptor) fall back to the default library content. - return ; + if (kind === "memo") return ; + return null; }; interface GlobalNavItem { @@ -477,10 +475,8 @@ const GlobalNavigation = () => { const location = useLocation(); const navigate = useNavigate(); const currentUser = useCurrentUser(); - const { data: notifications = [] } = useNotifications(); const { memoDetail, memoScope, setMemoScope, setMobileOpen } = useAppSidebar(); const { filters } = useMemoFilterContext(); - const unreadCount = notifications.filter((notification) => notification.status === UserNotification_Status.UNREAD).length; const routeKind = getSidebarRouteKind(location.pathname); const resolvedScope = resolveMemoScope(location.pathname, { currentUsername: currentUser?.username, @@ -519,15 +515,7 @@ const GlobalNavigation = () => { label: t("common.attachments"), path: ROUTES.ATTACHMENTS, icon: PaperclipIcon, - active: location.pathname === ROUTES.ATTACHMENTS, - }, - { - id: "inbox", - label: t("common.inbox"), - path: ROUTES.INBOX, - icon: BellIcon, - active: location.pathname === ROUTES.INBOX, - count: unreadCount, + active: routeKind === "attachments", }, ] : [ @@ -539,7 +527,13 @@ const GlobalNavigation = () => { active: routeKind === "explore" || routeKind === "profile" || routeKind === "memo", alwaysExpanded: true, }, - { id: "about", label: t("common.about"), path: ROUTES.ABOUT, icon: InfoIcon, active: location.pathname === ROUTES.ABOUT }, + { + id: "about", + label: t("common.about"), + path: ROUTES.ABOUT, + icon: InfoIcon, + active: Boolean(matchPath(ROUTES.ABOUT, location.pathname)), + }, ]; const scopeMenuContent = ( @@ -645,21 +639,66 @@ const GlobalNavigation = () => { ); }; -/** The sidebar/header brand slot: the Space switcher when signed in, the plain logo otherwise. */ +/** The sidebar/header brand slot: collection scope on collection routes, instance brand elsewhere. */ const SidebarBrand = ({ className }: { className?: string }) => { const currentUser = useCurrentUser(); + const location = useLocation(); - if (currentUser) { + if (currentUser && routeSupportsCollectionScope(location.pathname)) { return ; } return ( - + ); }; +const InboxFooterLink = () => { + const t = useTranslate(); + const location = useLocation(); + const { setMobileOpen } = useAppSidebar(); + const { data: notifications = [] } = useNotifications(); + const unreadCount = notifications.filter((notification) => notification.status === UserNotification_Status.UNREAD).length; + const active = getSidebarRouteKind(location.pathname) === "inbox"; + const accessibleLabel = unreadCount > 0 ? `${t("common.inbox")}, ${unreadCount} ${t("inbox.unread")}` : t("common.inbox"); + + return ( + + + setMobileOpen(false)} + aria-label={accessibleLabel} + aria-current={active ? "page" : undefined} + className={cn( + "flex size-9 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-sidebar-accent/65 hover:text-foreground md:size-8", + SIDEBAR_ROW_FOCUS_CLASSES, + active && "bg-sidebar-accent text-sidebar-accent-foreground", + )} + /> + } + > + + + {unreadCount > 0 && ( + + + {accessibleLabel} + + + ); +}; + const AppSidebar = ({ className }: { className?: string }) => { const t = useTranslate(); const currentUser = useCurrentUser(); @@ -692,7 +731,12 @@ const AppSidebar = ({ className }: { className?: string }) => {