From 9732fc7a3dbbc7835f6eee1e25a806820b573a71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Wed, 13 May 2026 13:33:16 +0200 Subject: [PATCH 1/2] Add OL renderer --- .../BaseHTMLEngineProvider.tsx | 5 + .../HTMLRenderers/NumberedItemRenderer.tsx | 34 +++++++ .../HTMLRenderers/OLRenderer.tsx | 51 ++++++++++ .../HTMLRenderers/ULRenderer.tsx | 3 +- .../HTMLEngineProvider/HTMLRenderers/index.ts | 2 + src/components/RenderHTML.tsx | 8 +- tests/unit/BulletListRendererTest.tsx | 93 +++++++++++++++++++ 7 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/NumberedItemRenderer.tsx create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/OLRenderer.tsx diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx index 13dc7fbaaebb..e9047e2fd588 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx @@ -191,6 +191,11 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim contentModel: HTMLContentModel.block, mixedUAStyles: styles.mv3, }), + ol: HTMLElementModel.fromCustomModel({ + tagName: 'ol', + contentModel: HTMLContentModel.block, + mixedUAStyles: styles.mv3, + }), 'sparkles-icon': HTMLElementModel.fromCustomModel({ tagName: 'sparkles-icon', contentModel: HTMLContentModel.mixed, diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/NumberedItemRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/NumberedItemRenderer.tsx new file mode 100644 index 000000000000..48d2c4417431 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/NumberedItemRenderer.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import {StyleSheet, View} from 'react-native'; +import type {TNode} from 'react-native-render-html'; +import {TNodeChildrenRenderer} from 'react-native-render-html'; +import Text from '@components/Text'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; + +const markerStyles = StyleSheet.create({ + marker: { + fontSize: variables.fontSizeNormal, + lineHeight: variables.fontSizeNormalHeight, + minWidth: 32, + textAlign: 'right', + paddingHorizontal: 8, + }, +}); + +function NumberedItemRenderer({tnode, index}: {tnode: TNode; index: number}) { + const styles = useThemeStyles(); + const theme = useTheme(); + + return ( + + {`${index}.`} + + + + + ); +} + +export default NumberedItemRenderer; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/OLRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/OLRenderer.tsx new file mode 100644 index 000000000000..63381b173bad --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/OLRenderer.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {CustomRendererProps, TBlock} from 'react-native-render-html'; +import {TNodeRenderer} from 'react-native-render-html'; +import useThemeStyles from '@hooks/useThemeStyles'; +import NumberedItemRenderer from './NumberedItemRenderer'; + +function buildLiIndices(children: CustomRendererProps['tnode']['children']): Map { + const map = new Map(); + let counter = 0; + for (const [i, child] of children.entries()) { + if (child.tagName === 'li') { + counter += 1; + map.set(i, counter); + } + } + return map; +} + +function OLRenderer({tnode, style}: CustomRendererProps) { + const styles = useThemeStyles(); + const liIndices = buildLiIndices(tnode.children); + + return ( + + {tnode.children.map((child, index) => { + const key = `${child.tagName ?? 'node'}-${index}`; + const liIndex = liIndices.get(index); + if (liIndex !== undefined) { + return ( + + ); + } + return ( + + ); + })} + + ); +} + +export default OLRenderer; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ULRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ULRenderer.tsx index c42c714c053f..e0eeb65d975b 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ULRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ULRenderer.tsx @@ -8,8 +8,7 @@ import BulletItemRenderer from './BulletItemRenderer'; /** * Bypasses the library's internal ULRenderer (which wraps children in MarkedListItem) * and renders
    as a plain block container that draws bullet markers around each - * direct
  • child — matching how / render.
  • is left - * unregistered globally so that
    1. still uses the library's default numeric markers. + * direct
    2. child — matching how / render. */ function ULRenderer({tnode, style}: CustomRendererProps) { const styles = useThemeStyles(); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/index.ts b/src/components/HTMLEngineProvider/HTMLRenderers/index.ts index ef1d630ce007..45cd28e8615b 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/index.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/index.ts @@ -13,6 +13,7 @@ import MentionHereRenderer from './MentionHereRenderer'; import MentionReportRenderer from './MentionReportRenderer'; import MentionUserRenderer from './MentionUserRenderer'; import NextStepEmailRenderer from './NextStepEmailRenderer'; +import OLRenderer from './OLRenderer'; import PreRenderer from './PreRenderer'; import RBRRenderer from './RBRRenderer'; import ShortMentionRenderer from './ShortMentionRenderer'; @@ -31,6 +32,7 @@ const HTMLEngineProviderComponentList: CustomTagRendererRecord = { a: AnchorRenderer, code: CodeRenderer, img: ImageRenderer, + ol: OLRenderer, ul: ULRenderer, video: VideoRenderer, diff --git a/src/components/RenderHTML.tsx b/src/components/RenderHTML.tsx index eccac7bb4044..6d31af35218c 100644 --- a/src/components/RenderHTML.tsx +++ b/src/components/RenderHTML.tsx @@ -5,6 +5,7 @@ import useHasTextAncestor from '@hooks/useHasTextAncestor'; import useWindowDimensions from '@hooks/useWindowDimensions'; import Parser from '@libs/Parser'; import BulletItemRenderer from './HTMLEngineProvider/HTMLRenderers/BulletItemRenderer'; +import OLRenderer from './HTMLEngineProvider/HTMLRenderers/OLRenderer'; import SparklesIconRenderer from './HTMLEngineProvider/HTMLRenderers/SparklesIconRenderer'; import ULRenderer from './HTMLEngineProvider/HTMLRenderers/ULRenderer'; @@ -14,8 +15,8 @@ type LinkPressHandler = NonNullable['onPress']; const RE_BRACKET_ESCAPE = /&#9[13];/g; // Matches consecutive duplicate or tags, keeping only the outermost one. const RE_EMOJI_OPEN_OR_CLOSE = /(]*>)(?:]*>)+|(<\/emoji[^>]*>)(?:<\/emoji[^>]*>)+/g; -// Strips orphaned
      tags inside
        that would render as extra empty bullets. -const RE_BR_CLEANUP = /\s*(<\/ul>)|(<\/li>)\s*\s*(?=<(?:li|\/ul)>)/gi; +// Strips one or more orphaned
        tags inside
          and
            that would render as extra empty items. +const RE_BR_CLEANUP = /(?:\s*)+\s*(<\/(?:ul|ol)>)|(<\/li>)(?:\s*)+\s*(?=<(?:li|\/(?:ul|ol))>)/gi; type RenderHTMLProps = { /** HTML string to render */ @@ -46,7 +47,7 @@ function RenderHTML({html: htmlParam, onLinkPress, isSelectable}: RenderHTMLProp .replaceAll(RE_BRACKET_ESCAPE, (m) => (m.at(7) === '1' ? '[' : ']')) // Remove double tag if exists and keep the outermost tag (always the original tag). .replaceAll(RE_EMOJI_OPEN_OR_CLOSE, '$1$2') - // Strip orphaned
            tags inside
              that would render as extra empty bullets + // Strip orphaned
              tags inside
                and
                  that would render as extra empty items .replaceAll(RE_BR_CLEANUP, '$1$2') ); }, [htmlParam]); @@ -63,6 +64,7 @@ function RenderHTML({html: htmlParam, onLinkPress, isSelectable}: RenderHTMLProp /* eslint-disable @typescript-eslint/naming-convention */ 'bullet-item': BulletItemRenderer, 'sparkles-icon': SparklesIconRenderer, + ol: OLRenderer, ul: ULRenderer, }; diff --git a/tests/unit/BulletListRendererTest.tsx b/tests/unit/BulletListRendererTest.tsx index 8e18194d3d53..cfc2c650c350 100644 --- a/tests/unit/BulletListRendererTest.tsx +++ b/tests/unit/BulletListRendererTest.tsx @@ -2,6 +2,8 @@ import {render, screen} from '@testing-library/react-native'; import React from 'react'; import type {CustomRendererProps, TBlock} from 'react-native-render-html'; import BulletItemRenderer from '@components/HTMLEngineProvider/HTMLRenderers/BulletItemRenderer'; +import NumberedItemRenderer from '@components/HTMLEngineProvider/HTMLRenderers/NumberedItemRenderer'; +import OLRenderer from '@components/HTMLEngineProvider/HTMLRenderers/OLRenderer'; import ULRenderer from '@components/HTMLEngineProvider/HTMLRenderers/ULRenderer'; import RenderHTML from '@components/RenderHTML'; import CONST from '@src/CONST'; @@ -82,6 +84,50 @@ describe('Bullet list rendering', () => { }); }); + describe('OLRenderer', () => { + it('wraps each
                1. child with a numbered marker', () => { + render( + // @ts-expect-error — only the props read by the renderer are needed for this test + , + ); + expect(screen.getByText('1.')).toBeTruthy(); + expect(screen.getByText('2.')).toBeTruthy(); + expect(screen.getByText('First')).toBeTruthy(); + expect(screen.getByText('Second')).toBeTruthy(); + }); + + it('renders non-
                2. children with the default node renderer', () => { + render( + // @ts-expect-error — only the props read by the renderer are needed for this test + , + ); + expect(screen.queryByText('1.')).toBeNull(); + expect(screen.getByText('stray child')).toBeTruthy(); + }); + }); + + describe('NumberedItemRenderer', () => { + it('renders a numbered marker next to the item content', () => { + render( + , + ); + expect(screen.getByText('1.')).toBeTruthy(); + expect(screen.getByText('First item')).toBeTruthy(); + }); + }); + describe('RenderHTML strips orphaned
                  tags inside
                    ', () => { it('strips
                    immediately before
                  ', () => { render(); @@ -103,6 +149,16 @@ describe('Bullet list rendering', () => { expect(capturedSource.html).toBe('
                  • One
                  • Two
                  '); }); + it('strips multiple consecutive
                  before
              ', () => { + render(); + expect(capturedSource.html).toBe('
              • One
              '); + }); + + it('strips multiple consecutive
              between and
            • ', () => { + render(); + expect(capturedSource.html).toBe('
              • One
              • Two
              '); + }); + it('does not strip
              outside of
                lists', () => { render(); expect(capturedSource.html).toBe('

                line1
                line2

                '); @@ -113,4 +169,41 @@ describe('Bullet list rendering', () => { expect(capturedSource.html).toBe('
                • One
                  still one
                • Two
                '); }); }); + + describe('RenderHTML strips orphaned
                tags inside
                  ', () => { + it('strips
                  immediately before
                ', () => { + render(); + expect(capturedSource.html).toBe('
                1. One
                2. Two
                '); + }); + + it('strips
                (no slash) immediately before
          ', () => { + render(); + expect(capturedSource.html).toBe('
          1. One
          2. Two
          '); + }); + + it('strips
          appearing between and the next
        • inside
            ', () => { + render(); + expect(capturedSource.html).toBe('
            1. One
            2. Two
            '); + }); + + it('leaves a valid
              /
            1. list untouched', () => { + render(); + expect(capturedSource.html).toBe('
              1. One
              2. Two
              '); + }); + + it('strips multiple consecutive
              before
            ', () => { + render(); + expect(capturedSource.html).toBe('
            1. One
            '); + }); + + it('strips multiple consecutive
            between and
          1. inside
              ', () => { + render(); + expect(capturedSource.html).toBe('
              1. One
              2. Two
              '); + }); + + it('preserves
              that lives inside
            1. as an in-item line break', () => { + render(); + expect(capturedSource.html).toBe('
              1. One
                still one
              2. Two
              '); + }); + }); }); From 196aeac91ee7274394125a7acb699b4a40407f5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Thu, 14 May 2026 16:31:22 +0200 Subject: [PATCH 2/2] move styles to style file --- .../HTMLRenderers/NumberedItemRenderer.tsx | 19 ++++--------------- src/styles/index.ts | 8 ++++++++ 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/NumberedItemRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/NumberedItemRenderer.tsx index 48d2c4417431..4808ba84a837 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/NumberedItemRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/NumberedItemRenderer.tsx @@ -1,29 +1,18 @@ import React from 'react'; -import {StyleSheet, View} from 'react-native'; +import {View} from 'react-native'; import type {TNode} from 'react-native-render-html'; import {TNodeChildrenRenderer} from 'react-native-render-html'; import Text from '@components/Text'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import variables from '@styles/variables'; -const markerStyles = StyleSheet.create({ - marker: { - fontSize: variables.fontSizeNormal, - lineHeight: variables.fontSizeNormalHeight, - minWidth: 32, - textAlign: 'right', - paddingHorizontal: 8, - }, -}); +type NumberedItemRendererProps = {tnode: TNode; index: number}; -function NumberedItemRenderer({tnode, index}: {tnode: TNode; index: number}) { +function NumberedItemRenderer({tnode, index}: NumberedItemRendererProps) { const styles = useThemeStyles(); - const theme = useTheme(); return ( - {`${index}.`} + {`${index}.`} diff --git a/src/styles/index.ts b/src/styles/index.ts index acabe0c39d15..70d10e7d0cc6 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -324,6 +324,14 @@ const staticStyles = (theme: ThemeColors) => fontStyle: FontUtils.fontFamily.platform.EXP_NEUE_ITALIC.fontStyle, }, + numberedListItemMarker: { + fontSize: variables.fontSizeNormal, + lineHeight: variables.fontSizeNormalHeight, + minWidth: 32, + textAlign: 'right', + paddingHorizontal: 8, + }, + autoCompleteSuggestionContainer: { flexDirection: 'row', alignItems: 'center',