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 8 additions and 65 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';
@@ -54,7 +54,6 @@ import {
GIF_PREVIEW_MAX_WIDTH,
gifPreviewBox,
} from '../../utils/gifPreviewSize';
import { sameProvider } from './embedProvider';
const linkStyles = { color: color.Success.Main };
@@ -2498,10 +2497,7 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
const resolveEmbed = (prev: IPreviewUrlResponse): MediaEmbed | null => {
if (embed) return embed;
const ogUrl = prev['og:url'];
// [Gitea #326] …but only from an og:url on the posted link's own
// provider: the page sets og:url, so any site could otherwise show up
// as a playable YouTube (etc.) card that links to itself.
return typeof ogUrl === 'string' && ogUrl !== url && sameProvider(url, ogUrl)
return typeof ogUrl === 'string' && ogUrl !== url
? parseMediaEmbed(ogUrl, window.location.hostname)
: null;
};
@@ -1,22 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { sameProvider } from './embedProvider';
test("sameProvider: a page cannot borrow another provider's embed via og:url (#326)", () => {
const yt = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
assert.equal(sameProvider('https://evil.example/free-gift', yt), false);
assert.equal(sameProvider('https://youtube.com.evil.example/x', yt), false);
assert.equal(sameProvider('https://youtu.be/abc', yt), true);
assert.equal(sameProvider('https://m.youtube.com/shorts/abc', yt), true);
assert.equal(
sameProvider('https://vm.tiktok.com/ZM123/', 'https://www.tiktok.com/@a/video/1'),
true,
);
assert.equal(sameProvider('https://spotify.link/abc', 'https://open.spotify.com/track/1'), true);
assert.equal(sameProvider('https://on.soundcloud.com/x', 'https://soundcloud.com/a/b'), true);
assert.equal(
sameProvider('https://redd.it/abc', 'https://www.reddit.com/r/x/comments/abc/'),
true,
);
assert.equal(sameProvider('not a url', yt), false);
});
@@ -1,36 +0,0 @@
/**
* [Gitea #326] A page's og:url is chosen by the page itself, so a phishing
* link could serve `og:url = youtube.com/watch?v=…` and render as a playable
* YouTube card linking to the attacker. Only trust an og:url on the same
* provider as the link actually posted (including the provider's own
* short-link domains).
*/
const PROVIDER_ALIASES: Record<string, string> = {
'youtu.be': 'youtube.com',
'youtube-nocookie.com': 'youtube.com',
'spotify.link': 'spotify.com',
'redd.it': 'reddit.com',
'x.com': 'twitter.com',
'twitch.tv': 'twitch.tv',
'apple.co': 'apple.com',
};
/**
* Pure: the provider a host belongs to (last two labels, aliases folded). Hosts
* under two-part public suffixes (`*.co.uk`) share a value, which is harmless
* here: no embeddable provider lives under one.
*/
export const providerOf = (host: string): string => {
const parts = host.toLowerCase().replace(/\.$/, '').split('.');
const base = parts.slice(-2).join('.');
return PROVIDER_ALIASES[base] ?? base;
};
/** Pure: whether `ogUrl` is on the same provider as the posted `url`. */
export function sameProvider(url: string, ogUrl: string): boolean {
try {
return providerOf(new URL(url).hostname) === providerOf(new URL(ogUrl).hostname);
} catch {
return false;
}
}