Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 33cc0de97c fix(calls): the call PiP no longer opens on top of the composer (#323)
CI / Build & Quality Checks (pull_request) Successful in 3m22s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 15s
CI / Playwright smoke (e2e) (pull_request) Successful in 18m4s
The first-use PiP position was bottom-right, 72px from the bottom: on
desktop over Send, on phones over Send, Emoji and the message box, so
the first thing you typed or tapped in the text room hit the call.
Default to top-right under the room header instead. A dragged position
is still remembered.

Refs #323

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:47:34 -04:00
4 changed files with 11 additions and 81 deletions
+7 -2
View File
@@ -89,6 +89,8 @@ import { zIndices } from '../styles/zIndex';
const PIP_MIN_W = 200;
const PIP_MIN_H = 112;
/** Default PiP top: just below the 64px-ish room header. */
const PIP_DEFAULT_TOP = 72;
type Corner = 'se' | 'sw' | 'ne' | 'nw';
@@ -1046,8 +1048,11 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
el.style.left = `${Math.max(0, Math.min(savedPos.left, window.innerWidth - 280))}px`;
el.style.top = `${Math.max(0, Math.min(savedPos.top, window.innerHeight - 158))}px`;
} else {
el.style.left = `${window.innerWidth - 280 - 16}px`;
el.style.top = `${window.innerHeight - 158 - 72}px`;
// [Gitea #323] First use: top-right under the room header, over the
// timeline. Bottom-right sat on the composer (Send / Emoji, and on
// phones the whole message box), so typing landed on the call.
el.style.left = `${Math.max(0, window.innerWidth - 280 - 16)}px`;
el.style.top = `${PIP_DEFAULT_TOP}px`;
}
el.style.width = '280px';
el.style.height = '158px';
@@ -40,7 +40,6 @@ 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;
@@ -344,13 +343,7 @@ export function MText({
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
// [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;
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
return (
<>
@@ -408,13 +401,7 @@ export function MEmote({
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
// [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;
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
return (
<>
@@ -471,13 +458,7 @@ export function MNotice({
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
// [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;
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
return (
<>
+1 -23
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { analyzeLink, hostFromText, previewUrlsFor, registrableDomain } from './linkSafety';
import { analyzeLink, hostFromText, 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,25 +73,3 @@ 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'],
);
});
-34
View File
@@ -221,37 +221,3 @@ 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(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/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;
}
});
};