Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/scripts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
Expand Down
5 changes: 3 additions & 2 deletions packages/scripts/src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 }
Expand Down Expand Up @@ -115,7 +116,7 @@ export function renderActivityEventPreview({
info: Pick<ub.PostEvent['post'], 'key' | 'content'>
) {
const { sent, author } = getIdParts(info.key.id);
const contentSummary = getTextContent(info.content);
const contentSummary = getPostNotificationText(info.content);
return {
notification: {
body: lit(contentSummary),
Expand Down
54 changes: 54 additions & 0 deletions packages/scripts/src/postNotificationText.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
259 changes: 259 additions & 0 deletions packages/scripts/src/postNotificationText.ts
Original file line number Diff line number Diff line change
@@ -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<PostNotificationTextContent, null>,
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<PostNotificationTextContent, null>,
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);
}
Loading