Skip to content

feat(desktop): show full URL tooltip on masked link hover#1625

Merged
wesbillman merged 3 commits into
block:mainfrom
AaronGoldsmith:link-url-tooltip
Jul 8, 2026
Merged

feat(desktop): show full URL tooltip on masked link hover#1625
wesbillman merged 3 commits into
block:mainfrom
AaronGoldsmith:link-url-tooltip

Conversation

@AaronGoldsmith

Copy link
Copy Markdown
Contributor

Summary

Masked links — [text](url) where the visible text hides the destination — now
show a tooltip with the full URL on hover, mirroring Slack's anti-phishing
affordance. The reader can see where a "click here" link actually goes before
clicking. Bare pasted URLs and GFM autolinks are unchanged: their label already
is the destination, so a tooltip would be redundant.

Motivation

A message like [the quarterly report](https://intranet.example.com/…) renders
only the label. Without hovering, there's no way to tell a legitimate link from
a lookalike ([accounts.google.com](https://evil.example)). Slack added exactly
this hover-to-reveal behavior; this brings desktop Buzz to parity.

Scope

Deliberately narrow for a first version:

  • Masked links only. If the label already matches the destination
    (modulo cosmetics), no tooltip.
  • Desktop only. Web and mobile (which would need a long-press affordance)
    are out of scope.
  • Plain external links only. Message-link pills, file cards, buzz://
    deep links, and Linear-styled links keep their existing hover treatments.

How it works

  • isMaskedLink(label, href) (desktop/src/shared/lib/maskedLink.ts) parses
    both sides with the URL API and compares component-wise. Cosmetic
    differences stay tooltip-free (omitted scheme, trailing slash, host case)
    while spoof shapes always trigger.
  • The tooltip wrap lives in its own module
    (desktop/src/shared/ui/markdown/MaskedLinkTooltip.tsx) — markdown.tsx is
    at its file-size ceiling. It reuses the existing Radix tooltip primitive and
    the app-global TooltipProvider.
  • Tooltip content is pointer-events-none (never intercepts a click meant for
    the link or a spoiler wrapping it) and never truncates (max-w-md +
    overflow-wrap:anywhere) so the full destination stays auditable — most URLs
    fit on one line, long ones wrap at natural boundaries instead of mid-word.

Adversarial review

The first predicate draft used naive string normalization (scheme-stripping +
whole-URL lowercasing). An adversarial review pass (codex + gemini) found
spoofable false negatives, all now covered by tests:

Spoof Before Now
Scheme downgrade [https://x](http://x) missed masked
Case-only path/query change missed masked
Protocol-relative //host/… href bypassed masked
Userinfo label safe.com@evil.com missed masked

Also included

A second, separate commit fixes a pre-existing flaky e2e test
(spoiler.spec.ts) surfaced while validating this change: click({force:true})
computed its target from the link's bounding box while the freshly sent row was
still settling layout, so the click occasionally landed a few px off on the
author name (~1 in 3 cold runs, reproducible on main). Now waits for the
link's position to be stable across two animation frames before clicking.

Testing

  • just ci green (fmt, clippy, desktop lint, unit tests, mobile tests, builds).
  • 12 table-driven tests for isMaskedLink covering every spoof case above.
  • Playwright: full spoiler + link specs pass, including 4/4 cold-start runs of
    the previously flaky test.
  • Manually verified in the running app: masked links reveal the URL, bare links
    don't, and the documented spoof cases all trigger.

Not in scope

  • Web / mobile equivalents.
  • Tooltips on pills, file cards, Linear links, buzz:// anchors.
  • A separate, pre-existing link-resolution bug: scheme-less link targets
    ([docs](example.com/x)) render as relative URLs and resolve against the app
    origin. Independent of this change; tracked separately.

@AaronGoldsmith

Copy link
Copy Markdown
Contributor Author

Masked link — URL revealed on hover

A [the quarterly report](https://intranet.example.com/…) link. Hovering shows
the full destination on a single line; the tooltip floats above the message.

masked-link-tooltip

Bare link — no tooltip

A pasted https://example.com/plain-url. No tooltip, since the visible text
already is the destination.

bare-link-no-tooltip

@AaronGoldsmith AaronGoldsmith marked this pull request as ready for review July 8, 2026 05:58

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6bf9b0d449

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1677 to +1680
return (
<MaskedLinkTooltip disabled={isLinearLink} href={href} label={label}>
{anchor}
</MaskedLinkTooltip>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Suppress masked-link tooltips inside hidden spoilers

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__content transparent; 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ea27128.

SpoilerInline now provides a SpoilerHiddenContext (true while unrevealed, and always true for inert preview spoilers); MaskedLinkTooltip consumes 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.ts asserting 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).

AaronGoldsmith added a commit to AaronGoldsmith/buzz that referenced this pull request Jul 8, 2026
A masked link inside an unrevealed spoiler still installed a Radix tooltip
trigger on the hidden anchor. The spoiler only masks its content visually and
intercepts pointerdown/click to reveal — it does not suppress hover or focus —
and the tooltip content is portaled outside the spoiler, so hovering or
tab-focusing the hidden link leaked its URL before the spoiler was opened.

SpoilerInline now provides a SpoilerHiddenContext (true while unrevealed, and
always true for inert preview spoilers); MaskedLinkTooltip consumes it and
renders the plain anchor with no tooltip trigger while hidden. It re-enables
reactively when the spoiler is revealed. Covers both the hover and focus
vectors. Reported by Codex review on block#1625.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aaron Goldsmith <aargoldsmith@gmail.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

AaronGoldsmith and others added 3 commits July 8, 2026 07:57
Masked links — [text](url) where the visible text hides the destination —
now show a tooltip with the full URL on hover, mirroring Slack's
anti-phishing affordance. Bare pasted URLs and GFM autolinks are left
unchanged: their label already is the destination, modulo cosmetic
differences (omitted scheme, trailing slash, host case).

isMaskedLink parses both sides with the URL API and compares
component-wise so cosmetic differences stay tooltip-free while spoof
shapes always trigger: scheme downgrades ([https://x](http://x)),
case-only path/query changes, protocol-relative hrefs, and userinfo
labels (safe.com@evil.com) are all treated as masked. The tooltip
content is pointer-events-none so it can never intercept clicks meant
for the link or a spoiler wrapping it, and never truncates so the full
destination stays auditable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aaron Goldsmith <aargoldsmith@gmail.com>
The spoiler-link reveal test failed intermittently on cold-start runs
(~1 in 3), pre-existing on main and surfaced while validating the
masked-link tooltip. click({force: true}) computes its target point from
the link's bounding box while the freshly sent row is still settling
layout, so the click can land a few px above the link on the author
name. Wait for the link's position to be stable across two animation
frames before clicking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aaron Goldsmith <aargoldsmith@gmail.com>
A masked link inside an unrevealed spoiler still installed a Radix tooltip
trigger on the hidden anchor. The spoiler only masks its content visually and
intercepts pointerdown/click to reveal — it does not suppress hover or focus —
and the tooltip content is portaled outside the spoiler, so hovering or
tab-focusing the hidden link leaked its URL before the spoiler was opened.

SpoilerInline now provides a SpoilerHiddenContext (true while unrevealed, and
always true for inert preview spoilers); MaskedLinkTooltip consumes it and
renders the plain anchor with no tooltip trigger while hidden. It re-enables
reactively when the spoiler is revealed. Covers both the hover and focus
vectors. Reported by Codex review on block#1625.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aaron Goldsmith <aargoldsmith@gmail.com>
@wesbillman wesbillman enabled auto-merge (squash) July 8, 2026 15:23
@wesbillman wesbillman merged commit 01c6b85 into block:main Jul 8, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants