-
Notifications
You must be signed in to change notification settings - Fork 35
feat(desktop): show full URL tooltip on masked link hover #1625
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| import assert from "node:assert/strict"; | ||
| import test from "node:test"; | ||
|
|
||
| import { isMaskedLink } from "./maskedLink.ts"; | ||
|
|
||
| test("masked when label text differs from destination", () => { | ||
| assert.equal(isMaskedLink("click here", "https://example.com"), true); | ||
| assert.equal( | ||
| isMaskedLink("the docs", "https://docs.example.com/guide"), | ||
| true, | ||
| ); | ||
| }); | ||
|
|
||
| test("masked when label is a different URL (spoof)", () => { | ||
| assert.equal(isMaskedLink("https://good.com", "https://evil.com"), true); | ||
| assert.equal( | ||
| isMaskedLink("example.com/safe", "https://example.com/pwn"), | ||
| true, | ||
| ); | ||
| }); | ||
|
|
||
| test("not masked when label matches href exactly", () => { | ||
| assert.equal( | ||
| isMaskedLink("https://example.com/a/b", "https://example.com/a/b"), | ||
| false, | ||
| ); | ||
| }); | ||
|
|
||
| test("not masked across cosmetic differences", () => { | ||
| // scheme omitted in label | ||
| assert.equal(isMaskedLink("example.com", "https://example.com"), false); | ||
| // trailing slash | ||
| assert.equal(isMaskedLink("example.com", "https://example.com/"), false); | ||
| // GFM autolink of www URLs prefixes http:// | ||
| assert.equal( | ||
| isMaskedLink("www.example.com", "http://www.example.com"), | ||
| false, | ||
| ); | ||
| // case-insensitive | ||
| assert.equal(isMaskedLink("Example.COM", "https://example.com"), false); | ||
| // surrounding whitespace in label | ||
| assert.equal(isMaskedLink(" example.com ", "https://example.com"), false); | ||
| }); | ||
|
|
||
| test("not masked when there is nothing useful to compare", () => { | ||
| assert.equal(isMaskedLink("", "https://example.com"), false); | ||
| assert.equal(isMaskedLink(" ", "https://example.com"), false); | ||
| assert.equal(isMaskedLink("label", ""), false); | ||
| }); | ||
|
|
||
| test("non-http schemes are never masked", () => { | ||
| assert.equal(isMaskedLink("email me", "mailto:a@b.com"), false); | ||
| assert.equal( | ||
| isMaskedLink("open thread", "buzz://message?channel=x&id=y"), | ||
| false, | ||
| ); | ||
| }); | ||
|
|
||
| test("masked on scheme downgrade even when host matches", () => { | ||
| assert.equal( | ||
| isMaskedLink("https://accounts.example.com", "http://accounts.example.com"), | ||
| true, | ||
| ); | ||
| }); | ||
|
|
||
| test("not masked when label repeats href scheme exactly", () => { | ||
| assert.equal(isMaskedLink("http://example.com", "http://example.com"), false); | ||
| }); | ||
|
|
||
| test("masked when path or query differ only by case", () => { | ||
| assert.equal( | ||
| isMaskedLink( | ||
| "https://example.com/Reset?token=ABC123", | ||
| "https://example.com/reset?token=abc123", | ||
| ), | ||
| true, | ||
| ); | ||
| }); | ||
|
|
||
| test("host case differences alone are not masked", () => { | ||
| assert.equal( | ||
| isMaskedLink("Example.COM/path", "https://example.com/path"), | ||
| false, | ||
| ); | ||
| }); | ||
|
|
||
| test("protocol-relative hrefs are covered", () => { | ||
| assert.equal(isMaskedLink("company wiki", "//evil.example/login"), true); | ||
| assert.equal( | ||
| isMaskedLink("//evil.example/login", "//evil.example/login"), | ||
| false, | ||
| ); | ||
| }); | ||
|
|
||
| test("labels with userinfo are always masked", () => { | ||
| // Visually reads as safe.com but actually resolves to evil.com | ||
| assert.equal(isMaskedLink("safe.com@evil.com", "https://evil.com"), true); | ||
| assert.equal( | ||
| isMaskedLink("https://safe.com@evil.com", "https://evil.com"), | ||
| true, | ||
| ); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| /** | ||
| * A link is "masked" when its visible text is not the destination URL — | ||
| * `[click here](https://example.com)` — so the reader can't tell where it | ||
| * goes. Masked links get a hover tooltip showing the full destination | ||
| * (anti-phishing, mirrors Slack). Bare pasted URLs and GFM autolinks are | ||
| * not masked: their label already is the URL, modulo cosmetic differences | ||
| * (omitted scheme, trailing slash, host case). | ||
| * | ||
| * Deliberately strict where it matters: scheme downgrades (label says | ||
| * https, href is http), path/query case changes, and protocol-relative | ||
| * hrefs all count as masked. | ||
| */ | ||
| export function isMaskedLink(label: string, href: string): boolean { | ||
| const trimmedLabel = label.trim(); | ||
| if (!trimmedLabel) return false; | ||
|
|
||
| const hrefUrl = parseExternal(href); | ||
| if (!hrefUrl) return false; | ||
|
|
||
| const labelUrl = parseExternal(trimmedLabel, { assumeScheme: true }); | ||
| if (!labelUrl) return true; // label isn't URL-shaped at all | ||
|
|
||
| // A label with userinfo (`safe.com@evil.com`) visually leads with the | ||
| // wrong host — always treat it as masked. | ||
| if (labelUrl.username || labelUrl.password) return true; | ||
|
|
||
| // If the label spells out a scheme, it must match the destination's — | ||
| // otherwise [https://x](http://x) silently downgrades. | ||
| const labelHasScheme = /^https?:\/\//i.test(trimmedLabel); | ||
| if (labelHasScheme && labelUrl.protocol !== hrefUrl.protocol) return true; | ||
|
|
||
| // Host comparison is case-insensitive (the URL parser lowercases it); | ||
| // path/query/hash stay case-sensitive — many backends distinguish them. | ||
| return ( | ||
| labelUrl.host !== hrefUrl.host || | ||
| normalizePath(labelUrl.pathname) !== normalizePath(hrefUrl.pathname) || | ||
| labelUrl.search !== hrefUrl.search || | ||
| labelUrl.hash !== hrefUrl.hash | ||
| ); | ||
| } | ||
|
|
||
| function parseExternal( | ||
| value: string, | ||
| { assumeScheme = false }: { assumeScheme?: boolean } = {}, | ||
| ): URL | null { | ||
| let candidate = value; | ||
| if (/^\/\//.test(candidate)) { | ||
| candidate = `https:${candidate}`; | ||
| } else if (assumeScheme && !/^https?:\/\//i.test(candidate)) { | ||
| candidate = `https://${candidate}`; | ||
| } | ||
| if (!/^https?:\/\//i.test(candidate)) return null; | ||
| try { | ||
| return new URL(candidate); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| function normalizePath(pathname: string): string { | ||
| return pathname.length > 1 ? pathname.replace(/\/$/, "") : pathname; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import * as React from "react"; | ||
|
|
||
| import { isMaskedLink } from "@/shared/lib/maskedLink"; | ||
| import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; | ||
| import { SpoilerHiddenContext } from "./SpoilerInline"; | ||
|
|
||
| /** | ||
| * Masked link (`[text](url)` where the text hides the destination): reveal | ||
| * the full URL on hover so readers can see where a "click here" actually | ||
| * goes before clicking. Bare/autolinked URLs render children unchanged — | ||
| * their label already is the destination. | ||
| */ | ||
| export function MaskedLinkTooltip({ | ||
| href, | ||
| label, | ||
| disabled = false, | ||
| children, | ||
| }: { | ||
| href: string | undefined; | ||
| label: string; | ||
| disabled?: boolean; | ||
| children: React.ReactElement; | ||
| }) { | ||
| // A masked link inside an unrevealed spoiler must not expose its URL via a | ||
| // hover/focus tooltip — that would leak the spoiler's content early. | ||
| const hiddenInSpoiler = React.useContext(SpoilerHiddenContext); | ||
| if (disabled || hiddenInSpoiler || !href || !isMaskedLink(label, href)) { | ||
| return children; | ||
| } | ||
| return ( | ||
| <Tooltip> | ||
| <TooltipTrigger asChild>{children}</TooltipTrigger> | ||
| {/* pointer-events-none: the tooltip is purely informational and must | ||
| never intercept clicks meant for the link (or a spoiler wrapping | ||
| it) when it opens mid-interaction. | ||
| overflow-wrap:anywhere (not break-all): most URLs fit on one line | ||
| at max-w-md; only genuinely long ones wrap, breaking at natural | ||
| boundaries rather than mid-word. Never truncate — the full | ||
| destination must stay auditable for the anti-phishing use case. */} | ||
| <TooltipContent className="pointer-events-none max-w-md [overflow-wrap:anywhere]"> | ||
| {href} | ||
| </TooltipContent> | ||
| </Tooltip> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the masked link is inside an unrevealed spoiler, this wrapper still installs a Radix tooltip trigger on the hidden anchor. The spoiler CSS only makes
.buzz-spoiler__contenttransparent; it does not disable pointer/focus events, and the tooltip content is portaled outside the spoiler, so hovering or tab-focusing||[secret](https://private.example/path)||reveals the URL before the spoiler is opened. Please disable the tooltip while the trigger is within.buzz-spoiler[data-revealed="false"]or otherwise prevent hidden spoiler descendants from opening it.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in ea27128.
SpoilerInlinenow provides aSpoilerHiddenContext(true while unrevealed, and always true for inert preview spoilers);MaskedLinkTooltipconsumes it and renders the plain anchor with no Radix trigger while hidden, so neither hover nor focus can open the tooltip. It re-enables reactively when the spoiler is revealed.Added an e2e regression in
spoiler.spec.tsasserting the URL tooltip stays closed on both hover and focus of a masked link inside a hidden spoiler, then appears normally after reveal. The focus vector was the reproducible leak (the particles overlay intercepts the pointer, but focus bypasses it).