Add @handle.tld mention support: profile links and Bluesky notifications#165
Add @handle.tld mention support: profile links and Bluesky notifications#165jeherve wants to merge 25 commits into
Conversation
Mention facets only tried the DNS TXT (_atproto.<handle>) resolution method, which is empty for every *.bsky.social handle, then fabricated did:web:<handle> on the miss. That produced a mention pointing at a non-existent profile (e.g. did:web:pevohr.bsky.social instead of the account's real did:plc), which broke the rendered mention link and suppressed Bluesky's standard.site publication preview card on the companion post. Delegate resolution to Resolver::handle_to_did(), which already does DNS TXT plus the HTTPS .well-known/atproto-did fallback that bsky.social handles actually use. When a handle resolves to neither, leave the @handle as plain text instead of minting an unroutable did:web.
Remove the deprecated imagedestroy() call from the new post transformer test so composer lint passes, and move the dropped-mention debug log above the early return in carry_body_mentions() so a total drop (no handles fit) is still recorded.
Route the display-side @handle.tld linkifier through appview_url() instead of a hardcoded bsky.app profile URL, so mention links honour the atmosphere_appview_host / atmosphere_appview_url filters that self-hosted appviews rely on. The default output is unchanged (bsky.app).
There was a problem hiding this comment.
Pull request overview
Adds end-to-end Bluesky @handle.tld mention support across both display rendering (auto-linking handles to appview profiles) and publishing (ensuring mentions buried in long-form bodies are carried into Bluesky post text so they generate proper #mention facets and notifications). It also switches mention DID resolution to the full AT Protocol handle-resolution chain and memoizes resolutions per request to bound repeated lookups.
Changes:
- Introduces a display-side mention linkifier (
Atmosphere\Mention) hooked intothe_content, with a suppression guard for transformer/plain-text rendering paths. - Extends Bluesky post composition to carry resolvable body mentions into long-form/link-card and teaser-thread outputs while staying within the 300-grapheme limit.
- Updates facet mention resolution to use
Resolver::handle_to_did()with request-scoped memoization, and adds PHPUnit coverage for mention resolution and carry-over behavior.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/phpunit/tests/transformer/class-test-post.php | Adds long-form/teaser-thread tests verifying mention carry-over and facet shape; includes HTTP stubbing helper. |
| tests/phpunit/tests/transformer/class-test-facet.php | Adds tests for resolved DID mentions, unresolvable mention behavior, and resolve_handles() output; includes HTTP stubbing helper. |
| tests/phpunit/tests/transformer/class-test-document.php | Verifies document HTML content keeps mention links (linkifier active on document render path). |
| tests/phpunit/tests/class-test-mention.php | New unit tests for the display-side mention linkifier, host filter behavior, and suppression guard scoping. |
| readme.txt | Updates end-user documentation to describe @handle.tld mention linking + notification behavior. |
| includes/transformer/class-post.php | Implements body-mention carry-over into composed Bluesky post text and teaser-thread CTA; guards Bluesky text generation against linkified HTML. |
| includes/transformer/class-facet.php | Adds shared mention regex constant, request-scoped resolution cache, resolve_handles(), and switches mention DID resolution to Resolver chain. |
| includes/transformer/class-base.php | Suppresses mention auto-linking in the shared plain-text render path used for Bluesky text composition. |
| includes/class-mention.php | New mention linkifier that wraps bare handles in profile links via appview_url(), with protected-tag skipping and a suppression guard. |
| includes/class-atmosphere.php | Registers the mention linkifier on init so it applies to front-end and document-parser rendering. |
| .github/changelog/add-content-mentions | Adds a minor “added” changelog entry describing mention support. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…st cleanup - Skip email/ActivityPub domain halves in Facet::MENTION_PATTERN so they no longer drive handle resolution or mint stray mention facets. - Unwind the linkifier tag stack to the most recent same-name tag so nested protected tags keep inner text protected. - Remove the pre_http_request stub in both Facet/Post test tear_downs and prefix the bare add_filter() call. - Revert the imagedestroy restoration (it reintroduces a PHPCS deprecation).
There was a problem hiding this comment.
Some things that I checked:
- The @-mention detection misinterprets domain like WebFinger handles as profile handles:
@notiz.blog@notiz.blog - There is no lookup if accounts really exist, so even an example mention like
@example.comwill be linked.
Not (yet) checked: Does the implementation also takes care of the recent implemented "Custom Bluesky text"!?
# Conflicts: # includes/transformer/class-post.php
Pin the two halves of the interaction: - a mention buried only in the post body is not injected into a custom-text record (and is never even resolved), and - a mention typed directly into the custom text still produces a #mention facet so the account is notified.
Address review feedback on the mention detection: - Add a trailing boundary so '@notiz.blog@notiz.blog' is no longer mistaken for a standalone Bluesky handle (its leading half was being linked and faceted). A '.' is kept out of the boundary so a handle ending a sentence still matches. - Make Facet::MENTION_PATTERN the single source of truth and reuse it in the display linkifier, so the publish path and the front-end can no longer drift apart. - Add an 'atmosphere_link_mention' filter so a site can veto linking a handle (e.g. gate on a cached existence check), since the display linkifier intentionally does no per-render network lookup.
|
Thanks @pfefferle — addressed all three. Two new commits: 4aab0d2 (tests for the custom-text interaction) and a3a2dd1 (the WebFinger fix + shared pattern + filter). 1.
|
|
Review — Synthesized from three parallel lensed passes (security · correctness · WP-standards/i18n/BC) plus a manual read. Net: solid, well-defended feature; one blocking correctness bug and a consistency fix, then easy to merge. Correctness — blocking —
|
- Fix the bare-permalink branch in carry_body_mentions() to keep the `\n\n` separator before the URL. Without it a carried handle glued onto the permalink, over-extending MENTION_PATTERN and silently dropping the #mention facet. Adds a regression test for the empty-title + empty-excerpt post shape. - carry_mentions_into_teaser() now uses continue instead of break (a long handle no longer drops shorter ones after it) and logs dropped handles, matching the body-mention path. - Extend Mention::PROTECTED_TAGS with script/noscript/svg/iframe/title and match hyphenated custom elements on the tag stack. - Count graphemes (not code points) in both carry methods for consistency with the rest of the file. - Consolidate handle validation onto Resolver::is_valid_handle(), removing the drifted Facet copy that lacked the reserved-TLD check. - Cache only successful handle resolutions so a transient lookup failure can't deflate #mention facets across a bulk run. - Add @SInCE unreleased to the new mention API surface.
|
Thanks for the thorough review. Addressed in 8382ebb: Blocking — separator bug: The bare-permalink branch in Should fix — teaser consistency: Robustness: Minor / hygiene:
Deliberately left (happy to follow up if you'd like):
|
Code review — typed multi-lens passReviewed with three parallel lenses (correctness/fatals, security/escaping, perf-patterns-i18n-compat), each reading whole files. Blocking findings were confirmed against the code and 🔴 Blocking1. Uncapped full-body handle resolution — 2. Egress widening onto the lower-trust comment path — 3. Preview/publish grapheme-count divergence — 4. Self-closed protected tag suppresses linkification — 🟡 Non-blocking
✅ PositivesEscaping is solid ( Risk: HIGH — changes the core publish path for every post (full-body scan + live handle resolution) and adds network egress to both the publish and comment-sync paths. Findings #1–#4 are concrete; #1 and #2 (egress) are the most important. Happy to draft fixes. |
kraftbj
left a comment
There was a problem hiding this comment.
Two additional items from a review pass, both inline below. Neither is covered by the existing threads (verified against the prior passes).
Address PR review (pfefferle): the body @mention scan resolved an unbounded number of distinct handles, and the comment-sync path resolved commenter-supplied mentions. Each resolution is a DNS + HTTPS lookup to the mentioned host, so both were egress-amplification surfaces. - Cap distinct handles resolved per body scan at MAX_RESOLVED_HANDLES (20); handles past the cap are left unresolved and logged once. - Add a $with_mentions gate to Facet::extract(); the comment path passes false so an approved comment can no longer steer the server's outbound requests at an arbitrary host. Link/hashtag facets, which never touch the network, are unaffected. - Add Facet::handles_in(), a resolution-free companion used for boundary-safe mention membership tests.
Address PR review (pfefferle): the linkifier's tokenizer detected closing tags only by a leading slash, so a self-closed protected tag (`<svg/>`, `<iframe … />`) was pushed onto the tag stack and never popped — silently leaving every mention after it in the render as plain text. Share the tokenizer between the linkifier and a new strip_protected() helper via a private walk() method, and skip pushing self-closed tags. strip_protected() returns HTML with protected-tag text removed, so the publish-side body scan can honour the same protected-tag rules the display linkifier does.
Address PR review (kraftbj, pfefferle): - Match carried mentions on token boundaries. The "already visible" test used mb_stripos(), so a body handle that is a string prefix of a visible one (@alice.com inside @alice.company) was treated as present and never carried — the account was silently not notified. Compare handle sets via Facet::handles_in() instead. (kraftbj) - Honour protected tags when scanning the body for mentions. The scan ran on fully tag-stripped text, so a @handle inside a <code> sample or an existing <a> link was carried into the Bluesky post and minted a #mention facet even though the display linkifier leaves it as plain text. Scan the rendered HTML through Mention::strip_protected() so publish and display agree. (kraftbj) - Size the carry-over in the pre-publish projection. The carry-over lengthens the composed record, but projection skipped it entirely and under-reported the grapheme count. Projection now reserves space from the syntactic body handles (no DNS, an upper bound) so the reported count matches the record the publisher writes. (pfefferle) Split render_post_content_html() out of render_post_content_plain() so the body scan can work from HTML without re-running the_content.
|
Thanks @pfefferle — all four blocking findings from the HIGH-risk pass are addressed across three commits, each with regression coverage.
On the non-blocking items: the |
Normalize indentation in `class-test-document.php` by replacing stray space-indented lines with tabs around the method closing brace and following docblock. This is a formatting-only change to keep test code consistent with project coding standards.
pfefferle
left a comment
There was a problem hiding this comment.
Ran a deep review pass over the mention handling; 10 inline notes below, roughly ordered by severity.
The common thread in several of them (the protected-tag gap, per-chunk linkification, tag-strip gluing, the unquoted-attribute self-close check, and the 1 MB guard) is publish/display parity: the publish path and the front-end linkifier tokenize the content differently, so they disagree about what is a mention. A structural fix — both sides consuming one shared tokenization — would close that class at once, rather than patching case by case.
Three others are deliberate-looking behavior changes vs trunk (dot-mentions no longer matching, comment mention facets removed, the dropped did:web: fallback) that probably each deserve an explicit decision rather than a silent change.
The publish path and the front-end linkifier tokenized rendered content differently, so they disagreed about which `@handle.tld` counts as a mention. That gap let a handle the site displays as plain text still mint a Bluesky `#mention` facet (and notify the account), and vice versa. Route both sides through a single `Mention` walk: - `Mention::classify_handles()` replaces `strip_protected()` and returns the handles the linkifier would link (`linkable`) plus the ones that live only inside a protected `<code>`/`<pre>`/`<a>` region (`protected`). The post path resolves exactly the linkable set and passes `protected − linkable` to `Facet::extract()` as a deny-set, so a code-buried handle never mints a facet on the short-form, truncate-link, teaser, or link-card paths — not just the carry-over path. - The walk now threads the last plain-text character across inline tags, so `<b>bob</b>@example.com` reads as the email it renders as instead of being linkified as a bogus profile link. - Self-closing detection ignores quoted/unquoted attribute values, so `<a href=https://example.com/>` is no longer mistaken for a self-closed tag that leaves a nested `<a>` around an inner mention. - Both sides bail identically on >1 MB content, so a code-buried mention in a huge post is neither linked nor notified. Also harden resolution and restore a lost behaviour: - `Facet::extract()`'s mention pass caps distinct handle resolution at `MAX_RESOLVED_HANDLES` and skips re-resolving a within-call miss, so a record stuffed with unresolvable tokens can't fan out into an unbounded run of blocking DNS/HTTPS lookups per teaser entry. - The shared mention pattern no longer excludes a leading `.`, so the Twitter-style dot-mention idiom (`.@alice.bsky.social`) resolves and links again. - `carry_body_mentions()` fits the carried line to a fixpoint: a handle visible in the prose but truncated away when the prose shrinks to make room is rescued into the carried line rather than silently dropped. - An unresolvable handle still mints no fabricated `did:web:` facet by default, but a new `atmosphere_mention_didweb_fallback` filter lets a did:web-hosting site restore the pre-1.0 fallback.
Comment-path mention resolution stays off by default (an approved comment should not be able to steer the server's outbound DNS/HTTPS at an arbitrary host), but removing it outright left no way for a site that trusts its moderated commenters to keep that capability. Gate it on a new `atmosphere_resolve_comment_mentions` filter instead of a hard `false`.
|
Addressed all 10 notes across 29654c0 (parity + hardening) and 2b41fb8 (comment-mention filter); replies inline under each. Took the structural route you suggested: the protected-tag gap, per-chunk linkification, tag-strip gluing, the unquoted-attribute self-close, and the 1 MB guard are all now driven by a single The three behaviour-vs-trunk notes each got an explicit decision: dot-mentions restored (regex boundary), comment mentions kept off but now opt-in via
|
Fixes #142
Proposed changes:
Adds Bluesky
@handle.tldmention support so authors can notify accounts and link to their profiles. This works in two places: a display-side linkifier (includes/class-mention.php) wraps bare handles inthe_contentas profile links routed through theappview_url()helper (so they honor theatmosphere_appview_hostfilter), and a publishing-side carry-over pulls resolvable body mentions into long-form and teaser-thread Bluesky post text so mentioned accounts are notified even when the mention is buried deep in the body. It also replaces the unreliabledid:web:<handle>resolution fallback inFacet::resolve_mention()with the real handle-resolution chain (Resolver::handle_to_did()), memoized per request to bound lookups.Other information:
Testing instructions:
@handle.tld(e.g.@bsky.app).did:webprofile.composer lintandnpm run env-test— both pass (786 tests).Changelog entry
A changelog entry is already committed at
.github/changelog/add-content-mentions(Significance: minor, Type: added).