feat: add richtext facets for mentions, links, and hashtags

- Add richtext.js library for Bluesky-compatible facet parsing
- Add grain-rich-text component for rendering faceted text
- Parse facets on comment and gallery creation with handle resolution
- Render facets in comments, gallery descriptions, and profile bios
- Style facets as bold white with router navigation for mentions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

+922
docs/plans/2025-12-29-richtext-facets.md
··· 1 + # Rich Text Facets Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add Bluesky-compatible rich text facets (mentions, links, hashtags) to comments, gallery descriptions, and profile descriptions. 6 + 7 + **Architecture:** Adapt `~/code/tools/richtext.js` for Bluesky facet types. Parse facets on save for comments/galleries, parse on render for profiles. Create `<grain-rich-text>` component for display. 8 + 9 + **Tech Stack:** Lit components, Bluesky `app.bsky.richtext.facet` format, TextEncoder for UTF-8 byte positions. 10 + 11 + --- 12 + 13 + ## Task 1: Create richtext.js Library 14 + 15 + **Files:** 16 + - Create: `src/lib/richtext.js` 17 + 18 + **Step 1: Create the richtext library with Bluesky facet parsing** 19 + 20 + ```javascript 21 + // src/lib/richtext.js - Bluesky-compatible richtext parsing and rendering 22 + 23 + /** 24 + * Parse text for Bluesky facets: mentions, links, hashtags. 25 + * Returns { text, facets } with byte-indexed positions. 26 + * 27 + * @param {string} text - Plain text to parse 28 + * @param {function} resolveHandle - Optional async function to resolve @handle to DID 29 + * @returns {Promise<{ text: string, facets: Array }>} 30 + */ 31 + export async function parseTextToFacets(text, resolveHandle = null) { 32 + if (!text) return { text: '', facets: [] }; 33 + 34 + const facets = []; 35 + const encoder = new TextEncoder(); 36 + 37 + function getByteOffset(str, charIndex) { 38 + return encoder.encode(str.slice(0, charIndex)).length; 39 + } 40 + 41 + // Track claimed positions to avoid overlaps 42 + const claimedPositions = new Set(); 43 + 44 + function isRangeClaimed(start, end) { 45 + for (let i = start; i < end; i++) { 46 + if (claimedPositions.has(i)) return true; 47 + } 48 + return false; 49 + } 50 + 51 + function claimRange(start, end) { 52 + for (let i = start; i < end; i++) { 53 + claimedPositions.add(i); 54 + } 55 + } 56 + 57 + // URLs first (highest priority) 58 + const urlRegex = /https?:\/\/[^\s<>\[\]()]+/g; 59 + let urlMatch; 60 + while ((urlMatch = urlRegex.exec(text)) !== null) { 61 + const start = urlMatch.index; 62 + const end = start + urlMatch[0].length; 63 + 64 + if (!isRangeClaimed(start, end)) { 65 + claimRange(start, end); 66 + facets.push({ 67 + index: { 68 + byteStart: getByteOffset(text, start), 69 + byteEnd: getByteOffset(text, end), 70 + }, 71 + features: [{ 72 + $type: 'app.bsky.richtext.facet#link', 73 + uri: urlMatch[0], 74 + }], 75 + }); 76 + } 77 + } 78 + 79 + // Mentions: @handle or @handle.domain.tld 80 + const mentionRegex = /@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?/g; 81 + let mentionMatch; 82 + while ((mentionMatch = mentionRegex.exec(text)) !== null) { 83 + const start = mentionMatch.index; 84 + const end = start + mentionMatch[0].length; 85 + const handle = mentionMatch[0].slice(1); // Remove @ 86 + 87 + if (!isRangeClaimed(start, end)) { 88 + // Try to resolve handle to DID 89 + let did = null; 90 + if (resolveHandle) { 91 + try { 92 + did = await resolveHandle(handle); 93 + } catch (e) { 94 + // Skip this mention if resolution fails 95 + continue; 96 + } 97 + } 98 + 99 + if (did) { 100 + claimRange(start, end); 101 + facets.push({ 102 + index: { 103 + byteStart: getByteOffset(text, start), 104 + byteEnd: getByteOffset(text, end), 105 + }, 106 + features: [{ 107 + $type: 'app.bsky.richtext.facet#mention', 108 + did, 109 + }], 110 + }); 111 + } 112 + } 113 + } 114 + 115 + // Hashtags: #tag (alphanumeric, no leading numbers) 116 + const hashtagRegex = /#([a-zA-Z][a-zA-Z0-9_]*)/g; 117 + let hashtagMatch; 118 + while ((hashtagMatch = hashtagRegex.exec(text)) !== null) { 119 + const start = hashtagMatch.index; 120 + const end = start + hashtagMatch[0].length; 121 + const tag = hashtagMatch[1]; // Without # 122 + 123 + if (!isRangeClaimed(start, end)) { 124 + claimRange(start, end); 125 + facets.push({ 126 + index: { 127 + byteStart: getByteOffset(text, start), 128 + byteEnd: getByteOffset(text, end), 129 + }, 130 + features: [{ 131 + $type: 'app.bsky.richtext.facet#tag', 132 + tag, 133 + }], 134 + }); 135 + } 136 + } 137 + 138 + // Sort by byte position 139 + facets.sort((a, b) => a.index.byteStart - b.index.byteStart); 140 + 141 + return { text, facets }; 142 + } 143 + 144 + /** 145 + * Synchronous parsing for client-side render (no DID resolution). 146 + * Mentions display as-is without profile links. 147 + */ 148 + export function parseTextToFacetsSync(text) { 149 + if (!text) return { text: '', facets: [] }; 150 + 151 + const facets = []; 152 + const encoder = new TextEncoder(); 153 + 154 + function getByteOffset(str, charIndex) { 155 + return encoder.encode(str.slice(0, charIndex)).length; 156 + } 157 + 158 + const claimedPositions = new Set(); 159 + 160 + function isRangeClaimed(start, end) { 161 + for (let i = start; i < end; i++) { 162 + if (claimedPositions.has(i)) return true; 163 + } 164 + return false; 165 + } 166 + 167 + function claimRange(start, end) { 168 + for (let i = start; i < end; i++) { 169 + claimedPositions.add(i); 170 + } 171 + } 172 + 173 + // URLs 174 + const urlRegex = /https?:\/\/[^\s<>\[\]()]+/g; 175 + let urlMatch; 176 + while ((urlMatch = urlRegex.exec(text)) !== null) { 177 + const start = urlMatch.index; 178 + const end = start + urlMatch[0].length; 179 + 180 + if (!isRangeClaimed(start, end)) { 181 + claimRange(start, end); 182 + facets.push({ 183 + index: { 184 + byteStart: getByteOffset(text, start), 185 + byteEnd: getByteOffset(text, end), 186 + }, 187 + features: [{ 188 + $type: 'app.bsky.richtext.facet#link', 189 + uri: urlMatch[0], 190 + }], 191 + }); 192 + } 193 + } 194 + 195 + // Hashtags 196 + const hashtagRegex = /#([a-zA-Z][a-zA-Z0-9_]*)/g; 197 + let hashtagMatch; 198 + while ((hashtagMatch = hashtagRegex.exec(text)) !== null) { 199 + const start = hashtagMatch.index; 200 + const end = start + hashtagMatch[0].length; 201 + const tag = hashtagMatch[1]; 202 + 203 + if (!isRangeClaimed(start, end)) { 204 + claimRange(start, end); 205 + facets.push({ 206 + index: { 207 + byteStart: getByteOffset(text, start), 208 + byteEnd: getByteOffset(text, end), 209 + }, 210 + features: [{ 211 + $type: 'app.bsky.richtext.facet#tag', 212 + tag, 213 + }], 214 + }); 215 + } 216 + } 217 + 218 + facets.sort((a, b) => a.index.byteStart - b.index.byteStart); 219 + return { text, facets }; 220 + } 221 + 222 + /** 223 + * Render text with facets as HTML. 224 + * 225 + * @param {string} text - The text content 226 + * @param {Array} facets - Array of facet objects 227 + * @param {Object} options - Rendering options 228 + * @returns {string} HTML string 229 + */ 230 + export function renderFacetedText(text, facets, options = {}) { 231 + if (!text) return ''; 232 + 233 + // If no facets, just escape and return 234 + if (!facets || facets.length === 0) { 235 + return escapeHtml(text); 236 + } 237 + 238 + const encoder = new TextEncoder(); 239 + const decoder = new TextDecoder(); 240 + const bytes = encoder.encode(text); 241 + 242 + // Sort facets by start position 243 + const sortedFacets = [...facets].sort( 244 + (a, b) => a.index.byteStart - b.index.byteStart 245 + ); 246 + 247 + let result = ''; 248 + let lastEnd = 0; 249 + 250 + for (const facet of sortedFacets) { 251 + // Validate byte indices 252 + if (facet.index.byteStart < 0 || facet.index.byteEnd > bytes.length) { 253 + continue; // Skip invalid facets 254 + } 255 + 256 + // Add text before this facet 257 + if (facet.index.byteStart > lastEnd) { 258 + const beforeBytes = bytes.slice(lastEnd, facet.index.byteStart); 259 + result += escapeHtml(decoder.decode(beforeBytes)); 260 + } 261 + 262 + // Get the faceted text 263 + const facetBytes = bytes.slice(facet.index.byteStart, facet.index.byteEnd); 264 + const facetText = decoder.decode(facetBytes); 265 + 266 + // Determine facet type and render 267 + const feature = facet.features?.[0]; 268 + if (!feature) { 269 + result += escapeHtml(facetText); 270 + lastEnd = facet.index.byteEnd; 271 + continue; 272 + } 273 + 274 + const type = feature.$type || feature.__typename || ''; 275 + 276 + if (type.includes('link')) { 277 + const uri = feature.uri || ''; 278 + result += `<a href="${escapeHtml(uri)}" target="_blank" rel="noopener noreferrer" class="facet-link">${escapeHtml(facetText)}</a>`; 279 + } else if (type.includes('mention')) { 280 + // Extract handle from text (remove @) 281 + const handle = facetText.startsWith('@') ? facetText.slice(1) : facetText; 282 + result += `<a href="/profile/${escapeHtml(handle)}" class="facet-mention">${escapeHtml(facetText)}</a>`; 283 + } else if (type.includes('tag')) { 284 + // Hashtag - styled but not clickable for now 285 + result += `<span class="facet-tag">${escapeHtml(facetText)}</span>`; 286 + } else { 287 + result += escapeHtml(facetText); 288 + } 289 + 290 + lastEnd = facet.index.byteEnd; 291 + } 292 + 293 + // Add remaining text 294 + if (lastEnd < bytes.length) { 295 + const remainingBytes = bytes.slice(lastEnd); 296 + result += escapeHtml(decoder.decode(remainingBytes)); 297 + } 298 + 299 + return result; 300 + } 301 + 302 + function escapeHtml(text) { 303 + return text 304 + .replace(/&/g, '&amp;') 305 + .replace(/</g, '&lt;') 306 + .replace(/>/g, '&gt;') 307 + .replace(/"/g, '&quot;') 308 + .replace(/'/g, '&#039;'); 309 + } 310 + ``` 311 + 312 + **Step 2: Verify file exists** 313 + 314 + Run: `ls -la src/lib/richtext.js` 315 + Expected: File exists with correct permissions 316 + 317 + **Step 3: Commit** 318 + 319 + ```bash 320 + git add src/lib/richtext.js 321 + git commit -m "feat: add richtext library for Bluesky facets" 322 + ``` 323 + 324 + --- 325 + 326 + ## Task 2: Add Handle Resolution to grain-api 327 + 328 + **Files:** 329 + - Modify: `src/services/grain-api.js` 330 + 331 + **Step 1: Add resolveHandle method to GrainApiService** 332 + 333 + Add after the `getComments` method (around line 1095): 334 + 335 + ```javascript 336 + async resolveHandle(handle) { 337 + const query = ` 338 + query ResolveHandle($handle: String!) { 339 + socialGrainActorProfile(first: 1, where: { actorHandle: { eq: $handle } }) { 340 + edges { 341 + node { did } 342 + } 343 + } 344 + } 345 + `; 346 + 347 + const response = await this.#execute(query, { handle }); 348 + const did = response.data?.socialGrainActorProfile?.edges?.[0]?.node?.did; 349 + 350 + if (!did) { 351 + throw new Error(`Handle not found: ${handle}`); 352 + } 353 + 354 + return did; 355 + } 356 + ``` 357 + 358 + **Step 2: Verify syntax** 359 + 360 + Run: `node --check src/services/grain-api.js` 361 + Expected: No syntax errors 362 + 363 + **Step 3: Commit** 364 + 365 + ```bash 366 + git add src/services/grain-api.js 367 + git commit -m "feat: add resolveHandle method for mention facets" 368 + ``` 369 + 370 + --- 371 + 372 + ## Task 3: Create grain-rich-text Component 373 + 374 + **Files:** 375 + - Create: `src/components/atoms/grain-rich-text.js` 376 + 377 + **Step 1: Create the component** 378 + 379 + ```javascript 380 + // src/components/atoms/grain-rich-text.js 381 + import { LitElement, html, css } from 'lit'; 382 + import { unsafeHTML } from 'lit/directives/unsafe-html.js'; 383 + import { renderFacetedText, parseTextToFacetsSync } from '../../lib/richtext.js'; 384 + 385 + export class GrainRichText extends LitElement { 386 + static properties = { 387 + text: { type: String }, 388 + facets: { type: Array }, 389 + parse: { type: Boolean } 390 + }; 391 + 392 + static styles = css` 393 + :host { 394 + display: inline; 395 + } 396 + .facet-link { 397 + color: var(--color-link, #0066cc); 398 + text-decoration: none; 399 + } 400 + .facet-link:hover { 401 + text-decoration: underline; 402 + } 403 + .facet-mention { 404 + color: var(--color-link, #0066cc); 405 + text-decoration: none; 406 + } 407 + .facet-mention:hover { 408 + text-decoration: underline; 409 + } 410 + .facet-tag { 411 + color: var(--color-link, #0066cc); 412 + } 413 + `; 414 + 415 + constructor() { 416 + super(); 417 + this.text = ''; 418 + this.facets = null; 419 + this.parse = false; 420 + } 421 + 422 + render() { 423 + if (!this.text) return ''; 424 + 425 + let facetsToUse = this.facets; 426 + 427 + // If parse mode and no facets provided, parse on the fly 428 + if (this.parse && (!this.facets || this.facets.length === 0)) { 429 + const parsed = parseTextToFacetsSync(this.text); 430 + facetsToUse = parsed.facets; 431 + } 432 + 433 + const htmlContent = renderFacetedText(this.text, facetsToUse || []); 434 + return html`${unsafeHTML(htmlContent)}`; 435 + } 436 + } 437 + 438 + customElements.define('grain-rich-text', GrainRichText); 439 + ``` 440 + 441 + **Step 2: Verify syntax** 442 + 443 + Run: `node --check src/components/atoms/grain-rich-text.js` 444 + Expected: No syntax errors 445 + 446 + **Step 3: Commit** 447 + 448 + ```bash 449 + git add src/components/atoms/grain-rich-text.js 450 + git commit -m "feat: add grain-rich-text component for facet rendering" 451 + ``` 452 + 453 + --- 454 + 455 + ## Task 4: Integrate Facet Parsing into Comment Creation 456 + 457 + **Files:** 458 + - Modify: `src/services/mutations.js` 459 + 460 + **Step 1: Add import at top of file** 461 + 462 + ```javascript 463 + import { parseTextToFacets } from '../lib/richtext.js'; 464 + import { grainApi } from './grain-api.js'; 465 + ``` 466 + 467 + **Step 2: Modify createComment method (around line 103)** 468 + 469 + Replace the existing `createComment` method: 470 + 471 + ```javascript 472 + async createComment(galleryUri, text, replyToUri = null, focusUri = null) { 473 + const client = auth.getClient(); 474 + 475 + // Parse text for facets with handle resolution 476 + const resolveHandle = async (handle) => grainApi.resolveHandle(handle); 477 + const { facets } = await parseTextToFacets(text, resolveHandle); 478 + 479 + const input = { 480 + subject: galleryUri, 481 + text, 482 + createdAt: new Date().toISOString() 483 + }; 484 + 485 + // Only include facets if we found any 486 + if (facets && facets.length > 0) { 487 + input.facets = facets; 488 + } 489 + 490 + if (replyToUri) { 491 + input.replyTo = replyToUri; 492 + } 493 + 494 + if (focusUri) { 495 + input.focus = focusUri; 496 + } 497 + 498 + const result = await client.mutate(` 499 + mutation CreateComment($input: SocialGrainCommentInput!) { 500 + createSocialGrainComment(input: $input) { uri } 501 + } 502 + `, { input }); 503 + 504 + return result.createSocialGrainComment.uri; 505 + } 506 + ``` 507 + 508 + **Step 3: Verify syntax** 509 + 510 + Run: `node --check src/services/mutations.js` 511 + Expected: No syntax errors 512 + 513 + **Step 4: Commit** 514 + 515 + ```bash 516 + git add src/services/mutations.js 517 + git commit -m "feat: parse facets when creating comments" 518 + ``` 519 + 520 + --- 521 + 522 + ## Task 5: Integrate Facet Parsing into Gallery Creation 523 + 524 + **Files:** 525 + - Modify: `src/components/pages/grain-create-gallery.js` 526 + 527 + **Step 1: Add import at top of file** 528 + 529 + ```javascript 530 + import { parseTextToFacets } from '../../lib/richtext.js'; 531 + import { grainApi } from '../../services/grain-api.js'; 532 + ``` 533 + 534 + **Step 2: Modify the gallery creation in #handlePost (around line 219)** 535 + 536 + Find this code block: 537 + ```javascript 538 + // Create gallery record 539 + const galleryResult = await client.mutate(CREATE_GALLERY_MUTATION, { 540 + input: { 541 + title: this._title.trim(), 542 + ...(this._description.trim() && { description: this._description.trim() }), 543 + createdAt: now 544 + } 545 + }); 546 + ``` 547 + 548 + Replace with: 549 + ```javascript 550 + // Parse description for facets 551 + let facets = null; 552 + if (this._description.trim()) { 553 + const resolveHandle = async (handle) => grainApi.resolveHandle(handle); 554 + const parsed = await parseTextToFacets(this._description.trim(), resolveHandle); 555 + if (parsed.facets.length > 0) { 556 + facets = parsed.facets; 557 + } 558 + } 559 + 560 + // Create gallery record 561 + const galleryResult = await client.mutate(CREATE_GALLERY_MUTATION, { 562 + input: { 563 + title: this._title.trim(), 564 + ...(this._description.trim() && { description: this._description.trim() }), 565 + ...(facets && { facets }), 566 + createdAt: now 567 + } 568 + }); 569 + ``` 570 + 571 + **Step 3: Verify syntax** 572 + 573 + Run: `node --check src/components/pages/grain-create-gallery.js` 574 + Expected: No syntax errors 575 + 576 + **Step 4: Commit** 577 + 578 + ```bash 579 + git add src/components/pages/grain-create-gallery.js 580 + git commit -m "feat: parse facets when creating galleries" 581 + ``` 582 + 583 + --- 584 + 585 + ## Task 6: Add Facets to GraphQL Queries 586 + 587 + **Files:** 588 + - Modify: `src/services/grain-api.js` 589 + 590 + **Step 1: Add facets to getGalleryDetail comment query (around line 636)** 591 + 592 + Find the comment query section in `getGalleryDetail`: 593 + ```javascript 594 + socialGrainCommentViaSubject( 595 + first: 20 596 + sortBy: [{ field: createdAt, direction: ASC }] 597 + ) { 598 + totalCount 599 + edges { 600 + node { 601 + uri 602 + text 603 + createdAt 604 + ``` 605 + 606 + Add `facets` field after `text`: 607 + ```javascript 608 + uri 609 + text 610 + facets 611 + createdAt 612 + ``` 613 + 614 + **Step 2: Add facets field to gallery description query** 615 + 616 + In the same `getGalleryDetail` query, find where gallery fields are queried: 617 + ```javascript 618 + uri 619 + did 620 + actorHandle 621 + title 622 + description 623 + createdAt 624 + ``` 625 + 626 + Add `facets` after `description`: 627 + ```javascript 628 + uri 629 + did 630 + actorHandle 631 + title 632 + description 633 + facets 634 + createdAt 635 + ``` 636 + 637 + **Step 3: Update the comment transform (around line 700)** 638 + 639 + Find the comment mapping: 640 + ```javascript 641 + const comments = galleryNode.socialGrainCommentViaSubject?.edges?.map(edge => { 642 + const node = edge.node; 643 + const commentProfile = node.socialGrainActorProfileByDid; 644 + const focusPhoto = node.focusResolved; 645 + return { 646 + uri: node.uri, 647 + text: node.text, 648 + createdAt: node.createdAt, 649 + ``` 650 + 651 + Add facets to the returned object: 652 + ```javascript 653 + return { 654 + uri: node.uri, 655 + text: node.text, 656 + facets: node.facets || [], 657 + createdAt: node.createdAt, 658 + ``` 659 + 660 + **Step 4: Update gallery return object (around line 717)** 661 + 662 + Find the return statement: 663 + ```javascript 664 + return { 665 + uri: galleryNode.uri, 666 + title: galleryNode.title, 667 + description: galleryNode.description, 668 + ``` 669 + 670 + Add facets: 671 + ```javascript 672 + return { 673 + uri: galleryNode.uri, 674 + title: galleryNode.title, 675 + description: galleryNode.description, 676 + facets: galleryNode.facets || [], 677 + ``` 678 + 679 + **Step 5: Verify syntax** 680 + 681 + Run: `node --check src/services/grain-api.js` 682 + Expected: No syntax errors 683 + 684 + **Step 6: Commit** 685 + 686 + ```bash 687 + git add src/services/grain-api.js 688 + git commit -m "feat: query facets for comments and gallery descriptions" 689 + ``` 690 + 691 + --- 692 + 693 + ## Task 7: Update grain-comment to Render Facets 694 + 695 + **Files:** 696 + - Modify: `src/components/molecules/grain-comment.js` 697 + 698 + **Step 1: Add import and facets property** 699 + 700 + Add at top of file: 701 + ```javascript 702 + import '../atoms/grain-rich-text.js'; 703 + ``` 704 + 705 + Add to static properties: 706 + ```javascript 707 + static properties = { 708 + uri: { type: String }, 709 + handle: { type: String }, 710 + displayName: { type: String }, 711 + avatarUrl: { type: String }, 712 + text: { type: String }, 713 + facets: { type: Array }, // Add this line 714 + createdAt: { type: String }, 715 + ``` 716 + 717 + **Step 2: Initialize facets in constructor** 718 + 719 + Add after `this.text = '';`: 720 + ```javascript 721 + this.facets = []; 722 + ``` 723 + 724 + **Step 3: Update render method (around line 162)** 725 + 726 + Find: 727 + ```javascript 728 + <span class="text">${this.text}</span> 729 + ``` 730 + 731 + Replace with: 732 + ```javascript 733 + <span class="text"><grain-rich-text .text=${this.text} .facets=${this.facets}></grain-rich-text></span> 734 + ``` 735 + 736 + **Step 4: Verify syntax** 737 + 738 + Run: `node --check src/components/molecules/grain-comment.js` 739 + Expected: No syntax errors 740 + 741 + **Step 5: Commit** 742 + 743 + ```bash 744 + git add src/components/molecules/grain-comment.js 745 + git commit -m "feat: render comment facets with grain-rich-text" 746 + ``` 747 + 748 + --- 749 + 750 + ## Task 8: Pass Facets to grain-comment in Comment Sheet 751 + 752 + **Files:** 753 + - Modify: `src/components/organisms/grain-comment-sheet.js` 754 + 755 + **Step 1: Find comment rendering and add facets** 756 + 757 + Find where `<grain-comment>` is used and add `.facets` property. 758 + 759 + Look for pattern like: 760 + ```javascript 761 + <grain-comment 762 + uri=${comment.uri} 763 + handle=${comment.handle} 764 + ... 765 + text=${comment.text} 766 + ``` 767 + 768 + Add facets: 769 + ```javascript 770 + .facets=${comment.facets || []} 771 + ``` 772 + 773 + **Step 2: Verify syntax** 774 + 775 + Run: `node --check src/components/organisms/grain-comment-sheet.js` 776 + Expected: No syntax errors 777 + 778 + **Step 3: Commit** 779 + 780 + ```bash 781 + git add src/components/organisms/grain-comment-sheet.js 782 + git commit -m "feat: pass facets to grain-comment in comment sheet" 783 + ``` 784 + 785 + --- 786 + 787 + ## Task 9: Update Gallery Detail to Render Description Facets 788 + 789 + **Files:** 790 + - Modify: `src/components/pages/grain-gallery-detail.js` 791 + 792 + **Step 1: Add import** 793 + 794 + Add at top: 795 + ```javascript 796 + import '../atoms/grain-rich-text.js'; 797 + ``` 798 + 799 + **Step 2: Update description rendering (around line 399)** 800 + 801 + Find: 802 + ```javascript 803 + ${this._gallery.description ? html` 804 + <p class="description">${this._gallery.description}</p> 805 + ` : ''} 806 + ``` 807 + 808 + Replace with: 809 + ```javascript 810 + ${this._gallery.description ? html` 811 + <p class="description"><grain-rich-text .text=${this._gallery.description} .facets=${this._gallery.facets || []}></grain-rich-text></p> 812 + ` : ''} 813 + ``` 814 + 815 + **Step 3: Verify syntax** 816 + 817 + Run: `node --check src/components/pages/grain-gallery-detail.js` 818 + Expected: No syntax errors 819 + 820 + **Step 4: Commit** 821 + 822 + ```bash 823 + git add src/components/pages/grain-gallery-detail.js 824 + git commit -m "feat: render gallery description facets" 825 + ``` 826 + 827 + --- 828 + 829 + ## Task 10: Update Profile Header to Render Bio Facets 830 + 831 + **Files:** 832 + - Modify: `src/components/organisms/grain-profile-header.js` 833 + 834 + **Step 1: Add import** 835 + 836 + Add at top: 837 + ```javascript 838 + import '../atoms/grain-rich-text.js'; 839 + ``` 840 + 841 + **Step 2: Update bio rendering (around line 302)** 842 + 843 + Find: 844 + ```javascript 845 + ${description ? html`<div class="bio">${description}</div>` : ''} 846 + ``` 847 + 848 + Replace with: 849 + ```javascript 850 + ${description ? html`<div class="bio"><grain-rich-text .text=${description} parse></grain-rich-text></div>` : ''} 851 + ``` 852 + 853 + Note: Using `parse` attribute since profile lexicon doesn't store facets yet. 854 + 855 + **Step 3: Verify syntax** 856 + 857 + Run: `node --check src/components/organisms/grain-profile-header.js` 858 + Expected: No syntax errors 859 + 860 + **Step 4: Commit** 861 + 862 + ```bash 863 + git add src/components/organisms/grain-profile-header.js 864 + git commit -m "feat: render profile bio with client-side facet parsing" 865 + ``` 866 + 867 + --- 868 + 869 + ## Task 11: Final Verification 870 + 871 + **Step 1: Check all files exist and have no syntax errors** 872 + 873 + Run: 874 + ```bash 875 + node --check src/lib/richtext.js && \ 876 + node --check src/components/atoms/grain-rich-text.js && \ 877 + node --check src/services/mutations.js && \ 878 + node --check src/services/grain-api.js && \ 879 + node --check src/components/pages/grain-create-gallery.js && \ 880 + node --check src/components/molecules/grain-comment.js && \ 881 + node --check src/components/pages/grain-gallery-detail.js && \ 882 + node --check src/components/organisms/grain-profile-header.js && \ 883 + echo "All files OK" 884 + ``` 885 + 886 + Expected: "All files OK" 887 + 888 + **Step 2: Run the dev server and test manually** 889 + 890 + Run: `npm run dev` 891 + 892 + Test checklist: 893 + - [ ] Create a comment with a URL - verify it becomes clickable 894 + - [ ] Create a comment with @mention - verify it links to profile 895 + - [ ] Create a comment with #hashtag - verify it's styled 896 + - [ ] Create a gallery with description containing URL - verify rendering 897 + - [ ] View a profile with URLs in bio - verify they're clickable 898 + 899 + **Step 3: Final commit with all changes** 900 + 901 + ```bash 902 + git add -A 903 + git status 904 + # If all looks good: 905 + git commit -m "feat: complete richtext facet support for comments, galleries, and profiles" 906 + ``` 907 + 908 + --- 909 + 910 + ## Summary 911 + 912 + | File | Change | 913 + |------|--------| 914 + | `src/lib/richtext.js` | New - Parsing and rendering library | 915 + | `src/components/atoms/grain-rich-text.js` | New - Display component | 916 + | `src/services/grain-api.js` | Add `resolveHandle`, query facets | 917 + | `src/services/mutations.js` | Parse facets on comment creation | 918 + | `src/components/pages/grain-create-gallery.js` | Parse facets on gallery creation | 919 + | `src/components/molecules/grain-comment.js` | Render facets | 920 + | `src/components/organisms/grain-comment-sheet.js` | Pass facets to comment | 921 + | `src/components/pages/grain-gallery-detail.js` | Render description facets | 922 + | `src/components/organisms/grain-profile-header.js` | Client-side facet parsing for bio |
+83
src/components/atoms/grain-rich-text.js
··· 1 + // src/components/atoms/grain-rich-text.js 2 + import { LitElement, html, css } from 'lit'; 3 + import { unsafeHTML } from 'lit/directives/unsafe-html.js'; 4 + import { renderFacetedText, parseTextToFacetsSync } from '../../lib/richtext.js'; 5 + import { router } from '../../router.js'; 6 + 7 + export class GrainRichText extends LitElement { 8 + static properties = { 9 + text: { type: String }, 10 + facets: { type: Array }, 11 + parse: { type: Boolean } 12 + }; 13 + 14 + static styles = css` 15 + :host { 16 + display: inline; 17 + } 18 + .facet-link { 19 + color: var(--color-text-primary, #fff); 20 + font-weight: var(--font-weight-semibold, 600); 21 + text-decoration: none; 22 + } 23 + .facet-link:hover { 24 + text-decoration: underline; 25 + } 26 + .facet-mention { 27 + color: var(--color-text-primary, #fff); 28 + font-weight: var(--font-weight-semibold, 600); 29 + text-decoration: none; 30 + } 31 + .facet-mention:hover { 32 + text-decoration: underline; 33 + } 34 + .facet-tag { 35 + color: var(--color-text-primary, #fff); 36 + font-weight: var(--font-weight-semibold, 600); 37 + } 38 + `; 39 + 40 + constructor() { 41 + super(); 42 + this.text = ''; 43 + this.facets = null; 44 + this.parse = false; 45 + } 46 + 47 + #handleClick = (e) => { 48 + const link = e.target.closest('.facet-mention'); 49 + if (link) { 50 + e.preventDefault(); 51 + const href = link.getAttribute('href'); 52 + if (href) { 53 + router.push(href); 54 + } 55 + } 56 + }; 57 + 58 + firstUpdated() { 59 + this.renderRoot.addEventListener('click', this.#handleClick); 60 + } 61 + 62 + disconnectedCallback() { 63 + super.disconnectedCallback(); 64 + this.renderRoot.removeEventListener('click', this.#handleClick); 65 + } 66 + 67 + render() { 68 + if (!this.text) return ''; 69 + 70 + let facetsToUse = this.facets; 71 + 72 + // If parse mode and no facets provided, parse on the fly 73 + if (this.parse && (!this.facets || this.facets.length === 0)) { 74 + const parsed = parseTextToFacetsSync(this.text); 75 + facetsToUse = parsed.facets; 76 + } 77 + 78 + const htmlContent = renderFacetedText(this.text, facetsToUse || []); 79 + return html`${unsafeHTML(htmlContent)}`; 80 + } 81 + } 82 + 83 + customElements.define('grain-rich-text', GrainRichText);
+4 -1
src/components/molecules/grain-comment.js
··· 1 1 import { LitElement, html, css } from 'lit'; 2 2 import { router } from '../../router.js'; 3 3 import '../atoms/grain-avatar.js'; 4 + import '../atoms/grain-rich-text.js'; 4 5 5 6 export class GrainComment extends LitElement { 6 7 static properties = { ··· 9 10 displayName: { type: String }, 10 11 avatarUrl: { type: String }, 11 12 text: { type: String }, 13 + facets: { type: Array }, 12 14 createdAt: { type: String }, 13 15 isReply: { type: Boolean }, 14 16 isOwner: { type: Boolean }, ··· 101 103 this.displayName = ''; 102 104 this.avatarUrl = ''; 103 105 this.text = ''; 106 + this.facets = []; 104 107 this.createdAt = ''; 105 108 this.isReply = false; 106 109 this.isOwner = false; ··· 159 162 <span class="handle" @click=${this.#handleProfileClick}> 160 163 ${this.handle} 161 164 </span> 162 - <span class="text">${this.text}</span> 165 + <span class="text"><grain-rich-text .text=${this.text} .facets=${this.facets}></grain-rich-text></span> 163 166 </div> 164 167 <div class="meta"> 165 168 <span class="time">${this.#formatTime(this.createdAt)}</span>
+1
src/components/organisms/grain-comment-sheet.js
··· 372 372 displayName=${comment.displayName} 373 373 avatarUrl=${comment.avatarUrl} 374 374 text=${comment.text} 375 + .facets=${comment.facets || []} 375 376 createdAt=${comment.createdAt} 376 377 ?is-reply=${comment.isReply} 377 378 ?isOwner=${comment.handle === auth.user?.handle}
+2 -1
src/components/organisms/grain-profile-header.js
··· 8 8 import '../atoms/grain-icon.js'; 9 9 import '../atoms/grain-spinner.js'; 10 10 import '../atoms/grain-toast.js'; 11 + import '../atoms/grain-rich-text.js'; 11 12 import '../molecules/grain-profile-stats.js'; 12 13 13 14 export class GrainProfileHeader extends LitElement { ··· 299 300 followerCount=${followerCount || 0} 300 301 followingCount=${followingCount || 0} 301 302 ></grain-profile-stats> 302 - ${description ? html`<div class="bio">${description}</div>` : ''} 303 + ${description ? html`<div class="bio"><grain-rich-text .text=${description} parse></grain-rich-text></div>` : ''} 303 304 </div> 304 305 </div> 305 306
+13
src/components/pages/grain-create-gallery.js
··· 2 2 import { router } from '../../router.js'; 3 3 import { auth } from '../../services/auth.js'; 4 4 import { draftGallery } from '../../services/draft-gallery.js'; 5 + import { parseTextToFacets } from '../../lib/richtext.js'; 6 + import { grainApi } from '../../services/grain-api.js'; 5 7 import '../atoms/grain-icon.js'; 6 8 import '../atoms/grain-button.js'; 7 9 import '../atoms/grain-input.js'; ··· 215 217 photoUris.push(photoResult.createSocialGrainPhoto.uri); 216 218 } 217 219 220 + // Parse description for facets 221 + let facets = null; 222 + if (this._description.trim()) { 223 + const resolveHandle = async (handle) => grainApi.resolveHandle(handle); 224 + const parsed = await parseTextToFacets(this._description.trim(), resolveHandle); 225 + if (parsed.facets.length > 0) { 226 + facets = parsed.facets; 227 + } 228 + } 229 + 218 230 // Create gallery record 219 231 const galleryResult = await client.mutate(CREATE_GALLERY_MUTATION, { 220 232 input: { 221 233 title: this._title.trim(), 222 234 ...(this._description.trim() && { description: this._description.trim() }), 235 + ...(facets && { facets }), 223 236 createdAt: now 224 237 } 225 238 });
+311
src/lib/richtext.js
··· 1 + // src/lib/richtext.js - Bluesky-compatible richtext parsing and rendering 2 + 3 + /** 4 + * Parse text for Bluesky facets: mentions, links, hashtags. 5 + * Returns { text, facets } with byte-indexed positions. 6 + * 7 + * @param {string} text - Plain text to parse 8 + * @param {function} resolveHandle - Optional async function to resolve @handle to DID 9 + * @returns {Promise<{ text: string, facets: Array }>} 10 + */ 11 + export async function parseTextToFacets(text, resolveHandle = null) { 12 + if (!text) return { text: '', facets: [] }; 13 + 14 + const facets = []; 15 + const encoder = new TextEncoder(); 16 + 17 + function getByteOffset(str, charIndex) { 18 + return encoder.encode(str.slice(0, charIndex)).length; 19 + } 20 + 21 + // Track claimed positions to avoid overlaps 22 + const claimedPositions = new Set(); 23 + 24 + function isRangeClaimed(start, end) { 25 + for (let i = start; i < end; i++) { 26 + if (claimedPositions.has(i)) return true; 27 + } 28 + return false; 29 + } 30 + 31 + function claimRange(start, end) { 32 + for (let i = start; i < end; i++) { 33 + claimedPositions.add(i); 34 + } 35 + } 36 + 37 + // URLs first (highest priority) 38 + const urlRegex = /https?:\/\/[^\s<>\[\]()]+/g; 39 + let urlMatch; 40 + while ((urlMatch = urlRegex.exec(text)) !== null) { 41 + const start = urlMatch.index; 42 + const end = start + urlMatch[0].length; 43 + 44 + if (!isRangeClaimed(start, end)) { 45 + claimRange(start, end); 46 + facets.push({ 47 + index: { 48 + byteStart: getByteOffset(text, start), 49 + byteEnd: getByteOffset(text, end), 50 + }, 51 + features: [{ 52 + $type: 'app.bsky.richtext.facet#link', 53 + uri: urlMatch[0], 54 + }], 55 + }); 56 + } 57 + } 58 + 59 + // Mentions: @handle or @handle.domain.tld 60 + const mentionRegex = /@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?/g; 61 + let mentionMatch; 62 + while ((mentionMatch = mentionRegex.exec(text)) !== null) { 63 + const start = mentionMatch.index; 64 + const end = start + mentionMatch[0].length; 65 + const handle = mentionMatch[0].slice(1); // Remove @ 66 + 67 + if (!isRangeClaimed(start, end)) { 68 + // Try to resolve handle to DID 69 + let did = null; 70 + if (resolveHandle) { 71 + try { 72 + did = await resolveHandle(handle); 73 + } catch (e) { 74 + // Handle not found - skip this mention 75 + continue; 76 + } 77 + } 78 + 79 + if (did) { 80 + claimRange(start, end); 81 + facets.push({ 82 + index: { 83 + byteStart: getByteOffset(text, start), 84 + byteEnd: getByteOffset(text, end), 85 + }, 86 + features: [{ 87 + $type: 'app.bsky.richtext.facet#mention', 88 + did, 89 + }], 90 + }); 91 + } 92 + } 93 + } 94 + 95 + // Hashtags: #tag (alphanumeric, no leading numbers) 96 + const hashtagRegex = /#([a-zA-Z][a-zA-Z0-9_]*)/g; 97 + let hashtagMatch; 98 + while ((hashtagMatch = hashtagRegex.exec(text)) !== null) { 99 + const start = hashtagMatch.index; 100 + const end = start + hashtagMatch[0].length; 101 + const tag = hashtagMatch[1]; // Without # 102 + 103 + if (!isRangeClaimed(start, end)) { 104 + claimRange(start, end); 105 + facets.push({ 106 + index: { 107 + byteStart: getByteOffset(text, start), 108 + byteEnd: getByteOffset(text, end), 109 + }, 110 + features: [{ 111 + $type: 'app.bsky.richtext.facet#tag', 112 + tag, 113 + }], 114 + }); 115 + } 116 + } 117 + 118 + // Sort by byte position 119 + facets.sort((a, b) => a.index.byteStart - b.index.byteStart); 120 + 121 + return { text, facets }; 122 + } 123 + 124 + /** 125 + * Synchronous parsing for client-side render (no DID resolution). 126 + * Mentions display as-is without profile links. 127 + */ 128 + export function parseTextToFacetsSync(text) { 129 + if (!text) return { text: '', facets: [] }; 130 + 131 + const facets = []; 132 + const encoder = new TextEncoder(); 133 + 134 + function getByteOffset(str, charIndex) { 135 + return encoder.encode(str.slice(0, charIndex)).length; 136 + } 137 + 138 + const claimedPositions = new Set(); 139 + 140 + function isRangeClaimed(start, end) { 141 + for (let i = start; i < end; i++) { 142 + if (claimedPositions.has(i)) return true; 143 + } 144 + return false; 145 + } 146 + 147 + function claimRange(start, end) { 148 + for (let i = start; i < end; i++) { 149 + claimedPositions.add(i); 150 + } 151 + } 152 + 153 + // URLs 154 + const urlRegex = /https?:\/\/[^\s<>\[\]()]+/g; 155 + let urlMatch; 156 + while ((urlMatch = urlRegex.exec(text)) !== null) { 157 + const start = urlMatch.index; 158 + const end = start + urlMatch[0].length; 159 + 160 + if (!isRangeClaimed(start, end)) { 161 + claimRange(start, end); 162 + facets.push({ 163 + index: { 164 + byteStart: getByteOffset(text, start), 165 + byteEnd: getByteOffset(text, end), 166 + }, 167 + features: [{ 168 + $type: 'app.bsky.richtext.facet#link', 169 + uri: urlMatch[0], 170 + }], 171 + }); 172 + } 173 + } 174 + 175 + // Mentions: @handle or @handle.domain.tld (no DID resolution in sync mode) 176 + const mentionRegex = /@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?/g; 177 + let mentionMatch; 178 + while ((mentionMatch = mentionRegex.exec(text)) !== null) { 179 + const start = mentionMatch.index; 180 + const end = start + mentionMatch[0].length; 181 + 182 + if (!isRangeClaimed(start, end)) { 183 + claimRange(start, end); 184 + facets.push({ 185 + index: { 186 + byteStart: getByteOffset(text, start), 187 + byteEnd: getByteOffset(text, end), 188 + }, 189 + features: [{ 190 + $type: 'app.bsky.richtext.facet#mention', 191 + did: null, // No DID in sync mode 192 + }], 193 + }); 194 + } 195 + } 196 + 197 + // Hashtags 198 + const hashtagRegex = /#([a-zA-Z][a-zA-Z0-9_]*)/g; 199 + let hashtagMatch; 200 + while ((hashtagMatch = hashtagRegex.exec(text)) !== null) { 201 + const start = hashtagMatch.index; 202 + const end = start + hashtagMatch[0].length; 203 + const tag = hashtagMatch[1]; 204 + 205 + if (!isRangeClaimed(start, end)) { 206 + claimRange(start, end); 207 + facets.push({ 208 + index: { 209 + byteStart: getByteOffset(text, start), 210 + byteEnd: getByteOffset(text, end), 211 + }, 212 + features: [{ 213 + $type: 'app.bsky.richtext.facet#tag', 214 + tag, 215 + }], 216 + }); 217 + } 218 + } 219 + 220 + facets.sort((a, b) => a.index.byteStart - b.index.byteStart); 221 + return { text, facets }; 222 + } 223 + 224 + /** 225 + * Render text with facets as HTML. 226 + * 227 + * @param {string} text - The text content 228 + * @param {Array} facets - Array of facet objects 229 + * @param {Object} options - Rendering options 230 + * @returns {string} HTML string 231 + */ 232 + export function renderFacetedText(text, facets, options = {}) { 233 + if (!text) return ''; 234 + 235 + // If no facets, just escape and return 236 + if (!facets || facets.length === 0) { 237 + return escapeHtml(text); 238 + } 239 + 240 + const encoder = new TextEncoder(); 241 + const decoder = new TextDecoder(); 242 + const bytes = encoder.encode(text); 243 + 244 + // Sort facets by start position 245 + const sortedFacets = [...facets].sort( 246 + (a, b) => a.index.byteStart - b.index.byteStart 247 + ); 248 + 249 + let result = ''; 250 + let lastEnd = 0; 251 + 252 + for (const facet of sortedFacets) { 253 + // Validate byte indices 254 + if (facet.index.byteStart < 0 || facet.index.byteEnd > bytes.length) { 255 + continue; // Skip invalid facets 256 + } 257 + 258 + // Add text before this facet 259 + if (facet.index.byteStart > lastEnd) { 260 + const beforeBytes = bytes.slice(lastEnd, facet.index.byteStart); 261 + result += escapeHtml(decoder.decode(beforeBytes)); 262 + } 263 + 264 + // Get the faceted text 265 + const facetBytes = bytes.slice(facet.index.byteStart, facet.index.byteEnd); 266 + const facetText = decoder.decode(facetBytes); 267 + 268 + // Determine facet type and render 269 + const feature = facet.features?.[0]; 270 + if (!feature) { 271 + result += escapeHtml(facetText); 272 + lastEnd = facet.index.byteEnd; 273 + continue; 274 + } 275 + 276 + const type = feature.$type || feature.__typename || ''; 277 + 278 + if (type.includes('link')) { 279 + const uri = feature.uri || ''; 280 + result += `<a href="${escapeHtml(uri)}" target="_blank" rel="noopener noreferrer" class="facet-link">${escapeHtml(facetText)}</a>`; 281 + } else if (type.includes('mention')) { 282 + // Extract handle from text (remove @) 283 + const handle = facetText.startsWith('@') ? facetText.slice(1) : facetText; 284 + result += `<a href="/profile/${escapeHtml(handle)}" class="facet-mention">${escapeHtml(facetText)}</a>`; 285 + } else if (type.includes('tag')) { 286 + // Hashtag - styled but not clickable for now 287 + result += `<span class="facet-tag">${escapeHtml(facetText)}</span>`; 288 + } else { 289 + result += escapeHtml(facetText); 290 + } 291 + 292 + lastEnd = facet.index.byteEnd; 293 + } 294 + 295 + // Add remaining text 296 + if (lastEnd < bytes.length) { 297 + const remainingBytes = bytes.slice(lastEnd); 298 + result += escapeHtml(decoder.decode(remainingBytes)); 299 + } 300 + 301 + return result; 302 + } 303 + 304 + function escapeHtml(text) { 305 + return text 306 + .replace(/&/g, '&amp;') 307 + .replace(/</g, '&lt;') 308 + .replace(/>/g, '&gt;') 309 + .replace(/"/g, '&quot;') 310 + .replace(/'/g, '&#039;'); 311 + }
+27
src/services/grain-api.js
··· 610 610 actorHandle 611 611 title 612 612 description 613 + facets 613 614 createdAt 614 615 socialGrainActorProfileByDid { 615 616 displayName ··· 642 643 node { 643 644 uri 644 645 text 646 + facets 645 647 createdAt 646 648 actorHandle 647 649 replyTo ··· 704 706 return { 705 707 uri: node.uri, 706 708 text: node.text, 709 + facets: node.facets || [], 707 710 createdAt: node.createdAt, 708 711 handle: node.actorHandle, 709 712 displayName: commentProfile?.displayName || '', ··· 718 721 uri: galleryNode.uri, 719 722 title: galleryNode.title, 720 723 description: galleryNode.description, 724 + facets: galleryNode.facets || [], 721 725 createdAt: galleryNode.createdAt, 722 726 handle: galleryNode.actorHandle, 723 727 displayName: profile?.displayName || '', ··· 1038 1042 node { 1039 1043 uri 1040 1044 text 1045 + facets 1041 1046 createdAt 1042 1047 actorHandle 1043 1048 replyTo ··· 1078 1083 return { 1079 1084 uri: node.uri, 1080 1085 text: node.text, 1086 + facets: node.facets || [], 1081 1087 createdAt: node.createdAt, 1082 1088 handle: node.actorHandle, 1083 1089 displayName: profile?.displayName || '', ··· 1093 1099 pageInfo: connection.pageInfo || { hasNextPage: false, endCursor: null }, 1094 1100 totalCount: connection.totalCount || 0 1095 1101 }; 1102 + } 1103 + 1104 + async resolveHandle(handle) { 1105 + const query = ` 1106 + query ResolveHandle($handle: String!) { 1107 + socialGrainActorProfile(first: 1, where: { actorHandle: { eq: $handle } }) { 1108 + edges { 1109 + node { did } 1110 + } 1111 + } 1112 + } 1113 + `; 1114 + 1115 + const response = await this.#execute(query, { handle }); 1116 + const did = response.data?.socialGrainActorProfile?.edges?.[0]?.node?.did; 1117 + 1118 + if (!did) { 1119 + throw new Error(`Handle not found: ${handle}`); 1120 + } 1121 + 1122 + return did; 1096 1123 } 1097 1124 } 1098 1125
+12
src/services/mutations.js
··· 1 1 import { auth } from './auth.js'; 2 2 import { recordCache } from './record-cache.js'; 3 + import { parseTextToFacets } from '../lib/richtext.js'; 4 + import { grainApi } from './grain-api.js'; 3 5 4 6 class MutationsService { 5 7 async createFavorite(galleryUri) { ··· 102 104 103 105 async createComment(galleryUri, text, replyToUri = null, focusUri = null) { 104 106 const client = auth.getClient(); 107 + 108 + // Parse text for facets with handle resolution 109 + const resolveHandle = async (handle) => grainApi.resolveHandle(handle); 110 + const { facets } = await parseTextToFacets(text, resolveHandle); 111 + 105 112 const input = { 106 113 subject: galleryUri, 107 114 text, 108 115 createdAt: new Date().toISOString() 109 116 }; 117 + 118 + // Only include facets if we found any 119 + if (facets && facets.length > 0) { 120 + input.facets = facets; 121 + } 110 122 111 123 if (replyToUri) { 112 124 input.replyTo = replyToUri;