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.
pull/6051/head
boojack 2 weeks ago
parent a5c10a7221
commit e3e4ae1051

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

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

@ -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 = () => {
setViewState((prev) => {
const newState = { ...prev, orderByTimeAsc: !prev.orderByTimeAsc };
persistToStorage(newState);
return newState;
});
};
const setTimeBasis = (field: MemoTimeBasis) => {
const updateState = (patch: Partial<ViewState> | ((prev: ViewState) => Partial<ViewState>)) => {
setViewState((prev) => {
const newState = { ...prev, timeBasis: field };
const newState = { ...prev, ...(typeof patch === "function" ? patch(prev) : patch) };
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;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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