refactor(web): drop the memo origin scope

The "all" / "preserve" origin scope existed to decide whether returning
from a memo detail should clear the remembered Space. With the Space now
part of the origin URL itself, nothing branches on it any more; it was only
computed, serialized into router state as fromScope, and threaded through
props as parentScope.

Router state is now just { from }, createMemoNavigationState takes one
argument, and resolveMemoDetailOrigin / resolveMemoParentPage return the
origin page as a string. The archived-memo fallback to /archived stays.
This commit is contained in:
johnnyjoygh 2026-09-07 09:33:47 +08:00
parent af008b2153
commit 9da3765b19
29 changed files with 63 additions and 226 deletions

View file

@ -270,7 +270,6 @@ const MemoDetailSidebarContent = () => {
memo={memoDetail.memo}
parentMemo={memoDetail.parentMemo}
parentPage={memoDetail.from}
parentScope={memoDetail.fromScope}
hasExplicitOrigin={memoDetail.hasExplicitOrigin}
commentCount={memoDetail.commentCount}
forceReadonly={memoDetail.readonly}

View file

@ -63,7 +63,7 @@ export const AttachmentSourceChip = ({
return (
<Link
to={`/${memoName}`}
state={createMemoNavigationState(`${location.pathname}${location.search}`, "preserve")}
state={createMemoNavigationState(`${location.pathname}${location.search}`)}
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

@ -3,7 +3,6 @@ import { type ComponentType, forwardRef, useCallback, useImperativeHandle, useRe
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 { MEMO_COMMENTS_ANCHOR_ID } from "@/lib/memo-comments";
@ -17,7 +16,6 @@ interface Props {
comments: Memo[];
commentCount?: number;
parentPage?: string;
parentScope?: MemoOriginScope;
hasMoreComments?: boolean;
isFetchingMoreComments?: boolean;
onLoadMoreComments?: () => void;
@ -29,19 +27,7 @@ export interface MemoCommentSectionHandle {
}
const MemoCommentSection = forwardRef<MemoCommentSectionHandle, Props>(
(
{
memo,
comments,
commentCount = comments.length,
parentPage,
parentScope,
hasMoreComments,
isFetchingMoreComments,
onLoadMoreComments,
},
ref,
) => {
({ memo, comments, commentCount = comments.length, parentPage, hasMoreComments, isFetchingMoreComments, onLoadMoreComments }, ref) => {
const t = useTranslate();
const currentUser = useCurrentUser();
const [showEditor, setShowEditor] = useState(false);
@ -154,7 +140,7 @@ const MemoCommentSection = forwardRef<MemoCommentSectionHandle, Props>(
)}
{comments.map((comment) => (
<div className="w-full" key={comment.name} id={extractMemoIdFromName(comment.name)}>
<MemoView memo={comment} parentPage={parentPage} parentScope={parentScope} showCreator showSpace compact />
<MemoView memo={comment} parentPage={parentPage} showCreator showSpace compact />
</div>
))}
{hasMoreComments && (

View file

@ -7,7 +7,6 @@ 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";
@ -26,7 +25,6 @@ export interface MemoMarkdownRendererProps {
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;
}
@ -67,7 +65,6 @@ export const MemoMarkdownRendererCore = ({
resolvedMentionUsernames,
memoName,
parentPage,
parentScope,
compact,
mathRemarkPlugins = [],
mathRehypePlugins = [],
@ -134,7 +131,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} parentPage={parentPage} parentScope={parentScope} compact={compact} {...props}>
<AnchorLink href={href} memoName={memoName} parentPage={parentPage} compact={compact} {...props}>
{children}
</AnchorLink>
);
@ -195,7 +192,6 @@ export const MemoMarkdownRenderer = memo(
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

@ -40,7 +40,6 @@ const MemoContent = (props: MemoContentProps) => {
resolvedMentionUsernames={resolvedMentionUsernames}
memoName={props.memoName}
parentPage={props.parentPage}
parentScope={props.parentScope}
compact={Boolean(props.compact)}
/>
</div>

View file

@ -2,7 +2,7 @@ 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 { createMemoNavigationState } from "../../MemoView/navigation";
import type { ReactMarkdownProps } from "./types";
interface AnchorLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement>, ReactMarkdownProps {
@ -11,7 +11,6 @@ interface AnchorLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement>,
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;
@ -26,17 +25,7 @@ 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,
parentPage,
parentScope,
compact,
children,
className,
node: _node,
...props
}: AnchorLinkProps) => {
export const AnchorLink = ({ href, memoName, parentPage, 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));
@ -57,7 +46,7 @@ export const AnchorLink = ({
return (
<Link
to={`/${memoName}${href}`}
state={parentPage && parentScope ? createMemoNavigationState(parentPage, parentScope) : undefined}
state={parentPage ? createMemoNavigationState(parentPage) : undefined}
onClick={handleClick}
className={classes}
{...props}

View file

@ -1,6 +1,5 @@
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;
@ -10,7 +9,6 @@ export interface MemoContentProps {
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

@ -19,7 +19,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 { createMemoNavigationState } from "@/components/MemoView/navigation";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { useInstance } from "@/contexts/InstanceContext";
import { useOverflowTitle } from "@/hooks";
@ -37,7 +37,6 @@ interface Props {
memo: Memo;
parentMemo?: Memo;
parentPage?: string;
parentScope?: MemoOriginScope;
hasExplicitOrigin?: boolean;
commentCount?: number;
className?: string;
@ -54,13 +53,11 @@ const BacklinkRow = ({
relation,
snippet,
parentPage,
parentScope,
referencedByLabel,
}: {
relation: MemoRelation;
snippet: string;
parentPage?: string;
parentScope?: MemoOriginScope;
referencedByLabel: string;
}) => {
const { ref, title } = useOverflowTitle<HTMLSpanElement>(snippet);
@ -74,7 +71,7 @@ const BacklinkRow = ({
aria-label={`${referencedByLabel}: ${snippet}`}
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}
state={parentPage ? createMemoNavigationState(parentPage) : undefined}
title={title}
viewTransition
>
@ -90,7 +87,6 @@ const MemoDetailSidebar = ({
memo,
parentMemo,
parentPage,
parentScope,
hasExplicitOrigin = false,
commentCount,
className,
@ -219,7 +215,7 @@ const MemoDetailSidebar = ({
aria-label={`${t("memo.parent-memo")}: ${parentSnippet}`}
className={cn(SIDEBAR_ROW_CLASSES, "text-muted-foreground hover:bg-sidebar-accent/65 hover:text-foreground")}
to={`/${parentMemo.name}`}
state={parentPage && parentScope ? createMemoNavigationState(parentPage, parentScope) : undefined}
state={parentPage ? createMemoNavigationState(parentPage) : undefined}
title={parentSnippet}
viewTransition
>
@ -235,7 +231,6 @@ const MemoDetailSidebar = ({
relation={relation}
snippet={backlinkSnippet(relation)}
parentPage={parentPage}
parentScope={parentScope}
referencedByLabel={t("common.referenced-by")}
/>
);

View file

@ -1,21 +1,20 @@
import MemoSnippetLink from "@/components/MemoView/components/MemoSnippetLink";
import { createMemoNavigationState, type MemoOriginScope } from "@/components/MemoView/navigation";
import { createMemoNavigationState } 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, parentScope, className }: RelationCardProps) => {
const RelationCard = ({ memo, parentPage, className }: RelationCardProps) => {
return (
<MemoSnippetLink
name={memo.name}
snippet={memo.snippet}
to={`/${memo.name}`}
state={parentPage && parentScope ? createMemoNavigationState(parentPage, parentScope) : undefined}
state={parentPage ? createMemoNavigationState(parentPage) : undefined}
className={className}
/>
);

View file

@ -1,7 +1,6 @@
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";
@ -13,11 +12,10 @@ interface RelationListViewProps {
relations: MemoRelation[];
currentMemoName?: string;
parentPage?: string;
parentScope?: MemoOriginScope;
className?: string;
}
function RelationListView({ relations, currentMemoName, parentPage, parentScope, className }: RelationListViewProps) {
function RelationListView({ relations, currentMemoName, parentPage, className }: RelationListViewProps) {
const t = useTranslate();
const [activeTab, setActiveTab] = useState<"referencing" | "referenced">("referencing");
const { ref: viewportRef, isNearViewport } = useNearViewport<HTMLDivElement>();
@ -81,12 +79,7 @@ function RelationListView({ relations, currentMemoName, parentPage, parentScope,
return null;
}
return (
<RelationCard
key={getRelationMemoName(relation, direction)}
memo={resolvedMemos[memo.name] ?? memo}
parentPage={parentPage}
parentScope={parentScope}
/>
<RelationCard key={getRelationMemoName(relation, direction)} memo={resolvedMemos[memo.name] ?? memo} parentPage={parentPage} />
);
})}
</MetadataSection>

View file

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

View file

@ -25,7 +25,7 @@ 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 { isMemoDetailPath, resolveMemoParentPage } from "./navigation";
import type { MemoViewHandle, MemoViewProps } from "./types";
const MemoShareImageDialog = lazyWithReload(() => import("../MemoActionMenu/MemoShareImageDialog"));
@ -36,7 +36,6 @@ const MemoView = forwardRef<MemoViewHandle, MemoViewProps>((props, ref) => {
memo: memoData,
className,
parentPage: parentPageProp,
parentScope: parentScopeProp,
compact,
timeDisplay,
showCreator,
@ -55,9 +54,8 @@ const MemoView = forwardRef<MemoViewHandle, MemoViewProps>((props, ref) => {
const isArchived = memoData.state === State.ARCHIVED;
const readonly = memoData.creator !== currentUser?.name && !isSuperUser(currentUser);
const location = useLocation();
const { parentPage, parentScope } = resolveMemoOrigin({
const parentPage = resolveMemoParentPage({
explicitParentPage: parentPageProp,
explicitParentScope: parentScopeProp,
pathname: location.pathname,
search: location.search,
memoName: memoData.name,
@ -135,7 +133,6 @@ const MemoView = forwardRef<MemoViewHandle, MemoViewProps>((props, ref) => {
creator,
currentUser,
parentPage,
parentScope,
cardWidth,
isArchived,
readonly,
@ -150,7 +147,6 @@ const MemoView = forwardRef<MemoViewHandle, MemoViewProps>((props, ref) => {
creator,
currentUser,
parentPage,
parentScope,
cardWidth,
isArchived,
readonly,

View file

@ -7,14 +7,13 @@ 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";
import { isMemoDetailPath } from "./navigation";
export interface MemoViewContextValue {
memo: Memo;
creator: User | undefined;
currentUser: User | undefined;
parentPage: string;
parentScope: MemoOriginScope;
cardWidth: number;
isArchived: boolean;
readonly: boolean;

View file

@ -32,8 +32,7 @@ const BlurOverlay: React.FC<{ onClick?: () => void }> = ({ onClick }) => {
};
const MemoBody: React.FC<MemoBodyProps> = ({ compact }) => {
const { memo, parentPage, parentScope, showBlurredContent, blurred, readonly, openEditor, openPreview, toggleBlurVisibility } =
useMemoViewContext();
const { memo, parentPage, showBlurredContent, blurred, readonly, openEditor, openPreview, toggleBlurVisibility } = useMemoViewContext();
const { handleMemoContentClick, handleMemoContentDoubleClick } = useMemoHandlers({ readonly, openEditor, openPreview });
@ -58,7 +57,6 @@ const MemoBody: React.FC<MemoBodyProps> = ({ compact }) => {
<MemoContent
memoName={memo.name}
parentPage={parentPage}
parentScope={parentScope}
content={memo.content}
attachments={memo.attachments}
onClick={handleMemoContentClick}
@ -66,7 +64,7 @@ const MemoBody: React.FC<MemoBodyProps> = ({ compact }) => {
compact={Boolean(compact)}
/>
<AttachmentListView attachments={attachmentOnlyItems} onImagePreview={openPreview} />
<RelationListView relations={referencedMemos} currentMemoName={memo.name} parentPage={parentPage} parentScope={parentScope} />
<RelationListView relations={referencedMemos} currentMemoName={memo.name} parentPage={parentPage} />
{memo.location && <LocationDisplayView location={memo.location} />}
</ClampedSection>
<MemoReactionListView memo={memo} reactions={memo.reactions} />

View file

@ -10,7 +10,7 @@ import { useMemoViewContext, useMemoViewDerived } from "../MemoViewContext";
import { createMemoNavigationState } from "../navigation";
const MemoCommentListView: React.FC = () => {
const { memo, parentPage, parentScope } = useMemoViewContext();
const { memo, parentPage } = useMemoViewContext();
const { isInMemoDetailPage, commentAmount } = useMemoViewDerived();
const { ref: viewportRef, isNearViewport } = useNearViewport<HTMLDivElement>();
@ -32,7 +32,7 @@ const MemoCommentListView: React.FC = () => {
<span className="text-xs text-muted-foreground">Comments{commentAmount > 1 ? ` (${commentAmount})` : ""}</span>
<Link
to={`/${memo.name}#${MEMO_COMMENTS_ANCHOR_ID}`}
state={createMemoNavigationState(parentPage, parentScope)}
state={createMemoNavigationState(parentPage)}
className="flex items-center gap-0.5 text-xs text-muted-foreground/80 hover:underline underline-offset-2 transition-colors"
>
View all
@ -46,7 +46,7 @@ const MemoCommentListView: React.FC = () => {
<Link
key={comment.name}
to={`/${memo.name}#${uid}`}
state={createMemoNavigationState(parentPage, parentScope)}
state={createMemoNavigationState(parentPage)}
viewTransition
className="rounded-md bg-muted/40 px-2 py-1 transition-colors hover:bg-muted/60"
>

View file

@ -29,15 +29,15 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ timeDisplay = "relative", showC
const t = useTranslate();
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
const { memo, creator, currentUser, parentPage, parentScope, isArchived, readonly, openEditor } = useMemoViewContext();
const { memo, creator, currentUser, parentPage, 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: createMemoNavigationState(parentPage, parentScope) });
}, [memo.name, parentPage, parentScope, navigateTo]);
navigateTo(`/${memo.name}`, { state: createMemoNavigationState(parentPage) });
}, [memo.name, parentPage, navigateTo]);
const { unpinMemo } = useMemoActions(memo);

View file

@ -1,11 +1,8 @@
import { getProfileUsername, isMemoScopeRoute } from "@/lib/memo-views";
import { ROUTES, resolveCollectionRoute } from "@/router/routes";
export type MemoOriginScope = "all" | "preserve";
import { getProfileUsername } from "@/lib/memo-views";
import { ROUTES } from "@/router/routes";
export interface MemoNavigationState {
from: string;
fromScope: MemoOriginScope;
}
interface ResolveMemoDetailOriginOptions {
@ -14,7 +11,6 @@ interface ResolveMemoDetailOriginOptions {
interface ResolveMemoParentPageOptions {
explicitParentPage?: string;
explicitParentScope?: MemoOriginScope;
pathname: string;
search: string;
memoName: string;
@ -40,32 +36,13 @@ export const isMemoResourcePath = (pathname: string): boolean => {
return memoID.length > 0 && !memoID.includes("/");
};
/** Whether this origin represents a collection whose complete URL should be preserved. */
export const isMemoCollectionOrigin = (page: string): boolean => {
const pathname = page.split(/[?#]/, 1)[0] || ROUTES.HOME;
return isMemoScopeRoute(pathname) || resolveCollectionRoute(pathname).isCollection;
};
export const createMemoNavigationState = (from: string): MemoNavigationState => ({ from });
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;
/** Reads the origin page out of router state; without one, a detail returns to its collection. */
export const resolveMemoDetailOrigin = (state: unknown, options: ResolveMemoDetailOriginOptions = {}): string => {
const value = state && typeof state === "object" ? (state as { from?: 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"
: options.memoArchived
? "preserve"
: "all";
return { parentPage, parentScope };
return explicitParentPage || (options.memoArchived ? ROUTES.ARCHIVED : ROUTES.HOME);
};
/** Whether the current route is the canonical detail page for this memo. */
@ -79,33 +56,12 @@ export const isMemoDetailPath = (pathname: string, memoName: string): boolean =>
* 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 = ({ explicitParentPage, pathname, search, memoName }: ResolveMemoParentPageOptions): string => {
if (explicitParentPage !== undefined) return explicitParentPage || ROUTES.HOME;
if (isMemoDetailPath(pathname, memoName)) return ROUTES.HOME;
return `${pathname}${search}`;
};
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);

View file

@ -1,5 +1,4 @@
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import type { MemoOriginScope } from "./navigation";
/** How the header names the memo's time: relative to now, or just the clock time for lists that already name the day. */
export type MemoTimeDisplay = "relative" | "time";
@ -14,7 +13,6 @@ export interface MemoViewProps {
showSpace?: boolean;
className?: string;
parentPage?: string;
parentScope?: MemoOriginScope;
shareImageDialogOpen?: boolean;
onShareImageDialogOpenChange?: (open: boolean) => void;
}

View file

@ -157,7 +157,7 @@ const UserMemoMap = ({ creator, className }: Props) => {
</div>
<Link
to={`/memos/${memo.name.split("/").pop()}`}
state={createMemoNavigationState(parentPage, "all")}
state={createMemoNavigationState(parentPage)}
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,6 +1,5 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import type { MemoOriginScope } from "@/components/MemoView/navigation";
import type { PrimaryMemoScope } from "@/lib/memo-views";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
@ -11,7 +10,6 @@ export interface MemoDetailSidebarDescriptor {
memo: Memo;
parentMemo?: Memo;
from?: string;
fromScope?: MemoOriginScope;
hasExplicitOrigin?: boolean;
commentCount?: number;
readonly?: boolean;

View file

@ -41,8 +41,6 @@ export const isCalendarRoute = (pathname: string): boolean => {
/**
* Routes that render a memo collection the sidebar can narrow: the scope routes, a user
* profile and the calendar. Views, calendar days and tags apply in place on all of them.
* This is a different question from `isMemoCollectionOrigin` (MemoView/navigation.ts),
* which asks whether a memo origin is a collection whose full URL is worth returning to.
*/
export const isMemoCollectionRoute = (pathname: string): boolean =>
isMemoScopeRoute(pathname) || getProfileUsername(pathname) !== undefined || isCalendarRoute(pathname);

View file

@ -6,7 +6,7 @@ import MemoCommentSection, { type MemoCommentSectionHandle } from "@/components/
import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext";
import MemoView, { type MemoViewHandle } from "@/components/MemoView";
import { computeCommentAmount } from "@/components/MemoView/MemoViewContext";
import { createMemoNavigationState, type MemoOriginScope, resolveMemoDetailOrigin } from "@/components/MemoView/navigation";
import { createMemoNavigationState, resolveMemoDetailOrigin } from "@/components/MemoView/navigation";
import { useAppSidebar } from "@/contexts/AppSidebarContext";
import { useAuth } from "@/contexts/AuthContext";
import { useInstance } from "@/contexts/InstanceContext";
@ -25,7 +25,6 @@ const MemoSidebarRegistration = ({
memo,
parentMemo,
from,
fromScope,
hasExplicitOrigin,
commentCount,
readonly,
@ -37,7 +36,6 @@ const MemoSidebarRegistration = ({
memo: Memo;
parentMemo?: Memo;
from: string;
fromScope: MemoOriginScope;
hasExplicitOrigin: boolean;
commentCount?: number;
readonly: boolean;
@ -53,7 +51,6 @@ const MemoSidebarRegistration = ({
memo,
parentMemo,
from,
fromScope,
hasExplicitOrigin,
commentCount,
readonly,
@ -65,7 +62,6 @@ const MemoSidebarRegistration = ({
}, [
commentCount,
from,
fromScope,
hasExplicitOrigin,
memo,
onCommentCreate,
@ -113,8 +109,7 @@ const MemoDetail = () => {
const hasExplicitOrigin =
!!locationState && typeof locationState === "object" && typeof (locationState as { from?: unknown }).from === "string";
const resolvedOrigin = resolveMemoDetailOrigin(locationState, { memoArchived: memo?.state === State.ARCHIVED });
const parentPage = !hasExplicitOrigin && !currentUser && memo?.state !== State.ARCHIVED ? ROUTES.EXPLORE : resolvedOrigin.parentPage;
const parentScope = resolvedOrigin.parentScope;
const parentPage = !hasExplicitOrigin && !currentUser && memo?.state !== State.ARCHIVED ? ROUTES.EXPLORE : resolvedOrigin;
const memoName = memo?.name ?? memoNameFromParams;
const displayMemo = useReactMemo(() => {
if (!memo) return undefined;
@ -197,7 +192,6 @@ const MemoDetail = () => {
memo={displayMemo}
parentMemo={parentMemo}
from={parentPage}
fromScope={parentScope}
hasExplicitOrigin={hasExplicitOrigin}
commentCount={isShareMode ? undefined : commentCount}
readonly={isShareMode}
@ -213,7 +207,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={createMemoNavigationState(parentPage, parentScope)}
state={createMemoNavigationState(parentPage)}
viewTransition
>
<ArrowUpLeftFromCircleIcon className="w-4 h-auto shrink-0 opacity-60 mr-2" />
@ -227,7 +221,6 @@ const MemoDetail = () => {
memo={displayMemo}
compact={false}
parentPage={parentPage}
parentScope={parentScope}
shareImageDialogOpen={shareImageDialogOpen}
showCreator
showVisibility
@ -242,7 +235,6 @@ const MemoDetail = () => {
comments={comments}
commentCount={commentCount}
parentPage={parentPage}
parentScope={parentScope}
hasMoreComments={hasNextComments}
isFetchingMoreComments={isFetchingNextComments}
onLoadMoreComments={fetchNextComments}

View file

@ -67,9 +67,7 @@ describe("<AttachmentMediaGrid>", () => {
fireEvent.click(memoLink);
expect(onPreview).not.toHaveBeenCalled();
expect(screen.getByTestId("location-state")).toHaveTextContent(
'{"from":"/spaces/product/attachments?filter=tagSearch%3Awork","fromScope":"preserve"}',
);
expect(screen.getByTestId("location-state")).toHaveTextContent('{"from":"/spaces/product/attachments?filter=tagSearch%3Awork"}');
});
it("keeps video duration inside the existing play badge", () => {

View file

@ -24,7 +24,6 @@ const renderFootnote = (compact = false, parentPage?: string) =>
resolvedMentionUsernames={new Set()}
memoName="memos/abc123"
parentPage={parentPage}
parentScope={parentPage ? "preserve" : undefined}
compact={compact}
/>
</div>

View file

@ -83,7 +83,6 @@ describe("MemoDetailSidebar", () => {
memo={memo}
parentMemo={parentMemo}
parentPage="/explore?filter=tagSearch%3Awork"
parentScope="preserve"
hasExplicitOrigin
commentCount={3}
onEdit={onEdit}
@ -144,9 +143,7 @@ describe("MemoDetailSidebar", () => {
relations: [createIncomingReference("memos/public")],
});
renderSidebar(
<MemoDetailSidebar memo={memo} parentPage="/explore" parentScope="all" commentCount={1} onEdit={vi.fn()} onCommentCreate={vi.fn()} />,
);
renderSidebar(<MemoDetailSidebar memo={memo} parentPage="/explore" commentCount={1} onEdit={vi.fn()} onCommentCreate={vi.fn()} />);
expect(screen.queryByRole("button", { name: "common.edit" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "memo.comment.write-a-comment" })).toBeInTheDocument();
@ -163,9 +160,7 @@ describe("MemoDetailSidebar", () => {
content: "Body",
});
renderSidebar(
<MemoDetailSidebar memo={memo} parentPage="/" parentScope="all" commentCount={0} onEdit={vi.fn()} onCommentCreate={vi.fn()} />,
);
renderSidebar(<MemoDetailSidebar memo={memo} parentPage="/" commentCount={0} onEdit={vi.fn()} onCommentCreate={vi.fn()} />);
expect(screen.getByRole("link", { name: "memo.go-to:common.home" })).toHaveAttribute("href", "/");
expect(screen.queryByText("memo.on-this-memo")).not.toBeInTheDocument();
@ -187,7 +182,7 @@ describe("MemoDetailSidebar", () => {
content: "Body",
});
renderSidebar(<MemoDetailSidebar memo={memo} parentPage="/" parentScope="all" commentCount={0} />);
renderSidebar(<MemoDetailSidebar memo={memo} parentPage="/" commentCount={0} />);
fireEvent.click(screen.getByRole("button", { name: "common.share" }));
expect(await screen.findByRole("menuitem", { name: "memo.copy-link" })).toBeInTheDocument();
@ -202,16 +197,7 @@ describe("MemoDetailSidebar", () => {
content: "Archived body",
});
renderSidebar(
<MemoDetailSidebar
memo={memo}
parentPage="/archived"
parentScope="preserve"
commentCount={2}
onEdit={vi.fn()}
onCommentCreate={vi.fn()}
/>,
);
renderSidebar(<MemoDetailSidebar memo={memo} parentPage="/archived" commentCount={2} onEdit={vi.fn()} onCommentCreate={vi.fn()} />);
expect(screen.getByRole("link", { name: "memo.go-to:common.archived" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: /memo.comment.self/ })).toHaveTextContent("2");
@ -237,7 +223,6 @@ describe("MemoDetailSidebar", () => {
memo={memo}
parentMemo={parentMemo}
parentPage="/explore"
parentScope="all"
hasExplicitOrigin
commentCount={4}
forceReadonly

View file

@ -81,7 +81,7 @@ describe("MemoFilterProvider", () => {
);
render(<RouterProvider router={router} />);
fireEvent.click(screen.getByRole("button", { name: "Select Tasks" }));
const state = { from: origin, fromScope: "preserve" };
const state = { from: origin };
await act(() => router.navigate("/memos/1", { state }));
await waitFor(() => expect(screen.getByTestId("filters")).toHaveTextContent("[]"));
expect(router.state.location.search).toBe("");

View file

@ -62,7 +62,6 @@ vi.mock("@/components/MemoView/MemoViewContext", () => ({
creator: state.creator,
currentUser: state.currentUser,
parentPage: "/explore?filter=tagSearch%3Awork",
parentScope: "preserve",
isArchived: false,
readonly: false,
openEditor: vi.fn(),
@ -142,9 +141,7 @@ describe("MemoHeader navigation", () => {
expect(timestamp).toHaveFocus();
fireEvent.click(timestamp);
expect(screen.getByTestId("location")).toHaveTextContent(
'/memos/123|{"from":"/explore?filter=tagSearch%3Awork","fromScope":"preserve"}',
);
expect(screen.getByTestId("location")).toHaveTextContent('/memos/123|{"from":"/explore?filter=tagSearch%3Awork"}');
});
it.each([false, true])("keeps the Space pill beside the timestamp when showCreator=%s", (showCreator) => {

View file

@ -1,11 +1,9 @@
import { describe, expect, it } from "vitest";
import {
createMemoNavigationState,
isMemoCollectionOrigin,
isMemoDetailPath,
isMemoResourcePath,
resolveMemoDetailOrigin,
resolveMemoOrigin,
resolveMemoParentPage,
withMemoFilter,
} from "@/components/MemoView/navigation";
@ -32,53 +30,25 @@ describe("memo view navigation", () => {
).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("keeps a Profile map origin intact", () => {
expect(resolveMemoParentPage({ pathname: "/u/alice", search: "?view=map", memoName: "memos/123" })).toBe("/u/alice?view=map");
});
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 Home for direct and shared resource entries", () => {
expect(resolveMemoDetailOrigin(undefined)).toBe("/");
expect(resolveMemoDetailOrigin({ unrelated: true })).toBe("/");
});
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 a user-level Archived origin without changing the remembered Space", () => {
expect(resolveMemoDetailOrigin(undefined, { memoArchived: true })).toEqual({ parentPage: "/archived", parentScope: "preserve" });
it("uses the user archive for a direct entry to an archived memo", () => {
expect(resolveMemoDetailOrigin(undefined, { memoArchived: true })).toBe("/archived");
});
it("keeps an explicit origin ahead of the archived fallback", () => {
expect(resolveMemoDetailOrigin(createMemoNavigationState("/u/alice", "all"), { memoArchived: true })).toEqual({
parentPage: "/u/alice",
parentScope: "all",
});
expect(resolveMemoDetailOrigin(createMemoNavigationState("/u/alice"), { memoArchived: true })).toBe("/u/alice");
});
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("round-trips an explicit origin through router state", () => {
expect(resolveMemoDetailOrigin(createMemoNavigationState("/explore?filter=tagSearch%3Awork"))).toBe("/explore?filter=tagSearch%3Awork");
});
it.each([

View file

@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveQuickFindSubmission } from "@/components/AppSidebar/QuickFindDialog";
import { getRouteActionPolicy, getSidebarRouteKind } from "@/components/AppSidebar/routes";
import { CalendarHeader } from "@/components/CalendarView/CalendarHeader";
import { resolveMemoDetailOrigin, resolveMemoOrigin } from "@/components/MemoView/navigation";
import { resolveMemoDetailOrigin, resolveMemoParentPage } from "@/components/MemoView/navigation";
import Calendar from "@/pages/Calendar";
import { buildCollectionPath, getSpaceSwitchPath, resolveCollectionRoute } from "@/router/routes";
import { SpaceRoute } from "@/router/SpaceRoute";
@ -59,10 +59,10 @@ describe("Space route contract", () => {
it("carries the full calendar origin through a permanent memo link", () => {
const parentPage = "/spaces/a/calendar/2026/09/06?filter=tagSearch%3Awork";
expect(
resolveMemoOrigin({ pathname: "/spaces/a/calendar/2026/09/06", search: "?filter=tagSearch%3Awork", memoName: "memos/x" }),
).toEqual({ parentPage, parentScope: "preserve" });
expect(resolveMemoDetailOrigin({ from: parentPage })).toEqual({ parentPage, parentScope: "preserve" });
expect(resolveMemoDetailOrigin(undefined)).toEqual({ parentPage: "/", parentScope: "all" });
resolveMemoParentPage({ pathname: "/spaces/a/calendar/2026/09/06", search: "?filter=tagSearch%3Awork", memoName: "memos/x" }),
).toBe(parentPage);
expect(resolveMemoDetailOrigin({ from: parentPage })).toBe(parentPage);
expect(resolveMemoDetailOrigin(undefined)).toBe("/");
});
});