feat: surface newly created memo above pinned list

pull/6027/head
johnnyjoygh 3 weeks ago
parent deddf71d7b
commit 6eb17864df

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

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

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

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

@ -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<MemoHeaderProps> = ({ 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<MemoHeaderProps> = ({ showCreator, showVisibility, sh
) : (
<TimeDisplay displayTime={displayTime} timeTooltip={timeTooltip} onGotoDetail={handleGotoMemoDetailPage} />
)}
{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">
{t("memo.new-badge")}
</span>
)}
</div>
<div className="flex flex-row justify-end items-center select-none shrink-0 gap-2">

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

@ -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<NewMemoContextValue>({
newMemoName: null,
markNewMemo: () => {},
});
export function NewMemoProvider({ children }: { children: ReactNode }) {
const [newMemoName, setNewMemoName] = useState<string | null>(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 <NewMemoContext.Provider value={value}>{children}</NewMemoContext.Provider>;
}
export function useNewMemo() {
return useContext(NewMemoContext);
}

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

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

@ -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 (
<div className="w-full min-h-full bg-background text-foreground">
<PagedMemoList
renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility showPinned compact />}
listSort={listSort}
orderBy={orderBy}
filter={memoFilter}
enabled={isInitialized}
showMemoEditor
/>
<NewMemoProvider>
<PagedMemoList
renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility showPinned compact />}
listSort={listSort}
orderBy={orderBy}
filter={memoFilter}
enabled={isInitialized}
showMemoEditor
/>
</NewMemoProvider>
</div>
);
};

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

@ -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);
});
});
Loading…
Cancel
Save