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:
parent
05f882e43a
commit
cdd13ca24e
65 changed files with 1818 additions and 249 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
|
|||
confirmDeleteMemo,
|
||||
} = useMemoActionHandlers({
|
||||
memo,
|
||||
parentScope: props.parentScope,
|
||||
onEdit: props.onEdit,
|
||||
setDeleteDialogOpen,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const STUB_CONTEXT: MemoViewContextValue = {
|
|||
creator: undefined,
|
||||
currentUser: undefined,
|
||||
parentPage: "/",
|
||||
parentScope: "all",
|
||||
cardWidth: 0,
|
||||
isArchived: false,
|
||||
readonly: true,
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
|||
37
web/src/components/MemoView/components/MemoSpaceBadge.tsx
Normal file
37
web/src/components/MemoView/components/MemoSpaceBadge.tsx
Normal 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;
|
||||
119
web/src/components/MemoView/navigation.ts
Normal file
119
web/src/components/MemoView/navigation.ts
Normal 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()}`;
|
||||
};
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
115
web/tests/memo-action-navigation.test.tsx
Normal file
115
web/tests/memo-action-navigation.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
127
web/tests/memo-header-navigation.test.tsx
Normal file
127
web/tests/memo-header-navigation.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
49
web/tests/memo-space-badge.test.tsx
Normal file
49
web/tests/memo-space-badge.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
76
web/tests/memo-tag-navigation.test.tsx
Normal file
76
web/tests/memo-tag-navigation.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
127
web/tests/memo-view-navigation.test.ts
Normal file
127
web/tests/memo-view-navigation.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
143
web/tests/quick-find-navigation.test.tsx
Normal file
143
web/tests/quick-find-navigation.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue