Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e0d2cc2
BlockRenderer, postContent: parse markdown tables from inline message…
jamesacklin May 13, 2026
4076a78
BlockRenderer: table aesthetics
jamesacklin May 13, 2026
15d325f
BlockRenderer: reset measurement state when block changes
jamesacklin May 13, 2026
2fadcad
logic/markdown: preserve rows that omit a leading pipe
jamesacklin May 13, 2026
4b4f9a4
logic/markdown: define and parse GroupMentions, replace
jamesacklin May 13, 2026
705fbd1
logic/markdown: add groupMentionPlugin
jamesacklin May 13, 2026
8593700
prettier
jamesacklin May 13, 2026
5aecd45
api: move markdown from shared into api
jamesacklin May 13, 2026
58e06e5
prettier
jamesacklin May 13, 2026
443e4cc
api: move markdown from lib to client; avoids circular imports
jamesacklin May 14, 2026
537bf87
extractTables: prevent splitting table too early
jamesacklin May 14, 2026
a216e30
groupMentionPlugin: catch |@all| valid table syntax
jamesacklin May 14, 2026
7051479
extractTables: add explicit default, fix comments
jamesacklin May 14, 2026
57c00ae
api/client: show first cell of table in notification preview
jamesacklin May 14, 2026
817fbb0
extractTables: escape every `|` in the output
jamesacklin May 14, 2026
aa1db9e
extractTables: cheap escape for paragraphs wthout |
jamesacklin May 14, 2026
947933e
Merge branch 'develop' into jamesacklin/md-table-blocks
jamesacklin May 14, 2026
c3b01ba
extractTables.test.ts: prettier
jamesacklin May 14, 2026
44aa2c7
Merge branch 'jamesacklin/md-table-blocks' of https://github.com/tlon…
jamesacklin May 14, 2026
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
10 changes: 5 additions & 5 deletions packages/api/src/__tests__/postContent.video.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { expect, test } from 'vitest';

import { convertContent } from '../client/postContent';
import { convertContentRaw } from '../client/postContent';

