feat(composer): hashtag suggestion pills + derive t tags on kind-1 notes - #741
Conversation
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>
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
zapcooking-frontend | 233ad35 | Sep 19 2026, 05:26 PM |
Deploying with
|
| 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>
Deploying frontend with
|
| 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 |
…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>
There was a problem hiding this comment.
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
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.tsto centralize the hashtag pattern, cap, counting, andt-tag derivation from note bodies (with tests). - Adds
$lib/hashtagPills.tsand 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
ttags 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.
| const pattern = new RegExp(`(?<![\\p{L}\\p{N}_])#${escapeRegex(tag)}(?![\\p{L}\\p{N}_])`, 'giu'); | ||
| let out = content.replace(pattern, ''); |
There was a problem hiding this comment.
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.
| 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'); | ||
| }); |
There was a problem hiding this comment.
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>


Impact first: web kind-1 notes ship with zero
ttagsBefore this PR the composer published every kind-1 note with an empty
ttag set. Hashtags typed in the body stayed in the body only. That means:#tfilters inFoodstrFeedOptimized.sveltecan 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.#tfilter 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
fix(composer): derive t tags from typed hashtags on kind-1 notes— new$lib/hashtags.tsholds the feed's own hashtag pattern and derivesttags from the final body at publish time: lowercase, de-duplicated. The composer pushes them next to thep/qtags.refactor(feed): share MAX_HASHTAGS and the hashtag count with the composer—MAX_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.feat(composer): hashtag suggestion pills under the kind-1 editor—$lib/hashtagPills.tsplus the row inPostComposer.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:foodstrleads as the canonical tag,foodgoes last so it does not read as a duplicate offoodstrin the two most valuable slots.gratitudeis out: it is not inFOOD_HASHTAGSand 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
PostComposeronly.ReplyComposerand the article/recipe editors are untouched.One deviation to flag
extractHashtagstrims trailing sentence punctuation from the derived tag value. "Loving this #foodstr." publishes["t","foodstr"], not["t","foodstr."]. The feed's pattern capturesfoodstr.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, becausefoodstr.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) pluszapcooking-<slug>and onezapcooking-<tag>per selected tag:src/routes/create/+page.svelte:349src/routes/create/gated/+page.svelte:204(alsozapcookingand the gated tag)src/routes/fork/[slug]/+page.svelte:249src/routes/souschef/+page.svelte:583src/lib/recipePack.ts:176Web-authored recipes are visible to iOS's
#t: zapcooking / nostrcookingquery. No separate concern needed. (reads/LongformEditorModal.sveltepublishes 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.svelte-check: 0 errors.The repo has no component tests, so the logic lives in the
$libmodules and the Svelte component only wires it.Gates still open (by hand, yours)
ttags. This proves commit 1.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:
The counter sits to the right of the scroller and does not move.
🤖 Generated with Claude Code