import React, { useCallback, useMemo } from 'react'; import { Text, View, StyleSheet, Linking } from 'react-native'; import { usePaywallContext } from '../../context/PaywallContext'; import { applyStyles, textStyles, childSpacingStyle, parseSize, parseColor } from '../../utils/styles'; import { resolveFontDescriptor } from '../../utils/fonts'; import { buildSmartTextReplacements, interpolateSmartText } from '../../utils/smartText'; import { textAccessibilityRole } from '../../utils/rendering'; import { formatDate, isValidISODate } from '@namiml/sdk-core'; import { useInheritedFocusedStyle } from '../../context/FocusContext'; import { resolveSymbolGlyph } from '../../utils/symbolGlyphs'; interface Props { component: any; scaleFactor: number; isSymbol?: boolean; parentDirection?: string; } type MarkdownToken = | { type: 'text'; value: string } | { type: 'bold'; value: string } | { type: 'italic'; value: string } | { type: 'link'; value: string; href: string }; function parseEmphasis(input: string): MarkdownToken[] { const tokens: MarkdownToken[] = []; const EMPHASIS_REGEX = /(\*\*([^*]+)\*\*|\*([^*]+)\*)/g; let start = 0; let match: RegExpExecArray | null; while ((match = EMPHASIS_REGEX.exec(input)) !== null) { if (match.index > start) { tokens.push({ type: 'text', value: input.slice(start, match.index) }); } if (match[2] != null) { tokens.push({ type: 'bold', value: match[2] }); } else if (match[3] != null) { tokens.push({ type: 'italic', value: match[3] }); } start = match.index + match[0].length; } if (start < input.length) { tokens.push({ type: 'text', value: input.slice(start) }); } return tokens; } function parseInlineMarkdown(input: string): MarkdownToken[] { const tokens: MarkdownToken[] = []; const LINK_REGEX = /\[([^\]]+)\]\(([^)\s]+)\)/g; let start = 0; let match: RegExpExecArray | null; while ((match = LINK_REGEX.exec(input)) !== null) { if (match.index > start) { tokens.push(...parseEmphasis(input.slice(start, match.index))); } tokens.push({ type: 'link', value: match[1], href: match[2] }); start = match.index + match[0].length; } if (start < input.length) { tokens.push(...parseEmphasis(input.slice(start))); } return tokens; } export const NamiText: React.FC = ({ component, scaleFactor, isSymbol, parentDirection }) => { const ctx = usePaywallContext(); const isFocused = useInheritedFocusedStyle(); const smartTextSku = component?.smartTextSku ?? component?.sku; const replacements = useMemo( () => buildSmartTextReplacements(ctx.state, ctx.flow, smartTextSku), [ctx.state, ctx.flow, smartTextSku] ); const resolvedText = useMemo(() => { const raw = component.text ?? component.title ?? ''; const resolved = interpolateSmartText(raw, replacements); const str = resolved == null ? '' : String(resolved); if (component.dateTimeFormat && isValidISODate(str)) { return formatDate(str, component.dateTimeFormat); } return str; }, [component.text, component.title, replacements, component.dateTimeFormat]); const resolvedListItems = useMemo(() => { if (Array.isArray(component.texts)) { return component.texts .map((value: any) => { const resolved = interpolateSmartText(value, replacements); return resolved == null ? '' : String(resolved); }) .filter((value: string) => value.trim().length > 0); } return resolvedText .split('\n') .map((value: string) => value.trim()) .filter(Boolean); }, [component.texts, resolvedText, replacements]); const inlineTokens = useMemo( () => parseInlineMarkdown(resolvedText), [resolvedText], ); const hasInlineFormatting = useMemo( () => hasRichInlineContent(inlineTokens, resolvedText), [inlineTokens, resolvedText], ); const resolvedListItemTokens = useMemo( () => resolvedListItems.map((item: string) => parseInlineMarkdown(item)), [resolvedListItems], ); if (!resolvedText && !isSymbol && component.component !== 'text-list') return null; const containerStyle = useMemo( () => applyStyles(component, scaleFactor, isFocused, parentDirection), [component, scaleFactor, isFocused, parentDirection], ); const txtStyle = useMemo( () => textStyles(component, scaleFactor, isFocused), [component, scaleFactor, isFocused] ); const linkColor = parseColor(component.linkColor) ?? '#0000EE'; const linkStyle = useMemo(() => [styles.link, { color: linkColor }], [linkColor]); const emphasisBaseStyle = useMemo( () => ({ ...(txtStyle.color ? { color: txtStyle.color } : {}), ...(txtStyle.fontSize ? { fontSize: txtStyle.fontSize } : {}), ...(txtStyle.lineHeight ? { lineHeight: txtStyle.lineHeight } : {}), // NAM-1409: bold/italic inline-markdown spans should inherit letterSpacing too. ...(txtStyle.letterSpacing ? { letterSpacing: txtStyle.letterSpacing } : {}), }), [txtStyle.color, txtStyle.fontSize, txtStyle.lineHeight, txtStyle.letterSpacing], ); const baseFontName = component.fontName ?? component.fontFamily; const boldHostedFont = useMemo( () => resolveFontDescriptor(baseFontName, { bold: true, italic: false }), [baseFontName], ); const italicHostedFont = useMemo( () => resolveFontDescriptor(baseFontName, { bold: false, italic: true }), [baseFontName], ); const boldStyle = useMemo( () => [ emphasisBaseStyle, boldHostedFont.family ? { fontFamily: boldHostedFont.family, fontWeight: 'normal' as const, fontStyle: 'normal' as const, } : styles.bold, ], [boldHostedFont.family, emphasisBaseStyle], ); const italicStyle = useMemo( () => [ emphasisBaseStyle, italicHostedFont.family ? { fontFamily: italicHostedFont.family, fontWeight: 'normal' as const, fontStyle: 'normal' as const, ...(italicHostedFont.variant !== 'italic' && italicHostedFont.variant !== 'boldItalic' ? { transform: [{ skewX: '-10deg' as const }] } : {}), } : styles.italic, ], [italicHostedFont.family, italicHostedFont.variant, emphasisBaseStyle], ); const openHref = useCallback((href: string) => { Linking.canOpenURL(href).then((canOpen) => { if (canOpen) { Linking.openURL(href); } }); }, []); if (component.component === 'text-list') { const bullet = component.bulletComponent; // Resolve the bullet from its authored text, then its icon name (tolerant of // Ant-style names like "CheckOutlined"), falling back to a dot. Previously an // unmapped name (e.g. a checkmark) silently fell through to the dot. const bulletText = bullet?.text ?? (resolveSymbolGlyph(bullet?.name) || '\u2022'); const inlineGap = parseSize(component.spacing, scaleFactor) ?? 0; return ( {resolvedListItems.map((item: string, i: number) => ( {bulletText} {hasRichInlineContent(resolvedListItemTokens[i], item) ? ( {resolvedListItemTokens[i].map((token: MarkdownToken, index: number) => { if (token.type === 'link') { return ( openHref(token.href)} suppressHighlighting > {token.value} ); } if (token.type === 'bold') { return {token.value}; } if (token.type === 'italic') { return {token.value}; } return {token.value}; })} ) : ( {item} )} ))} ); } const fitContent = component.width === 'fitContent'; const numberOfLines = component.maxLines ?? (fitContent ? 1 : undefined); const ellipsizeMode = fitContent ? 'clip' : undefined; return ( {hasInlineFormatting ? ( inlineTokens.map((token: MarkdownToken, index: number) => { if (token.type === 'link') { return ( openHref(token.href)} suppressHighlighting > {token.value} ); } if (token.type === 'bold') { return {token.value}; } if (token.type === 'italic') { return {token.value}; } return {token.value}; }) ) : ( resolvedText )} ); }; function hasRichInlineContent(tokens: MarkdownToken[], originalText: string): boolean { return !( tokens.length === 1 && tokens[0]?.type === 'text' && tokens[0]?.value === originalText ); } const styles = StyleSheet.create({ listRow: { flexDirection: 'row', alignItems: 'flex-start', }, listText: { flexShrink: 1, flexWrap: 'wrap', }, link: { textDecorationLine: 'underline', }, bold: { fontWeight: '700', }, italic: { fontStyle: 'italic', }, });