feat(web): add link preview view setting

Add a persisted "Link preview" toggle (default on) to the memo
display-settings popover, mirroring the compact-mode setting. When turned
off, link-preview cards are hidden across all memo views and the metadata
request is skipped (the gate sits at the paragraph renderer and reads the
preference from ViewContext). Resolves the request in #6045.
This commit is contained in:
boojack 2026-06-25 20:45:51 +08:00
parent a5c10a7221
commit e3e4ae1051
47 changed files with 133 additions and 23 deletions

View file

@ -1,4 +1,5 @@
import type { Element } from "hast";
import { useLinkPreviewEnabled } from "@/contexts/ViewContext";
import { markdownStyles } from "@/lib/markdownStyles";
import { cn } from "@/lib/utils";
import LinkMetadataCard from "../LinkMetadataCard";
@ -46,7 +47,8 @@ export function getSingleLinkHref(node?: Element): string | undefined {
export const Paragraph = ({ children, className, node, ...props }: ParagraphProps) => {
const { blockDepth } = useMarkdownRenderContext();
const href = blockDepth === 0 ? getSingleLinkHref(node) : undefined;
const linkPreviewEnabled = useLinkPreviewEnabled();
const href = blockDepth === 0 && linkPreviewEnabled ? getSingleLinkHref(node) : undefined;
const paragraph = (
<p className={cn(markdownStyles.paragraph, className)} {...props}>
{children}

View file

@ -12,8 +12,8 @@ interface Props {
function MemoDisplaySettingMenu({ className }: Props) {
const t = useTranslate();
const { orderByTimeAsc, timeBasis, compactMode, setTimeBasis, toggleSortOrder, setCompactMode } = useView();
const isApplying = orderByTimeAsc !== false || timeBasis !== "create_time" || compactMode;
const { orderByTimeAsc, timeBasis, compactMode, linkPreview, setTimeBasis, toggleSortOrder, setCompactMode, setLinkPreview } = useView();
const isApplying = orderByTimeAsc !== false || timeBasis !== "create_time" || compactMode || !linkPreview;
return (
<Popover>
@ -57,6 +57,10 @@ function MemoDisplaySettingMenu({ className }: Props) {
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.compact-mode")}</span>
<Switch checked={compactMode} onCheckedChange={setCompactMode} />
</div>
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.link-preview")}</span>
<Switch checked={linkPreview} onCheckedChange={setLinkPreview} />
</div>
</div>
</PopoverContent>
</Popover>

View file

@ -7,21 +7,26 @@ interface ViewState {
timeBasis?: MemoTimeBasis;
sortTimeField?: MemoTimeBasis;
compactMode: boolean;
linkPreview: boolean;
}
interface ViewContextValue {
orderByTimeAsc: boolean;
timeBasis: MemoTimeBasis;
compactMode: boolean;
linkPreview: boolean;
toggleSortOrder: () => void;
setTimeBasis: (field: MemoTimeBasis) => void;
setCompactMode: (value: boolean) => void;
setLinkPreview: (value: boolean) => void;
}
const ViewContext = createContext<ViewContextValue | null>(null);
const LOCAL_STORAGE_KEY = "memos-view-setting";
const DEFAULT_VIEW_STATE: ViewState = { orderByTimeAsc: false, compactMode: false, linkPreview: true };
export function ViewProvider({ children }: { children: ReactNode }) {
const getInitialState = (): ViewState => {
try {
@ -31,15 +36,16 @@ export function ViewProvider({ children }: { children: ReactNode }) {
const cachedTimeBasis = data.timeBasis ?? data.sortTimeField;
const timeBasis = cachedTimeBasis === "create_time" || cachedTimeBasis === "update_time" ? cachedTimeBasis : undefined;
return {
orderByTimeAsc: Boolean(data.orderByTimeAsc ?? false),
orderByTimeAsc: Boolean(data.orderByTimeAsc ?? DEFAULT_VIEW_STATE.orderByTimeAsc),
timeBasis,
compactMode: Boolean(data.compactMode ?? false),
compactMode: Boolean(data.compactMode ?? DEFAULT_VIEW_STATE.compactMode),
linkPreview: Boolean(data.linkPreview ?? DEFAULT_VIEW_STATE.linkPreview),
};
}
} catch (error) {
console.warn("Failed to load view settings from localStorage:", error);
}
return { orderByTimeAsc: false, compactMode: false };
return { ...DEFAULT_VIEW_STATE };
};
const [viewState, setViewState] = useState(getInitialState);
@ -53,29 +59,18 @@ export function ViewProvider({ children }: { children: ReactNode }) {
}
};
const toggleSortOrder = () => {
const updateState = (patch: Partial<ViewState> | ((prev: ViewState) => Partial<ViewState>)) => {
setViewState((prev) => {
const newState = { ...prev, orderByTimeAsc: !prev.orderByTimeAsc };
const newState = { ...prev, ...(typeof patch === "function" ? patch(prev) : patch) };
persistToStorage(newState);
return newState;
});
};
const setTimeBasis = (field: MemoTimeBasis) => {
setViewState((prev) => {
const newState = { ...prev, timeBasis: field };
persistToStorage(newState);
return newState;
});
};
const setCompactMode = (value: boolean) => {
setViewState((prev) => {
const newState = { ...prev, compactMode: value };
persistToStorage(newState);
return newState;
});
};
const toggleSortOrder = () => updateState((prev) => ({ orderByTimeAsc: !prev.orderByTimeAsc }));
const setTimeBasis = (field: MemoTimeBasis) => updateState({ timeBasis: field });
const setCompactMode = (value: boolean) => updateState({ compactMode: value });
const setLinkPreview = (value: boolean) => updateState({ linkPreview: value });
return (
<ViewContext.Provider
@ -83,9 +78,11 @@ export function ViewProvider({ children }: { children: ReactNode }) {
orderByTimeAsc: viewState.orderByTimeAsc,
timeBasis,
compactMode: viewState.compactMode,
linkPreview: viewState.linkPreview,
toggleSortOrder,
setTimeBasis,
setCompactMode,
setLinkPreview,
}}
>
{children}
@ -100,3 +97,10 @@ export function useView() {
}
return context;
}
// Read the link-preview preference from deep inside the markdown renderer, which
// can render outside a ViewProvider (tests, isolated previews). Defaults to
// enabled when no provider is present, preserving the historical behavior.
export function useLinkPreviewEnabled() {
return useContext(ViewContext)?.linkPreview ?? DEFAULT_VIEW_STATE.linkPreview;
}

View file

@ -195,6 +195,7 @@
"label": "المرشحات"
},
"compact-mode": "الوضع المضغوط",
"link-preview": "معاينة الرابط",
"links": "روابط",
"load-more": "تحميل المزيد",
"no-archived-memos": "لا يوجد مذكرات مؤرشفة",

View file

@ -247,6 +247,7 @@
"label": "Филтри"
},
"compact-mode": "Компактен режим",
"link-preview": "Преглед на връзка",
"links": "Връзки",
"load-more": "Заредете повече",
"no-archived-memos": "Няма архивирани бележки.",

View file

@ -209,6 +209,7 @@
"label": "Filtres"
},
"compact-mode": "Mode compacte",
"link-preview": "Previsualització d'enllaços",
"links": "Enllaços",
"load-more": "Carrega més",
"no-archived-memos": "No hi ha notes arxivades.",

View file

@ -209,6 +209,7 @@
"label": "Filtry"
},
"compact-mode": "Kompaktní režim",
"link-preview": "Náhled odkazu",
"links": "Odkazy",
"load-more": "Načíst více",
"no-archived-memos": "Žádné archivované poznámky.",

View file

@ -247,6 +247,7 @@
"label": "Filtre"
},
"compact-mode": "Kompakt tilstand",
"link-preview": "Linkforhåndsvisning",
"links": "Links",
"load-more": "Indlæs mere",
"no-archived-memos": "Ingen arkiverede notater.",

View file

@ -209,6 +209,7 @@
"label": "Filter"
},
"compact-mode": "Kompaktmodus",
"link-preview": "Linkvorschau",
"links": "Links",
"load-more": "Mehr laden",
"no-archived-memos": "Keine archivierten Notizen.",

View file

@ -247,6 +247,7 @@
"label": "Φίλτρα"
},
"compact-mode": "Συμπαγής λειτουργία",
"link-preview": "Προεπισκόπηση συνδέσμου",
"links": "Εδαφος διά παιγνίδι γκολφ",
"load-more": "Φορτώστε περισσότερα",
"no-archived-memos": "Δεν υπάρχουν αρχειοθετημένα σημειώματα.",

View file

@ -209,6 +209,7 @@
"label": "Filters"
},
"compact-mode": "Compact mode",
"link-preview": "Link preview",
"links": "Links",
"load-more": "Load more",
"no-archived-memos": "No archived memos.",

View file

@ -265,6 +265,7 @@
"label": "Filters"
},
"compact-mode": "Compact mode",
"link-preview": "Link preview",
"links": "Links",
"load-more": "Load more",
"no-archived-memos": "No archived memos.",

View file

@ -209,6 +209,7 @@
"label": "Filtros"
},
"compact-mode": "Modo compacto",
"link-preview": "Vista previa de enlaces",
"links": "Enlaces",
"load-more": "Cargar más",
"no-archived-memos": "No hay memos archivados.",

View file

@ -247,6 +247,7 @@
"label": "Filtrid"
},
"compact-mode": "Kompaktne režiim",
"link-preview": "Lingi eelvaade",
"links": "Lingid",
"load-more": "Laadige rohkem",
"no-archived-memos": "Arhiveeritud memosid pole.",

View file

@ -195,6 +195,7 @@
"label": "فیلترها"
},
"compact-mode": "حالت فشرده",
"link-preview": "پیش‌نمایش پیوند",
"links": "پیوندها",
"load-more": "بارگذاری بیشتر",
"no-archived-memos": "یادداشت آرشیو شده‌ای وجود ندارد.",

