Skip to content

feat: add hashtags - #1059

Closed
chrismessina wants to merge 6 commits into
dev-null-sh:productionfrom
chrismessina:feat/hashtags
Closed

chrismessina wants to merge 6 commits into
dev-null-sh:productionfrom
chrismessina:feat/hashtags

Conversation

@chrismessina

@chrismessina chrismessina commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Builds on #1064 (hex color swatches). Until that merges, this diff includes its commit, feat: show hex colors as swatches.

There's no way to tag a commit with a topic, so the only way to find posts about, say, PHP is the meaning-based search. This adds #hashtags:

  • Links: #php in a commit is a link to a search for it.
  • Colors stay colors: a hex value like #ff8a4c or #fff is never a tag; feat: show hex colors as swatches #1064 draws it with a swatch.
  • Suggestions: typing # in the commit, reply, or quote composer suggests up to six hashtags. Your own come first (most used, then most recent), then tags from the last 30 days ranked by how many people used them, then alphabetically.

The parsing rule is Bluesky's TAG_REGEX (atproto rich-text/util.ts), ported rather than imported because the server needs it in PHP. #node.js, #.net, and #c++ are tags, trailing punctuation isn't part of them, and #123 stays an issue reference because a tag needs at least one character that isn't a digit or punctuation. Changes from Bluesky:

  • Tags in code don't count.
  • A tag can follow the > that starts a quoted line (>#php), where Bluesky needs whitespace.
  • Tags are stored lowercased and shown as written.
  • 3, 4, 6, or 8-digit hex values aren't tags (they're colors, from feat: show hex colors as swatches #1064).
  • The limit is 64 characters rather than 64 graphemes, because PCRE and browsers split some scripts into graphemes differently.

Changes

  • App\Services\Hashtags and resources/js/lib/hashtags.ts hold the same rule, kept in sync like Mentions. PHP spells out the whitespace JavaScript's \s matches, so both sides find the same tags.
  • Storage: commits.hashtags (jsonb, hidden), written by CreateCommit from the body as kept (after emoji shortcodes) and by CreateWorldReply. It's nulled with the body by DeleteCommitContent and DeleteUser, and included in the data export. Answers to the data guideline's questions:
    • Why: suggestions aggregate the tags of every visible recent commit on each keystroke, which would otherwise mean re-parsing all of those bodies every time. No index is added: nothing here would use one. Exact tag search, if hashtags are wanted, adds a GIN index with it.
    • Who sees it: whoever can see the body.
    • When it's deleted: with the body.
  • Migrations: two of them, to keep blocking short. A nullable column (a brief table lock, no rewrite), then a backfill outside a transaction, so each update commits on its own and holds only that row's lock. The backfill updates a commit only while its body is unchanged and its tags are still null, so a commit erased meanwhile never gets its tags back.
  • Signed API: for signed-in requests, CommitResource (not tombstones) and the draft preview add hashtags positions ({tag, offset, length}, code-point offsets including the #, like mentions), so the mobile app can link tags without its own parser.
  • Suggestions: GET search/hashtags (HashtagSuggestionController → ListHashtagSuggestionsRequest → HashtagSuggestionsQuery). It reads only commits the user may see (approved ones, plus their own not yet approved), skips muted people and blocks in either direction, and shares the people rate limit.
  • Links and mentions inside tags: a link or mention can't start inside a word that begins with a # where a tag could start (at the start, after whitespace or after >), up to a backtick. So #asp.net and #docs:example.com aren't links and #@alice and #release:@alice notify nobody, while [#12](https://…), C#:@alice and a # in code don't block anything. That's one lookbehind on the existing patterns (Hashtags::WordSoFar, and its copy in mentions.ts and urls.ts), so mention notifications and positions, link cards, the 23-character link count, URL shortening and the composer all agree without tracking where tags are. CreateCommit now finds mentions and the link card in the body after emoji shortcodes are replaced, as it already did for hashtags. Bluesky avoids the overlap by starting mentions and links only after whitespace or (, and Mastodon by not allowing punctuation in tags; adopting Bluesky's start rule would also stop linking “https://…”, which this app tests for. It stays a little broader than the tag rule: after a>#x: or in a # word longer than a tag (65 to 255 characters), a mention or link is blocked too.
  • Web: LinkifiedText links tags and keeps them whole, in a step before feat: show hex colors as swatches #1064's colors.
  • Composer:
    • Typing a mention inside a tag suggests hashtags, not people, and a tag ends at the same zero-width characters the parser uses.
    • Accepting a suggestion replaces the whole word, keeps the case you wrote, and adds no space before sentence punctuation. Mentions get the same whole-word fix: @ta|yotwell used to become @taylorotwell yotwell.
    • Hashtag suggestions open while you type a tag, not when the caret moves into one.
  • Translation and docs: the translator is told to keep #hashtags (it isn't checked afterward), and KNOWLEDGE_BASE.md has a Hashtags section.

Decision for review: trending tags (not in this PR) are planned to count distinct authors across everyone, ignoring each viewer's mutes and blocks, so one cached list serves everyone. That's an exception to PRODUCT.md's "mutes and blocks filter everywhere". @nunomaduro, overrule this if it shouldn't be.

Validation

Local, macOS, PHP 8.5.12, PostgreSQL 18 with pgvector:

  • pest --parallel --ci: 4,102 passed (on production at bb1acc1 plus feat: show hex colors as swatches #1064).
  • bun run test:unit: 137 passed. vp test run: all 806 tests passed (422 + 384 across its two projects). It can't load the 35 node:test files: bun run test:unit runs the 28 .ts ones, and the 7 .tsx ones run nowhere, as on production.
  • pint --test, rector --dry-run, vp fmt --check, vp lint, and tsc (app and tests): pass. vp lint reports warnings, all from lines this branch doesn't change. Type coverage: 100%.
  • phpstan: 2 errors, both in EmbedKnowledgeBaseCommand.php, which this branch doesn't change. They're the same on production.
  • Each new test failed before the code it covers was written, except two backfill cases written alongside their fix and the composer's quoted-line tests, which were written after their fix and fail when it's removed.
  • Not run locally: the 100% line-coverage gate (no Xdebug or pcov for PHP 8.5 here); hosted CI runs it.

Visual evidence

"Before" is a commit on the live site (base code) and "after" is the same kind of text on this branch, at 1440×900, 1366×650, and 390×844, light and dark.

Layout shift, measured with PerformanceObserver (layout-shift):

Viewport Theme Commit page Typing #, narrowing, Enter
1440×900 light / dark 0 / 0 0 / 0
1366×650 light / dark 0 / 0 0 / 0
390×844 light / dark 0 / 0 0 / 0

In every run, a bare # suggested #laravel and #node.js, #no narrowed to #node.js, and Enter inserted #node.js .

Device verification

macOS 27.0.1, Chrome 156.0.8078.48 driven by Playwright with mobile emulation (390×844, touch). Typing, the popover, and Enter worked; scrolling, client-side navigation, and fresh reloads weren't exercised separately. No hardware or simulator was used, so the on-screen keyboard and safe areas aren't verified. The suggestion popover is the same component mention suggestions already use on mobile.

Independent review

  • Reviewer: Codex (gpt-6.1-sol, high effort), read-only, source and diff only.
  • Revisions: this PR's own commits, 96a0b8de9566acfa3a819545df0d94a1be6b68c7 (feat: show hex colors as swatches #1064's head) → 5f996f9a5516868c694757ef423bdad8527e6434. feat: show hex colors as swatches #1064 was reviewed separately (CODE: PASS).
  • CODE: PASS, with no findings. Earlier rounds on this PR drove the fixes described above. This round confirmed the last two composer selection cases fixed. It also checked that hashtags and colors each render once, that a hex value never becomes a tag, and that the rebase dropped nothing.
  • Follow-up a86340d, the same digit-only color change as feat: show hex colors as swatches #1064's, cherry-picked: reviewed on feat: show hex colors as swatches #1064 (CODE: PASS). It doesn't touch hashtags, since a digit-only word is never a tag.
  • VISUAL: not independently reviewed. The screenshots and layout-shift measurements were checked by the author only, on the branch before its final parsing and composer changes.

Limitations

  • Tag links use the meaning-based search until an exact tag page exists. It finds related commits, not exactly the tagged ones.
  • The mobile app needs its own change to link tags (positions are in the API now) and to suggest them.
  • The Following page's suggestions render the raw body, so URLs and mentions inside code still link there. That was already the case; tags are now skipped in code.
  • Line coverage was not measured locally.
  • Tag links search by meaning, which needs three characters, so #r finds nothing. Exact tag search is the next step if hashtags are wanted.
  • A tag of 33 or more Turkish capital İ grows past 64 characters when lowercased, so suggesting it inserts a tag that won't link.
  • A U+0085 at the start of a typed prefix is trimmed before the suggestion lookup.
  • A tag containing a stray backtick (#a`b:@alice) still lets the mention after the backtick through.
  • A link card chosen explicitly, or by a scheduled commit, is checked against the body before emoji shortcodes are replaced (a P3 from an earlier review round, left open).
  • The rule that keeps links and mentions out of tags is slightly broader than tags: after a>#x:, or inside a # word of 65 to 255 characters, a link or mention is blocked too.

A hex color written like a hashtag, such as #ff8a4c or #fff, shows with a
small swatch of the color. It needs at least one letter, so dev-null-sh#123 stays an
issue reference, and colors in code are left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
chrismessina and others added 4 commits October 23, 2026 12:18
Hashtags follow Bluesky's rule. They are stored on commits for
suggestions and the data export, linked on the web, suggested in the
commit, reply and quote composers, and sent as positions in the signed
API. Hex colors render as swatches. A link or mention never starts
inside a hashtag, so "#asp.net" and "#release:@alice" are hashtags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Accepting a suggestion replaced only what was before the caret, so
picking #node.js inside "#n|ode.js" gave "#node.js ode.js". It now
replaces the whole word, keeps the case of what was already written,
and adds no space before punctuation. Hashtags are suggested while
typing one, not when the caret moves into one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A completed mention stays apart from a mention right after it, and a
hashtag stops before code. At the length limit the caret lands right
after the completion. Hashtag suggestions open after an emoji shortcode
is typed, and stay closed when the caret only moves back to where typing
left it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Changing the selection and collapsing it again no longer reopens hashtag
suggestions. After an emoji shortcode is replaced, suggestions open once
the caret is back where typing left it, even if the browser reported the
caret elsewhere in between.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#000 and #000000 are black, and #333333 is gray, but they showed as plain
text because a color needed a letter. Issue numbers never start with a
zero and aren't 6 or 8 digits long, so those count as colors now, while
dev-null-sh#123 and dev-null-sh#333 still stay plain. The pattern also spells its zero-width
characters as escapes instead of the invisible characters themselves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@xHeaven

xHeaven commented Oct 23, 2026

Copy link
Copy Markdown
Member

Note

This message is generated by an AI agent on behalf of @xHeaven. It may contain errors—verify important details.

Closing this in line with Brent’s decision on #9:

We won't do hashtags

@xHeaven xHeaven closed this Oct 23, 2026
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