diff --git a/packages/scripts/package.json b/packages/scripts/package.json index ef9ed20869..b4228cdda7 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "scripts": { - "build": "esbuild src/index.ts --bundle --format=iife --global-name=tlon --outfile=build/bundle.js", + "build": "esbuild src/index.ts --bundle --format=iife --global-name=tlon --conditions=react-native --outfile=build/bundle.js", "test": "vitest --passWithNoTests" }, "keywords": [], diff --git a/packages/scripts/src/index.ts b/packages/scripts/src/index.ts index 8065b3384f..8c51a5491e 100644 --- a/packages/scripts/src/index.ts +++ b/packages/scripts/src/index.ts @@ -1,5 +1,4 @@ import { parseContactUpdateEvent } from '@tloncorp/api/client/activityApi'; -import { getTextContent } from '@tloncorp/api/client/postContent'; import type * as ub from '@tloncorp/api/urbit'; import { ActivityIncomingEvent, @@ -9,6 +8,8 @@ import { } from '@tloncorp/api/urbit/activity'; import { da, render } from '@urbit/aura'; +import { getPostNotificationText } from './postNotificationText'; + type PreviewContentNode = | { type: 'channelTitle'; channelId: string } | { type: 'groupTitle'; groupId: string } @@ -115,7 +116,7 @@ export function renderActivityEventPreview({ info: Pick ) { const { sent, author } = getIdParts(info.key.id); - const contentSummary = getTextContent(info.content); + const contentSummary = getPostNotificationText(info.content); return { notification: { body: lit(contentSummary), diff --git a/packages/scripts/src/postNotificationText.test.ts b/packages/scripts/src/postNotificationText.test.ts new file mode 100644 index 0000000000..32e3d7937f --- /dev/null +++ b/packages/scripts/src/postNotificationText.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { + PostNotificationTextConfig, + getPostNotificationText, +} from './postNotificationText'; + +describe('postNotificationText', () => { + it('extracts plain text from story content without a DOM-backed parser', () => { + expect( + getPostNotificationText([ + { + inline: [ + 'hello ', + { bold: ['there'] }, + { break: null }, + 'friend', + { break: null }, + ], + }, + ]) + ).toBe('hello there\nfriend'); + }); + + it('leaves markdown table syntax as text for notification previews', () => { + expect( + getPostNotificationText( + [ + { + inline: [ + '| A | B |', + { break: null }, + '|---|---|', + { break: null }, + '| 1 | 2 |', + { break: null }, + ], + }, + ], + PostNotificationTextConfig.inlineConfig + ) + ).toBe('| A | B |\n|---|---|\n| 1 | 2 |'); + }); + + it('skips inline verses that only contain a trailing break', () => { + expect( + getPostNotificationText([ + { inline: ['hi'] }, + { inline: [{ break: null }] }, + { inline: ['bye'] }, + ]) + ).toBe('hi\nbye'); + }); +}); diff --git a/packages/scripts/src/postNotificationText.ts b/packages/scripts/src/postNotificationText.ts new file mode 100644 index 0000000000..c4fe0f3eae --- /dev/null +++ b/packages/scripts/src/postNotificationText.ts @@ -0,0 +1,259 @@ +import type { ContentReference } from '@tloncorp/api/types/references'; +import type { Verse } from '@tloncorp/api/urbit/channel'; +import type { Block, Inline, Listing } from '@tloncorp/api/urbit/content'; +import { + isBlockCode, + isBlockLink, + isBlockquote, + isBold, + isBreak, + isCite, + isCode, + isHeader, + isImage, + isInlineCode, + isItalics, + isLink, + isList, + isSect, + isShip, + isStrikethrough, + isTask, +} from '@tloncorp/api/urbit/content'; + +// This module is bundled into native notification rendering, which runs without +// a DOM. Keep it intentionally dependency-light and avoid importing the richer +// API postContent serializers or markdown/table parsing helpers here. +export type PostNotificationTextContent = (Verse | ContentReference)[] | null; + +export interface PostNotificationTextConfig { + blockSeparator: string; + includeLinebreaks: boolean; + includeRefTag: boolean; + indentDepth?: number; +} + +export namespace PostNotificationTextConfig { + export const defaultConfig: PostNotificationTextConfig = Object.freeze({ + blockSeparator: '\n', + includeLinebreaks: true, + includeRefTag: true, + }); + + export const inlineConfig: PostNotificationTextConfig = Object.freeze({ + blockSeparator: ' ', + includeLinebreaks: false, + includeRefTag: false, + }); +} + +const VIDEO_REGEX = /(\.mov|\.mp4|\.ogv|\.webm)(?:\?.*)?$/i; + +function previewInlineString( + inlines: Inline[], + config: PostNotificationTextConfig +): string { + return inlines + .map((inline, index) => + previewInline(inline, index, inlines.length, config) + ) + .join(''); +} + +function previewInline( + inline: Inline, + index: number, + total: number, + config: PostNotificationTextConfig +): string { + if (typeof inline === 'string') { + return inline; + } + + if (isBold(inline)) { + return previewInlineString(inline.bold, config); + } + if (isItalics(inline)) { + return previewInlineString(inline.italics, config); + } + if (isStrikethrough(inline)) { + return previewInlineString(inline.strike, config); + } + if (isInlineCode(inline)) { + return inline['inline-code']; + } + if (isLink(inline)) { + return inline.link.content ?? inline.link.href; + } + if (isBreak(inline)) { + return index === total - 1 ? '' : '\n'; + } + if (isShip(inline)) { + return inline.ship; + } + if (isSect(inline)) { + return `@${inline.sect ?? 'all'}`; + } + if (isTask(inline)) { + const prefix = inline.task.checked ? '[x] ' : '[ ] '; + return `${prefix}${previewInlineString(inline.task.content, config)}`; + } + if (isBlockquote(inline)) { + return `> ${previewInlineString(inline.blockquote, config)}`; + } + if (isBlockCode(inline)) { + return `\`\`\`\n${inline.code}\n\`\`\``; + } + + return 'Unknown content type'; +} + +function previewListing( + listing: Listing, + config: PostNotificationTextConfig +): string { + if (isList(listing)) { + const out: string[] = []; + out.push(previewInlineString(listing.list.contents, config)); + + const currentIndentDepth = config.indentDepth ?? 0; + const effectiveIndentDepth = config.includeLinebreaks + ? currentIndentDepth + : 0; + const delimiter = (index: number) => { + switch (listing.list.type) { + case 'tasklist': + case 'unordered': + return '-'; + case 'ordered': + return `${index + 1}.`; + } + }; + + out.push( + ...listing.list.items.map( + (child, index) => + `${'\t'.repeat(effectiveIndentDepth)}${delimiter(index)} ${previewListing( + child, + { + ...config, + indentDepth: currentIndentDepth + 1, + } + )}` + ) + ); + return out.join(config.blockSeparator); + } + + return previewInlineString(listing.item, config); +} + +function previewBlock( + block: Block, + config: PostNotificationTextConfig +): string { + if (isImage(block)) { + return VIDEO_REGEX.test(block.image.src) ? '(Video)' : '(Image)'; + } + if (isHeader(block)) { + return previewInlineString(block.header.content, config); + } + if (isCode(block)) { + return `\`\`\`${block.code.lang ?? ''}\n${block.code.code}\n\`\`\``; + } + if (isCite(block)) { + return config.includeRefTag ? '(Ref)' : ''; + } + if (isBlockLink(block)) { + return ''; + } + if ('listing' in block) { + return previewListing(block.listing, config); + } + if ('rule' in block) { + return '---'; + } + + return 'Unknown content type'; +} + +function previewInlineVerse( + inlines: Inline[], + config: PostNotificationTextConfig +): string[] { + const out: string[] = []; + let current: Inline[] = []; + + function flushCurrent() { + if (current.length === 0) { + return; + } + if ( + !current.every( + (inline) => typeof inline === 'string' && inline.trim() === '' + ) + ) { + const preview = previewInlineString(current, config); + if (preview.length > 0) { + out.push(preview); + } + } + current = []; + } + + for (const inline of inlines) { + if (isBlockquote(inline) || isBlockCode(inline)) { + flushCurrent(); + out.push(previewInline(inline, 0, 1, config)); + } else { + current.push(inline); + } + } + flushCurrent(); + + return out; +} + +function isContentReference( + verse: Verse | ContentReference +): verse is ContentReference { + return 'type' in verse && verse.type === 'reference'; +} + +function previewPostNotificationContent( + postContent: Exclude, + config: PostNotificationTextConfig = PostNotificationTextConfig.defaultConfig +): string { + return postContent + .flatMap((verse) => { + if (isContentReference(verse)) { + return config.includeRefTag ? ['(Ref)'] : []; + } + if ('block' in verse) { + return [previewBlock(verse.block, config)]; + } + if ('inline' in verse) { + return previewInlineVerse(verse.inline, config); + } + return ['Unknown content type']; + }) + .join(config.blockSeparator) + .trim(); +} + +export function getPostNotificationText( + postContent: Exclude, + config?: PostNotificationTextConfig +): string; +export function getPostNotificationText( + postContent: PostNotificationTextContent, + config?: PostNotificationTextConfig +): string | null; +export function getPostNotificationText( + postContent: PostNotificationTextContent, + config: PostNotificationTextConfig = PostNotificationTextConfig.defaultConfig +): string | null { + return postContent == null + ? null + : previewPostNotificationContent(postContent, config); +}