u
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import React, { memo, useState } from 'react';
|
||||
import React, { memo, useState, PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import LanguageRoundedIcon from '@mui/icons-material/LanguageRounded';
|
||||
import {
|
||||
CODE_BLOCK_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') {
|
||||
const out = [];
|
||||
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 k = key();
|
||||
if (endUrl !== -1 && SAFE_URL.test(url)) {
|
||||
push(
|
||||
<a key={k} href={url} target="_blank" rel="noopener noreferrer">
|
||||
{parseInline(label, k)}
|
||||
</a>,
|
||||
);
|
||||
if (/^https?:\/\//i.test(url)) {
|
||||
push(
|
||||
<WebLinkCard key={k} url={url} label={label}>
|
||||
{parseInline(label, k)}
|
||||
</WebLinkCard>,
|
||||
);
|
||||
} else {
|
||||
push(
|
||||
<a key={k} href={url} target="_blank" rel="noopener noreferrer">
|
||||
{parseInline(label, k)}
|
||||
</a>,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Half-typed (or unsafe) link: show the label only.
|
||||
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;
|
||||
i++;
|
||||
}
|
||||
@@ -322,7 +695,7 @@ const rootSx = (theme) => ({
|
||||
whiteSpace: 'normal',
|
||||
'& > *:first-of-type': { mt: 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': { fontSize: '1.2rem' },
|
||||
'& h2': { fontSize: '1.1rem' },
|
||||
@@ -497,10 +870,10 @@ function HealingMarkdown({ text, streaming = false }) {
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<p key={k}>
|
||||
<Box component="div" className="md-p" key={k}>
|
||||
{parseInline(b.text, k)}
|
||||
{tail}
|
||||
</p>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user