Skip to content

feat(composer): hashtag suggestion pills + derive t tags on kind-1 notes - #741

Merged
spe1020 merged 6 commits into
mainfrom
feat/composer-hashtag-pills
Sep 19, 2026
Merged

spe1020 merged 6 commits into
mainfrom
feat/composer-hashtag-pills

Conversation

@spe1020

@spe1020 spe1020 commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Impact first: web kind-1 notes ship with zero t tags

Before this PR the composer published every kind-1 note with an empty t tag set. Hashtags typed in the body stayed in the body only. That means:

  • None of the seven relay-side #t filters in FoodstrFeedOptimized.svelte can ever match a web-authored note. The Following and load-more paths query relays with #t: FOOD_HASHTAGS; web posts never come back from those queries.
  • Every web-authored food post is invisible in the iOS OnlyFood feed. iOS queries kind-1 with a #t filter over the 85-tag food set. Android does the same.

Nobody noticed because web's client-side content-word scan compensates on web alone. Commit 1 fixes this. Without it the pills in commit 3 would add text and achieve nothing.

Commits, in order

  1. fix(composer): derive t tags from typed hashtags on kind-1 notes — new $lib/hashtags.ts holds the feed's own hashtag pattern and derives t tags from the final body at publish time: lowercase, de-duplicated. The composer pushes them next to the p/q tags.
  2. refactor(feed): share MAX_HASHTAGS and the hashtag count with the composerMAX_HASHTAGS, the pattern and the max(content, t) count move out of the feed component into $lib/hashtags.ts. Feed and composer import the same module, so the counter and the filter cannot disagree. The module comment records that 5 is under review (100-event sample: 69 rejected, ~61 were 100+ tag aggregators, the 6–20 band was mostly genuine food posts, natural break near 20) and names the iOS and Android copies that must move with it.
  3. feat(composer): hashtag suggestion pills under the kind-1 editor$lib/hashtagPills.ts plus the row in PostComposer.svelte. Set is the measured eight, in this order: foodstr, coffee, cooking, breakfast, dinner, lunch, cookstr, food. The row scrolls horizontally in a single line and never wraps, so the order is the design: foodstr leads as the canonical tag, food goes last so it does not read as a duplicate of foodstr in the two most valuable slots. gratitude is out: it is not in FOOD_HASHTAGS and not a food word, so a note carrying only it misses the Global feed and every relay-side filter. iOS currently ships a different order and Android has no pills; the same set and order should go to both (noted on the cross-platform issues). Tap appends on a trailing tag line, tap again removes every whole-token match case-insensitively and tidies whitespace. Body is the single source of truth. Counter reads "n/5 tags" via the shared module; at the cap unselected pills dim and stop responding, a selected pill still toggles off, typing past the cap turns the counter red. Shown on every open of this composer, including Pantry-only.

Scope is kind-1 notes in PostComposer only. ReplyComposer and the article/recipe editors are untouched.

One deviation to flag

extractHashtags trims trailing sentence punctuation from the derived tag value. "Loving this #foodstr." publishes ["t","foodstr"], not ["t","foodstr."]. The feed's pattern captures foodstr. as the token, and the count is left exactly as the feed counts it, so the cap cannot drift. Only the wire value is trimmed, because foodstr. would never match a #t: ["foodstr"] relay filter. Say the word if you would rather ship the raw token.

Article path report (not fixed here, not broken)

Recipes are fine. Every kind-30023 recipe publish path tags ["t","zapcooking"] (RECIPE_TAG_PREFIX_NEW) plus zapcooking-<slug> and one zapcooking-<tag> per selected tag:

  • src/routes/create/+page.svelte:349
  • src/routes/create/gated/+page.svelte:204 (also zapcooking and the gated tag)
  • src/routes/fork/[slug]/+page.svelte:249
  • src/routes/souschef/+page.svelte:583
  • recipe packs in src/lib/recipePack.ts:176

Web-authored recipes are visible to iOS's #t: zapcooking / nostrcooking query. No separate concern needed. (reads/LongformEditorModal.svelte publishes non-recipe long-form and is out of scope.)