View file

@ -247,6 +247,7 @@
"label": "Suodattimet"
},
"compact-mode": "Tiivis tila",
"link-preview": "Linkin esikatselu",
"links": "Linkit",
"load-more": "Lataa lisää",
"no-archived-memos": "Ei arkistoituja muistioita.",

View file

@ -209,6 +209,7 @@
"label": "Filtres"
},
"compact-mode": "Mode compact",
"link-preview": "Aperçu des liens",
"links": "Liens",
"load-more": "Charger plus",
"no-archived-memos": "Aucune note archivée.",

View file

@ -209,6 +209,7 @@
"label": "Filtros"
},
"compact-mode": "Modo compacto",
"link-preview": "Vista previa de ligazóns",
"links": "Ligazóns",
"load-more": "Cargar máis",
"no-archived-memos": "Sen memos arquivadas.",

View file

@ -195,6 +195,7 @@
"label": "फिल्टर"
},
"compact-mode": "कॉम्पैक्ट मोड",
"link-preview": "लिंक पूर्वावलोकन",
"links": "लिंक",
"load-more": "और लोड करें",
"no-archived-memos": "कोई संग्रहीत मेमो नहीं।",

View file

@ -209,6 +209,7 @@
"label": "Filteri"
},
"compact-mode": "Kompaktni način",
"link-preview": "Pregled poveznice",
"links": "Linkovi",
"load-more": "Učitaj više",
"no-archived-memos": "Nema arhiviranih memoa.",

View file

@ -209,6 +209,7 @@
"label": "Szűrők"
},
"compact-mode": "Kompakt mód",
"link-preview": "Hivatkozás előnézete",
"links": "Hivatkozások",
"load-more": "Több betöltése",
"no-archived-memos": "Nincsenek archivált jegyzetek.",

View file

@ -195,6 +195,7 @@
"label": "Filter"
},
"compact-mode": "Mode ringkas",
"link-preview": "Pratinjau tautan",
"links": "Tautan",
"load-more": "Muat lebih banyak",
"no-archived-memos": "Tidak ada memo yang diarsipkan.",

View file

@ -209,6 +209,7 @@
"label": "Filtri"
},
"compact-mode": "Modalità compatta",
"link-preview": "Anteprima dei link",
"links": "Link",
"load-more": "Carica altro",
"no-archived-memos": "Nessun memo archiviato.",

View file

@ -227,6 +227,7 @@
"label": "フィルター"
},
"compact-mode": "コンパクト表示",
"link-preview": "リンクプレビュー",
"links": "リンク",
"load-more": "さらに読み込む",
"no-archived-memos": "アーカイブされたメモはありません。",

