alf: the atproto Latency Fabric alf.fly.dev/
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add facet detection to demo (links, mentions, hashtags)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+63 -4
+63 -4
demo/client/index.ts
··· 273 273 } 274 274 275 275 // --------------------------------------------------------------------------- 276 + // Facet detection — URLs, @mentions (resolved to DIDs), #hashtags 277 + // ATProto facets use UTF-8 byte offsets, not character offsets. 278 + // --------------------------------------------------------------------------- 279 + 280 + interface Facet { 281 + index: { byteStart: number; byteEnd: number }; 282 + features: Array<{ $type: string; [key: string]: unknown }>; 283 + } 284 + 285 + async function detectFacets(text: string): Promise<Facet[]> { 286 + const encoder = new TextEncoder(); 287 + const facets: Facet[] = []; 288 + 289 + function byteOffset(charIdx: number): number { 290 + return encoder.encode(text.slice(0, charIdx)).length; 291 + } 292 + 293 + // URLs 294 + const urlRegex = /https?:\/\/[^\s\]>)'"<]+/g; 295 + let m: RegExpExecArray | null; 296 + while ((m = urlRegex.exec(text)) !== null) { 297 + const url = m[0].replace(/[.,;:!?'")\]]+$/, ''); // trim trailing punctuation 298 + const byteStart = byteOffset(m.index); 299 + const byteEnd = byteStart + encoder.encode(url).length; 300 + facets.push({ index: { byteStart, byteEnd }, features: [{ $type: 'app.bsky.richtext.facet#link', uri: url }] }); 301 + } 302 + 303 + // @mentions — resolve handles to DIDs in parallel 304 + const mentionRegex = /(?<![^\s])@([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; 305 + const pending: Array<{ index: { byteStart: number; byteEnd: number }; handle: string }> = []; 306 + while ((m = mentionRegex.exec(text)) !== null) { 307 + const byteStart = byteOffset(m.index); 308 + const byteEnd = byteStart + encoder.encode(m[0]).length; 309 + pending.push({ index: { byteStart, byteEnd }, handle: m[1] }); 310 + } 311 + await Promise.all(pending.map(async ({ index, handle }) => { 312 + try { 313 + const res = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`); 314 + if (res.ok) { 315 + const data = await res.json() as { did?: string }; 316 + if (data.did) facets.push({ index, features: [{ $type: 'app.bsky.richtext.facet#mention', did: data.did }] }); 317 + } 318 + } catch (_) { /* skip unresolvable handles */ } 319 + })); 320 + 321 + // #hashtags 322 + const tagRegex = /(?<![^\s])#([a-zA-Z][a-zA-Z0-9_]*)/g; 323 + while ((m = tagRegex.exec(text)) !== null) { 324 + const byteStart = byteOffset(m.index); 325 + const byteEnd = byteStart + encoder.encode(m[0]).length; 326 + facets.push({ index: { byteStart, byteEnd }, features: [{ $type: 'app.bsky.richtext.facet#tag', tag: m[1] }] }); 327 + } 328 + 329 + return facets; 330 + } 331 + 332 + // --------------------------------------------------------------------------- 276 333 // Schedule / update post 277 334 // --------------------------------------------------------------------------- 278 335 ··· 343 400 headers['x-scheduled-at'] = new Date(scheduledAtValue).toISOString(); 344 401 } 345 402 403 + const facets = await detectFacets(text); 346 404 const record: Record<string, unknown> = { 347 405 $type: 'app.bsky.feed.post', 348 406 text, 349 407 createdAt: new Date().toISOString(), 350 408 }; 409 + if (facets.length > 0) record.facets = facets; 351 410 if (embed) record.embed = embed; 352 411 353 412 const response = await alfFetch('/xrpc/com.atproto.repo.createRecord', { ··· 404 463 405 464 try { 406 465 const uri = decodeURIComponent(editingUri!); 407 - const updateBody: Record<string, unknown> = { 408 - uri, 409 - record: { $type: 'app.bsky.feed.post', text, createdAt: new Date().toISOString() }, 410 - }; 466 + const facets = await detectFacets(text); 467 + const record: Record<string, unknown> = { $type: 'app.bsky.feed.post', text, createdAt: new Date().toISOString() }; 468 + if (facets.length > 0) record.facets = facets; 469 + const updateBody: Record<string, unknown> = { uri, record }; 411 470 if (scheduledAtValue) { 412 471 updateBody.scheduledAt = new Date(scheduledAtValue).toISOString(); 413 472 }