Tests

  • src/lib/hashtags.test.ts — extraction, de-dup, punctuation, URL fragments, count parity with the feed pattern, max(content, t) count, cap value.
  • src/lib/hashtagPills.test.ts — the iOS set, select, deselect, typed tag shows as selected, cap counting, no-op at cap, toggle-off at cap.
  • Full suite: 140 files, 1976 tests pass. svelte-check: 0 errors.

The repo has no component tests, so the logic lives in the $lib modules and the Svelte component only wires it.

Gates still open (by hand, yours)

  • Open the composer, tap two pills, publish, confirm the published event carries the t tags. This proves commit 1.
  • Confirm that post appears in the iOS OnlyFood feed.

Pill row at the smallest widths

Checked headless in Chrome at 320px and 375px with a seeded read-only login. One row, horizontal scroll, no wrap. At rest a partial pill is visible at the right edge, so the scroll is discoverable:

Viewport Fully visible Cut at the edge
320px #foodstr, #coffee #cooking
375px #foodstr, #coffee, #cooking #breakfast

The counter sits to the right of the scroller and does not move.

🤖 Generated with Claude Code

spe1020 and others added 3 commits September 19, 2026 12:22
Web published kind-1 notes with zero `t` tags. Hashtags typed in the body
stayed in the body only, so no relay-side `#t` filter could ever match a
web-authored note: the seven `#t` queries in the food feed on web, and the
iOS and Android OnlyFood feeds, which query kind-1 with `#t` over the food
tag set. Nobody noticed because web's client-side content-word scan
compensated on web alone.

`$lib/hashtags` holds the food feed's own hashtag pattern and derives the
tags from the final body at publish time: lowercase, de-duplicated,
trailing sentence punctuation dropped. The composer pushes them next to
the `p` and `q` tags.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…poser

Move the food feed's hashtag cap, its hashtag pattern and the count it
judges against (greater of body tokens and `t` tags) into $lib/hashtags.
The feed imports them; the composer's counter in the next commit imports
the same. A composer counting with a copied regex is the drift that
produced the recipeDiscoveryService bug in the hidden-recipe work.

The module comment records that 5 is under review (a 100-event sample
rejected 69; about 61 were 100+ tag aggregators, but the 6–20 band was
mostly genuine food posts, so the break is near 20) and names the iOS
and Android copies that must move with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A subtle row of tappable hashtag pills below the editor. Tapping appends
the tag on a trailing tag line; tapping again removes every whole-token
match, case-insensitively, and tidies the whitespace. Nothing is added
unless the user taps. The body stays the single source of truth: a pill
reads as selected when its tag is in the body, typed or tapped.