View file

@ -209,6 +209,7 @@
"label": "ფილტრები"
},
"compact-mode": "კომპაქტური რეჟიმი",
"link-preview": "ბმულის გადახედვა",
"links": "ლინკები",
"load-more": "მეტის ჩატვირთვა",
"no-archived-memos": "დაარქივებული მემოები არ არის.",

View file

@ -195,6 +195,7 @@
"label": "필터"
},
"compact-mode": "간략 보기",
"link-preview": "링크 미리보기",
"links": "링크",
"load-more": "더보기",
"no-archived-memos": "보관처리된 메모가 없습니다.",

View file

@ -247,6 +247,7 @@
"label": "Filtrai"
},
"compact-mode": "Kompaktiškas režimas",
"link-preview": "Nuorodos peržiūra",
"links": "Nuorodos",
"load-more": "Įkelti daugiau",
"no-archived-memos": "Nėra archyvuotų atmintinių.",

View file

@ -247,6 +247,7 @@
"label": "Filtri"
},
"compact-mode": "Kompaktais režīms",
"link-preview": "Saites priekšskatījums",
"links": "Saites",
"load-more": "Ielādēt vairāk",
"no-archived-memos": "Nav arhivētu piezīmju.",

View file

@ -195,6 +195,7 @@
"label": "फिल्टर"
},
"compact-mode": "संक्षिप्त मोड",
"link-preview": "दुवा पूर्वावलोकन",
"links": "लिंक्स",
"load-more": "अधिक लोड करा",
"no-archived-memos": "कोणतेही संग्रहित मेमो नाहीत.",

View file

@ -209,6 +209,7 @@
"label": "Filtre"
},
"compact-mode": "Kompakt modus",
"link-preview": "Forhåndsvisning av lenke",
"links": "Linker",
"load-more": "Last inn mer",
"no-archived-memos": "Ingen arkiverte memoer.",

View file

@ -209,6 +209,7 @@
"label": "Filters"
},
"compact-mode": "Compacte modus",
"link-preview": "Linkvoorbeeld",
"links": "Links",
"load-more": "Meer laden",
"no-archived-memos": "Geen gearchiveerde memos.",

View file

@ -209,6 +209,7 @@
"label": "Filtry"
},
"compact-mode": "Tryb kompaktowy",
"link-preview": "Podgląd linków",
"links": "Linki",
"load-more": "Załaduj więcej",
"no-archived-memos": "Brak zarchiwizowanych notatek.",

View file

@ -195,6 +195,7 @@
"label": "Filtros"
},
"compact-mode": "Modo compacto",
"link-preview": "Pré-visualização de links",
"links": "Links",
"load-more": "Carregar mais",
"no-archived-memos": "Nenhum memo arquivado.",

View file

@ -209,6 +209,7 @@
"label": "Filtros"
},
"compact-mode": "Modo compacto",
"link-preview": "Pré-visualização de hiperligações",
"links": "Links",
"load-more": "Carregar mais",
"no-archived-memos": "Não existem memos arquivados.",

View file

@ -247,6 +247,7 @@
"label": "Filtre"
},
"compact-mode": "Mod compact",
"link-preview": "Previzualizare link",
"links": "Legături",
"load-more": "Încărcați mai multe",
"no-archived-memos": "Fără memorii arhivate.",

View file

@ -209,6 +209,7 @@
"label": "Фильтры"
},
"compact-mode": "Компактный режим",
"link-preview": "Предпросмотр ссылок",
"links": "Ссылки",
"load-more": "Загрузить еще",
"no-archived-memos": "Нет заархивированных записей.",

View file

@ -247,6 +247,7 @@
"label": "Filtre"
},
"compact-mode": "Kompaktný režim",
"link-preview": "Náhľad odkazu",
"links": "Odkazy",
"load-more": "Načítať viac",
"no-archived-memos": "Žiadne archivované poznámky.",

View file

@ -209,6 +209,7 @@
"label": "Filtri"
},
"compact-mode": "Strnjeni način",
"link-preview": "Predogled povezave",
"links": "Povezave",
"load-more": "Naloži več",
"no-archived-memos": "Ni arhiviranih beležk.",

