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.
This commit is contained in:
amblued 2026-08-26 09:16:28 +08:00
parent 05f882e43a
commit cdd13ca24e
65 changed files with 1818 additions and 249 deletions

View file

@ -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

View file

@ -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 (
<div className={SIDEBAR_SECTION_STACK_CLASSES}>
@ -405,6 +405,8 @@ const MemoDetailSidebarContent = () => {
return (
<MemoDetailSidebar
memo={memoDetail.memo}
parentPage={memoDetail.from}
parentScope={memoDetail.fromScope}
forceReadonly={memoDetail.readonly}
onShareImageOpen={memoDetail.onShareImageOpen}
className="pb-2"
@ -414,8 +416,6 @@ const MemoDetailSidebarContent = () => {
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 <CollectionSidebarContent context={kind} />;
@ -424,10 +424,8 @@ const RouteSidebarContent = () => {
if (kind === "attachments") return <AttachmentsSidebarContent />;
if (kind === "inbox") return <InboxSidebarContent />;
if (kind === "settings") return <SettingsSidebarContent />;
if (kind === "memo" && memoDetail) return <MemoDetailSidebarContent />;
// 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 <CollectionSidebarContent context={currentUser ? "home" : "explore"} />;
if (kind === "memo") return <MemoDetailSidebarContent />;
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 <SpaceSwitcher className={className} />;
}
return (
<Link to={ROUTES.EXPLORE} className={cn("min-w-0 rounded-md focus-visible:outline-none", className)}>
<Link to={currentUser ? ROUTES.HOME : ROUTES.EXPLORE} className={cn("min-w-0 rounded-md focus-visible:outline-none", className)}>
<MemosLogo compact />
</Link>
);
};
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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Link
to={ROUTES.INBOX}
onClick={() => 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",
)}
/>
}
>
<span className="relative flex">
<BellIcon className="size-4" strokeWidth={1.8} />
{unreadCount > 0 && (
<span
aria-hidden="true"
data-inbox-unread-indicator
className="absolute -end-0.5 -top-0.5 size-1.5 rounded-full bg-primary ring-2 ring-sidebar"
/>
)}
</span>
</TooltipTrigger>
<TooltipContent side="top">{accessibleLabel}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
const AppSidebar = ({ className }: { className?: string }) => {
const t = useTranslate();
const currentUser = useCurrentUser();
@ -692,7 +731,12 @@ const AppSidebar = ({ className }: { className?: string }) => {
</div>
<footer className="shrink-0 border-t border-border/70">
{currentUser ? (
<UserMenu />
<div className="flex h-10 min-w-0 items-center pe-1">
<div className="min-w-0 flex-1">
<UserMenu />
</div>
<InboxFooterLink />
</div>
) : (
<Link
to={ROUTES.AUTH}

View file

@ -6,13 +6,12 @@ import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/compone
import { Input } from "@/components/ui/input";
import { useAppSidebar } from "@/contexts/AppSidebarContext";
import { type MemoFilter, replaceFiltersByFactor, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { useSpaceContext } from "@/contexts/SpaceContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useMemoViews } from "@/hooks/useUserQueries";
import { BUILTIN_TASKS_VIEW_ID, getMemoViewId, isMemoScopeRoute } from "@/lib/memo-views";
import { ROUTES } from "@/router/routes";
import { useTranslate } from "@/utils/i18n";
export const isQuickFindCollectionRoute = (pathname: string) => isMemoScopeRoute(pathname) || pathname.startsWith("/u/");
import { getRouteActionPolicy, getSidebarRouteKind } from "./routes";
export const buildQuickFindFilters = (query: string, currentFilters: MemoFilter[], preserveCurrentScope: boolean): MemoFilter[] => {
const words = Array.from(new Set(query.trim().split(/\s+/).filter(Boolean)));
@ -20,10 +19,32 @@ export const buildQuickFindFilters = (query: string, currentFilters: MemoFilter[
return preserveCurrentScope ? replaceFiltersByFactor(currentFilters, "contentSearch", contentFilters) : contentFilters;
};
export interface QuickFindSubmission {
filters: MemoFilter[];
destination?: string;
switchToAll: boolean;
}
export const resolveQuickFindSubmission = (pathname: string, query: string, currentFilters: MemoFilter[]): QuickFindSubmission => {
const routePolicy = getRouteActionPolicy(pathname);
const filters = buildQuickFindFilters(query, currentFilters, routePolicy.searchScope !== "all");
const filterQuery = stringifyFilters(filters);
return {
filters,
destination: routePolicy.searchDestination
? filterQuery
? `${routePolicy.searchDestination}?filter=${filterQuery}`
: routePolicy.searchDestination
: undefined,
switchToAll: routePolicy.searchScope === "all",
};
};
const getScopeLabel = (pathname: string, t: ReturnType<typeof useTranslate>) => {
if (pathname === ROUTES.ARCHIVED) return t("common.archived");
if (pathname === ROUTES.EXPLORE) return t("common.explore");
if (pathname.startsWith("/u/")) return t("common.profile");
const routeKind = getSidebarRouteKind(pathname);
if (routeKind === "archived") return t("common.archived");
if (routeKind === "explore") return t("common.explore");
if (routeKind === "profile") return t("common.profile");
return t("common.memos");
};
@ -34,13 +55,18 @@ const QuickFindDialog = () => {
const currentUser = useCurrentUser();
const { data: memoViews = [] } = useMemoViews(currentUser?.name);
const { filters, setFilters, setMemoView, memoView } = useMemoFilterContext();
const { clearSelectedSpace, selectedSpace, selectedSpaceName } = useSpaceContext();
const { quickFindOpen, setQuickFindOpen } = useAppSidebar();
const [query, setQuery] = useState("");
const collectionRoute = isQuickFindCollectionRoute(location.pathname);
const viewApplies = isMemoScopeRoute(location.pathname);
const selectedMemoView = viewApplies ? memoViews.find((item) => getMemoViewId(item.name) === memoView) : undefined;
const scopeLabel =
const lensLabel =
viewApplies && memoView === BUILTIN_TASKS_VIEW_ID ? t("common.tasks") : selectedMemoView?.title || getScopeLabel(location.pathname, t);
const routePolicy = getRouteActionPolicy(location.pathname);
const scopeLabel =
routePolicy.searchScope === "remembered-collection" && selectedSpaceName
? `${selectedSpace?.title || t("space.current")} · ${lensLabel}`
: lensLabel;
useEffect(() => {
if (!quickFindOpen) return;
@ -53,15 +79,19 @@ const QuickFindDialog = () => {
}, [filters, quickFindOpen]);
const submitQuery = () => {
const nextFilters = buildQuickFindFilters(query, filters, collectionRoute);
const submission = resolveQuickFindSubmission(location.pathname, query, filters);
if (collectionRoute) {
setFilters(nextFilters);
} else {
const filterQuery = stringifyFilters(nextFilters);
setFilters(nextFilters);
if (submission.switchToAll) {
// This is an explicit cross-Space action, so switch the collection state
// to All without inserting an intermediate Home history entry.
clearSelectedSpace();
}
setFilters(submission.filters);
if (submission.destination) {
setMemoView(undefined);
navigate(filterQuery ? `${ROUTES.HOME}?filter=${filterQuery}` : ROUTES.HOME);
navigate(submission.destination);
}
setQuickFindOpen(false);

View file

@ -4,13 +4,69 @@ import { ROUTES } from "@/router/routes";
export type SidebarRouteKind = MemoScope | "profile" | "views" | "attachments" | "inbox" | "settings" | "memo" | "empty";
export type RouteSearchScope = "remembered-collection" | "profile" | "all";
export type RouteComposePlacement = "remembered-space" | "unassigned";
export interface RouteActionPolicy {
searchScope: RouteSearchScope;
/** When absent, Quick Find stays on the current route. */
searchDestination?: string;
composePlacement: RouteComposePlacement;
}
export const getSidebarRouteKind = (path: string): SidebarRouteKind => {
if (isMemoScopeRoute(path)) return resolveMemoScope(path);
if (matchPath("/u/:username", path)) return "profile";
if (path === ROUTES.VIEWS) return "views";
if (path === ROUTES.ATTACHMENTS) return "attachments";
if (path === ROUTES.INBOX) return "inbox";
if (path === ROUTES.SETTING) return "settings";
if (matchPath("/memos/:uid", path) || matchPath(`${ROUTES.SHARED_MEMO}/:token`, path)) return "memo";
const normalizedPath = path.length > 1 ? path.replace(/\/+$/, "") : path;
if (isMemoScopeRoute(normalizedPath)) return resolveMemoScope(normalizedPath);
if (matchPath("/u/:username", normalizedPath)) return "profile";
if (matchPath(ROUTES.VIEWS, normalizedPath)) return "views";
if (matchPath(ROUTES.ATTACHMENTS, normalizedPath)) return "attachments";
if (matchPath(ROUTES.INBOX, normalizedPath)) return "inbox";
if (matchPath(ROUTES.SETTING, normalizedPath)) return "settings";
if (matchPath("/memos/:uid", normalizedPath) || matchPath(`${ROUTES.SHARED_MEMO}/:token`, normalizedPath)) return "memo";
return "empty";
};
/** Routes whose collections are filtered by the remembered All / Space scope. */
export const routeSupportsCollectionScope = (path: string): boolean => {
const kind = getSidebarRouteKind(path);
return kind === "home" || kind === "explore" || kind === "archived" || kind === "attachments";
};
/**
* Keeps read/search scope and write placement explicit at route boundaries.
* A remembered Space is ambient collection state, so global and canonical
* resource routes must not silently use it as a creation target.
*/
export const getRouteActionPolicy = (path: string): RouteActionPolicy => {
const kind = getSidebarRouteKind(path);
if (kind === "home" || kind === "explore" || kind === "archived") {
return {
searchScope: "remembered-collection",
composePlacement: "remembered-space",
};
}
if (kind === "attachments") {
return {
searchScope: "remembered-collection",
searchDestination: ROUTES.HOME,
composePlacement: "remembered-space",
};
}
if (kind === "profile") {
return {
searchScope: "profile",
// Re-enter the memo list tab on the same profile (dropping ?view=map).
searchDestination: path.length > 1 ? path.replace(/\/+$/, "") : path,
composePlacement: "unassigned",
};
}
return {
searchScope: "all",
searchDestination: ROUTES.HOME,
composePlacement: "unassigned",
};
};

View file

@ -1,8 +1,10 @@
import { ExternalLinkIcon } from "lucide-react";
import { Link } from "react-router-dom";
import { createMemoNavigationState } from "@/components/MemoView/navigation";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ROUTES } from "@/router/routes";
import { useTranslate } from "@/utils/i18n";
interface AttachmentMetadataLineProps {
@ -61,6 +63,9 @@ export const AttachmentSourceChip = ({
return (
<Link
to={`/${memoName}`}
// Attachments is collection-scoped, but Memo filters render on Home.
// Carry a Home origin so direct-resource handling preserves this scope.
state={createMemoNavigationState(ROUTES.HOME, "preserve")}
className="inline-flex max-w-full items-center truncate rounded-full border border-border/60 bg-muted/30 px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted/50"
>
<span className="truncate">{t("attachment-library.labels.memo")}</span>

View file

@ -133,9 +133,10 @@ function MemoCommentMessage({ notification }: Props) {
</p>
</div>
<div
<button
type="button"
onClick={handleNavigateToMemo}
className="p-2 sm:p-3 rounded-lg bg-gradient-to-br from-primary/[0.06] to-primary/[0.03] hover:from-primary/[0.1] hover:to-primary/[0.06] cursor-pointer border border-primary/30 hover:border-primary/50 transition-all duration-200 group/comment shadow-sm hover:shadow"
className="w-full p-2 text-left sm:p-3 rounded-lg bg-gradient-to-br from-primary/[0.06] to-primary/[0.03] hover:from-primary/[0.1] hover:to-primary/[0.06] cursor-pointer border border-primary/30 hover:border-primary/50 transition-all duration-200 group/comment shadow-sm hover:shadow"
>
<div className="flex items-start gap-2">
<div className="w-5 h-5 flex items-center justify-center shrink-0">
@ -148,7 +149,7 @@ function MemoCommentMessage({ notification }: Props) {
</p>
</div>
</div>
</div>
</button>
</div>
</div>
</div>

View file

@ -137,9 +137,10 @@ function MemoMentionMessage({ notification }: Props) {
</div>
)}
<div
<button
type="button"
onClick={handleNavigate}
className="p-2 sm:p-3 rounded-lg bg-gradient-to-br from-primary/[0.06] to-primary/[0.03] hover:from-primary/[0.1] hover:to-primary/[0.06] cursor-pointer border border-primary/30 hover:border-primary/50 transition-all duration-200 group/comment shadow-sm hover:shadow"
className="w-full p-2 text-left sm:p-3 rounded-lg bg-gradient-to-br from-primary/[0.06] to-primary/[0.03] hover:from-primary/[0.1] hover:to-primary/[0.06] cursor-pointer border border-primary/30 hover:border-primary/50 transition-all duration-200 group/comment shadow-sm hover:shadow"
>
<div className="flex items-start gap-2">
<div className="w-5 h-5 flex items-center justify-center shrink-0">
@ -154,7 +155,7 @@ function MemoMentionMessage({ notification }: Props) {
</p>
</div>
</div>
</div>
</button>
</div>
</div>
</div>

View file

@ -56,6 +56,7 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
confirmDeleteMemo,
} = useMemoActionHandlers({
memo,
parentScope: props.parentScope,
onEdit: props.onEdit,
setDeleteDialogOpen,
});

View file

@ -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,

View file

@ -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;

View file

@ -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) => (
<div className="w-full" key={comment.name} id={extractMemoIdFromName(comment.name)}>
<MemoView memo={comment} parentPage={parentPage} showCreator compact />
<MemoView memo={comment} parentPage={parentPage} parentScope={parentScope} showCreator showSpace compact />
</div>
))}
{hasMoreComments && (

View file

@ -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<string>;
/** 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 (
<AnchorLink href={href} memoName={memoName} compact={compact} {...props}>
<AnchorLink href={href} memoName={memoName} parentPage={parentPage} parentScope={parentScope} compact={compact} {...props}>
{children}
</AnchorLink>
);
@ -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),
);

View file

@ -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<HTMLSpanElement> {
node?: Element; // AST node from react-markdown
@ -17,9 +19,10 @@ interface TagProps extends React.HTMLAttributes<HTMLSpanElement> {
}
export const Tag: React.FC<TagProps> = ({ "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<TagProps> = ({ "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;
}

View file

@ -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)}
/>
</div>

View file

@ -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<HTMLAnchorElement>, 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<HTMLAnchorElement>,
* 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<HTMLAnchorElement>) => {
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 (
<Link to={`/${memoName}${href}`} onClick={handleClick} className={classes} {...props}>
<Link
to={`/${memoName}${href}`}
state={parentPage && parentScope ? createMemoNavigationState(parentPage, parentScope) : undefined}
onClick={handleClick}
className={classes}
{...props}
>
{children}
</Link>
);

View file

@ -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;

View file

@ -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<HTMLSpanElement>(snippet);
const relatedMemo = getRelationMemo(relation, "referenced");
if (!relatedMemo) {
@ -39,6 +52,7 @@ const BacklinkRow = ({ relation, snippet }: { relation: MemoRelation; snippet: s
<Link
className={cn(SIDEBAR_ROW_CLASSES, "text-muted-foreground hover:bg-sidebar-accent/65 hover:text-foreground")}
to={`/${relatedMemo.name}`}
state={parentPage && parentScope ? createMemoNavigationState(parentPage, parentScope) : undefined}
title={title}
viewTransition
>
@ -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 =
<SidebarSection label={t("common.referenced-by")}>
{referenced.map((relation) => {
const relatedMemo = getRelationMemo(relation, "referenced");
return <BacklinkRow key={`referenced-${relatedMemo?.name}`} relation={relation} snippet={backlinkSnippet(relation)} />;
return (
<BacklinkRow
key={`referenced-${relatedMemo?.name}`}
relation={relation}
snippet={backlinkSnippet(relation)}
parentPage={parentPage}
parentScope={parentScope}
/>
);
})}
</SidebarSection>
)}

View file

@ -13,6 +13,7 @@ export const EditorToolbar: FC<EditorToolbarProps> = ({
onSave,
onCancel,
memoName,
space,
onAudioRecorderClick,
viewToggles,
onInsertImages,
@ -56,7 +57,7 @@ export const EditorToolbar: FC<EditorToolbarProps> = ({
viewToggles={viewToggles}
onInsertImages={onInsertImages}
/>
<VisibilitySelector value={visibility} onChange={handleVisibilityChange} />
<VisibilitySelector value={visibility} space={space} onChange={handleVisibilityChange} />
</div>
<div className="flex flex-row justify-end items-center gap-2">

View file

@ -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);

View file

@ -72,6 +72,9 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
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<MemoEditorProps> = ({
onSave={handleSave}
onCancel={onCancel ? handleCancel : undefined}
memoName={memoName}
space={editorSpace}
onAudioRecorderClick={handleAudioRecorderClick}
viewToggles={viewToggles}
onInsertImages={handleInsertImages}

View file

@ -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";

View file

@ -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 (
<MemoSnippetLink name={memo.name} snippet={memo.snippet} to={`/${memo.name}`} state={{ from: parentPage }} className={className} />
<MemoSnippetLink
name={memo.name}
snippet={memo.snippet}
to={`/${memo.name}`}
state={parentPage && parentScope ? createMemoNavigationState(parentPage, parentScope) : undefined}
className={className}
/>
);
};

View file

@ -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<HTMLDivElement>();
@ -79,7 +81,12 @@ function RelationListView({ relations, currentMemoName, parentPage, className }:
return null;
}
return (
<RelationCard key={getRelationMemoName(relation, direction)} memo={resolvedMemos[memo.name] ?? memo} parentPage={parentPage} />
<RelationCard
key={getRelationMemoName(relation, direction)}
memo={resolvedMemos[memo.name] ?? memo}
parentPage={parentPage}
parentScope={parentScope}
/>
);
})}
</MetadataSection>

View file

@ -29,6 +29,7 @@ const STUB_CONTEXT: MemoViewContextValue = {
creator: undefined,
currentUser: undefined,
parentPage: "/",
parentScope: "all",
cardWidth: 0,
isArchived: false,
readonly: true,

View file

@ -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<MemoViewProps> = (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<HTMLDivElement>(null);
const [showEditor, setShowEditor] = useState(false);
const [EditorComponent, setEditorComponent] = useState<ComponentType<MemoEditorProps>>();
@ -31,7 +42,14 @@ const MemoView: React.FC<MemoViewProps> = (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<MemoViewProps> = (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<MemoViewProps> = (props: MemoViewProps) => {
creator,
currentUser,
parentPage,
parentScope,
cardWidth,
isArchived,
readonly,
@ -107,6 +125,7 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
creator,
currentUser,
parentPage,
parentScope,
cardWidth,
isArchived,
readonly,
@ -124,7 +143,7 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
ref={cardRef}
tabIndex={readonly ? -1 : 0}
>
<MemoHeader showCreator={showCreator} showVisibility={showVisibility} showPinned={showPinned} />
<MemoHeader showCreator={showCreator} showVisibility={showVisibility} showPinned={showPinned} showSpace={showSpace} />
<MemoBody compact={compact} />

View file

@ -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;

View file

@ -32,7 +32,8 @@ const BlurOverlay: React.FC<{ onClick?: () => void }> = ({ onClick }) => {
};
const MemoBody: React.FC<MemoBodyProps> = ({ 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<MemoBodyProps> = ({ compact }) => {
<ClampedSection enabled={Boolean(compact)}>
<MemoContent
memoName={memo.name}
parentPage={parentPage}
parentScope={parentScope}
content={memo.content}
attachments={memo.attachments}
onClick={handleMemoContentClick}
@ -63,7 +66,7 @@ const MemoBody: React.FC<MemoBodyProps> = ({ compact }) => {
compact={Boolean(compact)}
/>
<AttachmentListView attachments={attachmentOnlyItems} onImagePreview={openPreview} />
<RelationListView relations={referencedMemos} currentMemoName={memo.name} parentPage={parentPage} />
<RelationListView relations={referencedMemos} currentMemoName={memo.name} parentPage={parentPage} parentScope={parentScope} />
{memo.location && <LocationDisplayView location={memo.location} />}
</ClampedSection>
<MemoReactionListView memo={memo} reactions={memo.reactions} />

View file

@ -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<HTMLDivElement>();
@ -30,6 +31,7 @@ const MemoCommentListView: React.FC = () => {
<span className="text-xs text-muted-foreground">Comments{commentAmount > 1 ? ` (${commentAmount})` : ""}</span>
<Link
to={`/${memo.name}#comments`}
state={createMemoNavigationState(parentPage, parentScope)}
className="flex items-center gap-0.5 text-xs text-muted-foreground/80 hover:underline underline-offset-2 transition-colors"
>
View all
@ -43,6 +45,7 @@ const MemoCommentListView: React.FC = () => {
<Link
key={comment.name}
to={`/${memo.name}#${uid}`}
state={createMemoNavigationState(parentPage, parentScope)}
viewTransition
className="rounded-md bg-muted/40 px-2 py-1 transition-colors hover:bg-muted/60"
>

View file

@ -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<MemoHeaderProps> = ({ showCreator, showVisibility, showPinned }) => {
const MemoHeader: React.FC<MemoHeaderProps> = ({ 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<MemoHeaderProps> = ({ showCreator, showVisibility, sh
? `${t("common.last-updated-at")}: ${updateTime.toLocaleString(i18n.language)}`
: undefined,
};
const spaceMetadata = showSpace && memo.space ? <MemoSpaceBadge spaceName={memo.space} /> : null;
return (
<div className="w-full flex flex-row justify-between items-center gap-2">
<div className="w-auto max-w-[calc(100%-8rem)] grow flex flex-row justify-start items-center">
<div className="flex w-full items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
{showCreator && creator ? (
<CreatorDisplay creator={creator} displayTime={displayTime} timeTooltip={timeTooltip} onGotoDetail={handleGotoMemoDetailPage} />
<CreatorDisplay
creator={creator}
displayTime={displayTime}
timeTooltip={timeTooltip}
trailingMetadata={spaceMetadata}
onGotoDetail={handleGotoMemoDetailPage}
/>
) : (
<TimeDisplay displayTime={displayTime} timeTooltip={timeTooltip} onGotoDetail={handleGotoMemoDetailPage} />
<div data-slot="memo-header-meta" className="flex min-w-0 items-center gap-1.5">
<TimeDisplay displayTime={displayTime} timeTooltip={timeTooltip} onGotoDetail={handleGotoMemoDetailPage} />
{spaceMetadata}
</div>
)}
{memo.name === newMemoName && (
<span className="ml-2 shrink-0 rounded-full bg-primary/10 px-1.5 py-0.5 text-xs font-medium leading-none text-primary">
@ -103,7 +115,7 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh
</TooltipProvider>
)}
<MemoActionMenu memo={memo} readonly={readonly} onEdit={openEditor} />
<MemoActionMenu memo={memo} parentScope={parentScope} readonly={readonly} onEdit={openEditor} />
</div>
</div>
);
@ -113,15 +125,16 @@ interface CreatorDisplayProps {
creator: User;
displayTime: React.ReactNode;
timeTooltip: TimeTooltipContent;
trailingMetadata?: React.ReactNode;
onGotoDetail: () => void;
}
const CreatorDisplay: React.FC<CreatorDisplayProps> = ({ creator, displayTime, timeTooltip, onGotoDetail }) => (
<div className="w-full flex flex-row justify-start items-center">
const CreatorDisplay: React.FC<CreatorDisplayProps> = ({ creator, displayTime, timeTooltip, trailingMetadata, onGotoDetail }) => (
<div className="flex min-w-0 items-center">
<Link className="w-auto hover:opacity-80 rounded-md transition-colors" to={`/u/${encodeURIComponent(creator.username)}`} viewTransition>
<UserAvatar className="mr-2 shrink-0" avatarUrl={creator.avatarUrl} />
</Link>
<div className="w-full flex flex-col justify-center items-start">
<div className="flex min-w-0 flex-col items-start justify-center">
<Link
className="block leading-tight hover:opacity-80 rounded-md transition-colors truncate text-muted-foreground"
to={`/u/${encodeURIComponent(creator.username)}`}
@ -129,14 +142,18 @@ const CreatorDisplay: React.FC<CreatorDisplayProps> = ({ creator, displayTime, t
>
{creator.displayName || creator.username}
</Link>
<TimeTooltip content={timeTooltip}>
<span
className="w-auto -mt-0.5 text-xs leading-tight text-muted-foreground select-none cursor-pointer hover:opacity-80 transition-colors text-left"
onClick={onGotoDetail}
>
{displayTime}
</span>
</TimeTooltip>
<div data-slot="memo-header-meta" className="flex min-w-0 items-center gap-1.5">
<TimeTooltip content={timeTooltip}>
<button
type="button"
className="w-auto -mt-0.5 border-0 bg-transparent p-0 text-xs leading-tight text-muted-foreground select-none cursor-pointer hover:opacity-80 transition-colors text-left"
onClick={onGotoDetail}
>
{displayTime}
</button>
</TimeTooltip>
{trailingMetadata}
</div>
</div>
</div>
);
@ -164,12 +181,13 @@ interface TimeDisplayProps {
const TimeDisplay: React.FC<TimeDisplayProps> = ({ displayTime, timeTooltip, onGotoDetail }) => (
<TimeTooltip content={timeTooltip}>
<span
className="w-auto text-sm leading-tight text-muted-foreground select-none cursor-pointer hover:text-foreground transition-colors text-left"
<button
type="button"
className="w-auto border-0 bg-transparent p-0 text-sm leading-tight text-muted-foreground select-none cursor-pointer hover:text-foreground transition-colors text-left"
onClick={onGotoDetail}
>
{displayTime}
</span>
</button>
</TimeTooltip>
);

View file

@ -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 (
<Badge
variant="outline"
shape="pill"
title={accessibleLabel}
className="min-w-0 max-w-24 shrink gap-1 border-border/60 bg-muted/30 px-1.5 py-0 text-[11px] font-normal text-muted-foreground sm:max-w-32"
>
<UserLockIcon aria-hidden="true" className="size-3 shrink-0" strokeWidth={1.8} />
<span className="truncate">
{knownTitle && <span className="sr-only">{spaceLabel}: </span>}
{title}
</span>
</Badge>
);
};
export default MemoSpaceBadge;

View file

@ -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()}`;
};

View file

@ -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 {

View file

@ -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) => {
<div className="flex flex-col gap-2.5 p-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<span className="inline-flex rounded-full border border-border/70 bg-muted/50 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Memo
</span>
<div className="flex flex-wrap items-center gap-1">
<span className="inline-flex rounded-full border border-border/70 bg-muted/50 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Memo
</span>
<MemoSpaceBadge spaceName={memo.space} />
</div>
<span className="block text-[11px] font-medium text-muted-foreground">
{memo.createTime &&
timestampDate(memo.createTime).toLocaleDateString(undefined, {
@ -144,6 +151,7 @@ const UserMemoMap = ({ creator, className }: Props) => {
</div>
<Link
to={`/memos/${memo.name.split("/").pop()}`}
state={createMemoNavigationState(parentPage, "all")}
className="inline-flex items-center gap-1 rounded-full border border-border bg-background px-2.5 py-1 text-[11px] font-medium text-foreground transition-all hover:border-primary/40 hover:text-primary"
>
Open

View file

@ -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;
}

View file

@ -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

View file

@ -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<SpaceContextValue | null>(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<CollectionScope>(
() => (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<SpaceContextValue>(
() => ({
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 <SpaceContext.Provider value={value}>{children}</SpaceContext.Provider>;
@ -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,
};

View file

@ -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) }

View file

@ -3,4 +3,7 @@ export const combineCELFilters = (...filters: Array<string | undefined>): 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;

View file

@ -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";
}

View file

@ -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 (
<PagedMemoList
renderer={(memo: Memo, { compact }) => <MemoView key={getMemoKey(memo)} memo={memo} showVisibility compact={compact} />}
renderer={(memo: Memo, { compact }) => (
<MemoView key={getMemoKey(memo)} memo={memo} showVisibility showSpace={!selectedSpaceName} compact={compact} />
)}
listSort={listSort}
state={State.ARCHIVED}
orderBy={orderBy}

View file

@ -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 (
<PagedMemoList
renderer={(memo: Memo, { compact }) => <MemoView key={getMemoKey(memo)} memo={memo} showCreator showVisibility compact={compact} />}
renderer={(memo: Memo, { compact }) => (
<MemoView key={getMemoKey(memo)} memo={memo} showCreator showVisibility showSpace={!selectedSpaceName} compact={compact} />
)}
listSort={listSort}
orderBy={orderBy}
filter={memoFilter}

View file

@ -41,7 +41,7 @@ const Home = () => {
<NewMemoProvider>
<PagedMemoList
renderer={(memo: Memo, { compact }) => (
<MemoView key={getMemoKey(memo)} memo={memo} showVisibility showPinned compact={compact} />
<MemoView key={getMemoKey(memo)} memo={memo} showVisibility showPinned showSpace={!selectedSpaceName} compact={compact} />
)}
listSort={listSort}
orderBy={orderBy}

View file

@ -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 (
<section className="@container flex min-h-full w-full flex-col items-center pb-8 pt-3 md:pt-6">
<MentionResolutionProvider contents={mentionResolutionContents} userNames={userResolutionNames}>
<MemoSidebarRegistration memo={displayMemo} from={parentPage} readonly={isShareMode} onShareImageOpen={handleShareImageOpen} />
<MemoSidebarRegistration
memo={displayMemo}
from={parentPage}
fromScope={parentScope}
readonly={isShareMode}
onShareImageOpen={handleShareImageOpen}
/>
<div className="w-full max-w-2xl px-4 sm:px-6">
<div className="w-full">
{!isShareMode && parentMemo && (
@ -129,7 +139,7 @@ const MemoDetail = () => {
<Link
className="px-3 py-1 border border-border rounded-lg max-w-xs w-auto text-sm flex flex-row justify-start items-center flex-nowrap text-muted-foreground hover:shadow hover:opacity-80"
to={`/${parentMemo.name}`}
state={locationState}
state={createMemoNavigationState(parentPage, parentScope)}
viewTransition
>
<ArrowUpLeftFromCircleIcon className="w-4 h-auto shrink-0 opacity-60 mr-2" />
@ -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}

View file

@ -87,7 +87,7 @@ const UserProfile = () => {
{activeTab === "memos" ? (
<PagedMemoList
renderer={(memo: Memo, { compact }) => (
<MemoView key={getMemoKey(memo)} memo={memo} showVisibility showPinned compact={compact} />
<MemoView key={getMemoKey(memo)} memo={memo} showVisibility showPinned showSpace compact={compact} />
)}
listSort={listSort}
orderBy={orderBy}

View file

@ -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);

View file

@ -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: () => <span>Memos logo</span>,
@ -48,7 +51,10 @@ vi.mock("@/components/StatisticsView", () => ({
}));
vi.mock("@/components/AppSidebar/TagsSection", () => ({
default: () => <div>Tags</div>,
default: (props: unknown) => {
tagsSectionHook(props);
return <div>Tags</div>;
},
}));
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(
<MemoryRouter initialEntries={[path]}>
<AppSidebar />
</MemoryRouter>,
);
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(
<MemoryRouter initialEntries={[path]}>
<AppSidebar />
</MemoryRouter>,
);
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", () => {
</MemoryRouter>,
);
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(
<MemoryRouter initialEntries={["/explore"]}>
<AppSidebar />
</MemoryRouter>,
);
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(
<MemoryRouter initialEntries={["/u/alice"]}>
<AppSidebar />
</MemoryRouter>,
);
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(
<MemoryRouter initialEntries={["/inbox"]}>
<AppSidebar />
</MemoryRouter>,
);
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(
<MemoryRouter initialEntries={[path]}>
<AppSidebar />
</MemoryRouter>,
);
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(
<MemoryRouter initialEntries={["/404"]}>
<AppSidebar />
</MemoryRouter>,
);
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(
<MemoryRouter initialEntries={["/about"]}>
<MemoryRouter initialEntries={[path]}>
<AppSidebar />
</MemoryRouter>,
);
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(
<MemoryRouter initialEntries={["/attachments"]}>
<AppSidebar />
@ -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();
});

View file

@ -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",
});
});
});

View file

@ -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 <output data-testid="location-state">{JSON.stringify(location.state)}</output>;
};
describe("<AttachmentMediaGrid>", () => {
it("links a media attachment to its source memo without replacing the direct file action", () => {
const attachment = create(AttachmentSchema, {
@ -47,8 +52,9 @@ describe("<AttachmentMediaGrid>", () => {
const onPreview = vi.fn();
render(
<MemoryRouter>
<MemoryRouter initialEntries={["/attachments"]}>
<AttachmentMediaGrid groups={groups} onPreview={onPreview} />
<LocationStateProbe />
</MemoryRouter>,
);
@ -61,6 +67,7 @@ describe("<AttachmentMediaGrid>", () => {
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", () => {

View file

@ -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);

View file

@ -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,

View file

@ -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<typeof import("react-router-dom")>()),
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,
});
});
});

View file

@ -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("<Home>", () => {
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", () => {

View file

@ -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 }) => (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/memos/1"]}>{children}</MemoryRouter>
</QueryClientProvider>
);
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();
});
});

View file

@ -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 <output data-testid="location">{`${location.pathname}${location.hash}`}</output>;
return (
<>
<output data-testid="location">{`${location.pathname}${location.hash}`}</output>
<output data-testid="origin">{typeof location.state?.from === "string" ? location.state.from : ""}</output>
</>
);
};
const renderFootnote = (compact = false) =>
const renderFootnote = (compact = false, parentPage?: string) =>
render(
<MemoryRouter>
<div data-memo-content>
@ -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}
/>
</div>
@ -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<HTMLAnchorElement>("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");
});
});

View file

@ -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: () => <span>avatar</span>,
}));
vi.mock("@/components/MemoView/components/MemoSpaceBadge", () => ({
default: ({ spaceName }: { spaceName?: string }) => <span data-testid="memo-space">{spaceName}</span>,
}));
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 (
<output data-testid="location">
{location.pathname}|{JSON.stringify(location.state)}
</output>
);
};
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(
<MemoryRouter initialEntries={["/explore?filter=tagSearch%3Awork"]}>
<MemoHeader showCreator={showCreator} />
<LocationProbe />
</MemoryRouter>,
);
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(
<MemoryRouter>
<MemoHeader showCreator={showCreator} showSpace />
</MemoryRouter>,
);
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();
});
});

View file

@ -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(<MemoSpaceBadge spaceName="spaces/product" />);
expect(screen.getByTitle("Space: Product")).toHaveTextContent("Space: Product");
});
it("uses the shared pill presentation", () => {
state.spaces = [{ name: "spaces/product", title: "Product" }];
render(<MemoSpaceBadge spaceName="spaces/product" />);
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(<MemoSpaceBadge spaceName="spaces/private" />);
expect(screen.getByTitle("Space")).toHaveTextContent("Space");
expect(screen.queryByText("spaces/private")).not.toBeInTheDocument();
});
it("omits placement for an unassigned memo", () => {
const { container } = render(<MemoSpaceBadge />);
expect(container).toBeEmptyDOMElement();
});
});

View file

@ -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<typeof import("@/contexts/MemoFilterContext")>();
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(
<MemoryRouter initialEntries={["/memos/parent"]}>
<Tag data-tag="work">#work</Tag>
</MemoryRouter>,
);
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(
<MemoryRouter initialEntries={["/memos/parent"]}>
<Tag data-tag="work">#work</Tag>
</MemoryRouter>,
);
fireEvent.click(screen.getByText("#work"));
expect(clearSelectedSpace).not.toHaveBeenCalled();
expect(navigateTo).toHaveBeenCalledWith("/u/alice?filter=tagSearch%3Awork");
});
});

View file

@ -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");
});
});

View file

@ -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<typeof import("@/contexts/MemoFilterContext")>();
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 (
<>
<output data-testid="path">{`${location.pathname}${location.search}`}</output>
<output data-testid="scope">{collectionScope.kind === "space" ? collectionScope.name : collectionScope.kind}</output>
<button type="button" onClick={() => setQuickFindOpen(true)}>
Open Quick Find
</button>
<QuickFindDialog />
</>
);
};
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: (
<SpaceProvider>
<AppSidebarProvider>
<Harness />
</AppSidebarProvider>
</SpaceProvider>
),
},
],
{ initialEntries: ["/inbox"] },
);
render(<RouterProvider router={router} />);
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: (
<SpaceProvider>
<AppSidebarProvider>
<Harness />
</AppSidebarProvider>
</SpaceProvider>
),
},
],
{ initialEntries: ["/about"] },
);
render(<RouterProvider router={router} />);
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");
});
});

View file

@ -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", () => {

View file

@ -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 (
<div>
<output data-testid="selected-name">{selectedSpaceName ?? "Memos"}</output>
<output data-testid="selected-title">{selectedSpace?.title ?? ""}</output>
<output data-testid="collection-scope">
{collectionScope.kind === "space" ? `${collectionScope.kind}:${collectionScope.name}` : collectionScope.kind}
</output>
<output data-testid="memo-filter">{memoFilter ?? "all"}</output>
<button type="button" onClick={selectMemos}>
Select Memos
</button>
<button type="button" onClick={clearSelectedSpace}>
Clear Space in place
</button>
<button type="button" onClick={() => spaces[0] && selectSpace(spaces[0])}>
Select first Space
</button>
@ -46,7 +53,10 @@ const Probe = () => {
);
};
const CurrentPath = () => <output data-testid="path">{useLocation().pathname}</output>;
const CurrentPath = () => {
const location = useLocation();
return <output data-testid="path">{`${location.pathname}${location.search}`}</output>;
};
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", () => {

View file

@ -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<Record<string, unknown>>,
memoViewProps: [] as Array<Record<string, unknown>>,
filterOptions: [] as Array<Record<string, unknown>>,
}));
vi.mock("@/components/MemoView", () => ({
default: () => <div />,
default: (props: Record<string, unknown>) => {
state.memoViewProps.push(props);
return <div />;
},
}));
vi.mock("@/components/PagedMemoList", () => ({
default: (props: Record<string, unknown>) => {
state.listProps.push(props);
return <div />;
const renderer = props.renderer as
| ((memo: { name: string; space: string }, options: { compact: boolean }) => React.ReactNode)
| undefined;
return <div>{renderer?.({ name: "memos/test", space: "spaces/product" }, { compact: false })}</div>;
},
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(
<>
<Explore />
@ -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 });
});
});

View file

@ -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() });

View file

@ -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(<VisibilitySelector value={value} onChange={onChange} />);
const openMenu = async (value: Visibility, onChange = vi.fn(), space?: string) => {
render(<VisibilitySelector value={value} space={space} onChange={onChange} />);
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(<VisibilitySelector value={Visibility.SPACE} onChange={vi.fn()} />);
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();

View file

@ -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(<VisibilityIcon visibility={Visibility.VISIBILITY_UNSPECIFIED} />);
@ -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", () => {