MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s
- GET /link-preview?url= fetches the page on the server (https only, SSRF-guarded like webhooks), reads Open Graph/Twitter tags, title, description and icon from the head, loads picture and icon (no SVG) and keeps them in link_previews (migration 6) for 7 days; readers' browsers never contact the page. Off in the demo; 300 fetches per address in 15 minutes. - Compact card above the bullet (icon, site, title, one line, small picture); the duplicate link badge is left out. - ✕ turns the address into [title](url), so hiding syncs with the note; "Link-Vorschauen anzeigen" under appearance switches all off per device. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
86 lines
2.8 KiB
TypeScript
86 lines
2.8 KiB
TypeScript
// Link previews in notes: a card above a bullet that contains a plain web address. The server fetches
|
|
// the page (https only) and keeps title, description, picture and icon; the browser only talks to
|
|
// MindSift. Hiding the card for one link turns the address into a named link [Titel](url), so the
|
|
// choice syncs with the note; a global switch (this device) turns all previews off.
|
|
import { useEffect, useState } from 'react';
|
|
import { getToken } from './api';
|
|
|
|
export interface LinkPreview {
|
|
key: string;
|
|
url: string;
|
|
title: string;
|
|
description: string;
|
|
siteName: string;
|
|
hasImage: boolean;
|
|
hasIcon: boolean;
|
|
ok: boolean;
|
|
}
|
|
|
|
// First plain https address in a text (not inside [text](url), not right after "(" or "<")
|
|
const BARE_URL_RE = /(^|[\s])(https:\/\/[^\s<>()\]]+[^\s<>()\].,;:!?'"])/;
|
|
|
|
export function firstBareUrl(content: string): string | null {
|
|
const m = BARE_URL_RE.exec(content || '');
|
|
return m ? m[2] : null;
|
|
}
|
|
|
|
/** Replaces the address by a named link, which shows no preview. */
|
|
export function hidePreviewContent(content: string, url: string, title: string): string {
|
|
const label = (title || new URL(url).hostname).replace(/[[\]]/g, '').trim() || url;
|
|
return content.replace(url, `[${label}](${url})`);
|
|
}
|
|
|
|
const cache = new Map<string, Promise<LinkPreview | null>>();
|
|
|
|
export function loadLinkPreview(url: string): Promise<LinkPreview | null> {
|
|
let p = cache.get(url);
|
|
if (!p) {
|
|
p = (async () => {
|
|
const token = getToken();
|
|
const res = await fetch(`/api/v1/link-preview?url=${encodeURIComponent(url)}`, {
|
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
});
|
|
if (!res.ok) return null; // demo, http link, offline: just no card
|
|
const data = (await res.json()) as LinkPreview;
|
|
return data.ok ? data : null;
|
|
})().catch(() => null);
|
|
cache.set(url, p);
|
|
}
|
|
return p;
|
|
}
|
|
|
|
const SETTING_KEY = 'mindsift_link_previews';
|
|
const SETTING_EVENT = 'mindsift:link-previews';
|
|
|
|
export function linkPreviewsEnabled(): boolean {
|
|
try {
|
|
return localStorage.getItem(SETTING_KEY) !== 'off';
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
export function setLinkPreviewsEnabled(on: boolean): void {
|
|
try {
|
|
localStorage.setItem(SETTING_KEY, on ? 'on' : 'off');
|
|
} catch {
|
|
// private mode: only for this page
|
|
}
|
|
window.dispatchEvent(new CustomEvent(SETTING_EVENT));
|
|
}
|
|
|
|
/** The global switch, kept in sync across all open notes and tabs. */
|
|
export function useLinkPreviewsEnabled(): boolean {
|
|
const [on, setOn] = useState(linkPreviewsEnabled);
|
|
useEffect(() => {
|
|
const update = () => setOn(linkPreviewsEnabled());
|
|
window.addEventListener(SETTING_EVENT, update);
|
|
window.addEventListener('storage', update);
|
|
return () => {
|
|
window.removeEventListener(SETTING_EVENT, update);
|
|
window.removeEventListener('storage', update);
|
|
};
|
|
}, []);
|
|
return on;
|
|
}
|