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 }) => {
-
@@ -148,7 +149,7 @@ function MemoCommentMessage({ notification }: Props) {
-
+
diff --git a/web/src/components/Inbox/MemoMentionMessage.tsx b/web/src/components/Inbox/MemoMentionMessage.tsx
index 25119fbe..9397deca 100644
--- a/web/src/components/Inbox/MemoMentionMessage.tsx
+++ b/web/src/components/Inbox/MemoMentionMessage.tsx
@@ -137,9 +137,10 @@ function MemoMentionMessage({ notification }: Props) {
)}
-
@@ -154,7 +155,7 @@ function MemoMentionMessage({ notification }: Props) {
-
+
diff --git a/web/src/components/MemoActionMenu/MemoActionMenu.tsx b/web/src/components/MemoActionMenu/MemoActionMenu.tsx
index bc88a32a..10a87c15 100644
--- a/web/src/components/MemoActionMenu/MemoActionMenu.tsx
+++ b/web/src/components/MemoActionMenu/MemoActionMenu.tsx
@@ -56,6 +56,7 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
confirmDeleteMemo,
} = useMemoActionHandlers({
memo,
+ parentScope: props.parentScope,
onEdit: props.onEdit,
setDeleteDialogOpen,
});
diff --git a/web/src/components/MemoActionMenu/hooks.ts b/web/src/components/MemoActionMenu/hooks.ts
index d80f6444..ef68e5c7 100644
--- a/web/src/components/MemoActionMenu/hooks.ts
+++ b/web/src/components/MemoActionMenu/hooks.ts
@@ -4,6 +4,7 @@ import { useCallback } from "react";
import toast from "react-hot-toast";
import { useLocation } from "react-router-dom";
import { useInstance } from "@/contexts/InstanceContext";
+import { useSpaceContext } from "@/contexts/SpaceContext";
import { memoKeys, useDeleteMemo, useUpdateMemo } from "@/hooks/useMemoQueries";
import useNavigateTo from "@/hooks/useNavigateTo";
import { userKeys } from "@/hooks/useUserQueries";
@@ -13,22 +14,25 @@ import { State } from "@/types/proto/api/v1/common_pb";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import { checkAllTasks, uncheckAllTasks } from "@/utils/markdown-task-actions";
+import { isMemoDetailPath, type MemoOriginScope } from "../MemoView/navigation";
interface UseMemoActionHandlersOptions {
memo: Memo;
+ parentScope: MemoOriginScope;
onEdit?: () => void;
setDeleteDialogOpen: (open: boolean) => void;
}
-export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen }: UseMemoActionHandlersOptions) => {
+export const useMemoActionHandlers = ({ memo, parentScope, onEdit, setDeleteDialogOpen }: UseMemoActionHandlersOptions) => {
const t = useTranslate();
const location = useLocation();
const navigateTo = useNavigateTo();
const queryClient = useQueryClient();
const { profile } = useInstance();
+ const { clearSelectedSpace } = useSpaceContext();
const { mutateAsync: updateMemo } = useUpdateMemo();
const { mutateAsync: deleteMemo } = useDeleteMemo();
- const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`);
+ const isInMemoDetailPage = isMemoDetailPath(location.pathname, memo.name);
const memoUpdatedCallback = useCallback(() => {
// Invalidate user stats to trigger refetch
@@ -101,10 +105,13 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen }: Use
}
if (isInMemoDetailPage) {
+ if (parentScope === "all") {
+ clearSelectedSpace();
+ }
navigateTo(memo.state === State.ARCHIVED ? ROUTES.HOME : ROUTES.ARCHIVED);
}
memoUpdatedCallback();
- }, [memo.name, memo.state, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, updateMemo]);
+ }, [memo.name, memo.state, t, isInMemoDetailPage, parentScope, clearSelectedSpace, navigateTo, memoUpdatedCallback, updateMemo]);
const handleCopyLink = useCallback(() => {
let host = profile.instanceUrl;
@@ -144,10 +151,24 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen }: Use
queryClient.invalidateQueries({ queryKey: memoKeys.comments(memo.parent) });
}
if (isInMemoDetailPage) {
+ if (parentScope === "all") {
+ clearSelectedSpace();
+ }
navigateTo(ROUTES.HOME);
}
memoUpdatedCallback();
- }, [memo.name, memo.parent, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, deleteMemo, queryClient]);
+ }, [
+ memo.name,
+ memo.parent,
+ t,
+ isInMemoDetailPage,
+ parentScope,
+ clearSelectedSpace,
+ navigateTo,
+ memoUpdatedCallback,
+ deleteMemo,
+ queryClient,
+ ]);
return {
handleTogglePinMemoBtnClick,
diff --git a/web/src/components/MemoActionMenu/types.ts b/web/src/components/MemoActionMenu/types.ts
index 442c3422..7d46f371 100644
--- a/web/src/components/MemoActionMenu/types.ts
+++ b/web/src/components/MemoActionMenu/types.ts
@@ -1,7 +1,9 @@
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
+import type { MemoOriginScope } from "../MemoView/navigation";
export interface MemoActionMenuProps {
memo: Memo;
+ parentScope: MemoOriginScope;
readonly?: boolean;
className?: string;
onEdit?: () => void;
diff --git a/web/src/components/MemoCommentSection.tsx b/web/src/components/MemoCommentSection.tsx
index a1e7e936..ff234875 100644
--- a/web/src/components/MemoCommentSection.tsx
+++ b/web/src/components/MemoCommentSection.tsx
@@ -3,6 +3,7 @@ import { type ComponentType, useCallback, useState } from "react";
import { loadMemoEditor } from "@/components/MemoEditor/loader";
import type { MemoEditorProps } from "@/components/MemoEditor/types";
import MemoView from "@/components/MemoView";
+import type { MemoOriginScope } from "@/components/MemoView/navigation";
import { Button } from "@/components/ui/button";
import useCurrentUser from "@/hooks/useCurrentUser";
import { extractMemoIdFromName } from "@/lib/resource-names";
@@ -13,12 +14,21 @@ interface Props {
memo: Memo;
comments: Memo[];
parentPage?: string;
+ parentScope?: MemoOriginScope;
hasMoreComments?: boolean;
isFetchingMoreComments?: boolean;
onLoadMoreComments?: () => void;
}
-const MemoCommentSection = ({ memo, comments, parentPage, hasMoreComments, isFetchingMoreComments, onLoadMoreComments }: Props) => {
+const MemoCommentSection = ({
+ memo,
+ comments,
+ parentPage,
+ parentScope,
+ hasMoreComments,
+ isFetchingMoreComments,
+ onLoadMoreComments,
+}: Props) => {
const t = useTranslate();
const currentUser = useCurrentUser();
const [showEditor, setShowEditor] = useState(false);
@@ -113,7 +123,7 @@ const MemoCommentSection = ({ memo, comments, parentPage, hasMoreComments, isFet
)}
{comments.map((comment) => (
))}
{hasMoreComments && (
diff --git a/web/src/components/MemoContent/MemoMarkdownRenderer.tsx b/web/src/components/MemoContent/MemoMarkdownRenderer.tsx
index 7159710e..ec456e54 100644
--- a/web/src/components/MemoContent/MemoMarkdownRenderer.tsx
+++ b/web/src/components/MemoContent/MemoMarkdownRenderer.tsx
@@ -7,6 +7,7 @@ import { isMentionElement, isTagElement, isTaskListItemElement } from "@/types/m
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { lazyWithReload } from "@/utils/lazy";
import { resolveManagedAttachmentImageSource } from "@/utils/managed-attachment";
+import type { MemoOriginScope } from "../MemoView/navigation";
import { CodeBlock } from "./CodeBlock";
import { MarkdownRenderContext, rootMarkdownRenderContext } from "./MarkdownRenderContext";
import { Mention } from "./Mention";
@@ -23,6 +24,9 @@ export interface MemoMarkdownRendererProps {
resolvedMentionUsernames: Set;
/** Resource name of the memo (e.g. `memos/abc123`), used to target footnote links at the detail page. */
memoName?: string;
+ /** Collection page that opened the memo detail. */
+ parentPage?: string;
+ parentScope?: MemoOriginScope;
/** Whether the memo is rendered as a collapsed feed card. */
compact?: boolean;
}
@@ -62,6 +66,8 @@ export const MemoMarkdownRendererCore = ({
attachments = [],
resolvedMentionUsernames,
memoName,
+ parentPage,
+ parentScope,
compact,
mathRemarkPlugins = [],
mathRehypePlugins = [],
@@ -128,7 +134,7 @@ export const MemoMarkdownRendererCore = ({
// than opening a new tab; everything else is treated as an external link.
if (typeof href === "string" && href.startsWith("#")) {
return (
-
+
{children}
);
@@ -188,6 +194,8 @@ export const MemoMarkdownRenderer = memo(
previous.content === next.content &&
previous.attachments === next.attachments &&
previous.memoName === next.memoName &&
+ previous.parentPage === next.parentPage &&
+ previous.parentScope === next.parentScope &&
previous.compact === next.compact &&
haveEqualResolvedMentions(previous.resolvedMentionUsernames, next.resolvedMentionUsernames),
);
diff --git a/web/src/components/MemoContent/Tag.tsx b/web/src/components/MemoContent/Tag.tsx
index bcc3e9f3..8be7c3b6 100644
--- a/web/src/components/MemoContent/Tag.tsx
+++ b/web/src/components/MemoContent/Tag.tsx
@@ -2,6 +2,7 @@ import type { Element } from "hast";
import { useLocation } from "react-router-dom";
import { useAuth } from "@/contexts/AuthContext";
import { type MemoFilter, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext";
+import { useSpaceContext } from "@/contexts/SpaceContext";
import useNavigateTo from "@/hooks/useNavigateTo";
import { colorToHex } from "@/lib/color";
import { tagStyles } from "@/lib/markdownStyles";
@@ -9,6 +10,7 @@ import { findTagMetadata } from "@/lib/tag";
import { cn } from "@/lib/utils";
import { Routes } from "@/router";
import { useMemoViewContext } from "../MemoView/MemoViewContext";
+import { isMemoCollectionOrigin, isMemoResourcePath, withMemoFilter } from "../MemoView/navigation";
interface TagProps extends React.HTMLAttributes {
node?: Element; // AST node from react-markdown
@@ -17,9 +19,10 @@ interface TagProps extends React.HTMLAttributes {
}
export const Tag: React.FC = ({ "data-tag": dataTag, children, className, style, node: _node, ...props }) => {
- const { parentPage } = useMemoViewContext();
+ const { parentPage, parentScope } = useMemoViewContext();
const location = useLocation();
const navigateTo = useNavigateTo();
+ const { clearSelectedSpace } = useSpaceContext();
const { getFiltersByFactor, removeFilter, addFilter } = useMemoFilterContext();
const { userTagsSetting } = useAuth();
@@ -43,12 +46,11 @@ export const Tag: React.FC = ({ "data-tag": dataTag, children, classNa
e.stopPropagation();
// If the tag is clicked in a memo detail page, we should navigate to the memo list page.
- if (location.pathname.startsWith("/m")) {
- const pathname = parentPage || Routes.HOME;
- const searchParams = new URLSearchParams();
-
- searchParams.set("filter", stringifyFilters([{ factor: "tagSearch", value: tag }]));
- navigateTo(`${pathname}?${searchParams.toString()}`);
+ if (isMemoResourcePath(location.pathname)) {
+ if (parentScope === "all" && isMemoCollectionOrigin(parentPage)) {
+ clearSelectedSpace();
+ }
+ navigateTo(withMemoFilter(parentPage || Routes.HOME, stringifyFilters([{ factor: "tagSearch", value: tag }])));
return;
}
diff --git a/web/src/components/MemoContent/index.tsx b/web/src/components/MemoContent/index.tsx
index 177c73e6..a28192a3 100644
--- a/web/src/components/MemoContent/index.tsx
+++ b/web/src/components/MemoContent/index.tsx
@@ -39,6 +39,8 @@ const MemoContent = (props: MemoContentProps) => {
attachments={attachments}
resolvedMentionUsernames={resolvedMentionUsernames}
memoName={props.memoName}
+ parentPage={props.parentPage}
+ parentScope={props.parentScope}
compact={Boolean(props.compact)}
/>
diff --git a/web/src/components/MemoContent/markdown/AnchorLink.tsx b/web/src/components/MemoContent/markdown/AnchorLink.tsx
index 433700de..75be8db4 100644
--- a/web/src/components/MemoContent/markdown/AnchorLink.tsx
+++ b/web/src/components/MemoContent/markdown/AnchorLink.tsx
@@ -2,12 +2,16 @@ import { Link } from "react-router-dom";
import { markdownStyles } from "@/lib/markdownStyles";
import { cn } from "@/lib/utils";
import { findAnchorTarget } from "@/utils/markdown-manipulation";
+import { createMemoNavigationState, type MemoOriginScope } from "../../MemoView/navigation";
import type { ReactMarkdownProps } from "./types";
interface AnchorLinkProps extends React.AnchorHTMLAttributes, ReactMarkdownProps {
href: string;
/** Resource name of the enclosing memo (e.g. `memos/abc123`), when known. */
memoName?: string;
+ /** Collection page that rendered the enclosing memo. */
+ parentPage?: string;
+ parentScope?: MemoOriginScope;
/** Whether the memo is rendered as a collapsed feed card. */
compact?: boolean;
children: React.ReactNode;
@@ -22,7 +26,17 @@ interface AnchorLinkProps extends React.AnchorHTMLAttributes,
* below the fold, so we fall back to navigating to the memo detail page (with the hash), where
* MemoDetail scrolls the target into view.
*/
-export const AnchorLink = ({ href, memoName, compact, children, className, node: _node, ...props }: AnchorLinkProps) => {
+export const AnchorLink = ({
+ href,
+ memoName,
+ parentPage,
+ parentScope,
+ compact,
+ children,
+ className,
+ node: _node,
+ ...props
+}: AnchorLinkProps) => {
const handleClick = (event: React.MouseEvent) => {
if (compact) return; // Let the link navigate to the detail page.
const id = decodeURIComponent(href.slice(1));
@@ -41,7 +55,13 @@ export const AnchorLink = ({ href, memoName, compact, children, className, node:
if (memoName) {
return (
-
+
{children}
);
diff --git a/web/src/components/MemoContent/types.ts b/web/src/components/MemoContent/types.ts
index 4cf40868..4de996e7 100644
--- a/web/src/components/MemoContent/types.ts
+++ b/web/src/components/MemoContent/types.ts
@@ -1,5 +1,6 @@
import type React from "react";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
+import type { MemoOriginScope } from "../MemoView/navigation";
export interface MemoContentProps {
content: string;
@@ -7,6 +8,9 @@ export interface MemoContentProps {
attachments?: Attachment[];
/** Resource name of the memo (e.g. `memos/abc123`). Enables footnote links to target the memo detail page. */
memoName?: string;
+ /** Collection page that rendered the memo, preserved when an inline link opens its detail page. */
+ parentPage?: string;
+ parentScope?: MemoOriginScope;
/** The card renders collapsed (ClampedSection), so footnote links navigate instead of scrolling. */
compact?: boolean;
className?: string;
diff --git a/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx b/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx
index e83eab9f..fa2b17c6 100644
--- a/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx
+++ b/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx
@@ -8,6 +8,7 @@ import SidebarSection, { SIDEBAR_SECTION_STACK_CLASSES } from "@/components/AppS
import { extractHeadings } from "@/components/MemoContent/pipeline";
import { getRelationBuckets, getRelationMemo } from "@/components/MemoMetadata/Relation/relationHelpers";
import { useResolvedRelationMemos } from "@/components/MemoMetadata/Relation/useResolvedRelationMemos";
+import { createMemoNavigationState, type MemoOriginScope } from "@/components/MemoView/navigation";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { useInstance } from "@/contexts/InstanceContext";
import { useOverflowTitle } from "@/hooks";
@@ -23,12 +24,24 @@ import MemoSharePanel from "./MemoSharePanel";
interface Props {
memo: Memo;
+ parentPage?: string;
+ parentScope?: MemoOriginScope;
className?: string;
onShareImageOpen?: () => void;
forceReadonly?: boolean;
}
-const BacklinkRow = ({ relation, snippet }: { relation: MemoRelation; snippet: string }) => {
+const BacklinkRow = ({
+ relation,
+ snippet,
+ parentPage,
+ parentScope,
+}: {
+ relation: MemoRelation;
+ snippet: string;
+ parentPage?: string;
+ parentScope?: MemoOriginScope;
+}) => {
const { ref, title } = useOverflowTitle(snippet);
const relatedMemo = getRelationMemo(relation, "referenced");
if (!relatedMemo) {
@@ -39,6 +52,7 @@ const BacklinkRow = ({ relation, snippet }: { relation: MemoRelation; snippet: s
@@ -50,7 +64,7 @@ const BacklinkRow = ({ relation, snippet }: { relation: MemoRelation; snippet: s
);
};
-const MemoDetailSidebar = ({ memo, className, onShareImageOpen, forceReadonly = false }: Props) => {
+const MemoDetailSidebar = ({ memo, parentPage, parentScope, className, onShareImageOpen, forceReadonly = false }: Props) => {
const t = useTranslate();
const currentUser = useCurrentUser();
const { profile } = useInstance();
@@ -146,7 +160,15 @@ const MemoDetailSidebar = ({ memo, className, onShareImageOpen, forceReadonly =
{referenced.map((relation) => {
const relatedMemo = getRelationMemo(relation, "referenced");
- return ;
+ return (
+
+ );
})}
)}
diff --git a/web/src/components/MemoEditor/Toolbar/EditorToolbar.tsx b/web/src/components/MemoEditor/Toolbar/EditorToolbar.tsx
index c302581e..8e0bd0a1 100644
--- a/web/src/components/MemoEditor/Toolbar/EditorToolbar.tsx
+++ b/web/src/components/MemoEditor/Toolbar/EditorToolbar.tsx
@@ -13,6 +13,7 @@ export const EditorToolbar: FC = ({
onSave,
onCancel,
memoName,
+ space,
onAudioRecorderClick,
viewToggles,
onInsertImages,
@@ -56,7 +57,7 @@ export const EditorToolbar: FC = ({
viewToggles={viewToggles}
onInsertImages={onInsertImages}
/>
-
+
diff --git a/web/src/components/MemoEditor/Toolbar/VisibilitySelector.tsx b/web/src/components/MemoEditor/Toolbar/VisibilitySelector.tsx
index c9d17755..09bcd836 100644
--- a/web/src/components/MemoEditor/Toolbar/VisibilitySelector.tsx
+++ b/web/src/components/MemoEditor/Toolbar/VisibilitySelector.tsx
@@ -1,7 +1,6 @@
import { CheckIcon, ChevronDownIcon } from "lucide-react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import VisibilityIcon from "@/components/VisibilityIcon";
-import { useSpaceContext } from "@/contexts/SpaceContext";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { getAssignableVisibilityOptions, getVisibilityOption } from "@/utils/memo";
@@ -11,9 +10,8 @@ const VisibilitySelector = (props: VisibilitySelectorProps) => {
const { value, onChange } = props;
const compact = props.size === "compact";
const t = useTranslate();
- const { selectedSpaceName } = useSpaceContext();
- const visibilityOptions = getAssignableVisibilityOptions({ spaceSelected: Boolean(selectedSpaceName), current: value });
+ const visibilityOptions = getAssignableVisibilityOptions({ hasSpacePlacement: Boolean(props.space), current: value });
// Resolved from the full catalog, so the trigger names the memo's audience even
// when that audience is not currently on offer.
const currentOption = getVisibilityOption(value);
diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx
index dcc34fec..e0a1bfe6 100644
--- a/web/src/components/MemoEditor/index.tsx
+++ b/web/src/components/MemoEditor/index.tsx
@@ -72,6 +72,9 @@ const MemoEditorImpl: React.FC
= ({
const [isFormattingToolbarVisible, setFormattingToolbarVisible] = useLocalStorage(FORMATTING_TOOLBAR_STORAGE_KEY, false);
const memoName = memo?.name;
+ // Existing resources own their placement. New replies are not placed
+ // independently; only a new top-level memo inherits its host's target.
+ const editorSpace = memo ? memo.space : parentMemoName ? undefined : defaultSpace;
const canTranscribe = useMemo(() => {
const providerId = aiSetting.transcription?.providerId ?? "";
if (!providerId) return false;
@@ -362,6 +365,7 @@ const MemoEditorImpl: React.FC = ({
onSave={handleSave}
onCancel={onCancel ? handleCancel : undefined}
memoName={memoName}
+ space={editorSpace}
onAudioRecorderClick={handleAudioRecorderClick}
viewToggles={viewToggles}
onInsertImages={handleInsertImages}
diff --git a/web/src/components/MemoEditor/types/components.ts b/web/src/components/MemoEditor/types/components.ts
index fde3e178..911f4af0 100644
--- a/web/src/components/MemoEditor/types/components.ts
+++ b/web/src/components/MemoEditor/types/components.ts
@@ -57,6 +57,8 @@ export interface EditorToolbarProps {
onSave: () => void;
onCancel?: () => void;
memoName?: string;
+ /** The Space that owns the memo being created or edited, if any. */
+ space?: string;
onAudioRecorderClick: () => void;
viewToggles?: EditorViewToggles;
onInsertImages: (files: File[]) => void;
@@ -105,6 +107,8 @@ export interface InsertMenuProps {
export interface VisibilitySelectorProps {
value: Visibility;
onChange: (visibility: Visibility) => void;
+ /** The memo's actual placement; independent of the ambient collection scope. */
+ space?: string;
onOpenChange?: (open: boolean) => void;
/** "compact" renders a 13px trigger that blends into dense surfaces like the memo detail rail. */
size?: "default" | "compact";
diff --git a/web/src/components/MemoMetadata/Relation/RelationCard.tsx b/web/src/components/MemoMetadata/Relation/RelationCard.tsx
index 24069678..83cb6d27 100644
--- a/web/src/components/MemoMetadata/Relation/RelationCard.tsx
+++ b/web/src/components/MemoMetadata/Relation/RelationCard.tsx
@@ -1,15 +1,23 @@
import MemoSnippetLink from "@/components/MemoView/components/MemoSnippetLink";
+import { createMemoNavigationState, type MemoOriginScope } from "@/components/MemoView/navigation";
import type { MemoRelation_Memo } from "@/types/proto/api/v1/memo_service_pb";
interface RelationCardProps {
memo: MemoRelation_Memo;
parentPage?: string;
+ parentScope?: MemoOriginScope;
className?: string;
}
-const RelationCard = ({ memo, parentPage, className }: RelationCardProps) => {
+const RelationCard = ({ memo, parentPage, parentScope, className }: RelationCardProps) => {
return (
-
+
);
};
diff --git a/web/src/components/MemoMetadata/Relation/RelationListView.tsx b/web/src/components/MemoMetadata/Relation/RelationListView.tsx
index 3030080c..1e1c7e3e 100644
--- a/web/src/components/MemoMetadata/Relation/RelationListView.tsx
+++ b/web/src/components/MemoMetadata/Relation/RelationListView.tsx
@@ -1,6 +1,7 @@
import { LinkIcon, MilestoneIcon } from "lucide-react";
import { useMemo, useState } from "react";
import MetadataSection from "@/components/MemoMetadata/MetadataSection";
+import type { MemoOriginScope } from "@/components/MemoView/navigation";
import { useNearViewport } from "@/hooks/useNearViewport";
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
@@ -12,10 +13,11 @@ interface RelationListViewProps {
relations: MemoRelation[];
currentMemoName?: string;
parentPage?: string;
+ parentScope?: MemoOriginScope;
className?: string;
}
-function RelationListView({ relations, currentMemoName, parentPage, className }: RelationListViewProps) {
+function RelationListView({ relations, currentMemoName, parentPage, parentScope, className }: RelationListViewProps) {
const t = useTranslate();
const [activeTab, setActiveTab] = useState<"referencing" | "referenced">("referencing");
const { ref: viewportRef, isNearViewport } = useNearViewport();
@@ -79,7 +81,12 @@ function RelationListView({ relations, currentMemoName, parentPage, className }:
return null;
}
return (
-
+
);
})}
diff --git a/web/src/components/MemoPreview/MemoPreview.tsx b/web/src/components/MemoPreview/MemoPreview.tsx
index 2663071e..279c420b 100644
--- a/web/src/components/MemoPreview/MemoPreview.tsx
+++ b/web/src/components/MemoPreview/MemoPreview.tsx
@@ -29,6 +29,7 @@ const STUB_CONTEXT: MemoViewContextValue = {
creator: undefined,
currentUser: undefined,
parentPage: "/",
+ parentScope: "all",
cardWidth: 0,
isArchived: false,
readonly: true,
diff --git a/web/src/components/MemoView/MemoView.tsx b/web/src/components/MemoView/MemoView.tsx
index 2b154e6b..54129e19 100644
--- a/web/src/components/MemoView/MemoView.tsx
+++ b/web/src/components/MemoView/MemoView.tsx
@@ -14,13 +14,24 @@ import { MemoBody, MemoCommentListView, MemoHeader } from "./components";
import { MEMO_CARD_BASE_CLASSES } from "./constants";
import { useImagePreview } from "./hooks";
import { computeCommentAmount, MemoViewContext } from "./MemoViewContext";
+import { isMemoDetailPath, resolveMemoOrigin } from "./navigation";
import type { MemoViewProps } from "./types";
const MemoShareImageDialog = lazyWithReload(() => import("../MemoActionMenu/MemoShareImageDialog"));
const PreviewImageDialog = lazyWithReload(() => import("../PreviewImageDialog"));
const MemoView: React.FC = (props: MemoViewProps) => {
- const { memo: memoData, className, parentPage: parentPageProp, compact, showCreator, showVisibility, showPinned } = props;
+ const {
+ memo: memoData,
+ className,
+ parentPage: parentPageProp,
+ parentScope: parentScopeProp,
+ compact,
+ showCreator,
+ showVisibility,
+ showPinned,
+ showSpace,
+ } = props;
const cardRef = useRef(null);
const [showEditor, setShowEditor] = useState(false);
const [EditorComponent, setEditorComponent] = useState>();
@@ -31,7 +42,14 @@ const MemoView: React.FC = (props: MemoViewProps) => {
const creator = useResolvedUser(memoData.creator, { enabled: Boolean(showCreator || props.shareImageDialogOpen) });
const isArchived = memoData.state === State.ARCHIVED;
const readonly = memoData.creator !== currentUser?.name && !isSuperUser(currentUser);
- const parentPage = parentPageProp || "/";
+ const location = useLocation();
+ const { parentPage, parentScope } = resolveMemoOrigin({
+ explicitParentPage: parentPageProp,
+ explicitParentScope: parentScopeProp,
+ pathname: location.pathname,
+ search: location.search,
+ memoName: memoData.name,
+ });
// Blur content when any tag has blur_content enabled in the current user's tag settings.
const [showBlurredContent, setShowBlurredContent] = useState(false);
@@ -50,8 +68,7 @@ const MemoView: React.FC = (props: MemoViewProps) => {
}, []);
const closeEditor = useCallback(() => setShowEditor(false), []);
- const location = useLocation();
- const isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`) || location.pathname.startsWith("/memos/shares/");
+ const isInMemoDetailPage = isMemoDetailPath(location.pathname, memoData.name);
const showCommentPreview = !isInMemoDetailPage && computeCommentAmount(memoData) > 0;
// The card width is only needed by the share-image dialog. Keep feed cards
@@ -93,6 +110,7 @@ const MemoView: React.FC = (props: MemoViewProps) => {
creator,
currentUser,
parentPage,
+ parentScope,
cardWidth,
isArchived,
readonly,
@@ -107,6 +125,7 @@ const MemoView: React.FC = (props: MemoViewProps) => {
creator,
currentUser,
parentPage,
+ parentScope,
cardWidth,
isArchived,
readonly,
@@ -124,7 +143,7 @@ const MemoView: React.FC = (props: MemoViewProps) => {
ref={cardRef}
tabIndex={readonly ? -1 : 0}
>
-
+
diff --git a/web/src/components/MemoView/MemoViewContext.tsx b/web/src/components/MemoView/MemoViewContext.tsx
index 41ef385f..f682b1d6 100644
--- a/web/src/components/MemoView/MemoViewContext.tsx
+++ b/web/src/components/MemoView/MemoViewContext.tsx
@@ -7,12 +7,14 @@ import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
import type { User } from "@/types/proto/api/v1/user_service_pb";
import type { PreviewMediaItem } from "@/utils/media-item";
import { RELATIVE_TIME_THRESHOLD_MS } from "./constants";
+import { isMemoDetailPath, type MemoOriginScope } from "./navigation";
export interface MemoViewContextValue {
memo: Memo;
creator: User | undefined;
currentUser: User | undefined;
parentPage: string;
+ parentScope: MemoOriginScope;
cardWidth: number;
isArchived: boolean;
readonly: boolean;
@@ -41,7 +43,7 @@ export const useMemoViewDerived = () => {
const { timeBasis } = useView();
const location = useLocation();
- const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`) || location.pathname.startsWith("/memos/shares/");
+ const isInMemoDetailPage = isMemoDetailPath(location.pathname, memo.name);
const commentAmount = computeCommentAmount(memo);
const createTime = memo.createTime ? timestampDate(memo.createTime) : undefined;
diff --git a/web/src/components/MemoView/components/MemoBody.tsx b/web/src/components/MemoView/components/MemoBody.tsx
index 98ba794f..c73ee458 100644
--- a/web/src/components/MemoView/components/MemoBody.tsx
+++ b/web/src/components/MemoView/components/MemoBody.tsx
@@ -32,7 +32,8 @@ const BlurOverlay: React.FC<{ onClick?: () => void }> = ({ onClick }) => {
};
const MemoBody: React.FC = ({ compact }) => {
- const { memo, parentPage, showBlurredContent, blurred, readonly, openEditor, openPreview, toggleBlurVisibility } = useMemoViewContext();
+ const { memo, parentPage, parentScope, showBlurredContent, blurred, readonly, openEditor, openPreview, toggleBlurVisibility } =
+ useMemoViewContext();
const { handleMemoContentClick, handleMemoContentDoubleClick } = useMemoHandlers({ readonly, openEditor, openPreview });
@@ -56,6 +57,8 @@ const MemoBody: React.FC = ({ compact }) => {
= ({ compact }) => {
compact={Boolean(compact)}
/>
-
+
{memo.location && }
diff --git a/web/src/components/MemoView/components/MemoCommentListView.tsx b/web/src/components/MemoView/components/MemoCommentListView.tsx
index f2f0f4e6..cd44e4e2 100644
--- a/web/src/components/MemoView/components/MemoCommentListView.tsx
+++ b/web/src/components/MemoView/components/MemoCommentListView.tsx
@@ -6,9 +6,10 @@ import { useNearViewport } from "@/hooks/useNearViewport";
import { useUsersByNames } from "@/hooks/useUserQueries";
import { extractMemoIdFromName } from "@/lib/resource-names";
import { useMemoViewContext, useMemoViewDerived } from "../MemoViewContext";
+import { createMemoNavigationState } from "../navigation";
const MemoCommentListView: React.FC = () => {
- const { memo } = useMemoViewContext();
+ const { memo, parentPage, parentScope } = useMemoViewContext();
const { isInMemoDetailPage, commentAmount } = useMemoViewDerived();
const { ref: viewportRef, isNearViewport } = useNearViewport();
@@ -30,6 +31,7 @@ const MemoCommentListView: React.FC = () => {
Comments{commentAmount > 1 ? ` (${commentAmount})` : ""}
View all
@@ -43,6 +45,7 @@ const MemoCommentListView: React.FC = () => {
diff --git a/web/src/components/MemoView/components/MemoHeader.tsx b/web/src/components/MemoView/components/MemoHeader.tsx
index beceeb4b..6e37845b 100644
--- a/web/src/components/MemoView/components/MemoHeader.tsx
+++ b/web/src/components/MemoView/components/MemoHeader.tsx
@@ -17,21 +17,23 @@ import UserAvatar from "../../UserAvatar";
import VisibilityIcon from "../../VisibilityIcon";
import { useMemoActions } from "../hooks";
import { useMemoViewContext, useMemoViewDerived } from "../MemoViewContext";
+import { createMemoNavigationState } from "../navigation";
import type { MemoHeaderProps } from "../types";
+import MemoSpaceBadge from "./MemoSpaceBadge";
-const MemoHeader: React.FC = ({ showCreator, showVisibility, showPinned }) => {
+const MemoHeader: React.FC = ({ showCreator, showVisibility, showPinned, showSpace }) => {
const t = useTranslate();
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
- const { memo, creator, currentUser, parentPage, isArchived, readonly, openEditor } = useMemoViewContext();
+ const { memo, creator, currentUser, parentPage, parentScope, isArchived, readonly, openEditor } = useMemoViewContext();
const { createTime, updateTime, displayTime: memoDisplayTime, isDisplayingUpdatedTime, relativeTimeFormat } = useMemoViewDerived();
const { newMemoName } = useNewMemo();
const visibilityOption = getVisibilityOption(memo.visibility);
const navigateTo = useNavigateTo();
const handleGotoMemoDetailPage = useCallback(() => {
- navigateTo(`/${memo.name}`, { state: { from: parentPage } });
- }, [memo.name, parentPage, navigateTo]);
+ navigateTo(`/${memo.name}`, { state: createMemoNavigationState(parentPage, parentScope) });
+ }, [memo.name, parentPage, parentScope, navigateTo]);
const { unpinMemo } = useMemoActions(memo);
@@ -54,14 +56,24 @@ const MemoHeader: React.FC = ({ showCreator, showVisibility, sh
? `${t("common.last-updated-at")}: ${updateTime.toLocaleString(i18n.language)}`
: undefined,
};
+ const spaceMetadata = showSpace && memo.space ? : null;
return (
-
-
+
+
{showCreator && creator ? (
-
+
) : (
-
+
+
+ {spaceMetadata}
+
)}
{memo.name === newMemoName && (
@@ -103,7 +115,7 @@ const MemoHeader: React.FC = ({ showCreator, showVisibility, sh
)}
-
+
);
@@ -113,15 +125,16 @@ interface CreatorDisplayProps {
creator: User;
displayTime: React.ReactNode;
timeTooltip: TimeTooltipContent;
+ trailingMetadata?: React.ReactNode;
onGotoDetail: () => void;
}
-const CreatorDisplay: React.FC
= ({ creator, displayTime, timeTooltip, onGotoDetail }) => (
-
+const CreatorDisplay: React.FC
= ({ creator, displayTime, timeTooltip, trailingMetadata, onGotoDetail }) => (
+
-
+
= ({ creator, displayTime, t
>
{creator.displayName || creator.username}
-
-
- {displayTime}
-
-
+
+
+
+
+ {trailingMetadata}
+
);
@@ -164,12 +181,13 @@ interface TimeDisplayProps {
const TimeDisplay: React.FC
= ({ displayTime, timeTooltip, onGotoDetail }) => (
-
{displayTime}
-
+
);
diff --git a/web/src/components/MemoView/components/MemoSpaceBadge.tsx b/web/src/components/MemoView/components/MemoSpaceBadge.tsx
new file mode 100644
index 00000000..59b6cf93
--- /dev/null
+++ b/web/src/components/MemoView/components/MemoSpaceBadge.tsx
@@ -0,0 +1,37 @@
+import { UserLockIcon } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { useSpaceContext } from "@/contexts/SpaceContext";
+import { useTranslate } from "@/utils/i18n";
+
+interface MemoSpaceBadgeProps {
+ spaceName?: string;
+}
+
+const MemoSpaceBadge = ({ spaceName }: MemoSpaceBadgeProps) => {
+ const t = useTranslate();
+ const { spaces } = useSpaceContext();
+
+ if (!spaceName) return null;
+
+ const spaceLabel = t("space.current");
+ const knownTitle = spaces.find((space) => space.name === spaceName)?.title.trim();
+ const title = knownTitle || spaceLabel;
+ const accessibleLabel = knownTitle ? `${spaceLabel}: ${knownTitle}` : spaceLabel;
+
+ return (
+
+
+
+ {knownTitle && {spaceLabel}: }
+ {title}
+
+
+ );
+};
+
+export default MemoSpaceBadge;
diff --git a/web/src/components/MemoView/navigation.ts b/web/src/components/MemoView/navigation.ts
new file mode 100644
index 00000000..ca1c1732
--- /dev/null
+++ b/web/src/components/MemoView/navigation.ts
@@ -0,0 +1,119 @@
+import { isMemoScopeRoute } from "@/lib/memo-views";
+import { ROUTES } from "@/router/routes";
+
+export type MemoOriginScope = "all" | "preserve";
+
+export interface MemoNavigationState {
+ from: string;
+ fromScope: MemoOriginScope;
+}
+
+interface ResolveMemoDetailOriginOptions {
+ memoArchived?: boolean;
+}
+
+interface ResolveMemoParentPageOptions {
+ explicitParentPage?: string;
+ explicitParentScope?: MemoOriginScope;
+ pathname: string;
+ search: string;
+ memoName: string;
+}
+
+const normalizePathname = (pathname: string): string => {
+ const normalized = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
+ return normalized.toLowerCase();
+};
+
+/** Whether the current route is any canonical or shared Memo detail page. */
+export const isMemoResourcePath = (pathname: string): boolean => {
+ const normalizedPath = normalizePathname(pathname);
+ const sharedMemoPrefix = `${ROUTES.SHARED_MEMO}/`;
+ if (normalizedPath.startsWith(sharedMemoPrefix)) {
+ const shareToken = normalizedPath.slice(sharedMemoPrefix.length);
+ return shareToken.length > 0 && !shareToken.includes("/");
+ }
+
+ const directMemoPrefix = "/memos/";
+ if (!normalizedPath.startsWith(directMemoPrefix)) return false;
+ const memoID = normalizedPath.slice(directMemoPrefix.length);
+ return memoID.length > 0 && !memoID.includes("/");
+};
+
+/** Whether a page consumes the remembered All / exact-Space collection scope. */
+export const isMemoCollectionOrigin = (page: string): boolean => {
+ const pathname = page.split(/[?#]/, 1)[0] || ROUTES.HOME;
+ return isMemoScopeRoute(pathname) || normalizePathname(pathname) === ROUTES.ATTACHMENTS;
+};
+
+export const createMemoNavigationState = (from: string, fromScope: MemoOriginScope): MemoNavigationState => ({ from, fromScope });
+
+/** Reads router state while remaining compatible with older `{ from }` links. */
+export const resolveMemoDetailOrigin = (
+ state: unknown,
+ options: ResolveMemoDetailOriginOptions = {},
+): { parentPage: string; parentScope: MemoOriginScope } => {
+ const value = state && typeof state === "object" ? (state as { from?: unknown; fromScope?: unknown }) : undefined;
+ const explicitParentPage = typeof value?.from === "string" ? value.from : undefined;
+ const hasExplicitParent = explicitParentPage !== undefined;
+ const parentPage = explicitParentPage || (options.memoArchived ? ROUTES.ARCHIVED : ROUTES.HOME);
+ const parentScope =
+ value?.fromScope === "all" || value?.fromScope === "preserve"
+ ? value.fromScope
+ : hasExplicitParent && isMemoCollectionOrigin(parentPage)
+ ? "preserve"
+ : "all";
+ return { parentPage, parentScope };
+};
+
+/** Whether the current route is the canonical detail page for this memo. */
+export const isMemoDetailPath = (pathname: string, memoName: string): boolean => {
+ const normalizedPath = normalizePathname(pathname);
+ if (normalizedPath === `/${memoName}`.toLowerCase()) return true;
+ return normalizedPath.startsWith(`${ROUTES.SHARED_MEMO}/`) && isMemoResourcePath(normalizedPath);
+};
+
+/**
+ * Captures the list page that opened a memo so canonical detail routes can
+ * return to the real collection lens instead of assuming Home.
+ */
+export const resolveMemoOrigin = ({
+ explicitParentPage,
+ explicitParentScope,
+ pathname,
+ search,
+ memoName,
+}: ResolveMemoParentPageOptions): { parentPage: string; parentScope: MemoOriginScope } => {
+ if (explicitParentPage !== undefined) {
+ const parentPage = explicitParentPage || ROUTES.HOME;
+ return {
+ parentPage,
+ parentScope: explicitParentScope ?? (isMemoCollectionOrigin(parentPage) ? "preserve" : "all"),
+ };
+ }
+
+ if (isMemoDetailPath(pathname, memoName)) {
+ return { parentPage: ROUTES.HOME, parentScope: "all" };
+ }
+
+ return {
+ parentPage: `${pathname}${search}`,
+ parentScope: isMemoCollectionOrigin(pathname) ? "preserve" : "all",
+ };
+};
+
+export const resolveMemoParentPage = (options: ResolveMemoParentPageOptions): string => resolveMemoOrigin(options).parentPage;
+
+/** Replaces only the memo filter while preserving the rest of the origin query. */
+export const withMemoFilter = (page: string, filter: string): string => {
+ const [pathAndSearch] = page.split("#", 1);
+ const questionMark = pathAndSearch.indexOf("?");
+ const pathname = questionMark === -1 ? pathAndSearch : pathAndSearch.slice(0, questionMark);
+ const search = questionMark === -1 ? "" : pathAndSearch.slice(questionMark + 1);
+ const searchParams = new URLSearchParams(search);
+ if (/^\/u\//i.test(pathname) && searchParams.get("view") === "map") {
+ searchParams.delete("view");
+ }
+ searchParams.set("filter", filter);
+ return `${pathname || ROUTES.HOME}?${searchParams.toString()}`;
+};
diff --git a/web/src/components/MemoView/types.ts b/web/src/components/MemoView/types.ts
index b99ca73c..cd9d52ec 100644
--- a/web/src/components/MemoView/types.ts
+++ b/web/src/components/MemoView/types.ts
@@ -1,4 +1,5 @@
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
+import type { MemoOriginScope } from "./navigation";
export interface MemoViewProps {
memo: Memo;
@@ -6,8 +7,10 @@ export interface MemoViewProps {
showCreator?: boolean;
showVisibility?: boolean;
showPinned?: boolean;
+ showSpace?: boolean;
className?: string;
parentPage?: string;
+ parentScope?: MemoOriginScope;
shareImageDialogOpen?: boolean;
onShareImageDialogOpenChange?: (open: boolean) => void;
}
@@ -16,6 +19,7 @@ export interface MemoHeaderProps {
showCreator?: boolean;
showVisibility?: boolean;
showPinned?: boolean;
+ showSpace?: boolean;
}
export interface MemoBodyProps {
diff --git a/web/src/components/UserMemoMap/UserMemoMap.tsx b/web/src/components/UserMemoMap/UserMemoMap.tsx
index 80bebee6..4762e3b9 100644
--- a/web/src/components/UserMemoMap/UserMemoMap.tsx
+++ b/web/src/components/UserMemoMap/UserMemoMap.tsx
@@ -6,7 +6,9 @@ import { ArrowUpRightIcon, MapPinIcon } from "lucide-react";
import { useEffect, useMemo } from "react";
import { MapContainer, Marker, Popup, useMap } from "react-leaflet";
import MarkerClusterGroup from "react-leaflet-cluster";
-import { Link } from "react-router-dom";
+import { Link, useLocation } from "react-router-dom";
+import MemoSpaceBadge from "@/components/MemoView/components/MemoSpaceBadge";
+import { createMemoNavigationState } from "@/components/MemoView/navigation";
import { defaultMarkerIcon, ThemedTileLayer } from "@/components/map/map-utils";
import { useInfiniteMemos } from "@/hooks/useMemoQueries";
import { buildMemoCreatorFilter } from "@/lib/resource-names";
@@ -48,6 +50,8 @@ const MapFitBounds = ({ memos }: { memos: Memo[] }) => {
};
const UserMemoMap = ({ creator, className }: Props) => {
+ const location = useLocation();
+ const parentPage = `${location.pathname}${location.search}`;
const creatorFilter = useMemo(() => buildMemoCreatorFilter(creator), [creator]);
const { data, isLoading } = useInfiniteMemos(
@@ -130,9 +134,12 @@ const UserMemoMap = ({ creator, className }: Props) => {
-
- Memo
-
+
+
+ Memo
+
+
+
{memo.createTime &&
timestampDate(memo.createTime).toLocaleDateString(undefined, {
@@ -144,6 +151,7 @@ const UserMemoMap = ({ creator, className }: Props) => {
Open
diff --git a/web/src/contexts/AppSidebarContext.tsx b/web/src/contexts/AppSidebarContext.tsx
index 339afeb6..c62d7d3b 100644
--- a/web/src/contexts/AppSidebarContext.tsx
+++ b/web/src/contexts/AppSidebarContext.tsx
@@ -1,5 +1,6 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
+import type { MemoOriginScope } from "@/components/MemoView/navigation";
import type { MemoScope } from "@/lib/memo-views";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
@@ -9,6 +10,7 @@ export type InboxFilter = "all" | "unread" | "archived";
export interface MemoDetailSidebarDescriptor {
memo: Memo;
from?: string;
+ fromScope?: MemoOriginScope;
readonly?: boolean;
onShareImageOpen?: () => void;
}
diff --git a/web/src/contexts/GlobalMemoEditorContext.tsx b/web/src/contexts/GlobalMemoEditorContext.tsx
index 9c089213..74757fa9 100644
--- a/web/src/contexts/GlobalMemoEditorContext.tsx
+++ b/web/src/contexts/GlobalMemoEditorContext.tsx
@@ -1,5 +1,7 @@
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { type ComponentType, createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
+import { useLocation } from "react-router-dom";
+import { getRouteActionPolicy } from "@/components/AppSidebar/routes";
import { loadMemoEditor } from "@/components/MemoEditor/loader";
import type { MemoEditorProps } from "@/components/MemoEditor/types";
import { VisuallyHidden } from "@/components/ui/visually-hidden";
@@ -36,10 +38,13 @@ const findVisibleFocusTarget = (selector: string): HTMLElement | null =>
export function GlobalMemoEditorProvider({ children }: { children: ReactNode }) {
const t = useTranslate();
+ const location = useLocation();
const currentUserName = useCurrentUser()?.name;
const { selectedSpaceName } = useSpaceContext();
const { isUserSettingsInitialized } = useAuth();
const { setMobileOpen, setQuickFindOpen } = useAppSidebar();
+ const routePolicy = getRouteActionPolicy(location.pathname);
+ const composeSpace = routePolicy.composePlacement === "remembered-space" ? selectedSpaceName : undefined;
// One snapshot taken when the composer opens: keyed by the user who opened it, so
// signing out closes the composer in the same render and a different user signing
// in cannot resurrect it, and pinned to the Space that was selected at that moment.
@@ -83,10 +88,10 @@ export function GlobalMemoEditorProvider({ children }: { children: ReactNode })
.then(({ default: MemoEditor }) => {
if (openRequestVersionRef.current !== requestVersion) return;
setEditorComponent(() => MemoEditor);
- setOpened({ user: currentUserName, space: selectedSpaceName });
+ setOpened({ user: currentUserName, space: composeSpace });
})
.catch(() => undefined);
- }, [canOpen, currentUserName, selectedSpaceName, setMobileOpen, setQuickFindOpen]);
+ }, [canOpen, composeSpace, currentUserName, setMobileOpen, setQuickFindOpen]);
useEffect(() => {
// RootLayout remains mounted when a public instance moves from Home to
diff --git a/web/src/contexts/SpaceContext.tsx b/web/src/contexts/SpaceContext.tsx
index c083badd..615731a6 100644
--- a/web/src/contexts/SpaceContext.tsx
+++ b/web/src/contexts/SpaceContext.tsx
@@ -1,12 +1,14 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
-import { useNavigate } from "react-router-dom";
+import { useLocation, useNavigate } from "react-router-dom";
+import { routeSupportsCollectionScope } from "@/components/AppSidebar/routes";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useSpaces } from "@/hooks/useSpaceQueries";
-import { buildSpaceFilter } from "@/lib/cel-filter";
+import { buildCollectionScopeFilter, type CollectionScope } from "@/lib/cel-filter";
import { ROUTES } from "@/router/routes";
import type { Space } from "@/types/proto/api/v1/space_service_pb";
const SELECTED_SPACE_STORAGE_PREFIX = "memos-selected-space:";
+const ALL_COLLECTION_SCOPE: CollectionScope = { kind: "all" };
export const getSelectedSpaceStorageKey = (userName: string) => `${SELECTED_SPACE_STORAGE_PREFIX}${userName}`;
@@ -35,9 +37,12 @@ interface SpaceContextValue {
spaces: Space[];
selectedSpace?: Space;
selectedSpaceName?: string;
+ collectionScope: CollectionScope;
memoFilter?: string;
isLoadingSpaces: boolean;
isSpacesError: boolean;
+ /** Selects All without changing the current route. */
+ clearSelectedSpace: () => void;
selectSpace: (space: Space) => void;
selectMemos: () => void;
}
@@ -49,12 +54,13 @@ const SpaceContext = createContext
(null);
const NO_SPACES: Space[] = [];
function UserSpaceSession({ userName, children }: { userName: string; children: ReactNode }) {
- // Switching context lands on that context's feed, so the switcher doubles as the
- // brand slot's way home. Held in a ref because `navigate` changes identity on every
- // route change, which would otherwise rebuild the context value โ and re-render the
- // global composer and the editor tree it hosts โ on each navigation.
+ // Keep router values in refs so switching scope does not make the context callbacks
+ // change identity whenever the user navigates.
+ const location = useLocation();
const navigate = useNavigate();
+ const pathnameRef = useRef(location.pathname);
const navigateRef = useRef(navigate);
+ pathnameRef.current = location.pathname;
navigateRef.current = navigate;
const [selectedSpaceName, setSelectedSpaceName] = useState(() => readSelectedSpaceName(userName));
@@ -63,6 +69,10 @@ function UserSpaceSession({ userName, children }: { userName: string; children:
const spaces = spacesQuery.data ?? NO_SPACES;
const listedSelectedSpace = spaces.find((space) => space.name === selectedSpaceName);
const selectedSpace = listedSelectedSpace ?? (optimisticSpace?.name === selectedSpaceName ? optimisticSpace : undefined);
+ const collectionScope = useMemo(
+ () => (selectedSpaceName ? { kind: "space", name: selectedSpaceName } : ALL_COLLECTION_SCOPE),
+ [selectedSpaceName],
+ );
useEffect(() => {
if (listedSelectedSpace && optimisticSpace?.name === listedSelectedSpace.name) {
@@ -79,35 +89,59 @@ function UserSpaceSession({ userName, children }: { userName: string; children:
setSelectedSpaceName(undefined);
}, [selectedSpace, selectedSpaceName, spacesQuery.isSuccess, userName]);
+ const navigateAfterScopeChange = useCallback(() => {
+ // Scope and collection lens are independent. Preserve the active lens when
+ // switching All/Space; global and resource routes fall back to My memos.
+ if (!routeSupportsCollectionScope(pathnameRef.current)) {
+ navigateRef.current(ROUTES.HOME);
+ }
+ }, []);
+
const selectSpace = useCallback(
(space: Space) => {
writeSelectedSpaceName(userName, space.name);
setOptimisticSpace(space);
setSelectedSpaceName(space.name);
- navigateRef.current(ROUTES.HOME);
+ navigateAfterScopeChange();
},
- [userName],
+ [navigateAfterScopeChange, userName],
);
- const selectMemos = useCallback(() => {
+ const clearSelectedSpace = useCallback(() => {
writeSelectedSpaceName(userName, undefined);
setOptimisticSpace(undefined);
setSelectedSpaceName(undefined);
- navigateRef.current(ROUTES.HOME);
}, [userName]);
+ const selectMemos = useCallback(() => {
+ clearSelectedSpace();
+ navigateAfterScopeChange();
+ }, [clearSelectedSpace, navigateAfterScopeChange]);
+
const value = useMemo(
() => ({
spaces,
selectedSpace,
selectedSpaceName,
- memoFilter: buildSpaceFilter(selectedSpaceName),
+ collectionScope,
+ memoFilter: buildCollectionScopeFilter(collectionScope),
isLoadingSpaces: spacesQuery.isPending,
isSpacesError: spacesQuery.isError,
+ clearSelectedSpace,
selectSpace,
selectMemos,
}),
- [selectMemos, selectSpace, selectedSpace, selectedSpaceName, spaces, spacesQuery.isError, spacesQuery.isPending],
+ [
+ clearSelectedSpace,
+ collectionScope,
+ selectMemos,
+ selectSpace,
+ selectedSpace,
+ selectedSpaceName,
+ spaces,
+ spacesQuery.isError,
+ spacesQuery.isPending,
+ ],
);
return {children};
@@ -115,8 +149,10 @@ function UserSpaceSession({ userName, children }: { userName: string; children:
const anonymousValue: SpaceContextValue = {
spaces: [],
+ collectionScope: ALL_COLLECTION_SCOPE,
isLoadingSpaces: false,
isSpacesError: false,
+ clearSelectedSpace: () => undefined,
selectSpace: () => undefined,
selectMemos: () => undefined,
};
diff --git a/web/src/hooks/useFilteredMemoStats.ts b/web/src/hooks/useFilteredMemoStats.ts
index ecfcbe8f..7e5c49ec 100644
--- a/web/src/hooks/useFilteredMemoStats.ts
+++ b/web/src/hooks/useFilteredMemoStats.ts
@@ -24,7 +24,6 @@ export interface UseFilteredMemoStatsOptions {
context?: MemoStatsContext;
enabled?: boolean;
filter?: string;
- includeSpaceVisibility?: boolean;
}
const toDateString = (date: Date) => dayjs(date).format("YYYY-MM-DD");
@@ -41,7 +40,7 @@ const timestampsForBasis = (stats: UserStats, basis: MemoTimeBasis) => {
};
export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}): FilteredMemoStats => {
- const { userName, context, enabled = true, filter, includeSpaceVisibility = false } = options;
+ const { userName, context, enabled = true, filter } = options;
const currentUser = useCurrentUser();
const { timeBasis } = useView();
@@ -50,12 +49,7 @@ export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}):
// explore/archived: fetch backend grouped stats and aggregate them locally.
// ListAllUserStats AND's the request filter with the server's auth filter, so
// private memos are not included unless explicitly visible to the current user.
- const exploreVisibilityFilter =
- currentUser != null
- ? includeSpaceVisibility
- ? 'visibility in ["PUBLIC", "PROTECTED", "SPACE"]'
- : 'visibility in ["PUBLIC", "PROTECTED"]'
- : 'visibility in ["PUBLIC"]';
+ const exploreVisibilityFilter = currentUser != null ? 'visibility in ["PUBLIC", "PROTECTED", "SPACE"]' : 'visibility in ["PUBLIC"]';
const allUserStatsRequest =
context === "explore"
? { state: State.NORMAL, filter: combineCELFilters(filter, exploreVisibilityFilter) }
diff --git a/web/src/lib/cel-filter.ts b/web/src/lib/cel-filter.ts
index 553e9ab3..d0f63ff6 100644
--- a/web/src/lib/cel-filter.ts
+++ b/web/src/lib/cel-filter.ts
@@ -3,4 +3,7 @@ export const combineCELFilters = (...filters: Array): string
return conditions.length === 0 ? undefined : conditions.map((filter) => `(${filter})`).join(" && ");
};
-export const buildSpaceFilter = (spaceName?: string): string => (spaceName ? `space == ${JSON.stringify(spaceName)}` : "space == null");
+export type CollectionScope = { kind: "all" } | { kind: "space"; name: string };
+
+export const buildCollectionScopeFilter = (scope: CollectionScope): string | undefined =>
+ scope.kind === "space" ? `space == ${JSON.stringify(scope.name)}` : undefined;
diff --git a/web/src/lib/memo-views.ts b/web/src/lib/memo-views.ts
index 9de48a89..742ab4bb 100644
--- a/web/src/lib/memo-views.ts
+++ b/web/src/lib/memo-views.ts
@@ -10,8 +10,15 @@ export const getMemoViewId = (name: string): string => {
return parts.length === 4 ? parts[3] : name;
};
-export const isMemoScopeRoute = (pathname: string): boolean =>
- pathname === ROUTES.HOME || pathname === ROUTES.EXPLORE || pathname === ROUTES.ARCHIVED;
+const cleanPathname = (value: string): string => {
+ const pathname = value.split(/[?#]/, 1)[0] || ROUTES.HOME;
+ return pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
+};
+
+export const isMemoScopeRoute = (pathname: string): boolean => {
+ const comparablePath = cleanPathname(pathname).toLowerCase();
+ return comparablePath === ROUTES.HOME || comparablePath === ROUTES.EXPLORE || comparablePath === ROUTES.ARCHIVED;
+};
export const getMemoScopePath = (scope: MemoScope): string => {
if (scope === "explore") return ROUTES.EXPLORE;
@@ -19,8 +26,6 @@ export const getMemoScopePath = (scope: MemoScope): string => {
return ROUTES.HOME;
};
-const cleanPathname = (value: string): string => value.split(/[?#]/, 1)[0] || ROUTES.HOME;
-
interface ResolveMemoScopeOptions {
currentUsername?: string;
detailFrom?: string;
@@ -30,23 +35,24 @@ interface ResolveMemoScopeOptions {
export const resolveMemoScope = (pathname: string, options: ResolveMemoScopeOptions = {}): MemoScope => {
const cleanPath = cleanPathname(pathname);
- if (cleanPath === ROUTES.EXPLORE) return "explore";
- if (cleanPath === ROUTES.ARCHIVED) return "archived";
- if (cleanPath === ROUTES.HOME) return "home";
+ const comparablePath = cleanPath.toLowerCase();
+ if (comparablePath === ROUTES.EXPLORE) return "explore";
+ if (comparablePath === ROUTES.ARCHIVED) return "archived";
+ if (comparablePath === ROUTES.HOME) return "home";
- const profileMatch = cleanPath.match(/^\/u\/([^/]+)$/);
+ const profileMatch = cleanPath.match(/^\/u\/([^/]+)$/i);
if (profileMatch) {
return options.currentUsername && decodeURIComponent(profileMatch[1]) === options.currentUsername ? "home" : "explore";
}
- if (cleanPath.startsWith("/memos/") && options.detailFrom) {
+ if (comparablePath.startsWith("/memos/") && options.detailFrom) {
return resolveMemoScope(options.detailFrom, {
currentUsername: options.currentUsername,
fallback: options.fallback,
});
}
- if (cleanPath.startsWith("/memos/") && options.memoArchived) {
+ if (comparablePath.startsWith("/memos/") && options.memoArchived) {
return "archived";
}
diff --git a/web/src/pages/Archived.tsx b/web/src/pages/Archived.tsx
index ba3b945b..014aa52c 100644
--- a/web/src/pages/Archived.tsx
+++ b/web/src/pages/Archived.tsx
@@ -8,7 +8,7 @@ import { Memo } from "@/types/proto/api/v1/memo_service_pb";
const Archived = () => {
const user = useCurrentUser();
- const { memoFilter: contextFilter } = useSpaceContext();
+ const { memoFilter: contextFilter, selectedSpaceName } = useSpaceContext();
const memoFilter = useMemoFilters({
creatorName: user?.name,
@@ -24,7 +24,9 @@ const Archived = () => {
return (
}
+ renderer={(memo: Memo, { compact }) => (
+
+ )}
listSort={listSort}
state={State.ARCHIVED}
orderBy={orderBy}
diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx
index ebd5e687..c6a30251 100644
--- a/web/src/pages/Explore.tsx
+++ b/web/src/pages/Explore.tsx
@@ -11,12 +11,10 @@ const Explore = () => {
const { memoFilter: contextFilter, selectedSpaceName } = useSpaceContext();
// Determine visibility filter based on authentication status
- // - Logged-in users: Can see PUBLIC and PROTECTED memos, plus SPACE memos while a Space is selected
+ // - Logged-in users: Can see every audience the backend authorizes, including SPACE memos
// - Visitors: Can only see PUBLIC memos
// Note: The backend is responsible for filtering stats based on visibility permissions.
- const visibilities = currentUser
- ? [Visibility.PUBLIC, Visibility.PROTECTED, ...(selectedSpaceName ? [Visibility.SPACE] : [])]
- : [Visibility.PUBLIC];
+ const visibilities = currentUser ? [Visibility.PUBLIC, Visibility.PROTECTED, Visibility.SPACE] : [Visibility.PUBLIC];
const memoFilter = useMemoFilters({
includeMemoViews: true,
@@ -32,7 +30,9 @@ const Explore = () => {
return (
}
+ renderer={(memo: Memo, { compact }) => (
+
+ )}
listSort={listSort}
orderBy={orderBy}
filter={memoFilter}
diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx
index 919172a5..5e236829 100644
--- a/web/src/pages/Home.tsx
+++ b/web/src/pages/Home.tsx
@@ -41,7 +41,7 @@ const Home = () => {
(
-
+
)}
listSort={listSort}
orderBy={orderBy}
diff --git a/web/src/pages/MemoDetail.tsx b/web/src/pages/MemoDetail.tsx
index 70c9f232..0e949ee9 100644
--- a/web/src/pages/MemoDetail.tsx
+++ b/web/src/pages/MemoDetail.tsx
@@ -5,6 +5,7 @@ import { Link, Navigate, useLocation, useParams } from "react-router-dom";
import MemoCommentSection from "@/components/MemoCommentSection";
import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext";
import MemoView from "@/components/MemoView";
+import { createMemoNavigationState, type MemoOriginScope, resolveMemoDetailOrigin } from "@/components/MemoView/navigation";
import { useAppSidebar } from "@/contexts/AppSidebarContext";
import { useAuth } from "@/contexts/AuthContext";
import { useInstance } from "@/contexts/InstanceContext";
@@ -13,25 +14,28 @@ import { useInfiniteMemoComments, useMemo } from "@/hooks/useMemoQueries";
import { useSharedMemo, withShareAttachmentLinks } from "@/hooks/useMemoShareQueries";
import { memoNamePrefix } from "@/lib/resource-names";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
+import { State } from "@/types/proto/api/v1/common_pb";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { findMemoAnchorTarget } from "@/utils/markdown-manipulation";
const MemoSidebarRegistration = ({
memo,
from,
+ fromScope,
readonly,
onShareImageOpen,
}: {
memo: Memo;
- from?: string;
+ from: string;
+ fromScope: MemoOriginScope;
readonly: boolean;
onShareImageOpen: () => void;
}) => {
const { setMemoDetail } = useAppSidebar();
useEffect(() => {
- setMemoDetail({ memo, from, readonly, onShareImageOpen });
- }, [from, memo, onShareImageOpen, readonly, setMemoDetail]);
+ setMemoDetail({ memo, from, fromScope, readonly, onShareImageOpen });
+ }, [from, fromScope, memo, onShareImageOpen, readonly, setMemoDetail]);
useEffect(() => () => setMemoDetail(undefined), [setMemoDetail]);
@@ -45,7 +49,6 @@ const MemoDetail = () => {
const params = useParams();
const location = useLocation();
const { state: locationState, hash } = location;
- const parentPage = typeof locationState?.from === "string" ? locationState.from : undefined;
const handleShareImageOpen = useCallback(() => setShareImageDialogOpen(true), []);
// Detect share mode from the route parameter.
@@ -64,6 +67,7 @@ const MemoDetail = () => {
const memo = isShareMode ? memoFromShare : memoFromDirect;
const error = isShareMode ? shareError : directError;
const isLoading = isShareMode ? shareLoading : directLoading;
+ const { parentPage, parentScope } = resolveMemoDetailOrigin(locationState, { memoArchived: memo?.state === State.ARCHIVED });
const memoName = memo?.name ?? memoNameFromParams;
const displayMemo = useReactMemo(() => {
if (!memo) return undefined;
@@ -121,7 +125,13 @@ const MemoDetail = () => {
return (
-
+
{!isShareMode && parentMemo && (
@@ -129,7 +139,7 @@ const MemoDetail = () => {
@@ -142,10 +152,12 @@ const MemoDetail = () => {
memo={displayMemo}
compact={false}
parentPage={parentPage}
+ parentScope={parentScope}
shareImageDialogOpen={shareImageDialogOpen}
showCreator
showVisibility
showPinned
+ showSpace
onShareImageDialogOpenChange={setShareImageDialogOpen}
/>
{!isShareMode && (
@@ -153,6 +165,7 @@ const MemoDetail = () => {
memo={displayMemo}
comments={comments}
parentPage={parentPage}
+ parentScope={parentScope}
hasMoreComments={hasNextComments}
isFetchingMoreComments={isFetchingNextComments}
onLoadMoreComments={fetchNextComments}
diff --git a/web/src/pages/UserProfile.tsx b/web/src/pages/UserProfile.tsx
index 57e55d92..ab46e2d6 100644
--- a/web/src/pages/UserProfile.tsx
+++ b/web/src/pages/UserProfile.tsx
@@ -87,7 +87,7 @@ const UserProfile = () => {
{activeTab === "memos" ? (
(
-
+
)}
listSort={listSort}
orderBy={orderBy}
diff --git a/web/src/utils/memo.ts b/web/src/utils/memo.ts
index 84436d9d..375890f2 100644
--- a/web/src/utils/memo.ts
+++ b/web/src/utils/memo.ts
@@ -1,4 +1,4 @@
-import { BoxesIcon, Globe2Icon, LockIcon, type LucideIcon, UsersIcon } from "lucide-react";
+import { Globe2Icon, LockIcon, type LucideIcon, UserLockIcon, UsersIcon } from "lucide-react";
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
export interface VisibilityOption {
@@ -35,7 +35,7 @@ export const VISIBILITY_OPTIONS: readonly VisibilityOption[] = [
name: "SPACE",
labelKey: "memo.visibility.space",
descriptionKey: "memo.visibility.space-description",
- icon: BoxesIcon,
+ icon: UserLockIcon,
requiresSpace: true,
},
{
@@ -60,12 +60,12 @@ export const getVisibilityOption = (visibility: Visibility): VisibilityOption |
VISIBILITY_OPTIONS.find((option) => option.value === visibility);
/**
- * Audiences a memo can be set to. SPACE is offered while a Space is selected, and also
- * for a memo that already uses it โ so opening one outside its Space shows its real
- * audience instead of a blank control that quietly downgrades on the next pick.
+ * Audiences a memo can be set to. SPACE is offered when the memo is placed in a
+ * Space, and also when a legacy/inconsistent memo already uses it so the control
+ * can still name its current audience instead of silently downgrading it.
*/
-export const getAssignableVisibilityOptions = (options: { spaceSelected: boolean; current?: Visibility }): VisibilityOption[] =>
- VISIBILITY_OPTIONS.filter((option) => !option.requiresSpace || options.spaceSelected || option.value === options.current);
+export const getAssignableVisibilityOptions = (options: { hasSpacePlacement: boolean; current?: Visibility }): VisibilityOption[] =>
+ VISIBILITY_OPTIONS.filter((option) => !option.requiresSpace || options.hasSpacePlacement || option.value === options.current);
/** Audiences offered as a persistent default. A Space-scoped default has no meaning outside a Space. */
export const DEFAULT_VISIBILITY_OPTIONS: readonly VisibilityOption[] = VISIBILITY_OPTIONS.filter((option) => !option.requiresSpace);
diff --git a/web/tests/app-sidebar-logo.test.tsx b/web/tests/app-sidebar-logo.test.tsx
index 30f7bd4c..250c0253 100644
--- a/web/tests/app-sidebar-logo.test.tsx
+++ b/web/tests/app-sidebar-logo.test.tsx
@@ -8,6 +8,7 @@ import { SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, SIDEBAR_SECTION_ACTION_ICON_CLAS
const authState = vi.hoisted(() => ({
currentUser: { name: "users/test" } as { name: string } | undefined,
memoViews: [] as Array<{ name: string; title: string }>,
+ notifications: [] as Array<{ status: number }>,
}));
const sidebarState = vi.hoisted(() => ({
memoScope: "home" as "home" | "explore" | "archived",
@@ -21,11 +22,13 @@ const spaceState = vi.hoisted(() => ({
spaces: [] as Array<{ name: string; title: string; description: string }>,
selectedSpace: undefined as { name: string; title: string; description: string } | undefined,
selectedSpaceName: undefined as string | undefined,
- memoFilter: "space == null" as string | undefined,
+ memoFilter: undefined as string | undefined,
+ clearSelectedSpace: vi.fn(),
selectMemos: vi.fn(),
selectSpace: vi.fn(),
}));
const filteredStatsHook = vi.hoisted(() => vi.fn());
+const tagsSectionHook = vi.hoisted(() => vi.fn());
vi.mock("@/components/MemosLogo", () => ({
default: () => Memos logo,
@@ -48,7 +51,10 @@ vi.mock("@/components/StatisticsView", () => ({
}));
vi.mock("@/components/AppSidebar/TagsSection", () => ({
- default: () => Tags
,
+ default: (props: unknown) => {
+ tagsSectionHook(props);
+ return Tags
;
+ },
}));
vi.mock("@/contexts/AppSidebarContext", () => ({
@@ -121,7 +127,7 @@ vi.mock("@/hooks/useUserQueries", () => ({
memoViews: (parent?: string) => ["users", "memoViews", parent],
},
useMemoViews: () => ({ data: authState.memoViews }),
- useNotifications: () => ({ data: [] }),
+ useNotifications: () => ({ data: authState.notifications }),
useUser: () => ({ data: undefined }),
}));
@@ -155,6 +161,7 @@ describe("App sidebar logo", () => {
beforeEach(() => {
authState.currentUser = { name: "users/test" };
authState.memoViews = [];
+ authState.notifications = [];
sidebarState.memoScope = "home";
sidebarState.mobileOpen = false;
globalEditorState.canOpen = true;
@@ -162,10 +169,12 @@ describe("App sidebar logo", () => {
spaceState.spaces = [];
spaceState.selectedSpace = undefined;
spaceState.selectedSpaceName = undefined;
- spaceState.memoFilter = "space == null";
+ spaceState.memoFilter = undefined;
+ spaceState.clearSelectedSpace.mockClear();
spaceState.selectMemos.mockClear();
spaceState.selectSpace.mockClear();
filteredStatsHook.mockClear();
+ tagsSectionHook.mockClear();
});
it("shows the context switcher and opens the global memo editor", () => {
@@ -181,6 +190,55 @@ describe("App sidebar logo", () => {
expect(screen.queryByText("common.calendar")).not.toBeInTheDocument();
});
+ it.each([
+ "/",
+ "/explore",
+ "/archived",
+ "/attachments",
+ "/Explore/",
+ "/Attachments/",
+ ])("shows the selected Space only on collection route %s", (path) => {
+ const product = { name: "spaces/product", title: "Product", description: "" };
+ spaceState.spaces = [product];
+ spaceState.selectedSpace = product;
+ spaceState.selectedSpaceName = product.name;
+ spaceState.memoFilter = 'space == "spaces/product"';
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole("button", { name: "space.switch: Product" })).toBeInTheDocument();
+ });
+
+ it.each([
+ "/inbox",
+ "/u/alice",
+ "/setting",
+ "/about",
+ "/views",
+ "/memos/123",
+ "/memos/shares/token",
+ "/404",
+ ])("uses the instance brand instead of the remembered Space on %s", (path) => {
+ const product = { name: "spaces/product", title: "Product", description: "" };
+ spaceState.spaces = [product];
+ spaceState.selectedSpace = product;
+ spaceState.selectedSpaceName = product.name;
+ spaceState.memoFilter = 'space == "spaces/product"';
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole("link", { name: "Memos logo" })).toHaveAttribute("href", "/");
+ expect(screen.queryByRole("button", { name: /^space\.switch:/ })).not.toBeInTheDocument();
+ });
+
it("scopes collection statistics to the selected Space", () => {
spaceState.selectedSpaceName = "spaces/product";
spaceState.memoFilter = 'space == "spaces/product"';
@@ -191,9 +249,61 @@ describe("App sidebar logo", () => {
,
);
- expect(filteredStatsHook).toHaveBeenCalledWith(
- expect.objectContaining({ context: "explore", filter: 'space == "spaces/product"', includeSpaceVisibility: true }),
+ expect(filteredStatsHook).toHaveBeenCalledWith(expect.objectContaining({ context: "explore", filter: 'space == "spaces/product"' }));
+ });
+
+ it("includes authorized Space memos in All Explore statistics", () => {
+ render(
+
+
+ ,
);
+
+ expect(filteredStatsHook).toHaveBeenCalledWith(expect.objectContaining({ context: "explore", filter: undefined }));
+ });
+
+ it("keeps Profile statistics and tag UI state independent of the remembered Space", () => {
+ spaceState.selectedSpaceName = "spaces/product";
+ spaceState.memoFilter = 'space == "spaces/product"';
+
+ render(
+
+
+ ,
+ );
+
+ expect(filteredStatsHook).toHaveBeenCalledWith(expect.objectContaining({ context: "profile", filter: undefined }));
+ expect(tagsSectionHook).toHaveBeenCalledWith(expect.objectContaining({ scope: "profile" }));
+ });
+
+ it("keeps Inbox compact while exposing its unread state accessibly", () => {
+ authState.notifications = [{ status: 1 }, { status: 1 }, { status: 2 }];
+
+ render(
+
+
+ ,
+ );
+
+ const inbox = screen.getByRole("link", { name: "common.inbox, 2 inbox.unread" });
+ expect(inbox).toHaveAttribute("aria-current", "page");
+ expect(inbox).not.toHaveTextContent("common.inbox");
+ expect(inbox).not.toHaveTextContent("2");
+ expect(inbox.querySelector("[data-inbox-unread-indicator]")).not.toBeNull();
+ expect(inbox.closest("footer")).not.toBeNull();
+ });
+
+ it.each([
+ ["/Attachments/", "common.attachments"],
+ ["/Inbox/", "common.inbox"],
+ ])("keeps %s active after route normalization", (path, label) => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole("link", { name: label })).toHaveAttribute("aria-current", "page");
});
it("hides the instance-level unused attachment collection in a Space", () => {
@@ -240,18 +350,18 @@ describe("App sidebar logo", () => {
expect(screen.queryByRole("link", { name: "common.home" })).not.toBeInTheDocument();
});
- it("falls back to the library content on a route without a specific tenant", () => {
+ it("does not inherit collection content on a route without a collection", () => {
render(
,
);
- expect(screen.getByText("Calendar")).toBeInTheDocument();
- expect(screen.getByRole("region", { name: "common.statistics" })).toBeInTheDocument();
- expect(screen.queryByRole("heading", { name: "common.statistics" })).not.toBeInTheDocument();
- expect(screen.getByText("common.views")).toBeInTheDocument();
- expect(screen.getByText("Tags")).toBeInTheDocument();
+ expect(screen.queryByText("Calendar")).not.toBeInTheDocument();
+ expect(screen.queryByRole("region", { name: "common.statistics" })).not.toBeInTheDocument();
+ expect(screen.queryByText("common.views")).not.toBeInTheDocument();
+ expect(screen.queryByText("Tags")).not.toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Memos logo" })).toHaveAttribute("href", "/");
expect(screen.getByRole("link", { name: "common.attachments" })).toHaveAttribute("href", "/attachments");
expect(screen.getByRole("link", { name: "common.inbox" })).toHaveAttribute("href", "/inbox");
expect(screen.queryByRole("link", { name: "common.home" })).not.toBeInTheDocument();
@@ -275,16 +385,16 @@ describe("App sidebar logo", () => {
expect(screen.getByRole("link", { name: "common.sign-in-to-memos" }).closest("footer")).not.toBeNull();
});
- it("marks About active for a guest on the About page", () => {
+ it.each(["/about", "/About/"])("marks About active for a guest on %s", (path) => {
authState.currentUser = undefined;
render(
-
+
,
);
expect(screen.getByRole("link", { name: "common.about" })).toHaveAttribute("aria-current", "page");
- expect(screen.getByText("Calendar")).toBeInTheDocument();
+ expect(screen.queryByText("Calendar")).not.toBeInTheDocument();
});
it("uses a compact scope menu and places views below the calendar", async () => {
@@ -336,7 +446,7 @@ describe("App sidebar logo", () => {
expect(deleteItem).toHaveAttribute("data-variant", "destructive");
});
- it("collapses inactive global destinations and defaults the scope icon to Home", async () => {
+ it("keeps collection navigation together and places Inbox in the user footer", async () => {
render(
@@ -347,7 +457,8 @@ describe("App sidebar logo", () => {
expectCollapsedNavPill(scopeTrigger, "common.home");
const inbox = screen.getByRole("link", { name: "common.inbox" });
- expectCollapsedNavPill(inbox, "common.inbox");
+ expect(inbox.closest("footer")).not.toBeNull();
+ expect(inbox.closest('nav[aria-label="Primary"]')).toBeNull();
const attachments = screen.getByRole("link", { name: "common.attachments" });
expectActiveNavPill(attachments, "common.attachments");
@@ -399,7 +510,8 @@ describe("App sidebar logo", () => {
);
expect(screen.getByRole("button", { name: "Open navigation" })).toHaveAttribute("data-mobile-navigation-trigger");
- expect(screen.getByRole("button", { name: "space.switch: common.memos" })).toHaveTextContent("Memos logo");
+ expect(screen.getByRole("link", { name: "Memos logo" })).toHaveAttribute("href", "/");
+ expect(screen.queryByRole("button", { name: /^space\.switch:/ })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "common.search" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "editor.new-memo" })).not.toBeInTheDocument();
});
diff --git a/web/tests/app-sidebar-routes.test.ts b/web/tests/app-sidebar-routes.test.ts
index 4992be6e..204f1e49 100644
--- a/web/tests/app-sidebar-routes.test.ts
+++ b/web/tests/app-sidebar-routes.test.ts
@@ -1,21 +1,106 @@
import { describe, expect, it } from "vitest";
-import { getSidebarRouteKind } from "@/components/AppSidebar/routes";
+import { getRouteActionPolicy, getSidebarRouteKind, routeSupportsCollectionScope } from "@/components/AppSidebar/routes";
describe("sidebar route content", () => {
it.each([
["/", "home"],
["/archived", "archived"],
["/explore", "explore"],
+ ["/Explore/", "explore"],
+ ["/ARCHIVED/", "archived"],
["/u/steven", "profile"],
+ ["/U/Steven/", "profile"],
["/views", "views"],
+ ["/Views/", "views"],
["/attachments", "attachments"],
+ ["/Attachments/", "attachments"],
["/inbox", "inbox"],
+ ["/Inbox/", "inbox"],
["/setting", "settings"],
+ ["/Setting/", "settings"],
["/memos/abc", "memo"],
+ ["/Memos/ABC/", "memo"],
["/memos/shares/token", "memo"],
+ ["/Memos/Shares/token/", "memo"],
["/about", "empty"],
["/404", "empty"],
])("maps %s to %s content", (path, kind) => {
expect(getSidebarRouteKind(path)).toBe(kind);
});
+
+ it.each([
+ ["/", true],
+ ["/explore", true],
+ ["/archived", true],
+ ["/attachments", true],
+ ["/Explore/", true],
+ ["/ARCHIVED/", true],
+ ["/Attachments/", true],
+ ["/u/steven", false],
+ ["/inbox", false],
+ ["/setting", false],
+ ["/views", false],
+ ["/about", false],
+ ["/memos/abc", false],
+ ["/memos/shares/token", false],
+ ["/404", false],
+ ])("reports whether %s supports the remembered collection scope", (path, expected) => {
+ expect(routeSupportsCollectionScope(path)).toBe(expected);
+ });
+
+ it.each(["/", "/explore", "/archived"])("keeps search and Compose in the remembered collection on %s", (path) => {
+ expect(getRouteActionPolicy(path)).toEqual({
+ searchScope: "remembered-collection",
+ composePlacement: "remembered-space",
+ });
+ });
+
+ it("keeps the remembered scope when Attachments sends search to Home", () => {
+ expect(getRouteActionPolicy("/attachments")).toEqual({
+ searchScope: "remembered-collection",
+ searchDestination: "/",
+ composePlacement: "remembered-space",
+ });
+ });
+
+ it("keeps Profile search on Profile but makes Compose unassigned", () => {
+ expect(getRouteActionPolicy("/u/steven")).toEqual({
+ searchScope: "profile",
+ searchDestination: "/u/steven",
+ composePlacement: "unassigned",
+ });
+ });
+
+ it("normalizes a Profile route without changing its spelling", () => {
+ expect(getRouteActionPolicy("/U/Steven/")).toEqual({
+ searchScope: "profile",
+ searchDestination: "/U/Steven",
+ composePlacement: "unassigned",
+ });
+ });
+
+ it.each(["/Explore/", "/ARCHIVED/"])("keeps normalized collection route %s in the remembered scope", (path) => {
+ expect(getRouteActionPolicy(path)).toEqual({
+ searchScope: "remembered-collection",
+ composePlacement: "remembered-space",
+ });
+ });
+
+ it.each([
+ "/inbox",
+ "/setting",
+ "/views",
+ "/about",
+ "/memos/abc",
+ "/memos/shares/token",
+ "/403",
+ "/404",
+ "/unknown",
+ ])("sends search to All and makes Compose unassigned on %s", (path) => {
+ expect(getRouteActionPolicy(path)).toEqual({
+ searchScope: "all",
+ searchDestination: "/",
+ composePlacement: "unassigned",
+ });
+ });
});
diff --git a/web/tests/attachment-library-source-link.test.tsx b/web/tests/attachment-library-source-link.test.tsx
index 5f0b75b2..c11adb03 100644
--- a/web/tests/attachment-library-source-link.test.tsx
+++ b/web/tests/attachment-library-source-link.test.tsx
@@ -1,6 +1,6 @@
import { create } from "@bufbuild/protobuf";
import { fireEvent, render, screen } from "@testing-library/react";
-import { MemoryRouter } from "react-router-dom";
+import { MemoryRouter, useLocation } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import AttachmentMediaGrid from "@/components/AttachmentLibrary/AttachmentMediaGrid";
import type { AttachmentLibraryMonthGroup } from "@/hooks/useAttachmentLibrary";
@@ -8,6 +8,11 @@ import { AttachmentSchema, MediaMetadataSchema, VideoMetadataSchema } from "@/ty
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
+const LocationStateProbe = () => {
+ const location = useLocation();
+ return ;
+};
+
describe("", () => {
it("links a media attachment to its source memo without replacing the direct file action", () => {
const attachment = create(AttachmentSchema, {
@@ -47,8 +52,9 @@ describe("", () => {
const onPreview = vi.fn();
render(
-
+
+
,
);
@@ -61,6 +67,7 @@ describe("", () => {
fireEvent.click(memoLink);
expect(onPreview).not.toHaveBeenCalled();
+ expect(screen.getByTestId("location-state")).toHaveTextContent('{"from":"/","fromScope":"preserve"}');
});
it("keeps video duration inside the existing play badge", () => {
diff --git a/web/tests/attachment-library-stats.test.ts b/web/tests/attachment-library-stats.test.ts
index 58cb5aff..73dcb10a 100644
--- a/web/tests/attachment-library-stats.test.ts
+++ b/web/tests/attachment-library-stats.test.ts
@@ -76,7 +76,21 @@ describe("buildAttachmentLibraryStats", () => {
expect(clients.listAttachments.mock.calls.map(([request]) => request.filter)).toEqual([filter, filter]);
});
- it("combines the current context with the unused attachment filter", async () => {
+ it("uses only the unused predicate in the All collection", async () => {
+ clients.listAttachments.mockResolvedValue({ attachments: [], nextPageToken: "" });
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const wrapper = ({ children }: PropsWithChildren) => createElement(QueryClientProvider, { client: queryClient }, children);
+ const { result } = renderHook(() => useUnusedAttachmentLibrary("en", undefined), { wrapper });
+
+ await waitFor(() => expect(result.current.isComplete).toBe(true));
+ expect(clients.listAttachments).toHaveBeenCalledWith(
+ expect.objectContaining({
+ filter: "(memo_id == null)",
+ }),
+ );
+ });
+
+ it("combines an explicit unassigned filter with the unused attachment predicate", async () => {
clients.listAttachments.mockResolvedValue({ attachments: [], nextPageToken: "" });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const wrapper = ({ children }: PropsWithChildren) => createElement(QueryClientProvider, { client: queryClient }, children);
diff --git a/web/tests/filtered-memo-stats.test.ts b/web/tests/filtered-memo-stats.test.ts
index b4be0e24..817df9d0 100644
--- a/web/tests/filtered-memo-stats.test.ts
+++ b/web/tests/filtered-memo-stats.test.ts
@@ -145,7 +145,7 @@ describe("useFilteredMemoStats", () => {
});
const filter = 'space == "spaces/product"';
- renderHook(() => useFilteredMemoStats({ context: "explore", filter, includeSpaceVisibility: true }), { wrapper });
+ renderHook(() => useFilteredMemoStats({ context: "explore", filter }), { wrapper });
expect(useAllUserStats).toHaveBeenCalledWith(
{
@@ -156,7 +156,26 @@ describe("useFilteredMemoStats", () => {
);
});
- it("uses the no-Space filter for Memos statistics", () => {
+ it("includes authorized Space memos in All Explore statistics", () => {
+ mockUseView.mockReturnValue({
+ timeBasis: "create_time",
+ orderByTimeAsc: false,
+ setOrderByTimeAsc: vi.fn(),
+ setTimeBasis: vi.fn(),
+ });
+
+ renderHook(() => useFilteredMemoStats({ context: "explore" }), { wrapper });
+
+ expect(useAllUserStats).toHaveBeenCalledWith(
+ {
+ state: State.NORMAL,
+ filter: '(visibility in ["PUBLIC", "PROTECTED", "SPACE"])',
+ },
+ { enabled: true },
+ );
+ });
+
+ it("supports an explicit unassigned filter", () => {
mockUseView.mockReturnValue({
timeBasis: "create_time",
orderByTimeAsc: false,
diff --git a/web/tests/global-memo-editor.test.tsx b/web/tests/global-memo-editor.test.tsx
index fbb938e1..e69b728d 100644
--- a/web/tests/global-memo-editor.test.tsx
+++ b/web/tests/global-memo-editor.test.tsx
@@ -14,6 +14,12 @@ const mocks = vi.hoisted(() => ({
setMobileOpen: vi.fn(),
setQuickFindOpen: vi.fn(),
selectedSpaceName: undefined as string | undefined,
+ pathname: "/",
+}));
+
+vi.mock("react-router-dom", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useLocation: () => ({ pathname: mocks.pathname }),
}));
vi.mock("@/components/MemoEditor/loader", () => ({
@@ -117,6 +123,7 @@ describe("GlobalMemoEditorProvider", () => {
mocks.setMobileOpen.mockClear();
mocks.setQuickFindOpen.mockClear();
mocks.selectedSpaceName = undefined;
+ mocks.pathname = "/";
});
it("opens a modal focus-mode editor, closes the sidebar surfaces, and restores focus after Escape", async () => {
@@ -276,4 +283,37 @@ describe("GlobalMemoEditorProvider", () => {
defaultSpace: "spaces/product",
});
});
+
+ it.each(["/explore", "/archived", "/attachments"])("inherits the remembered Space when composing from %s", async (pathname) => {
+ mocks.pathname = pathname;
+ mocks.selectedSpaceName = "spaces/product";
+ await openViaTrigger();
+
+ expect(mocks.editorProps).toMatchObject({
+ cacheKey: "global-memo-editor:spaces/product",
+ defaultSpace: "spaces/product",
+ });
+ });
+
+ it.each([
+ "/u/steven",
+ "/inbox",
+ "/setting",
+ "/views",
+ "/about",
+ "/memos/abc",
+ "/memos/shares/token",
+ "/403",
+ "/404",
+ "/unknown",
+ ])("creates an unassigned memo from %s even when a Space is remembered", async (pathname) => {
+ mocks.pathname = pathname;
+ mocks.selectedSpaceName = "spaces/product";
+ await openViaTrigger();
+
+ expect(mocks.editorProps).toMatchObject({
+ cacheKey: "global-memo-editor",
+ defaultSpace: undefined,
+ });
+ });
});
diff --git a/web/tests/home-loading-boundary.test.tsx b/web/tests/home-loading-boundary.test.tsx
index 5fabc97e..38f6db99 100644
--- a/web/tests/home-loading-boundary.test.tsx
+++ b/web/tests/home-loading-boundary.test.tsx
@@ -54,7 +54,7 @@ vi.mock("@/contexts/NewMemoContext", () => ({
vi.mock("@/contexts/SpaceContext", () => ({
useSpaceContext: () => ({
selectedSpaceName: state.selectedSpaceName,
- memoFilter: state.selectedSpaceName ? `space == "${state.selectedSpaceName}"` : "space == null",
+ memoFilter: state.selectedSpaceName ? `space == "${state.selectedSpaceName}"` : undefined,
}),
}));
@@ -78,7 +78,8 @@ describe("", () => {
expect(screen.getByTestId("memo-editor")).toBeInTheDocument();
expect(screen.getByTestId("memo-view")).toBeInTheDocument();
- expect(state.listProps).toMatchObject({ contextFilter: "space == null" });
+ expect(state.listProps).toMatchObject({ contextFilter: undefined });
+ expect(state.editorProps).toMatchObject({ cacheKey: "home-memo-editor", defaultSpace: undefined });
});
it("filters the feed and sets new memo placement to the selected Space", () => {
diff --git a/web/tests/memo-action-navigation.test.tsx b/web/tests/memo-action-navigation.test.tsx
new file mode 100644
index 00000000..0973e58b
--- /dev/null
+++ b/web/tests/memo-action-navigation.test.tsx
@@ -0,0 +1,115 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act, renderHook, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { MemoryRouter, useLocation } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { useMemoActionHandlers } from "@/components/MemoActionMenu/hooks";
+import type { MemoOriginScope } from "@/components/MemoView/navigation";
+import { State } from "@/types/proto/api/v1/common_pb";
+import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
+
+const mocks = vi.hoisted(() => ({
+ updateMemo: vi.fn(),
+ deleteMemo: vi.fn(),
+ clearSelectedSpace: vi.fn(),
+}));
+
+vi.mock("@/hooks/useMemoQueries", () => ({
+ memoKeys: { comments: (name: string) => ["memos", name, "comments"] },
+ useUpdateMemo: () => ({ mutateAsync: mocks.updateMemo }),
+ useDeleteMemo: () => ({ mutateAsync: mocks.deleteMemo }),
+}));
+
+vi.mock("@/hooks/useUserQueries", () => ({
+ userKeys: { stats: () => ["users", "stats"] },
+}));
+
+vi.mock("@/contexts/InstanceContext", () => ({
+ useInstance: () => ({ profile: { instanceUrl: "" } }),
+}));
+
+vi.mock("@/contexts/SpaceContext", () => ({
+ useSpaceContext: () => ({ clearSelectedSpace: mocks.clearSelectedSpace }),
+}));
+
+vi.mock("@/utils/i18n", () => ({
+ useTranslate: () => (key: string) => key,
+}));
+
+vi.mock("react-hot-toast", () => ({
+ default: { success: vi.fn(), error: vi.fn() },
+}));
+
+const createMemo = (state: State): Memo =>
+ ({
+ name: "memos/1",
+ content: "memo",
+ state,
+ parent: "",
+ }) as Memo;
+
+const renderActions = (state: State, parentScope: MemoOriginScope) => {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const wrapper = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ );
+
+ return renderHook(
+ () => {
+ const location = useLocation();
+ const handlers = useMemoActionHandlers({
+ memo: createMemo(state),
+ parentScope,
+ setDeleteDialogOpen: vi.fn(),
+ });
+ return { handlers, pathname: location.pathname };
+ },
+ { wrapper },
+ );
+};
+
+describe("Memo detail mutation navigation", () => {
+ beforeEach(() => {
+ mocks.updateMemo.mockReset().mockResolvedValue(undefined);
+ mocks.deleteMemo.mockReset().mockResolvedValue(undefined);
+ mocks.clearSelectedSpace.mockReset();
+ });
+
+ it.each([
+ [State.NORMAL, "/archived"],
+ [State.ARCHIVED, "/"],
+ ])("switches an All-origin %s memo to the global destination", async (state, destination) => {
+ const { result } = renderActions(state, "all");
+
+ await act(async () => {
+ await result.current.handlers.handleToggleMemoStatusClick();
+ });
+
+ await waitFor(() => expect(result.current.pathname).toBe(destination));
+ expect(mocks.clearSelectedSpace).toHaveBeenCalledOnce();
+ });
+
+ it("preserves an exact-Space origin when archiving", async () => {
+ const { result } = renderActions(State.NORMAL, "preserve");
+
+ await act(async () => {
+ await result.current.handlers.handleToggleMemoStatusClick();
+ });
+
+ await waitFor(() => expect(result.current.pathname).toBe("/archived"));
+ expect(mocks.clearSelectedSpace).not.toHaveBeenCalled();
+ });
+
+ it("clears a stale Space before deleting from an All origin", async () => {
+ const { result } = renderActions(State.NORMAL, "all");
+
+ await act(async () => {
+ await result.current.handlers.confirmDeleteMemo();
+ });
+
+ await waitFor(() => expect(result.current.pathname).toBe("/"));
+ expect(mocks.clearSelectedSpace).toHaveBeenCalledOnce();
+ });
+});
diff --git a/web/tests/memo-content-footnotes.test.tsx b/web/tests/memo-content-footnotes.test.tsx
index e8cbe918..a4db6103 100644
--- a/web/tests/memo-content-footnotes.test.tsx
+++ b/web/tests/memo-content-footnotes.test.tsx
@@ -7,10 +7,15 @@ const FOOTNOTE_MARKDOWN = "A statement with a note.[^1]\n\n[^1]: The footnote bo
const LocationProbe = () => {
const location = useLocation();
- return ;
+ return (
+ <>
+
+
+ >
+ );
};
-const renderFootnote = (compact = false) =>
+const renderFootnote = (compact = false, parentPage?: string) =>
render(
@@ -18,6 +23,8 @@ const renderFootnote = (compact = false) =>
content={FOOTNOTE_MARKDOWN}
resolvedMentionUsernames={new Set()}
memoName="memos/abc123"
+ parentPage={parentPage}
+ parentScope={parentPage ? "preserve" : undefined}
compact={compact}
/>
@@ -62,11 +69,12 @@ describe("memo footnotes", () => {
});
it("navigates compact cards to the memo detail footnote", () => {
- const { container } = renderFootnote(true);
+ const { container } = renderFootnote(true, "/archived?filter=tagSearch%3Awork");
const reference = container.querySelector("a[data-footnote-ref]");
fireEvent.click(reference!);
expect(screen.getByTestId("location")).toHaveTextContent("/memos/abc123#user-content-fn-1");
+ expect(screen.getByTestId("origin")).toHaveTextContent("/archived?filter=tagSearch%3Awork");
});
});
diff --git a/web/tests/memo-header-navigation.test.tsx b/web/tests/memo-header-navigation.test.tsx
new file mode 100644
index 00000000..f7c496dc
--- /dev/null
+++ b/web/tests/memo-header-navigation.test.tsx
@@ -0,0 +1,127 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { MemoryRouter, useLocation } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import MemoHeader from "@/components/MemoView/components/MemoHeader";
+
+const state = vi.hoisted(() => ({
+ creator: undefined as { username: string; displayName: string; avatarUrl: string } | undefined,
+}));
+
+vi.mock("@/components/RelativeTime", () => ({
+ default: () => <>time>,
+}));
+
+vi.mock("@/components/MemoActionMenu", () => ({
+ default: () => null,
+}));
+
+vi.mock("@/components/MemoReactionListView", () => ({
+ ReactionSelector: () => null,
+}));
+
+vi.mock("@/components/UserAvatar", () => ({
+ default: () => avatar,
+}));
+
+vi.mock("@/components/MemoView/components/MemoSpaceBadge", () => ({
+ default: ({ spaceName }: { spaceName?: string }) => {spaceName},
+}));
+
+vi.mock("@/components/ui/tooltip", () => ({
+ Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ TooltipTrigger: ({ children, render }: { children?: React.ReactNode; render?: React.ReactNode }) => <>{render || children}>,
+ TooltipContent: () => null,
+}));
+
+vi.mock("@/contexts/NewMemoContext", () => ({
+ useNewMemo: () => ({ newMemoName: undefined }),
+}));
+
+vi.mock("@/i18n", () => ({
+ default: { language: "en" },
+}));
+
+vi.mock("@/components/MemoView/hooks", () => ({
+ useMemoActions: () => ({ unpinMemo: vi.fn() }),
+}));
+
+vi.mock("@/components/MemoView/MemoViewContext", () => ({
+ useMemoViewContext: () => ({
+ memo: { name: "memos/123", visibility: 1, pinned: false, space: "spaces/product" },
+ creator: state.creator,
+ currentUser: undefined,
+ parentPage: "/explore?filter=tagSearch%3Awork",
+ parentScope: "preserve",
+ isArchived: false,
+ readonly: false,
+ openEditor: vi.fn(),
+ }),
+ useMemoViewDerived: () => ({
+ createTime: new Date("2026-08-26T00:00:00Z"),
+ updateTime: undefined,
+ displayTime: new Date("2026-08-26T00:00:00Z"),
+ isDisplayingUpdatedTime: false,
+ relativeTimeFormat: "auto",
+ }),
+}));
+
+vi.mock("@/utils/i18n", () => ({
+ useTranslate: () => (key: string) => key,
+}));
+
+const LocationProbe = () => {
+ const location = useLocation();
+ return (
+
+ );
+};
+
+describe("MemoHeader navigation", () => {
+ beforeEach(() => {
+ state.creator = undefined;
+ });
+
+ it.each([false, true])("uses a keyboard-operable timestamp and preserves origin when showCreator=%s", (showCreator) => {
+ if (showCreator) {
+ state.creator = { username: "alice", displayName: "Alice", avatarUrl: "" };
+ }
+
+ render(
+
+
+
+ ,
+ );
+
+ const timestamp = screen.getByRole("button", { name: "time" });
+ timestamp.focus();
+ expect(timestamp).toHaveFocus();
+
+ fireEvent.click(timestamp);
+ expect(screen.getByTestId("location")).toHaveTextContent(
+ '/memos/123|{"from":"/explore?filter=tagSearch%3Awork","fromScope":"preserve"}',
+ );
+ });
+
+ it.each([false, true])("keeps the Space pill beside the timestamp when showCreator=%s", (showCreator) => {
+ if (showCreator) {
+ state.creator = { username: "alice", displayName: "Alice", avatarUrl: "" };
+ }
+
+ render(
+
+
+ ,
+ );
+
+ const timestamp = screen.getByRole("button", { name: "time" });
+ const space = screen.getByTestId("memo-space");
+ const metadata = space.closest('[data-slot="memo-header-meta"]');
+
+ expect(metadata).toContainElement(timestamp);
+ expect(space.closest("a, button")).toBeNull();
+ });
+});
diff --git a/web/tests/memo-space-badge.test.tsx b/web/tests/memo-space-badge.test.tsx
new file mode 100644
index 00000000..a0746527
--- /dev/null
+++ b/web/tests/memo-space-badge.test.tsx
@@ -0,0 +1,49 @@
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import MemoSpaceBadge from "@/components/MemoView/components/MemoSpaceBadge";
+
+const state = vi.hoisted(() => ({
+ spaces: [] as Array<{ name: string; title: string }>,
+}));
+
+vi.mock("@/contexts/SpaceContext", () => ({
+ useSpaceContext: () => ({ spaces: state.spaces }),
+}));
+
+vi.mock("@/utils/i18n", () => ({
+ useTranslate: () => (key: string) => (key === "space.current" ? "Space" : key),
+}));
+
+describe("MemoSpaceBadge", () => {
+ beforeEach(() => {
+ state.spaces = [];
+ });
+
+ it("shows the title of a known Space", () => {
+ state.spaces = [{ name: "spaces/product", title: "Product" }];
+ render();
+
+ expect(screen.getByTitle("Space: Product")).toHaveTextContent("Space: Product");
+ });
+
+ it("uses the shared pill presentation", () => {
+ state.spaces = [{ name: "spaces/product", title: "Product" }];
+ render();
+
+ const badge = screen.getByTitle("Space: Product");
+ expect(badge).toHaveAttribute("data-slot", "badge");
+ expect(badge.querySelector(".lucide-user-lock")).not.toBeNull();
+ });
+
+ it("uses a neutral label rather than fetching or exposing an unknown Space", () => {
+ render();
+
+ expect(screen.getByTitle("Space")).toHaveTextContent("Space");
+ expect(screen.queryByText("spaces/private")).not.toBeInTheDocument();
+ });
+
+ it("omits placement for an unassigned memo", () => {
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/web/tests/memo-tag-navigation.test.tsx b/web/tests/memo-tag-navigation.test.tsx
new file mode 100644
index 00000000..e38d9cec
--- /dev/null
+++ b/web/tests/memo-tag-navigation.test.tsx
@@ -0,0 +1,76 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { Tag } from "@/components/MemoContent/Tag";
+
+const navigateTo = vi.hoisted(() => vi.fn());
+const clearSelectedSpace = vi.hoisted(() => vi.fn());
+const origin = vi.hoisted(() => ({
+ parentPage: "/" as string,
+ parentScope: "all" as "all" | "preserve",
+}));
+
+vi.mock("@/hooks/useNavigateTo", () => ({
+ default: () => navigateTo,
+}));
+
+vi.mock("@/components/MemoView/MemoViewContext", () => ({
+ useMemoViewContext: () => origin,
+}));
+
+vi.mock("@/contexts/SpaceContext", () => ({
+ useSpaceContext: () => ({ clearSelectedSpace }),
+}));
+
+vi.mock("@/contexts/MemoFilterContext", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useMemoFilterContext: () => ({
+ getFiltersByFactor: () => [],
+ removeFilter: vi.fn(),
+ addFilter: vi.fn(),
+ }),
+ };
+});
+
+vi.mock("@/contexts/AuthContext", () => ({
+ useAuth: () => ({ userTagsSetting: undefined }),
+}));
+
+describe("Memo tag navigation", () => {
+ beforeEach(() => {
+ navigateTo.mockClear();
+ clearSelectedSpace.mockClear();
+ origin.parentPage = "/";
+ origin.parentScope = "all";
+ });
+
+ it("switches to All only when a global detail tag enters a collection", () => {
+ render(
+
+ #work
+ ,
+ );
+
+ fireEvent.click(screen.getByText("#work"));
+
+ expect(clearSelectedSpace).toHaveBeenCalledOnce();
+ expect(navigateTo).toHaveBeenCalledWith("/?filter=tagSearch%3Awork");
+ });
+
+ it("returns a Profile-origin tag without clearing the remembered Space", () => {
+ origin.parentPage = "/u/alice?view=map";
+
+ render(
+
+ #work
+ ,
+ );
+
+ fireEvent.click(screen.getByText("#work"));
+
+ expect(clearSelectedSpace).not.toHaveBeenCalled();
+ expect(navigateTo).toHaveBeenCalledWith("/u/alice?filter=tagSearch%3Awork");
+ });
+});
diff --git a/web/tests/memo-view-navigation.test.ts b/web/tests/memo-view-navigation.test.ts
new file mode 100644
index 00000000..a3042588
--- /dev/null
+++ b/web/tests/memo-view-navigation.test.ts
@@ -0,0 +1,127 @@
+import { describe, expect, it } from "vitest";
+import {
+ createMemoNavigationState,
+ isMemoCollectionOrigin,
+ isMemoDetailPath,
+ isMemoResourcePath,
+ resolveMemoDetailOrigin,
+ resolveMemoOrigin,
+ resolveMemoParentPage,
+ withMemoFilter,
+} from "@/components/MemoView/navigation";
+
+describe("memo view navigation", () => {
+ it("captures the complete collection origin", () => {
+ expect(
+ resolveMemoParentPage({
+ pathname: "/archived",
+ search: "?filter=tagSearch%3Awork",
+ memoName: "memos/123",
+ }),
+ ).toBe("/archived?filter=tagSearch%3Awork");
+ });
+
+ it("keeps an explicit origin on a canonical memo route", () => {
+ expect(
+ resolveMemoParentPage({
+ explicitParentPage: "/explore?filter=contentSearch%3Aroadmap",
+ pathname: "/memos/123",
+ search: "",
+ memoName: "memos/123",
+ }),
+ ).toBe("/explore?filter=contentSearch%3Aroadmap");
+ });
+
+ it("marks collection cards as preserving the remembered scope", () => {
+ expect(resolveMemoOrigin({ pathname: "/explore", search: "?filter=tagSearch%3Awork", memoName: "memos/123" })).toEqual({
+ parentPage: "/explore?filter=tagSearch%3Awork",
+ parentScope: "preserve",
+ });
+ });
+
+ it("marks Profile cards as global without losing their Profile origin", () => {
+ expect(resolveMemoOrigin({ pathname: "/u/alice", search: "?view=map", memoName: "memos/123" })).toEqual({
+ parentPage: "/u/alice?view=map",
+ parentScope: "all",
+ });
+ });
+
+ it("uses an All Home origin for direct and shared resource entries", () => {
+ expect(resolveMemoDetailOrigin(undefined)).toEqual({ parentPage: "/", parentScope: "all" });
+ expect(resolveMemoDetailOrigin({ unrelated: true })).toEqual({ parentPage: "/", parentScope: "all" });
+ });
+
+ it("uses an All Archived origin for a direct archived memo", () => {
+ expect(resolveMemoDetailOrigin(undefined, { memoArchived: true })).toEqual({ parentPage: "/archived", parentScope: "all" });
+ });
+
+ it("keeps an explicit origin ahead of the archived fallback", () => {
+ expect(resolveMemoDetailOrigin(createMemoNavigationState("/u/alice", "all"), { memoArchived: true })).toEqual({
+ parentPage: "/u/alice",
+ parentScope: "all",
+ });
+ });
+
+ it("round-trips an explicit origin policy through router state", () => {
+ const state = createMemoNavigationState("/", "preserve");
+ expect(resolveMemoDetailOrigin(state)).toEqual({ parentPage: "/", parentScope: "preserve" });
+ });
+
+ it("infers policy for legacy from-only router state", () => {
+ expect(resolveMemoDetailOrigin({ from: "/archived" })).toEqual({ parentPage: "/archived", parentScope: "preserve" });
+ expect(resolveMemoDetailOrigin({ from: "/u/alice" })).toEqual({ parentPage: "/u/alice", parentScope: "all" });
+ });
+
+ it.each([
+ "/",
+ "/explore?filter=tagSearch%3Awork",
+ "/archived",
+ "/attachments",
+ ])("recognizes %s as a remembered collection origin", (page) => {
+ expect(isMemoCollectionOrigin(page)).toBe(true);
+ });
+
+ it.each([
+ "/memos/123",
+ "/memos/123/",
+ "/Memos/123",
+ "/memos/shares/token",
+ "/Memos/Shares/token/",
+ ])("does not treat %s as its own origin", (pathname) => {
+ expect(isMemoDetailPath(pathname, "memos/123")).toBe(true);
+ expect(resolveMemoParentPage({ pathname, search: "", memoName: "memos/123" })).toBe("/");
+ });
+
+ it("does not confuse a different memo route with the current memo detail", () => {
+ const pathname = "/memos/1234";
+ expect(isMemoDetailPath(pathname, "memos/123")).toBe(false);
+ expect(resolveMemoParentPage({ pathname, search: "", memoName: "memos/123" })).toBe(pathname);
+ });
+
+ it("rejects extra path segments after a share token", () => {
+ expect(isMemoDetailPath("/memos/shares/token/extra", "memos/123")).toBe(false);
+ });
+
+ it.each([
+ "/memos/123",
+ "/Memos/123/",
+ "/memos/shares/token",
+ "/Memos/Shares/token/",
+ ])("recognizes %s as a Memo resource route", (pathname) => {
+ expect(isMemoResourcePath(pathname)).toBe(true);
+ });
+
+ it.each(["/", "/memos", "/memos/123/extra", "/memos/shares/token/extra"])("rejects %s as a Memo resource route", (pathname) => {
+ expect(isMemoResourcePath(pathname)).toBe(false);
+ });
+
+ it("replaces the filter without dropping other origin query parameters", () => {
+ expect(withMemoFilter("/u/alice?view=memos&filter=old#section", "tagSearch:design")).toBe(
+ "/u/alice?view=memos&filter=tagSearch%3Adesign",
+ );
+ });
+
+ it("returns a Profile map origin to its memo list when applying a filter", () => {
+ expect(withMemoFilter("/u/alice?view=map", "tagSearch:design")).toBe("/u/alice?filter=tagSearch%3Adesign");
+ });
+});
diff --git a/web/tests/quick-find-navigation.test.tsx b/web/tests/quick-find-navigation.test.tsx
new file mode 100644
index 00000000..0e3c42f7
--- /dev/null
+++ b/web/tests/quick-find-navigation.test.tsx
@@ -0,0 +1,143 @@
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { createMemoryRouter, RouterProvider, useLocation } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import QuickFindDialog from "@/components/AppSidebar/QuickFindDialog";
+import { AppSidebarProvider, useAppSidebar } from "@/contexts/AppSidebarContext";
+import { getSelectedSpaceStorageKey, SpaceProvider, useSpaceContext } from "@/contexts/SpaceContext";
+
+const state = vi.hoisted(() => ({
+ currentUser: { name: "users/alice" } as { name: string } | undefined,
+ spaces: [{ name: "spaces/product", title: "Product", description: "" }],
+ filters: [] as Array<{ factor: "contentSearch"; value: string }>,
+ setFilters: vi.fn(),
+ setMemoView: vi.fn(),
+}));
+
+vi.mock("@/contexts/MemoFilterContext", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useMemoFilterContext: () => ({
+ filters: state.filters,
+ memoView: undefined,
+ setFilters: state.setFilters,
+ setMemoView: state.setMemoView,
+ }),
+ };
+});
+
+vi.mock("@/hooks/useCurrentUser", () => ({
+ default: () => state.currentUser,
+}));
+
+vi.mock("@/hooks/useSpaceQueries", () => ({
+ useSpaces: () => ({ data: state.spaces, isSuccess: true, isPending: false, isError: false }),
+}));
+
+vi.mock("@/hooks/useUserQueries", () => ({
+ useMemoViews: () => ({ data: [] }),
+}));
+
+vi.mock("@/utils/i18n", () => ({
+ useTranslate: () => (key: string) => key,
+}));
+
+const Harness = () => {
+ const location = useLocation();
+ const { setQuickFindOpen } = useAppSidebar();
+ const { collectionScope } = useSpaceContext();
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
+
+describe("Quick Find navigation", () => {
+ beforeEach(() => {
+ sessionStorage.clear();
+ state.currentUser = { name: "users/alice" };
+ state.filters = [];
+ state.setFilters.mockClear();
+ state.setMemoView.mockClear();
+ });
+
+ it("switches to All in one history step so Back returns directly to Inbox", async () => {
+ const storageKey = getSelectedSpaceStorageKey("users/alice");
+ sessionStorage.setItem(storageKey, "spaces/product");
+
+ const router = createMemoryRouter(
+ [
+ {
+ path: "*",
+ element: (
+
+
+
+
+
+ ),
+ },
+ ],
+ { initialEntries: ["/inbox"] },
+ );
+ render();
+
+ expect(screen.getByTestId("scope")).toHaveTextContent("spaces/product");
+ fireEvent.click(screen.getByRole("button", { name: "Open Quick Find" }));
+ const input = await screen.findByRole("textbox");
+ fireEvent.change(input, { target: { value: "roadmap" } });
+ fireEvent.submit(input.closest("form")!);
+
+ await waitFor(() => expect(screen.getByTestId("path")).toHaveTextContent("/?filter=contentSearch:roadmap"));
+ expect(state.setFilters).toHaveBeenCalledWith([{ factor: "contentSearch", value: "roadmap" }]);
+ expect(screen.getByTestId("scope")).toHaveTextContent("all");
+ expect(sessionStorage.getItem(storageKey)).toBeNull();
+
+ await act(async () => {
+ await router.navigate(-1);
+ });
+
+ expect(screen.getByTestId("path")).toHaveTextContent("/inbox");
+ });
+
+ it("preserves an anonymous global page in history", async () => {
+ state.currentUser = undefined;
+
+ const router = createMemoryRouter(
+ [
+ {
+ path: "*",
+ element: (
+
+
+
+
+
+ ),
+ },
+ ],
+ { initialEntries: ["/about"] },
+ );
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Open Quick Find" }));
+ const input = await screen.findByRole("textbox");
+ fireEvent.change(input, { target: { value: "roadmap" } });
+ fireEvent.submit(input.closest("form")!);
+
+ await waitFor(() => expect(screen.getByTestId("path")).toHaveTextContent("/?filter=contentSearch:roadmap"));
+
+ await act(async () => {
+ await router.navigate(-1);
+ });
+
+ expect(screen.getByTestId("path")).toHaveTextContent("/about");
+ });
+});
diff --git a/web/tests/quick-find.test.ts b/web/tests/quick-find.test.ts
index ab23c02b..60035bee 100644
--- a/web/tests/quick-find.test.ts
+++ b/web/tests/quick-find.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { buildQuickFindFilters, isQuickFindCollectionRoute } from "@/components/AppSidebar/QuickFindDialog";
+import { buildQuickFindFilters, resolveQuickFindSubmission } from "@/components/AppSidebar/QuickFindDialog";
import { type MemoFilter, replaceFiltersByFactor } from "@/contexts/MemoFilterContext";
describe("Quick Find", () => {
@@ -20,9 +20,70 @@ describe("Quick Find", () => {
it("starts a clean All search outside collection routes", () => {
expect(buildQuickFindFilters("project", scopedFilters, false)).toEqual([{ factor: "contentSearch", value: "project" }]);
- expect(isQuickFindCollectionRoute("/attachments")).toBe(false);
- expect(isQuickFindCollectionRoute("/archived")).toBe(true);
- expect(isQuickFindCollectionRoute("/u/steven")).toBe(true);
+ });
+
+ it.each(["/", "/explore", "/archived"])("keeps scoped filters and stays on %s", (pathname) => {
+ expect(resolveQuickFindSubmission(pathname, "project", scopedFilters)).toEqual({
+ filters: [
+ { factor: "tagSearch", value: "work" },
+ { factor: "displayTime", value: "2026-08-03" },
+ { factor: "contentSearch", value: "project" },
+ ],
+ destination: undefined,
+ switchToAll: false,
+ });
+ });
+
+ it("keeps the remembered collection filters when searching from Attachments", () => {
+ expect(resolveQuickFindSubmission("/attachments", "project", scopedFilters)).toEqual({
+ filters: [
+ { factor: "tagSearch", value: "work" },
+ { factor: "displayTime", value: "2026-08-03" },
+ { factor: "contentSearch", value: "project" },
+ ],
+ destination: "/?filter=tagSearch:work,displayTime:2026-08-03,contentSearch:project",
+ switchToAll: false,
+ });
+ });
+
+ it("keeps the remembered collection on a normalized Attachments route", () => {
+ expect(resolveQuickFindSubmission("/Attachments/", "project", scopedFilters)).toEqual({
+ filters: [
+ { factor: "tagSearch", value: "work" },
+ { factor: "displayTime", value: "2026-08-03" },
+ { factor: "contentSearch", value: "project" },
+ ],
+ destination: "/?filter=tagSearch:work,displayTime:2026-08-03,contentSearch:project",
+ switchToAll: false,
+ });
+ });
+
+ it("keeps Profile search on the Profile and returns its map tab to the memo list", () => {
+ expect(resolveQuickFindSubmission("/u/steven", "project", scopedFilters)).toEqual({
+ filters: [
+ { factor: "tagSearch", value: "work" },
+ { factor: "displayTime", value: "2026-08-03" },
+ { factor: "contentSearch", value: "project" },
+ ],
+ destination: "/u/steven?filter=tagSearch:work,displayTime:2026-08-03,contentSearch:project",
+ switchToAll: false,
+ });
+ });
+
+ it.each([
+ "/inbox",
+ "/setting",
+ "/views",
+ "/about",
+ "/memos/abc",
+ "/memos/shares/token",
+ "/404",
+ ])("starts a clean All search from %s", (pathname) => {
+ expect(resolveQuickFindSubmission(pathname, "project", scopedFilters)).toEqual({
+ filters: [{ factor: "contentSearch", value: "project" }],
+ destination: "/?filter=contentSearch:project",
+ switchToAll: true,
+ });
});
it("replaces only the selected date facet", () => {
diff --git a/web/tests/space-context.test.tsx b/web/tests/space-context.test.tsx
index 970c62c0..50f36d80 100644
--- a/web/tests/space-context.test.tsx
+++ b/web/tests/space-context.test.tsx
@@ -26,15 +26,22 @@ vi.mock("@/hooks/useSpaceQueries", () => ({
}));
const Probe = () => {
- const { memoFilter, spaces, selectedSpace, selectedSpaceName, selectMemos, selectSpace } = useSpaceContext();
+ const { clearSelectedSpace, collectionScope, memoFilter, spaces, selectedSpace, selectedSpaceName, selectMemos, selectSpace } =
+ useSpaceContext();
return (
+
+
@@ -46,7 +53,10 @@ const Probe = () => {
);
};
-const CurrentPath = () =>
;
+const CurrentPath = () => {
+ const location = useLocation();
+ return
;
+};
const renderProvider = (initialPath = "/explore") =>
render(
@@ -64,11 +74,12 @@ describe("SpaceProvider", () => {
state.query = { data: [], isSuccess: true, isPending: false, isError: false };
});
- it("uses Memos when the user has no stored Space selection", () => {
+ it("uses the All collection when the user has no stored Space selection", () => {
renderProvider();
expect(screen.getByTestId("selected-name")).toHaveTextContent("Memos");
- expect(screen.getByTestId("memo-filter")).toHaveTextContent("space == null");
+ expect(screen.getByTestId("collection-scope")).toHaveTextContent("all");
+ expect(screen.getByTestId("memo-filter")).toHaveTextContent("all");
});
it("restores a valid Space and stores changes only for the current user", () => {
@@ -79,6 +90,7 @@ describe("SpaceProvider", () => {
expect(screen.getByTestId("selected-name")).toHaveTextContent(product.name);
expect(screen.getByTestId("selected-title")).toHaveTextContent("Product");
+ expect(screen.getByTestId("collection-scope")).toHaveTextContent("space:spaces/product");
expect(screen.getByTestId("memo-filter")).toHaveTextContent('space == "spaces/product"');
fireEvent.click(screen.getByRole("button", { name: "Select Memos" }));
@@ -118,29 +130,64 @@ describe("SpaceProvider", () => {
expect(sessionStorage.getItem(getSelectedSpaceStorageKey("users/alice"))).toBe(newlyCreatedSpace.name);
});
- it("lands on the context's feed when switching to a Space", () => {
+ it.each([
+ "/",
+ "/explore",
+ "/archived?filter=tagSearch%3Awork",
+ "/attachments",
+ ])("preserves the current collection route when switching to a Space from %s", (initialPath) => {
const product = { name: "spaces/product", title: "Product", description: "" };
state.query.data = [product];
- renderProvider("/archived");
-
- expect(screen.getByTestId("path")).toHaveTextContent("/archived");
+ renderProvider(initialPath);
fireEvent.click(screen.getByRole("button", { name: "Select first Space" }));
expect(screen.getByTestId("selected-name")).toHaveTextContent(product.name);
- expect(screen.getByTestId("path")).toHaveTextContent("/");
+ expect(screen.getByTestId("path").textContent).toBe(initialPath);
});
- it("returns to the Memos feed when leaving a Space, even from another route", () => {
+ it.each([
+ "/",
+ "/explore",
+ "/archived?filter=tagSearch%3Awork",
+ "/attachments",
+ ])("preserves the current collection route when switching to All from %s", (initialPath) => {
const product = { name: "spaces/product", title: "Product", description: "" };
state.query.data = [product];
sessionStorage.setItem(getSelectedSpaceStorageKey("users/alice"), product.name);
- renderProvider("/explore");
+ renderProvider(initialPath);
fireEvent.click(screen.getByRole("button", { name: "Select Memos" }));
expect(screen.getByTestId("selected-name")).toHaveTextContent("Memos");
- expect(screen.getByTestId("path")).toHaveTextContent("/");
+ expect(screen.getByTestId("path").textContent).toBe(initialPath);
+ });
+
+ it.each([
+ ["Select first Space", "/inbox"],
+ ["Select Memos", "/u/alice"],
+ ])("falls back to Home when using %s outside a collection route", (action, initialPath) => {
+ const product = { name: "spaces/product", title: "Product", description: "" };
+ state.query.data = [product];
+ sessionStorage.setItem(getSelectedSpaceStorageKey("users/alice"), product.name);
+ renderProvider(initialPath);
+
+ fireEvent.click(screen.getByRole("button", { name: action }));
+
+ expect(screen.getByTestId("path").textContent).toBe("/");
+ });
+
+ it("can select All on a resource route without navigating", () => {
+ const product = { name: "spaces/product", title: "Product", description: "" };
+ state.query.data = [product];
+ sessionStorage.setItem(getSelectedSpaceStorageKey("users/alice"), product.name);
+ renderProvider("/memos/123");
+
+ fireEvent.click(screen.getByRole("button", { name: "Clear Space in place" }));
+
+ expect(screen.getByTestId("collection-scope")).toHaveTextContent("all");
+ expect(screen.getByTestId("path")).toHaveTextContent("/memos/123");
+ expect(sessionStorage.getItem(getSelectedSpaceStorageKey("users/alice"))).toBeNull();
});
it("isolates the active selection across account changes", () => {
diff --git a/web/tests/space-feed-pages.test.tsx b/web/tests/space-feed-pages.test.tsx
index db512e4a..0d50aacb 100644
--- a/web/tests/space-feed-pages.test.tsx
+++ b/web/tests/space-feed-pages.test.tsx
@@ -6,19 +6,26 @@ import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
const state = vi.hoisted(() => ({
selectedSpaceName: undefined as string | undefined,
- memoFilter: "space == null" as string | undefined,
+ memoFilter: undefined as string | undefined,
listProps: [] as Array
>,
+ memoViewProps: [] as Array>,
filterOptions: [] as Array>,
}));
vi.mock("@/components/MemoView", () => ({
- default: () => ,
+ default: (props: Record) => {
+ state.memoViewProps.push(props);
+ return ;
+ },
}));
vi.mock("@/components/PagedMemoList", () => ({
default: (props: Record) => {
state.listProps.push(props);
- return ;
+ const renderer = props.renderer as
+ | ((memo: { name: string; space: string }, options: { compact: boolean }) => React.ReactNode)
+ | undefined;
+ return {renderer?.({ name: "memos/test", space: "spaces/product" }, { compact: false })}
;
},
getMemoKey: (memo: { name: string }) => memo.name,
}));
@@ -42,12 +49,13 @@ vi.mock("@/hooks/useCurrentUser", () => ({
describe("Space-scoped feed pages", () => {
beforeEach(() => {
state.selectedSpaceName = undefined;
- state.memoFilter = "space == null";
+ state.memoFilter = undefined;
state.listProps = [];
+ state.memoViewProps = [];
state.filterOptions = [];
});
- it("uses the no-Space filter for Memos Explore and Archived", () => {
+ it("uses the All collection without a Space filter for Explore and Archived", () => {
render(
<>
@@ -56,8 +64,12 @@ describe("Space-scoped feed pages", () => {
);
expect(state.listProps).toHaveLength(2);
- expect(state.listProps[0]).toMatchObject({ contextFilter: "space == null" });
- expect(state.listProps[1]).toMatchObject({ contextFilter: "space == null" });
+ expect(state.listProps[0]).toMatchObject({ contextFilter: undefined });
+ expect(state.listProps[1]).toMatchObject({ contextFilter: undefined });
+ expect(state.filterOptions[0]).toMatchObject({
+ visibilities: [Visibility.PUBLIC, Visibility.PROTECTED, Visibility.SPACE],
+ });
+ expect(state.memoViewProps).toEqual([expect.objectContaining({ showSpace: true }), expect.objectContaining({ showSpace: true })]);
});
it("uses the selected Space filter and includes its member audience in Explore", () => {
@@ -69,5 +81,6 @@ describe("Space-scoped feed pages", () => {
expect(state.filterOptions[0]).toMatchObject({
visibilities: [Visibility.PUBLIC, Visibility.PROTECTED, Visibility.SPACE],
});
+ expect(state.memoViewProps[0]).toMatchObject({ showSpace: false });
});
});
diff --git a/web/tests/user-stats-filter-queries.test.tsx b/web/tests/user-stats-filter-queries.test.tsx
index 48fb7518..52f14235 100644
--- a/web/tests/user-stats-filter-queries.test.tsx
+++ b/web/tests/user-stats-filter-queries.test.tsx
@@ -36,7 +36,7 @@ describe("User statistics filter queries", () => {
expect(clients.getUserStats).toHaveBeenCalledWith({ name: "users/test", filter });
});
- it("sends the no-Space filter with grouped statistics", async () => {
+ it("sends an explicit unassigned filter with grouped statistics", async () => {
const filter = "space == null";
const { result } = renderHook(() => useAllUserStats({ state: State.ARCHIVED, filter }), { wrapper: createWrapper() });
diff --git a/web/tests/visibility-selector.test.tsx b/web/tests/visibility-selector.test.tsx
index 96491b32..3c92c153 100644
--- a/web/tests/visibility-selector.test.tsx
+++ b/web/tests/visibility-selector.test.tsx
@@ -1,14 +1,8 @@
import { fireEvent, render, screen } from "@testing-library/react";
-import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { beforeAll, describe, expect, it, vi } from "vitest";
import VisibilitySelector from "@/components/MemoEditor/Toolbar/VisibilitySelector";
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
-const space = vi.hoisted(() => ({ selectedSpaceName: undefined as string | undefined }));
-
-vi.mock("@/contexts/SpaceContext", () => ({
- useSpaceContext: () => ({ selectedSpaceName: space.selectedSpaceName }),
-}));
-
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
// Base UI menus reach for layout/pointer APIs jsdom doesn't implement.
@@ -19,19 +13,15 @@ beforeAll(() => {
Element.prototype.releasePointerCapture = vi.fn();
});
-const openMenu = async (value: Visibility, onChange = vi.fn()) => {
- render();
+const openMenu = async (value: Visibility, onChange = vi.fn(), space?: string) => {
+ render();
fireEvent.click(screen.getByRole("button"));
await screen.findByRole("menu");
return onChange;
};
describe("VisibilitySelector", () => {
- beforeEach(() => {
- space.selectedSpaceName = undefined;
- });
-
- it("omits the Space audience outside a Space", async () => {
+ it("omits the Space audience when the edited memo has no Space placement", async () => {
await openMenu(Visibility.PRIVATE);
expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual([
@@ -41,21 +31,20 @@ describe("VisibilitySelector", () => {
]);
});
- it("offers the Space audience with its own description while a Space is selected", async () => {
- space.selectedSpaceName = "spaces/product";
- await openMenu(Visibility.PRIVATE);
+ it("offers the Space audience with its own description when the edited memo belongs to a Space", async () => {
+ await openMenu(Visibility.PRIVATE, vi.fn(), "spaces/product");
const spaceItem = screen.getByRole("menuitem", { name: /memo\.visibility\.space/ });
expect(spaceItem).toHaveTextContent("memo.visibility.space-description");
});
- it("names a Space memo's audience on the trigger even outside its Space", () => {
+ it("names the current Space audience even when placement is unavailable", () => {
render();
expect(screen.getByRole("button")).toHaveTextContent("memo.visibility.space");
});
- it("keeps a Space memo's own audience selectable outside its Space", async () => {
+ it("keeps the current Space audience selectable when placement is unavailable", async () => {
await openMenu(Visibility.SPACE);
expect(screen.getByRole("menuitem", { name: /memo\.visibility\.space/ })).toBeInTheDocument();
diff --git a/web/tests/visibility-space.test.tsx b/web/tests/visibility-space.test.tsx
index 66a0c31a..e0286c47 100644
--- a/web/tests/visibility-space.test.tsx
+++ b/web/tests/visibility-space.test.tsx
@@ -1,4 +1,5 @@
import { render } from "@testing-library/react";
+import { UserLockIcon } from "lucide-react";
import { describe, expect, it } from "vitest";
import VisibilityIcon from "@/components/VisibilityIcon";
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
@@ -7,6 +8,7 @@ import {
convertVisibilityToString,
DEFAULT_VISIBILITY_OPTIONS,
getAssignableVisibilityOptions,
+ getVisibilityOption,
VISIBILITY_OPTIONS,
} from "@/utils/memo";
@@ -28,6 +30,10 @@ describe("Space visibility presentation", () => {
expect(new Set(rendered).size).toBe(VISIBILITY_OPTIONS.length);
});
+ it("uses user-lock for the Space audience", () => {
+ expect(getVisibilityOption(Visibility.SPACE)?.icon).toBe(UserLockIcon);
+ });
+
it("renders nothing for an unspecified audience", () => {
const { container } = render();
@@ -36,16 +42,16 @@ describe("Space visibility presentation", () => {
});
describe("assignable visibility options", () => {
- it("withholds the Space audience outside a Space", () => {
- expect(values(getAssignableVisibilityOptions({ spaceSelected: false }))).toEqual([
+ it("withholds the Space audience when the memo has no Space placement", () => {
+ expect(values(getAssignableVisibilityOptions({ hasSpacePlacement: false }))).toEqual([
Visibility.PRIVATE,
Visibility.PROTECTED,
Visibility.PUBLIC,
]);
});
- it("offers the Space audience while a Space is selected", () => {
- expect(values(getAssignableVisibilityOptions({ spaceSelected: true }))).toEqual([
+ it("offers the Space audience when the memo is placed in a Space", () => {
+ expect(values(getAssignableVisibilityOptions({ hasSpacePlacement: true }))).toEqual([
Visibility.PRIVATE,
Visibility.SPACE,
Visibility.PROTECTED,
@@ -53,8 +59,8 @@ describe("assignable visibility options", () => {
]);
});
- it("keeps a memo's own Space audience on offer outside its Space, so picking cannot silently downgrade it", () => {
- expect(values(getAssignableVisibilityOptions({ spaceSelected: false, current: Visibility.SPACE }))).toContain(Visibility.SPACE);
+ it("keeps a legacy Space audience on offer when placement is unavailable, so picking cannot silently downgrade it", () => {
+ expect(values(getAssignableVisibilityOptions({ hasSpacePlacement: false, current: Visibility.SPACE }))).toContain(Visibility.SPACE);
});
it("never offers the Space audience as a persistent default", () => {