View file

@ -247,6 +247,7 @@
"label": "Филтери"
},
"compact-mode": "Компактни режим",
"link-preview": "Преглед везе",
"links": "Линкови",
"load-more": "Учитај више",
"no-archived-memos": "Нема архивираних белешки.",

View file

@ -209,6 +209,7 @@
"label": "Filter"
},
"compact-mode": "Kompakt läge",
"link-preview": "Förhandsvisning av länk",
"links": "Länkar",
"load-more": "Ladda mer",
"no-archived-memos": "Inga arkiverade anteckningar.",

View file

@ -195,6 +195,7 @@
"label": "ตัวกรอง"
},
"compact-mode": "โหมดกะทัดรัด",
"link-preview": "ตัวอย่างลิงก์",
"links": "ลิงก์",
"load-more": "โหลดเพิ่มเติม",
"no-archived-memos": "ไม่มีบันทึกช่วยจำที่ถูกเก็บถาวร",

View file

@ -209,6 +209,7 @@
"label": "Filtreler"
},
"compact-mode": "Kompakt mod",
"link-preview": "Bağlantı önizlemesi",
"links": "Bağlantılar",
"load-more": "Daha fazla yükle",
"no-archived-memos": "Arşivlenmiş not yok.",

View file

@ -209,6 +209,7 @@
"label": "Фільтри"
},
"compact-mode": "Компактний режим",
"link-preview": "Попередній перегляд посилань",
"links": "Посилання",
"load-more": "Завантажити більше",
"no-archived-memos": "Немає архівованих нотаток.",

View file

@ -195,6 +195,7 @@
"label": "Bộ lọc"
},
"compact-mode": "Chế độ thu gọn",
"link-preview": "Xem trước liên kết",
"links": "Liên kết",
"load-more": "Tải thêm",
"no-archived-memos": "Không có ghi chú nào được lưu trữ.",

View file

@ -211,6 +211,7 @@
"label": "过滤器"
},
"compact-mode": "紧凑模式",
"link-preview": "链接预览",
"links": "链接",
"load-more": "加载更多",
"no-archived-memos": "没有已归档备忘录。",

View file

@ -247,6 +247,7 @@
"label": "過濾器"
},
"compact-mode": "精簡模式",
"link-preview": "連結預覽",
"links": "連結",
"load-more": "載入更多",
"no-archived-memos": "無已封存的備忘錄",

View file

@ -0,0 +1,57 @@
import { render, screen } from "@testing-library/react";
import type { Element } from "hast";
import { describe, expect, it, vi } from "vitest";
import { Paragraph } from "@/components/MemoContent/markdown/Paragraph";
const viewState = vi.hoisted(() => ({ linkPreview: true }));
// Paragraph reads the global preference via this hook.
vi.mock("@/contexts/ViewContext", () => ({
useLinkPreviewEnabled: () => viewState.linkPreview,
}));
// The link-preview card fetches metadata via this hook; stub it so the card would
// render whenever it is allowed to.
vi.mock("@/hooks/useMemoQueries", () => ({
useLinkMetadata: () => ({
data: { url: "https://example.com", title: "Example Title", description: "An example site", image: "" },
isSuccess: true,
}),
}));
// A single bare-link paragraph node, the shape that triggers a preview card.
const singleLinkNode = {
type: "element",
tagName: "p",
properties: {},
children: [
{
type: "element",
tagName: "a",
properties: { href: "https://example.com" },
children: [{ type: "text", value: "https://example.com" }],
},
],
} as unknown as Element;
const renderParagraph = () =>
render(
<Paragraph node={singleLinkNode}>
<a href="https://example.com">https://example.com</a>
</Paragraph>,
);
describe("<Paragraph /> link preview gating", () => {
it("renders the link preview card when the setting is enabled (default)", () => {
viewState.linkPreview = true;
renderParagraph();
expect(screen.getByText("Example Title")).toBeInTheDocument();
});
it("renders the plain link without a card when the setting is disabled", () => {
viewState.linkPreview = false;
renderParagraph();
expect(screen.queryByText("Example Title")).not.toBeInTheDocument();
expect(screen.getByText("https://example.com")).toBeInTheDocument();
});
});