test('convertContent keeps both blob and story videos when src matches', () => {
test('convertContentRaw keeps both blob and story videos when src matches', () => {
const src = 'https://cdn.example.com/clip.mp4';
const blob = JSON.stringify([
{
Expand Down Expand Up @@ -30,7 +30,7 @@ test('convertContent keeps both blob and story videos when src matches', () => {
},
];

const content = convertContent(story, blob);
const content = convertContentRaw(story, blob);
const videos = content.filter((block) => block.type === 'video');
expect(videos).toHaveLength(2);
expect(videos[0]).toMatchObject({
Expand All @@ -55,7 +55,7 @@ test('convertContent keeps both blob and story videos when src matches', () => {
});
});

test('convertContent keeps distinct blob and story videos when src differs', () => {
test('convertContentRaw keeps distinct blob and story videos when src differs', () => {
const blob = JSON.stringify([
{
type: 'video',
Expand All @@ -77,7 +77,7 @@ test('convertContent keeps distinct blob and story videos when src differs', ()
},
];

const content = convertContent(story, blob);
const content = convertContentRaw(story, blob);
const videoSrcs = content
.flatMap((block) => (block.type === 'video' ? [block.video.src] : []))
.sort();
Expand Down
28 changes: 24 additions & 4 deletions packages/api/src/client/postContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,23 @@ export type ListData = {
children?: ListData[];
};

export type TableAlignment = 'left' | 'center' | 'right';

export type TableCellData = {
content: InlineData[];
};

export type TableRowData = {
cells: TableCellData[];
};

export type TableBlockData = {
type: 'table';
header: TableRowData;
rows: TableRowData[];
align: (TableAlignment | null)[];
};

export type BlockData =
| BlockquoteBlockData
| ParagraphBlockData
Expand All @@ -170,7 +187,8 @@ export type BlockData =
| HeaderBlockData
| RuleBlockData
| ListBlockData
| BigEmojiBlockData;
| BigEmojiBlockData
| TableBlockData;

export type BlockType = BlockData['type'];

Expand Down Expand Up @@ -263,6 +281,8 @@ export function plaintextPreviewOf(
return plaintextPreviewOfListData(block.list, config);
case 'bigEmoji':
return block.emoji;
case 'table':
return '(Table)';
Comment thread
jamesacklin marked this conversation as resolved.
Outdated
}
})
.join(config.blockSeparator)
Expand Down Expand Up @@ -356,7 +376,7 @@ export function plaintextPreviewOfInline(
* The format is very loosely inspired by ProseMirror's internal representation,
* and could be converted to be compatible pretty easily.
*/
export function convertContent(
export function convertContentRaw(
input: unknown,
blob: string | undefined | null
): PostContent {
Expand Down Expand Up @@ -426,7 +446,7 @@ export function convertContent(
}

/**
* Same as `convertContent`, but does not parse the input, and
* Same as `convertContentRaw`, but does not parse the input, and
* applies more type strictness at callsite.
*/
export function convertContentSafe(
Expand Down Expand Up @@ -604,7 +624,7 @@ function convertListing(listing: ub.Listing): ListData {
}
}

function convertInlineContent(inlines: ub.Inline[]): InlineData[] {
export function convertInlineContent(inlines: ub.Inline[]): InlineData[] {
const nodes: InlineData[] = [];
inlines.forEach((inline, i) => {
if (typeof inline === 'string') {
Expand Down
149 changes: 148 additions & 1 deletion packages/app/ui/components/PostContent/BlockRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,15 @@
useCallback,
useContext,
useMemo,
useRef,
useState,
} from 'react';
import { ActivityIndicator, Linking, Platform } from 'react-native';
import {
ActivityIndicator,
type LayoutChangeEvent,
Linking,
Platform,
} from 'react-native';
import { ScrollView, View, ViewStyle, XStack, YStack, styled } from 'tamagui';

import { useNowPlayingController } from '../../contexts/nowPlaying';
Expand Down Expand Up @@ -678,6 +684,145 @@
});
HeaderText.displayName = 'HeaderText';

function alignToTextAlign(
align: cn.TableAlignment | null | undefined
): 'left' | 'center' | 'right' | 'auto' {
if (align === 'left' || align === 'center' || align === 'right') return align;
return 'auto';
}

const TABLE_MAX_COLUMN_WIDTH = 280;
Comment thread
patosullivan marked this conversation as resolved.

type TablePhase = 'measure-natural' | 'measure-wrapped' | 'done';

export function TableBlock({ block }: { block: cn.TableBlockData }) {
const columnCount = Math.max(
block.header.cells.length,
...block.rows.map((r) => r.cells.length)
);
const allRows = [block.header, ...block.rows];
const totalCells = columnCount * allRows.length;

const naturalDimsRef = useRef<Map<string, { w: number; h: number }>>(
new Map()
);
const finalHeightsRef = useRef<Map<string, number>>(new Map());
const pendingRef = useRef<Set<string>>(new Set());

const [phase, setPhase] = useState<TablePhase>('measure-natural');
const [columnWidths, setColumnWidths] = useState<number[] | null>(null);
const [rowHeights, setRowHeights] = useState<number[] | null>(null);

Check failure on line 715 in packages/app/ui/components/PostContent/BlockRenderer.tsx

View check run for this annotation

Claude / Claude Code Review

TableBlock measurement state not reset when block prop changes

TableBlock holds measurement state (`phase`, `columnWidths`, `rowHeights`, and the three refs) that initializes once and never resets when the `block` prop changes. Since `ContentRenderer` keys children by array index, editing a post so a table appears at the same index reuses this component instance — the new table renders with the previous table's stale column widths and row heights, and `handleCellLayout` silently drops the new `onLayout` events. Fix by attaching a key derived from block cont
Comment thread
jamesacklin marked this conversation as resolved.
const finalizeHeights = useCallback(() => {
const heights: number[] = [];
for (let row = 0; row < allRows.length; row++) {
let max = 0;
for (let col = 0; col < columnCount; col++) {
max = Math.max(max, finalHeightsRef.current.get(`${row}-${col}`) ?? 0);
}
heights.push(max);
}
setRowHeights(heights);
setPhase('done');
}, [columnCount, allRows.length]);

const handleCellLayout = useCallback(
(rowIdx: number, colIdx: number) => (e: LayoutChangeEvent) => {
const key = `${rowIdx}-${colIdx}`;
const { width, height } = e.nativeEvent.layout;

if (phase === 'measure-natural') {
naturalDimsRef.current.set(key, { w: width, h: height });
if (naturalDimsRef.current.size < totalCells) return;

const widths: number[] = [];
for (let col = 0; col < columnCount; col++) {
let max = 0;
for (let row = 0; row < allRows.length; row++) {
max = Math.max(max, naturalDimsRef.current.get(`${row}-${col}`)!.w);
}
widths.push(Math.min(max, TABLE_MAX_COLUMN_WIDTH));
}

// For cells whose natural width fits the (possibly capped) column,
// their natural height is the final height. For cells that exceed the
// cap, we mark them pending — they'll re-layout after widths apply.
for (let row = 0; row < allRows.length; row++) {
for (let col = 0; col < columnCount; col++) {
const k = `${row}-${col}`;
const dims = naturalDimsRef.current.get(k)!;
if (dims.w > widths[col]) {
pendingRef.current.add(k);
} else {
finalHeightsRef.current.set(k, dims.h);
}
}
}

setColumnWidths(widths);

if (pendingRef.current.size === 0) {
finalizeHeights();
} else {
setPhase('measure-wrapped');
}
return;
}

if (phase === 'measure-wrapped') {
if (!pendingRef.current.has(key)) return;
finalHeightsRef.current.set(key, height);
pendingRef.current.delete(key);
if (pendingRef.current.size === 0) {
finalizeHeights();
}
}
},
[phase, columnCount, allRows.length, totalCells, finalizeHeights]
);

return (
<ScrollView horizontal width="100%" maxWidth="100%">
<XStack opacity={phase === 'done' ? 1 : 0}>
{Array.from({ length: columnCount }).map((_, colIdx) => {
const align = block.align[colIdx] ?? null;
const textAlign = alignToTextAlign(align);
const colWidth = columnWidths?.[colIdx];
return (
<YStack key={colIdx}>
{allRows.map((row, rowIdx) => {
const cell = row.cells[colIdx] ?? { content: [] };
const isHeader = rowIdx === 0;
const rowHeight = rowHeights?.[rowIdx];
return (
<View
key={rowIdx}
paddingVertical="$xl"
paddingLeft={colIdx === 0 ? 0 : '$l'}
paddingRight="$l"
borderBottomWidth={rowIdx < allRows.length - 1 ? 1 : 0}
borderColor="$border"
{...(colWidth != null ? { width: colWidth } : {})}
{...(rowHeight != null ? { minHeight: rowHeight } : {})}
onLayout={handleCellLayout(rowIdx, colIdx)}
>
<LineRenderer
inlines={cell.content}
size="$label/m"
textAlign={textAlign}
{...(isHeader ? { color: '$tertiaryText' } : {})}
/>
</View>
);
})}
</YStack>
);
})}
</XStack>
</ScrollView>
);
}

export type BlockRenderer<T extends cn.BlockData> = (props: {
block: T;
}) => React.ReactNode;
Expand Down Expand Up @@ -709,6 +854,7 @@
bigEmoji: BigEmojiBlock,
file: FileUploadBlock,
voicememo: VoiceMemoBlock,
table: TableBlock,
};

type BlockSettings<T extends ComponentType> = Partial<ComponentProps<T>> & {
Expand All @@ -731,6 +877,7 @@
bigEmoji: BlockSettings<typeof BigEmojiBlock>;
file: BlockSettings<typeof FileUploadBlock>;
voicememo: BlockSettings<typeof VoiceMemoBlock>;
table: BlockSettings<typeof TableBlock>;
};

interface BlockRendererContextValue {
Expand Down
Loading
Loading