Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b3d1bad8f |
@@ -89,9 +89,6 @@ import { zIndices } from '../styles/zIndex';
|
||||
|
||||
const PIP_MIN_W = 200;
|
||||
const PIP_MIN_H = 112;
|
||||
/** [Gitea #323] Minimised PiP bar size. */
|
||||
const PIP_MINI_W = 220;
|
||||
const PIP_MINI_H = 40;
|
||||
|
||||
type Corner = 'se' | 'sw' | 'ne' | 'nw';
|
||||
|
||||
@@ -984,28 +981,6 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
);
|
||||
|
||||
const [pipIsFullscreen, setPipIsFullscreen] = useState(false);
|
||||
// [Gitea #323] Minimised PiP: a small draggable bar (room name + expand)
|
||||
// instead of the video. Remembered; phones start minimised.
|
||||
const [pipMinimized, setPipMinimized] = useState<boolean>(() => {
|
||||
try {
|
||||
const v = localStorage.getItem('pip-minimized');
|
||||
if (v !== null) return v === '1';
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
return window.innerWidth < 600;
|
||||
});
|
||||
const pipFullSizeRef = React.useRef({ width: 280, height: 158 });
|
||||
const togglePipMinimized = useCallback(() => {
|
||||
setPipMinimized((m) => {
|
||||
try {
|
||||
localStorage.setItem('pip-minimized', m ? '0' : '1');
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
return !m;
|
||||
});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const onFsChange = () => setPipIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', onFsChange);
|
||||
@@ -1103,26 +1078,6 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
}
|
||||
}, [pipMode, callVisible]);
|
||||
|
||||
// [Gitea #323] Apply the minimised / full PiP size (after the entry effect
|
||||
// below has positioned and sized it). Keeps the bar on screen.
|
||||
React.useEffect(() => {
|
||||
const el = callEmbedRef.current;
|
||||
if (!el || !pipMode) return;
|
||||
if (pipMinimized) {
|
||||
if (el.offsetHeight > PIP_MINI_H) {
|
||||
pipFullSizeRef.current = { width: el.offsetWidth, height: el.offsetHeight };
|
||||
}
|
||||
el.style.width = `${PIP_MINI_W}px`;
|
||||
el.style.height = `${PIP_MINI_H}px`;
|
||||
} else {
|
||||
el.style.width = `${pipFullSizeRef.current.width}px`;
|
||||
el.style.height = `${pipFullSizeRef.current.height}px`;
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
el.style.left = `${Math.max(0, Math.min(rect.left, window.innerWidth - el.offsetWidth))}px`;
|
||||
el.style.top = `${Math.max(0, Math.min(rect.top, window.innerHeight - el.offsetHeight))}px`;
|
||||
}, [pipMode, pipMinimized, callEmbedRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pipMode) return;
|
||||
const onPipWindowResize = (): void => {
|
||||
@@ -1441,50 +1396,19 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 1,
|
||||
// Minimised: cover the frame so only the bar shows.
|
||||
background: pipMinimized ? color.Surface.Container : 'transparent',
|
||||
background: 'transparent',
|
||||
cursor: 'grab',
|
||||
// [Gitea #43] The call's "Share your screen?" prompt is drawn
|
||||
// inside the frame: let the click reach its Share button.
|
||||
pointerEvents: pipPromptOpen ? 'none' : undefined,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: pipMinimized ? 'center' : 'flex-start',
|
||||
padding: '6px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: config.space.S100,
|
||||
alignItems: 'center',
|
||||
...(pipMinimized ? { width: '100%' } : {}),
|
||||
}}
|
||||
>
|
||||
{/* Leftmost: the top-right corner is a resize handle. */}
|
||||
<IconButton
|
||||
type="button"
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
aria-label={pipMinimized ? 'Expand call' : 'Minimise call'}
|
||||
aria-pressed={pipMinimized}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePipMinimized();
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.65)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
<Icon size="100" src={pipMinimized ? Icons.ChevronTop : Icons.Minus} />
|
||||
</IconButton>
|
||||
{!pipMinimized && document.fullscreenEnabled && (
|
||||
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}>
|
||||
{document.fullscreenEnabled && (
|
||||
<IconButton
|
||||
type="button"
|
||||
size="300"
|
||||
@@ -1516,23 +1440,14 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
pointerEvents: 'none',
|
||||
...(pipMinimized
|
||||
? {
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{pipMinimized ? `↗ ${callEmbed.room.name ?? 'Call'}` : '↗ Return to call'}
|
||||
↗ Return to call
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!pipMinimized && <PipMuteOverlay callEmbed={callEmbed} />}
|
||||
{(pipMinimized ? [] : (['se', 'sw', 'ne', 'nw'] as Corner[])).map((corner) => {
|
||||
<PipMuteOverlay callEmbed={callEmbed} />
|
||||
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => {
|
||||
const s = corner.includes('s');
|
||||
const e2 = corner.includes('e');
|
||||
const dots = [
|
||||
|
||||
@@ -40,6 +40,7 @@ import { parseGeoUri, scaleYDimension } from '../../utils/common';
|
||||
import { formatAccuracy } from '../../utils/geoShare';
|
||||
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
|
||||
import { FileHeader, FileDownloadButton } from './FileHeader';
|
||||
import { previewUrlsFor } from '../../utils/linkSafety';
|
||||
|
||||
// ≈ 20 lines; in rem so a larger font setting doesn't show fewer lines (Gitea #343).
|
||||
const COLLAPSE_MAX_HEIGHT_REM = 20;
|
||||
@@ -343,7 +344,13 @@ export function MText({
|
||||
if (typeof body !== 'string') return <BrokenContent />;
|
||||
const trimmedBody = trimReplyFromBody(body);
|
||||
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
||||
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
||||
// [Gitea #286] no genuine card for a site a deceptive link only pretends to be
|
||||
const urls = urlsMatch
|
||||
? previewUrlsFor(
|
||||
[...new Set(urlsMatch)],
|
||||
typeof customBody === 'string' ? customBody : undefined,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -401,7 +408,13 @@ export function MEmote({
|
||||
if (typeof body !== 'string') return <BrokenContent />;
|
||||
const trimmedBody = trimReplyFromBody(body);
|
||||
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
||||
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
||||
// [Gitea #286] no genuine card for a site a deceptive link only pretends to be
|
||||
const urls = urlsMatch
|
||||
? previewUrlsFor(
|
||||
[...new Set(urlsMatch)],
|
||||
typeof customBody === 'string' ? customBody : undefined,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -458,7 +471,13 @@ export function MNotice({
|
||||
if (typeof body !== 'string') return <BrokenContent />;
|
||||
const trimmedBody = trimReplyFromBody(body);
|
||||
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
||||
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
||||
// [Gitea #286] no genuine card for a site a deceptive link only pretends to be
|
||||
const urls = urlsMatch
|
||||
? previewUrlsFor(
|
||||
[...new Set(urlsMatch)],
|
||||
typeof customBody === 'string' ? customBody : undefined,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { analyzeLink, hostFromText, registrableDomain } from './linkSafety';
|
||||
import { analyzeLink, hostFromText, previewUrlsFor, registrableDomain } from './linkSafety';
|
||||
|
||||
test('visible text that names another site is a mismatch', () => {
|
||||
const r = analyzeLink('https://matrix.lotusguild.org/login', 'https://evil.example/login');
|
||||
@@ -73,3 +73,25 @@ test('shared hosting suffixes are separate sites (#335)', () => {
|
||||
);
|
||||
assert.equal(registrableDomain('news.bbc.co.uk'), 'bbc.co.uk');
|
||||
});
|
||||
|
||||
test('previewUrlsFor: no preview for a site a deceptive link only claims (#286)', () => {
|
||||
const urls = ['https://paypal.com'];
|
||||
assert.deepEqual(
|
||||
previewUrlsFor(urls, '<a href="https://evil.example/login">https://paypal.com</a>'),
|
||||
[],
|
||||
);
|
||||
// An honest link keeps its preview.
|
||||
assert.deepEqual(
|
||||
previewUrlsFor(urls, '<a href="https://www.paypal.com/x">https://paypal.com</a>'),
|
||||
urls,
|
||||
);
|
||||
assert.deepEqual(previewUrlsFor(urls, undefined), urls);
|
||||
// Other links in the message keep theirs.
|
||||
assert.deepEqual(
|
||||
previewUrlsFor(
|
||||
['https://paypal.com', 'https://lotusguild.org'],
|
||||
'see <a href="https://evil.example">paypal.com</a> and https://lotusguild.org',
|
||||
),
|
||||
['https://lotusguild.org'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -221,3 +221,37 @@ export const analyzeLink = (text: string, href: string): LinkSafety | null => {
|
||||
const other = shown.find((h) => registrableDomain(h) !== realSite);
|
||||
return { mismatch: other !== undefined, shownHost: other ?? shown[0], realHost, punycode };
|
||||
};
|
||||
|
||||
const ANCHOR_REG = /<a\s[^>]*href\s*=\s*(["'])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi;
|
||||
const decodeEntities = (s: string): string =>
|
||||
s
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&');
|
||||
|
||||
/**
|
||||
* [Gitea #286] The URLs to build link previews for. Previews come from the
|
||||
* plain `body`, but clicks follow `formatted_body`'s hrefs, and a sender
|
||||
* controls both: text reading "https://paypal.com" linked to evil.example got
|
||||
* a genuine PayPal card. Drop any body URL whose site is only *claimed* by a
|
||||
* deceptive link's text.
|
||||
*/
|
||||
export const previewUrlsFor = (urls: string[], formattedBody: string | undefined): string[] => {
|
||||
if (!formattedBody || urls.length === 0) return urls;
|
||||
const claimed = new Set<string>();
|
||||
Array.from(formattedBody.matchAll(ANCHOR_REG)).forEach((m) => {
|
||||
const safety = analyzeLink(decodeEntities(m[3]), decodeEntities(m[2]));
|
||||
if (safety?.mismatch && safety.shownHost) claimed.add(registrableDomain(safety.shownHost));
|
||||
});
|
||||
if (claimed.size === 0) return urls;
|
||||
return urls.filter((u) => {
|
||||
try {
|
||||
return !claimed.has(registrableDomain(new URL(u).hostname));
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user