feat(workspace): link previews above bullets with a web address (M5)
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>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 00:06:05 +01:00
1 parent 83408d120d
commit 4c26eb2960
16 files changed
+688 -6

No files matched your search

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { firstBareUrl, hidePreviewContent } from '../linkPreview';
describe('link preview helpers', () => {
it('finds the first plain https address', () => {
expect(firstBareUrl('https://example.com/a?b=1')).toBe('https://example.com/a?b=1');
expect(firstBareUrl('Rezept: https://koch.de/spaetzle, lecker!')).toBe('https://koch.de/spaetzle');
expect(firstBareUrl('siehe (https://x.de/p).')).toBeNull(); // inside brackets: not a plain address
expect(firstBareUrl('[Kochseite](https://koch.de)')).toBeNull();
expect(firstBareUrl('http://unsicher.de')).toBeNull();
expect(firstBareUrl('nur Text')).toBeNull();
});
it('hides a preview by turning the address into a named link', () => {
expect(hidePreviewContent('Lesen: https://koch.de/x', 'https://koch.de/x', 'Käsespätzle [Rezept]')).toBe(
'Lesen: [Käsespätzle Rezept](https://koch.de/x)'
);
expect(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', '')).toBe('[koch.de](https://koch.de/x)');
expect(firstBareUrl(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', 'T'))).toBeNull();
});
});
+85
View File
@@ -0,0 +1,85 @@
// 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;
}
+9 -4
View File
@@ -27,19 +27,24 @@ export function mediaContent(kind: 'image' | 'audio', id: string, caption = ''):
const loaded = new Map<string, Promise<{ blob: Blob; url: string }>>();
function loadMedia(id: string): Promise<{ blob: Blob; url: string }> {
let p = loaded.get(id);
return loadAuthorized(`/api/v1/media/${encodeURIComponent(id)}`);
}
/** Any picture of the API (needs the login, so no plain <img src>), as Blob plus blob: URL. */
export function loadAuthorized(path: string): Promise<{ blob: Blob; url: string }> {
let p = loaded.get(path);
if (!p) {
p = (async () => {
const token = getToken();
const res = await fetch(`/api/v1/media/${encodeURIComponent(id)}`, {
const res = await fetch(path, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(res.status === 404 ? 'Datei nicht gefunden' : `HTTP ${res.status}`);
const blob = await res.blob();
return { blob, url: URL.createObjectURL(blob) };
})();
p.catch(() => loaded.delete(id)); // try again next time (e.g. was offline)
loaded.set(id, p);
p.catch(() => loaded.delete(path)); // try again next time (e.g. was offline)
loaded.set(path, p);
}
return p;
}