+922
docs/plans/2025-12-29-richtext-facets.md
+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, '&')
305
+
.replace(/</g, '<')
306
+
.replace(/>/g, '>')
307
+
.replace(/"/g, '"')
308
+
.replace(/'/g, ''');
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
+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
+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
+1
src/components/organisms/grain-comment-sheet.js
+2
-1
src/components/organisms/grain-profile-header.js
+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
+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
});
+2
-1
src/components/pages/grain-gallery-detail.js
+2
-1
src/components/pages/grain-gallery-detail.js
···
11
11
import '../organisms/grain-comment-sheet.js';
12
12
import '../atoms/grain-spinner.js';
13
13
import '../atoms/grain-icon.js';
14
+
import '../atoms/grain-rich-text.js';
14
15
import '../organisms/grain-action-dialog.js';
15
16
16
17
const DELETE_GALLERY_MUTATION = `
···
397
398
<div class="content">
398
399
<p class="title">${this._gallery.title}</p>
399
400
${this._gallery.description ? html`
400
-
<p class="description">${this._gallery.description}</p>
401
+
<p class="description"><grain-rich-text .text=${this._gallery.description} .facets=${this._gallery.facets || []}></grain-rich-text></p>
401
402
` : ''}
402
403
<time class="timestamp">${this.#formatDate(this._gallery.createdAt)}</time>
403
404
</div>
+311
src/lib/richtext.js
+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, '&')
307
+
.replace(/</g, '<')
308
+
.replace(/>/g, '>')
309
+
.replace(/"/g, '"')
310
+
.replace(/'/g, ''');
311
+
}
+27
src/services/grain-api.js
+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
+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;