This commit is contained in:
seb
2026-10-06 03:40:46 +02:00
parent 9a5d90a99f
commit 186a9c8ad1

View File

@@ -1,5 +1,7 @@
import React, { memo, useState } from 'react'; import React, { memo, useState, PureComponent } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import LanguageRoundedIcon from '@mui/icons-material/LanguageRounded';
import { import {
CODE_BLOCK_PAIR, CODE_BLOCK_PAIR,
CODE_INLINE_PAIR, CODE_INLINE_PAIR,
@@ -87,6 +89,357 @@ function MarkdownImage({ src, alt }) {
); );
} }
function extractDomain(urlStr) {
if (!urlStr || typeof urlStr !== 'string') return '';
try {
const u = new URL(urlStr.startsWith('http') ? urlStr : `https://${urlStr}`);
return u.hostname.replace(/^www\./, '');
} catch {
return '';
}
}
class FaviconImg extends PureComponent {
state = {
srcIndex: 0,
hasError: false,
};
sources = [];
constructor(props) {
super(props);
this.sources = this.buildSources(props.url, props.domain);
}
componentDidUpdate(prevProps) {
if (this.props.url !== prevProps.url || this.props.domain !== prevProps.domain) {
this.sources = this.buildSources(this.props.url, this.props.domain);
this.setState({ srcIndex: 0, hasError: false });
}
}
buildSources(urlStr, domain) {
const list = [];
const host = domain || extractDomain(urlStr);
if (!host) return list;
if (host === 'localhost' || host === '127.0.0.1' || host.endsWith('.local')) {
try {
const origin = new URL(urlStr.startsWith('http') ? urlStr : `https://${urlStr}`).origin;
list.push(`${origin}/favicon.ico`);
} catch {}
} else {
list.push(`https://icons.duckduckgo.com/ip3/${host}.ico`);
try {
const origin = new URL(urlStr.startsWith('http') ? urlStr : `https://${urlStr}`).origin;
list.push(`${origin}/favicon.ico`);
} catch {}
}
return list;
}
handleError = () => {
if (this.state.srcIndex + 1 < this.sources.length) {
this.setState((prev) => ({ srcIndex: prev.srcIndex + 1 }));
} else {
this.setState({ hasError: true });
}
};
render() {
const { srcIndex, hasError } = this.state;
const currentSrc = this.sources[srcIndex];
if (!currentSrc || hasError) {
return (
<LanguageRoundedIcon
sx={{ fontSize: 16, color: 'text.secondary', flexShrink: 0 }}
/>
);
}
return (
<Box
component="img"
src={currentSrc}
alt=""
onError={this.handleError}
sx={{
width: 16,
height: 16,
borderRadius: '3px',
objectFit: 'contain',
flexShrink: 0,
display: 'inline-block',
verticalAlign: 'middle',
}}
/>
);
}
}
const metaCache = new Map();
function fetchUrlMetadata(url) {
if (!url || typeof url !== 'string' || !/^https?:\/\//i.test(url)) {
return Promise.resolve(null);
}
if (metaCache.has(url)) {
return metaCache.get(url);
}
const promise = (async () => {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 4000);
const res = await fetch(`https://api.microlink.io/?url=${encodeURIComponent(url)}`, {
signal: controller.signal,
});
clearTimeout(timer);
if (!res.ok) return null;
const json = await res.json();
if (json?.status === 'success' && json?.data) {
const d = json.data;
if (d.title && (d.description || d.image?.url)) {
return {
title: d.title,
description: d.description || '',
image: d.image?.url || null,
publisher: d.publisher || '',
logo: d.logo?.url || null,
};
}
}
return null;
} catch {
return null;
}
})();
metaCache.set(url, promise);
return promise;
}
class WebLinkCard extends PureComponent {
state = {
meta: null,
fetched: false,
};
_mounted = false;
componentDidMount() {
this._mounted = true;
const { url } = this.props;
if (url && /^https?:\/\//i.test(url)) {
fetchUrlMetadata(url).then((meta) => {
if (this._mounted) {
this.setState({ meta, fetched: true });
}
});
}
}
componentDidUpdate(prevProps) {
if (this.props.url !== prevProps.url) {
this.setState({ meta: null, fetched: false });
const { url } = this.props;
if (url && /^https?:\/\//i.test(url)) {
fetchUrlMetadata(url).then((meta) => {
if (this._mounted) {
this.setState({ meta, fetched: true });
}
});
}
}
}
componentWillUnmount() {
this._mounted = false;
}
render() {
const { url, label, children } = this.props;
const { meta } = this.state;
const domain = extractDomain(url);
// If metadata exists with title and (description or image), render small meta/og box!
if (meta && meta.title && (meta.description || meta.image)) {
return (
<Box
component="a"
href={url}
target="_blank"
rel="noopener noreferrer"
sx={{
display: 'flex',
alignItems: 'stretch',
my: 1,
maxWidth: 440,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : 'rgba(0, 0, 0, 0.02)'),
textDecoration: 'none',
color: 'inherit',
overflow: 'hidden',
cursor: 'pointer',
transition: 'all 160ms ease-in-out',
'&:hover': {
bgcolor: 'action.hover',
borderColor: 'primary.main',
boxShadow: (theme) =>
theme.palette.mode === 'dark'
? '0 2px 10px rgba(0,0,0,0.5)'
: '0 2px 10px rgba(0,0,0,0.08)',
},
}}
>
<Box
sx={{
flex: 1,
p: 1.2,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: 0.35,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6 }}>
<FaviconImg url={url} domain={domain} />
<Typography
variant="caption"
sx={{
fontSize: '0.68rem',
color: 'text.secondary',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontWeight: 500,
}}
>
{meta.publisher || domain}
</Typography>
</Box>
<Typography
sx={{
fontSize: '0.82rem',
fontWeight: 600,
lineHeight: 1.3,
color: 'text.primary',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{meta.title}
</Typography>
{meta.description ? (
<Typography
sx={{
fontSize: '0.72rem',
color: 'text.secondary',
lineHeight: 1.35,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{meta.description}
</Typography>
) : null}
</Box>
{meta.image ? (
<Box
sx={{
width: 84,
minWidth: 84,
bgcolor: 'action.hover',
position: 'relative',
overflow: 'hidden',
}}
>
<Box
component="img"
src={meta.image}
alt=""
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
}}
onError={(e) => {
e.currentTarget.parentElement.style.display = 'none';
}}
/>
</Box>
) : null}
</Box>
);
}
// Fallback: render favicon box with domain/label
const displayLabel = (typeof label === 'string' && label.trim() && label.trim() !== url)
? label.trim()
: (children || domain || url);
return (
<Box
component="a"
href={url}
target="_blank"
rel="noopener noreferrer"
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
my: 0.35,
mx: 0.2,
px: 1,
py: 0.35,
borderRadius: 1.5,
border: '1px solid',
borderColor: 'divider',
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : 'rgba(0, 0, 0, 0.02)'),
textDecoration: 'none',
color: 'primary.main',
verticalAlign: 'middle',
maxWidth: '100%',
transition: 'all 150ms ease-in-out',
'&:hover': {
bgcolor: 'action.hover',
borderColor: 'primary.main',
textDecoration: 'underline',
},
}}
>
<FaviconImg url={url} domain={domain} />
<Typography
component="span"
sx={{
fontSize: '0.8rem',
fontWeight: 500,
color: 'inherit',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
lineHeight: 1.25,
}}
>
{displayLabel}
</Typography>
</Box>
);
}
}
function parseInline(src, keyPrefix = 'i') { function parseInline(src, keyPrefix = 'i') {
const out = []; const out = [];
let buf = ''; let buf = '';
@@ -177,11 +530,19 @@ function parseInline(src, keyPrefix = 'i') {
const url = (endUrl === -1 ? src.slice(close + 2) : src.slice(close + 2, endUrl)).trim(); const url = (endUrl === -1 ? src.slice(close + 2) : src.slice(close + 2, endUrl)).trim();
const k = key(); const k = key();
if (endUrl !== -1 && SAFE_URL.test(url)) { if (endUrl !== -1 && SAFE_URL.test(url)) {
if (/^https?:\/\//i.test(url)) {
push(
<WebLinkCard key={k} url={url} label={label}>
{parseInline(label, k)}
</WebLinkCard>,
);
} else {
push( push(
<a key={k} href={url} target="_blank" rel="noopener noreferrer"> <a key={k} href={url} target="_blank" rel="noopener noreferrer">
{parseInline(label, k)} {parseInline(label, k)}
</a>, </a>,
); );
}
} else { } else {
// Half-typed (or unsafe) link: show the label only. // Half-typed (or unsafe) link: show the label only.
push(<span key={k}>{parseInline(label, k)}</span>); push(<span key={k}>{parseInline(label, k)}</span>);
@@ -192,6 +553,18 @@ function parseInline(src, keyPrefix = 'i') {
} }
} }
// Bare HTTP(S) URLs in markdown text
if ((src.startsWith('http://', i) || src.startsWith('https://', i)) && (i === 0 || /\s|[([<]/.test(src[i - 1]))) {
const match = src.slice(i).match(/^(https?:\/\/[^\s<>'")\]]+)/);
if (match) {
const rawUrl = match[1].replace(/[.,;:)]+$/, '');
const k = key();
push(<WebLinkCard key={k} url={rawUrl} label={rawUrl} />);
i += rawUrl.length;
continue;
}
}
buf += c; buf += c;
i++; i++;
} }
@@ -322,7 +695,7 @@ const rootSx = (theme) => ({
whiteSpace: 'normal', whiteSpace: 'normal',
'& > *:first-of-type': { mt: 0 }, '& > *:first-of-type': { mt: 0 },
'& > *:last-child': { mb: 0 }, '& > *:last-child': { mb: 0 },
'& p': { m: 0, mb: 1, whiteSpace: 'pre-line' }, '& p, & .md-p': { m: 0, mb: 1, whiteSpace: 'pre-line' },
'& h1, & h2, & h3, & h4, & h5, & h6': { m: 0, mt: 1.25, mb: 0.75, lineHeight: 1.3, fontWeight: 600 }, '& h1, & h2, & h3, & h4, & h5, & h6': { m: 0, mt: 1.25, mb: 0.75, lineHeight: 1.3, fontWeight: 600 },
'& h1': { fontSize: '1.2rem' }, '& h1': { fontSize: '1.2rem' },
'& h2': { fontSize: '1.1rem' }, '& h2': { fontSize: '1.1rem' },
@@ -497,10 +870,10 @@ function HealingMarkdown({ text, streaming = false }) {
} }
default: default:
return ( return (
<p key={k}> <Box component="div" className="md-p" key={k}>
{parseInline(b.text, k)} {parseInline(b.text, k)}
{tail} {tail}
</p> </Box>
); );
} }
})} })}