From 6eb17864df21f43de978b69d3deb45df7264780e Mon Sep 17 00:00:00 2001 From: johnnyjoygh Date: Sun, 21 Jun 2026 14:11:46 +0800 Subject: [PATCH] feat: surface newly created memo above pinned list --- web/src/components/MemoContent/constants.ts | 17 ++++++++-- web/src/components/MemoContent/hooks.ts | 6 ++-- web/src/components/MemoContent/index.tsx | 4 +-- web/src/components/MemoEditor/index.tsx | 8 +++++ .../MemoView/components/MemoHeader.tsx | 7 ++++ .../PagedMemoList/PagedMemoList.tsx | 11 +++++-- web/src/contexts/NewMemoContext.tsx | 29 ++++++++++++++++ web/src/hooks/useMemoSorting.ts | 13 ++++++++ web/src/locales/en.json | 1 + web/src/pages/Home.tsx | 19 ++++++----- web/tests/hoist-new-memo.test.ts | 33 +++++++++++++++++++ .../memo-content-compact-threshold.test.ts | 27 +++++++++++++++ 12 files changed, 157 insertions(+), 18 deletions(-) create mode 100644 web/src/contexts/NewMemoContext.tsx create mode 100644 web/tests/hoist-new-memo.test.ts create mode 100644 web/tests/memo-content-compact-threshold.test.ts diff --git a/web/src/components/MemoContent/constants.ts b/web/src/components/MemoContent/constants.ts index 33a0be5e2..79c1e5beb 100644 --- a/web/src/components/MemoContent/constants.ts +++ b/web/src/components/MemoContent/constants.ts @@ -4,13 +4,24 @@ import { defaultSchema } from "rehype-sanitize"; export const TASK_LIST_CLASS = "contains-task-list"; export const TASK_LIST_ITEM_CLASS = "task-list-item"; +// Content rows use leading-6 (1.5rem line-height = 24px at the 16px root font). +const ROW_HEIGHT_PX = 24; + // Compact mode display settings export const COMPACT_MODE_CONFIG = { - maxHeightVh: 60, // 60% of viewport height - gradientHeight: "h-24", // Tailwind class for gradient overlay + previewRows: 6, // collapsed preview height, in content rows + triggerRows: 8, // only fold when content is taller than this (2-row buffer over the preview) + gradientHeight: "h-12", // Tailwind class for the bottom fade overlay (~2 rows) } as const; -export const getMaxDisplayHeight = () => window.innerHeight * (COMPACT_MODE_CONFIG.maxHeightVh / 100); +// Height the collapsed preview is clamped to, in pixels. +export const getPreviewMaxHeightPx = () => COMPACT_MODE_CONFIG.previewRows * ROW_HEIGHT_PX; + +// Height a memo must exceed before it gets folded at all, in pixels. +export const getCompactTriggerHeightPx = () => COMPACT_MODE_CONFIG.triggerRows * ROW_HEIGHT_PX; + +// Whether content of the given rendered height should be collapsed. Kept pure for unit testing. +export const shouldCompactContent = (contentHeightPx: number, triggerHeightPx: number) => contentHeightPx > triggerHeightPx; export const COMPACT_STATES: Record<"ALL" | "SNIPPET", { textKey: string; next: "ALL" | "SNIPPET" }> = { ALL: { textKey: "memo.show-more", next: "SNIPPET" }, diff --git a/web/src/components/MemoContent/hooks.ts b/web/src/components/MemoContent/hooks.ts index 8ca09836f..bd88ece40 100644 --- a/web/src/components/MemoContent/hooks.ts +++ b/web/src/components/MemoContent/hooks.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { COMPACT_STATES, getMaxDisplayHeight } from "./constants"; +import { COMPACT_STATES, getCompactTriggerHeightPx, shouldCompactContent } from "./constants"; import type { ContentCompactView } from "./types"; export const useCompactMode = (enabled: boolean, revision: string) => { @@ -12,8 +12,8 @@ export const useCompactMode = (enabled: boolean, revision: string) => { return; } - const maxHeight = getMaxDisplayHeight(); - const shouldCompact = Math.max(containerRef.current.scrollHeight, containerRef.current.getBoundingClientRect().height) > maxHeight; + const contentHeight = Math.max(containerRef.current.scrollHeight, containerRef.current.getBoundingClientRect().height); + const shouldCompact = shouldCompactContent(contentHeight, getCompactTriggerHeightPx()); setMode((currentMode) => { if (!shouldCompact) { return undefined; diff --git a/web/src/components/MemoContent/index.tsx b/web/src/components/MemoContent/index.tsx index 069f0c183..79c3a59e3 100644 --- a/web/src/components/MemoContent/index.tsx +++ b/web/src/components/MemoContent/index.tsx @@ -3,7 +3,7 @@ import { memo, useMemo } from "react"; import { cn } from "@/lib/utils"; import { useTranslate } from "@/utils/i18n"; import { extractMentionUsernames } from "@/utils/remark-plugins/remark-mention"; -import { COMPACT_MODE_CONFIG } from "./constants"; +import { COMPACT_MODE_CONFIG, getPreviewMaxHeightPx } from "./constants"; import { useCompactLabel, useCompactMode } from "./hooks"; import { MemoMarkdownRenderer } from "./MemoMarkdownRenderer"; import { useResolvedMentionUsernames } from "./MentionResolutionContext"; @@ -36,7 +36,7 @@ const MemoContent = (props: MemoContentProps) => { showCompactMode === "ALL" && "overflow-hidden", contentClassName, )} - style={showCompactMode === "ALL" ? { maxHeight: `${COMPACT_MODE_CONFIG.maxHeightVh}vh` } : undefined} + style={showCompactMode === "ALL" ? { maxHeight: `${getPreviewMaxHeightPx()}px` } : undefined} onMouseUp={onClick} onDoubleClick={onDoubleClick} > diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index c2c59d036..1ba5ff32a 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "react-hot-toast"; import { useAuth } from "@/contexts/AuthContext"; import { useInstance } from "@/contexts/InstanceContext"; +import { useNewMemo } from "@/contexts/NewMemoContext"; import useCurrentUser from "@/hooks/useCurrentUser"; import { memoKeys } from "@/hooks/useMemoQueries"; import { userKeys } from "@/hooks/useUserQueries"; @@ -53,6 +54,7 @@ const MemoEditorImpl: React.FC = ({ const { state, actions, dispatch } = useEditorContext(); const { userGeneralSetting } = useAuth(); const { aiSetting, fetchSetting } = useInstance(); + const { markNewMemo } = useNewMemo(); const [isAudioRecorderOpen, setIsAudioRecorderOpen] = useState(false); const [isTranscribingAudio, setIsTranscribingAudio] = useState(false); @@ -280,6 +282,12 @@ const MemoEditorImpl: React.FC = ({ dispatch(actions.setTimestamps({ createTime: defaultCreateTime, updateTime: defaultCreateTime })); } + // Surface a freshly created top-level memo at the top of the list so it + // stays visible even when pinned memos would otherwise push it down. + if (!memoName && !parentMemoName) { + markNewMemo(result.memoName); + } + // Notify parent component of successful save onConfirm?.(result.memoName); } catch (error) { diff --git a/web/src/components/MemoView/components/MemoHeader.tsx b/web/src/components/MemoView/components/MemoHeader.tsx index e87bcb75c..5af6ed86a 100644 --- a/web/src/components/MemoView/components/MemoHeader.tsx +++ b/web/src/components/MemoView/components/MemoHeader.tsx @@ -2,6 +2,7 @@ import { BookmarkIcon } from "lucide-react"; import { useCallback, useState } from "react"; import { Link } from "react-router-dom"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { useNewMemo } from "@/contexts/NewMemoContext"; import useNavigateTo from "@/hooks/useNavigateTo"; import i18n from "@/i18n"; import { cn } from "@/lib/utils"; @@ -23,6 +24,7 @@ const MemoHeader: React.FC = ({ showCreator, showVisibility, sh const { memo, creator, currentUser, parentPage, isArchived, readonly, openEditor } = useMemoViewContext(); const { createTime, updateTime, displayTime: memoDisplayTime, isDisplayingUpdatedTime, relativeTimeFormat } = useMemoViewDerived(); + const { newMemoName } = useNewMemo(); const navigateTo = useNavigateTo(); const handleGotoMemoDetailPage = useCallback(() => { @@ -59,6 +61,11 @@ const MemoHeader: React.FC = ({ showCreator, showVisibility, sh ) : ( )} + {memo.name === newMemoName && ( + + {t("memo.new-badge")} + + )}
diff --git a/web/src/components/PagedMemoList/PagedMemoList.tsx b/web/src/components/PagedMemoList/PagedMemoList.tsx index 830e029d0..21bacc2f7 100644 --- a/web/src/components/PagedMemoList/PagedMemoList.tsx +++ b/web/src/components/PagedMemoList/PagedMemoList.tsx @@ -6,8 +6,10 @@ import { deriveDefaultCreateTimeFromFilters } from "@/components/MemoEditor/util import { Button } from "@/components/ui/button"; import { userServiceClient } from "@/connect"; import { useMemoFilterContext } from "@/contexts/MemoFilterContext"; +import { useNewMemo } from "@/contexts/NewMemoContext"; import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts"; import { useInfiniteMemos } from "@/hooks/useMemoQueries"; +import { hoistMemoToFront } from "@/hooks/useMemoSorting"; import { userKeys } from "@/hooks/useUserQueries"; import { State } from "@/types/proto/api/v1/common_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; @@ -102,8 +104,13 @@ const PagedMemoList = (props: Props) => { // Flatten pages into a single array of memos const memos = useMemo(() => data?.pages.flatMap((page) => page.memos) || [], [data]); - // Apply custom sorting if provided, otherwise use memos directly - const sortedMemoList = useMemo(() => (props.listSort ? props.listSort(memos) : memos), [memos, props.listSort]); + // Apply custom sorting if provided, otherwise use memos directly, then hoist + // a freshly created memo to the very top so it stays visible above pins. + const { newMemoName } = useNewMemo(); + const sortedMemoList = useMemo(() => { + const sorted = props.listSort ? props.listSort(memos) : memos; + return hoistMemoToFront(sorted, newMemoName); + }, [memos, props.listSort, newMemoName]); // Prefetch creators when new data arrives to improve performance useEffect(() => { diff --git a/web/src/contexts/NewMemoContext.tsx b/web/src/contexts/NewMemoContext.tsx new file mode 100644 index 000000000..79c5445a9 --- /dev/null +++ b/web/src/contexts/NewMemoContext.tsx @@ -0,0 +1,29 @@ +import { createContext, type ReactNode, useContext, useMemo, useState } from "react"; + +interface NewMemoContextValue { + // Name of the most recently created memo in this view, or null. Only one + // memo is ever "new" at a time: creating another overwrites it, which clears + // the previous one for free. + newMemoName: string | null; + markNewMemo: (name: string) => void; +} + +// Default is a safe no-op so MemoEditor/MemoView rendered outside a provider +// (dialogs, detail pages, other lists) keep working without a "new" marker. +const NewMemoContext = createContext({ + newMemoName: null, + markNewMemo: () => {}, +}); + +export function NewMemoProvider({ children }: { children: ReactNode }) { + const [newMemoName, setNewMemoName] = useState(null); + // setNewMemoName is identity-stable, so the value only changes with newMemoName. + // Memoize it so a Home re-render doesn't cascade into every subscribed memo row. + const value = useMemo(() => ({ newMemoName, markNewMemo: setNewMemoName }), [newMemoName]); + + return {children}; +} + +export function useNewMemo() { + return useContext(NewMemoContext); +} diff --git a/web/src/hooks/useMemoSorting.ts b/web/src/hooks/useMemoSorting.ts index 724a6c10c..8a193513a 100644 --- a/web/src/hooks/useMemoSorting.ts +++ b/web/src/hooks/useMemoSorting.ts @@ -20,6 +20,19 @@ const getMemoSortTime = (memo: Memo, timeBasis: MemoTimeBasis): Date | undefined return timestamp ? timestampDate(timestamp) : undefined; }; +// Moves the memo with the given name to the front of the list, above pinned +// memos, so a freshly created memo is immediately visible. Returns the input +// unchanged when the name is null, absent, or already first. +export const hoistMemoToFront = (memos: Memo[], name: string | null): Memo[] => { + if (!name) return memos; + const index = memos.findIndex((memo) => memo.name === name); + if (index <= 0) return memos; + const reordered = memos.slice(); + const [memo] = reordered.splice(index, 1); + reordered.unshift(memo); + return reordered; +}; + export const useMemoSorting = (options: UseMemoSortingOptions = {}): UseMemoSortingResult => { const { pinnedFirst = false, state = State.NORMAL } = options; const { orderByTimeAsc, timeBasis } = useView(); diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 3a5ad4eba..1c27f2b76 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -268,6 +268,7 @@ "load-more": "Load more", "no-archived-memos": "No archived memos.", "no-memos": "No memos.", + "new-badge": "New", "newest-first": "Newest first", "oldest-first": "Oldest first", "order": "Order", diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index a8b824a8b..04fb86b63 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -1,6 +1,7 @@ import MemoView from "@/components/MemoView"; import PagedMemoList from "@/components/PagedMemoList"; import { useInstance } from "@/contexts/InstanceContext"; +import { NewMemoProvider } from "@/contexts/NewMemoContext"; import { useMemoFilters, useMemoSorting } from "@/hooks"; import useCurrentUser from "@/hooks/useCurrentUser"; import { State } from "@/types/proto/api/v1/common_pb"; @@ -23,14 +24,16 @@ const Home = () => { return (
- } - listSort={listSort} - orderBy={orderBy} - filter={memoFilter} - enabled={isInitialized} - showMemoEditor - /> + + } + listSort={listSort} + orderBy={orderBy} + filter={memoFilter} + enabled={isInitialized} + showMemoEditor + /> +
); }; diff --git a/web/tests/hoist-new-memo.test.ts b/web/tests/hoist-new-memo.test.ts new file mode 100644 index 000000000..19df814b1 --- /dev/null +++ b/web/tests/hoist-new-memo.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { hoistMemoToFront } from "@/hooks/useMemoSorting"; +import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; + +const memo = (name: string) => ({ name }) as Memo; + +describe("hoistMemoToFront", () => { + it("moves the named memo to the front, above everything else", () => { + const list = [memo("memos/pin1"), memo("memos/pin2"), memo("memos/new")]; + expect(hoistMemoToFront(list, "memos/new").map((m) => m.name)).toEqual(["memos/new", "memos/pin1", "memos/pin2"]); + }); + + it("returns the list unchanged when the name is null", () => { + const list = [memo("memos/a"), memo("memos/b")]; + expect(hoistMemoToFront(list, null)).toBe(list); + }); + + it("returns the list unchanged when the name is absent", () => { + const list = [memo("memos/a"), memo("memos/b")]; + expect(hoistMemoToFront(list, "memos/missing")).toBe(list); + }); + + it("returns the list unchanged when the memo is already first", () => { + const list = [memo("memos/a"), memo("memos/b")]; + expect(hoistMemoToFront(list, "memos/a")).toBe(list); + }); + + it("does not mutate the input list", () => { + const list = [memo("memos/a"), memo("memos/new")]; + hoistMemoToFront(list, "memos/new"); + expect(list.map((m) => m.name)).toEqual(["memos/a", "memos/new"]); + }); +}); diff --git a/web/tests/memo-content-compact-threshold.test.ts b/web/tests/memo-content-compact-threshold.test.ts new file mode 100644 index 000000000..98c8a6d31 --- /dev/null +++ b/web/tests/memo-content-compact-threshold.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { + COMPACT_MODE_CONFIG, + getCompactTriggerHeightPx, + getPreviewMaxHeightPx, + shouldCompactContent, +} from "@/components/MemoContent/constants"; + +describe("compact folded preview sizing", () => { + it("clamps the collapsed preview to fewer rows than the fold trigger", () => { + // Preview must be shorter than the trigger, otherwise folding shows everything. + expect(COMPACT_MODE_CONFIG.previewRows).toBeLessThan(COMPACT_MODE_CONFIG.triggerRows); + expect(getPreviewMaxHeightPx()).toBeLessThan(getCompactTriggerHeightPx()); + }); + + it("folds only when content is taller than the trigger height", () => { + const trigger = getCompactTriggerHeightPx(); + expect(shouldCompactContent(trigger + 1, trigger)).toBe(true); + }); + + it("leaves content at or below the trigger height fully expanded", () => { + const trigger = getCompactTriggerHeightPx(); + // A memo a row or two over the preview but within the buffer stays open. + expect(shouldCompactContent(getPreviewMaxHeightPx() + 1, trigger)).toBe(false); + expect(shouldCompactContent(trigger, trigger)).toBe(false); + }); +});