The set is the one iOS ships (zapcooking_ios PR #83), measured from 60
days of relay usage on the OnlyFood relays: foodstr, food, cooking,
cookstr, breakfast, lunch, dinner, coffee. gratitude stays out; it is
not in FOOD_HASHTAGS and not a food word, so a note carrying only it
misses the Global feed and every relay-side filter.

The counter reads "n/5 tags", counted through $lib/hashtags so it cannot
drift from the feed's cap. At the cap, unselected pills dim and stop
responding; a selected pill still toggles off. Typing past the cap by
hand is flagged in red, not prevented.

The row shows on every open of this composer, including Pantry-only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 19, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
zapcooking-frontend 233ad35 Sep 19 2026, 05:26 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 19, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
frontend 233ad35 Sep 19 2026, 05:30 PM

Fits the theme. It is not in the food feed's tag set, so a note carrying
only it does not reach the Global feed; it rides alongside the food tags.
Web's set is now the iOS eight plus this one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 19, 2026

Copy link
Copy Markdown

Deploying frontend with  Cloudflare Pages  Cloudflare Pages

Latest commit: 233ad35
Status: ✅  Deploy successful!
Preview URL: https://1a6e9312.frontend-hvd.pages.dev
Branch Preview URL: https://feat-composer-hashtag-pills.frontend-hvd.pages.dev

View logs

…he scroll

One horizontal scrolling row instead of wrapping. On mobile the wrapped
row took three lines between the editor and the toolbar. A partially
visible pill at the right edge is the scroll affordance: at 320px the
third pill is cut, at 375px the fourth. The n/5 counter stays where it
was, outside the scroller.

gratitude is out. It is not in FOOD_HASHTAGS and not a food word, so a
note carrying only it misses the Global feed and every relay-side #t
filter. A pill that looks helpful and does nothing is worse than none.

Order is now foodstr, coffee, cooking, breakfast, dinner, lunch, cookstr,
food. With a scrolling row the order is the design; most people never
scroll past the third pill. foodstr leads as the canonical tag, then the
specific ones people reach for. food goes last: next to foodstr it reads
as the same choice twice. iOS and Android should ship this same order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

removeHashtag can unintentionally remove #tag text inside URL fragments (e.g. https://…/#foodstr) when toggling pills off, which risks mutating pasted links.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 Medium severity · 1 Low severity

Open (2)
What changed in this PR

Adds shared hashtag parsing/counting logic and composer UI to ensure kind-1 notes publish correct t tags (so relay-side #t filters can match web-authored posts), and introduces hashtag suggestion pills that manipulate body text while respecting the shared spam cap.

Changes:

  • Introduces $lib/hashtags.ts to centralize the hashtag pattern, cap, counting, and t-tag derivation from note bodies (with tests).
  • Adds $lib/hashtagPills.ts and tests to support “tap to add/remove hashtag” behavior with cap-aware toggling.
  • Wires the composer to (a) show pill UI + counter and (b) push derived t tags at publish time; refactors the feed to reuse shared hashtag count logic.
File Description
src/​lib/​hashtags.ts Shared hashtag regex, cap, counting, and t tag derivation for kind-1 note bodies.
src/​lib/​hashtags.test.ts Unit tests for extraction/counting/cap parity and punctuation trimming behavior.
src/​lib/​hashtagPills.ts Pure functions for suggested hashtag pills (selected state, cap behavior, append/remove/toggle).
src/​lib/​hashtagPills.test.ts Unit tests covering pill set/order and toggle/cap behaviors.
src/​components/​PostComposer.svelte Adds pill row UI + counter and appends derived t tags to published kind-1 events.
src/​components/​FoodstrFeedOptimized.svelte Refactors to use shared MAX_HASHTAGS and shared hashtag counting logic.
package.json Version bump.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/lib/hashtagPills.ts Outdated
Comment on lines +82 to +83
const pattern = new RegExp(`(?<![\\p{L}\\p{N}_])#${escapeRegex(tag)}(?![\\p{L}\\p{N}_])`, 'giu');
let out = content.replace(pattern, '');

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 233ad35. The left boundary is now the shared HASHTAG_PATTERN boundary (start of text or after whitespace), so #foodstr inside https://x.y/#foodstr or glued to a word is no longer a match. It was never selected either, since extractHashtags ignores the same cases.

Comment on lines +69 to +76
it('removes every occurrence, case-insensitively', () => {
expect(removeHashtag('#Foodstr eggs #FOODSTR and #foodstr', 'foodstr')).toBe('eggs and');
});

it('leaves longer tags that share the prefix alone', () => {
expect(removeHashtag('#foodstr #foodstrlove #food', 'food')).toBe('#foodstr #foodstrlove');
expect(removeHashtag('#food_pics #food', 'food')).toBe('#food_pics');
});

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.

Added in 233ad35: removeHashtag leaves #foodstr untouched inside https://x.y/page#foodstr and https://x.y/#foodstr (also when a real #foodstr tag line is removed from the same body), and leaves word#foodstr alone as extraction does.

removeHashtag matched `#tag` after any non-word character, so toggling a
pill off could strip `#foodstr` out of a pasted `https://x.y/#foodstr`.
The token boundary is now the shared HASHTAG_PATTERN's: start of text or
after whitespace. A `#` inside a URL or glued to a word is not a hashtag
to the feed, was never selected, and is left untouched. Regression tests
cover both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@spe1020
spe1020 merged commit 732c448 into main Sep 19, 2026
6 of 7 checks passed
@spe1020
spe1020 deleted the feat/composer-hashtag-pills branch September 19, 2026 17:26
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