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
10 changes: 7 additions & 3 deletions apps/mobile/src/__tests__/BotGroupChatScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,11 @@ const h = vi.hoisted(() => ({
platform: { OS: 'ios' }, headerBlur: [] as number[], headerFade: [] as (number | undefined)[],
}));

// Keep the real attachment components; only replace native reader and filesystem surfaces for jsdom.
// Keep production message components; adapt native surfaces for jsdom.
vi.mock('react-native-webview', () => ({ WebView: () => null }));
vi.mock('react-native-uitextview', () => ({
UITextView: ({ children, testID }: any) => el('span', { 'data-testid': testID }, children),
}));
vi.mock('expo-modules-core', () => ({ requireOptionalNativeModule: () => null }));
vi.mock('expo-file-system', () => ({ Directory: class {}, File: class {}, Paths: { cache: 'file:///test-cache' } }));
vi.mock('expo-web-browser', () => ({ openBrowserAsync: vi.fn(async () => ({})) }));
Expand All @@ -60,10 +63,10 @@ vi.mock('react-native', () => {
{ 'data-testid': testID, 'aria-label': accessibilityLabel, disabled, onClick: onPress },
typeof children === 'function' ? children({ pressed: false }) : children),
ActivityIndicator: ({ testID }: any) => el('i', { 'data-testid': testID ?? 'spinner' }),
StyleSheet: { create: (value: unknown) => value, hairlineWidth: 1 },
StyleSheet: { create: (value: unknown) => value, flatten: (style: unknown) => Object.assign({}, ...[style].flat(Infinity).filter(Boolean)), hairlineWidth: 1 },
Platform: { get OS() { return h.platform.OS; }, select: (value: any) => value[h.platform.OS] },
Alert: { alert: h.alert },
useWindowDimensions: () => ({ width: 390, height: 844 }),
useWindowDimensions: () => ({ width: 390, height: 844, fontScale: 1, scale: 3 }),
AppState: { get currentState() { return h.appState; }, addEventListener: (_event: string, listener: (state: string) => void) => {
h.appStateListeners.add(listener); return { remove: () => h.appStateListeners.delete(listener) };
} },
Expand Down Expand Up @@ -122,6 +125,7 @@ vi.mock('@/platform/chrome/NativePullDownMenu', () => ({
},
}));
vi.mock('@/components/AppText', () => ({
MAX_FONT_SIZE_MULTIPLIER: 1.5,
Text: ({ children, testID }: any) => el('span', { 'data-testid': testID }, children),
TextInput: ({ testID, ...props }: any) => { h.inputs[testID] = props; return el('input', { 'data-testid': testID, value: props.value ?? '', readOnly: true }); },
}));
Expand Down
52 changes: 47 additions & 5 deletions apps/mobile/src/__tests__/BotGroupMarkdownText.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,39 @@ vi.mock('react-native', async () => {
const React = await import('react');
const flat = (style: unknown): Record<string, unknown> =>
Object.assign({}, ...[style].flat(Infinity).filter(Boolean));
const host = (tag: string) => ({ children, style, horizontal, onLayout, testID }: any) => {
const host = (tag: string) => ({ children, style, horizontal, onLayout, testID, selectable }: any) => {
React.useEffect(() => { onLayout?.({ nativeEvent: { layout: { width: 280 } } }); }, [onLayout]);
return React.createElement(tag, { 'data-style': JSON.stringify(flat(style)),
'data-horizontal': horizontal, 'data-testid': testID }, children);
'data-horizontal': horizontal, 'data-testid': testID, 'data-selectable': selectable }, children);
};
return {
View: host('div'),
ScrollView: host('section'),
Text: host('span'),
useWindowDimensions: () => ({ width: 360, height: 800, fontScale: 1, scale: 3 }),
StyleSheet: { create: (s: any) => s, flatten: (s: any) => s, hairlineWidth: 1 },
StyleSheet: { create: (s: any) => s, flatten: flat, hairlineWidth: 1 },
Platform: { OS: 'ios', select: (s: any) => s.ios ?? s.default },
};
});
vi.mock('@/components/AppText', async () => ({ Text: (await import('react-native')).Text }));
vi.mock('@/components/AppText', async () => ({ Text: (await import('react-native')).Text, MAX_FONT_SIZE_MULTIPLIER: 1.5 }));
vi.mock('react-native-uitextview', async () => {
const React = await import('react');
const { StyleSheet } = await import('react-native');
return { UITextView: ({ children, uiTextView, selectable, style }: any) => React.createElement('span', {
'data-uitextview': uiTextView ? 'root' : 'span', 'data-selectable': selectable,
'data-style': JSON.stringify(StyleSheet.flatten(style) ?? {}),
}, children) };
});
vi.mock('@/session/SelectionMenuText', async () => ({ SelectionMenuText: (await import('react-native')).Text }));
vi.mock('@/session/ShareMessageCheckbox', () => ({ useCancelShareSelectionRowTap: () => null }));
vi.mock('@/i18n', () => ({ i18n: { t: (key: string) => key } }));
vi.mock('@/theme', () => ({
fontWeight: { regular: '400', medium: '500', semibold: '600', bold: '700' },
monoFont: 'Menlo',
useThemedStyles: (make: (colors: any) => any) => make(new Proxy({}, { get: () => '#000' })),
}));

import { BotGroupMarkdownText } from '@/session/BotGroupMessageText';
import { BotGroupMarkdownText, BotGroupUserText } from '@/session/BotGroupMessageText';

let root: Root;
let node: HTMLDivElement;
Expand Down Expand Up @@ -137,3 +148,34 @@ it.each([['x', ''], ['a long image description that wraps inside its column', 'y
if (!otherAlt) expect(imageWidths.reduce((a, b) => a + b, 0)).toBe(280);
},
);

// Regression: iOS RN Text selectable offers only whole-block Copy. Exercise the
// production body with native-carrier fakes; real selection handles need Simulator.
// Existing layout tests did not cover the UITextView root/child native contract.
it.each(['ios', 'android'])('uses system partial-selection text for formatted group bodies and own mentions on %s', async (platform) => {
const { Platform } = await import('react-native');
Platform.OS = platform as typeof Platform.OS;
try {
await act(async () => root.render(createElement('div', null,
createElement(BotGroupMarkdownText, { content: 'Select **part** of this paragraph.\n\n> quote\n\n- item\n\n~~~text\ncode\n~~~\n\n| Cell | Other |\n| --- | --- |\n| value | next |' }),
createElement(BotGroupUserText, { content: '@Cindy keep my words', mentionLabels: ['Cindy'] }),
)));
const roots = [...node.querySelectorAll('[data-selectable="true"]')];
expect(roots.map(el => el.textContent)).toEqual([
'Select part of this paragraph.', 'quote', 'item', 'code', 'Cell', 'Other', 'value', 'next', '@Cindy keep my words',
]);
for (const text of roots) {
if (platform === 'ios') {
expect(text.getAttribute('data-uitextview')).toBe('root');
// Mixing RN Text descendants into a UITextView breaks styled selection.
expect(text.querySelector('span:not([data-uitextview="span"])')).toBeNull();
} else {
expect(text.hasAttribute('data-uitextview')).toBe(false);
expect(text.querySelector('[data-uitextview]')).toBeNull();
}
}
expect(styleOf('part').fontWeight).toBe('600');
} finally {
Platform.OS = 'ios';
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -312,11 +312,12 @@ it('renders message bodies natively with in-place per-block selectable text', ()
// iOS 部分选择:RN Text 底层是 UILabel 只能整块拷贝(facebook/react-native#13938),
// 可选中块在 iOS 换用 react-native-uitextview(真 UITextView,系统选择手柄);
// Android 维持 AppText selectable(本身有手柄)。嵌套 span 必须同为 UITextView 家族。
expect(source).toContain("from 'react-native-uitextview'");
expect(source).toContain('function MarkdownSelectableText({');
expect(source).toContain("if (selectable && allowIosUITextView && Platform.OS === 'ios') {");
expect(source).toContain('uiTextView');
expect(source).toContain('function MarkdownSelectableSpan(');
const selectableTextSource = readFileSync(resolve(process.cwd(), 'src/session/MarkdownSelectableText.tsx'), 'utf8');
expect(selectableTextSource).toContain("from 'react-native-uitextview'");
expect(selectableTextSource).toContain('function MarkdownSelectableText({');
expect(selectableTextSource).toContain("if (selectable && allowIosUITextView && Platform.OS === 'ios') {");
expect(selectableTextSource).toContain('uiTextView');
expect(selectableTextSource).toContain('function MarkdownSelectableSpan(');
expect(source).toContain('const SpanText = ctx.SpanText ?? MessageBodyText;');
expect(markdownBodySource).toContain('blockSelectable && allowIosUITextView && Platform.OS === \'ios\'');

Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/__tests__/messageSelectionHighlight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import { describe, expect, it } from 'vitest';
* 是另一语义,不受本守卫约束。
*/
describe('mobile message text selection highlight', () => {
it('never overrides the platform selection highlight in the message renderer', async () => {
const source = await readFile(resolve(process.cwd(), 'src/session/MessageRenderer.tsx'), 'utf8');
it.each(['MessageRenderer.tsx', 'MarkdownSelectableText.tsx'])('never overrides the platform selection highlight in %s', async (file) => {
const source = await readFile(resolve(process.cwd(), 'src/session', file), 'utf8');

expect(source).not.toMatch(/selectionColor=/);
});
Expand Down
17 changes: 14 additions & 3 deletions apps/mobile/src/__tests__/userMessageCollapse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,24 @@ import * as React from 'react';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import ts from 'typescript';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { i18n } from '@/i18n';
import { buildMessageContentLayout, nextSettledContentWidth } from '@/session/messageContentLayout';
import { selectableTextVerticalOffset } from '@/session/selectableTextAlignment';
import { MarkdownSelectableText } from '@/session/MarkdownSelectableText';
import { rendererFunction, rendererSource, executeRenderer } from './fixtures/messageRendererComponents';
import { PR_WATCH_EXPANDED_BLANK_FIXTURE } from '@/__tests__/fixtures/prWatchExpandedBlank';

vi.mock('react-native', () => ({
Platform: { OS: 'ios' },
useWindowDimensions: () => ({ fontScale: 1 }),
StyleSheet: { flatten: (style: unknown) => Object.assign({}, ...[style].flat(Infinity).filter(Boolean)) },
}));
vi.mock('react-native-uitextview', () => ({ UITextView: (props: SurfaceProps) => UITextView(props) }));
vi.mock('@/components/AppText', () => ({ Text: (props: SurfaceProps) => Text(props), MAX_FONT_SIZE_MULTIPLIER: 1.5 }));
vi.mock('@/session/SelectionMenuText', () => ({ SelectionMenuText: (props: SurfaceProps) => Text(props) }));
vi.mock('@/session/ShareMessageCheckbox', () => ({ useCancelShareSelectionRowTap: () => null }));

import {
AUTOMATION_USER_MESSAGE_COLLAPSED_LINES,
AUTOMATION_USER_MESSAGE_VISUAL_LINE_THRESHOLD,
Expand Down Expand Up @@ -184,7 +195,7 @@ const Text = nativeText('rn-text');
const UITextView = nativeText('ios-text-view');
const ScrollView = (props: SurfaceProps) => React.createElement('div', { 'data-scroll': true }, props.children);
const { BubbleHarness, MarkdownHarness } = executeRenderer<{ BubbleHarness: React.ComponentType<any>; MarkdownHarness: React.ComponentType<any> }>(
rendererFunction('MarkdownSelectableText').getText(rendererSource) + String.raw`
String.raw`
function MarkdownHarness({ pinContentWidth = false, allowIosUITextView = true, boundTextRuns = false, selectable, text, layout, code = false, codeCopyEnabled = true }) {
const styles = {};
const onOpenPayload = undefined;
Expand Down Expand Up @@ -232,7 +243,7 @@ return { BubbleHarness, MarkdownHarness };
SelectionQuoteContext: React.createContext(null), useWindowDimensions: () => ({ fontScale: 1 }),
Platform: { OS: 'ios' }, StyleSheet: { flatten: (style: unknown) => style },
selectableTextVerticalOffset, MAX_FONT_SIZE_MULTIPLIER: 2, i18n,
View: NativeView, Text, SelectionMenuText: Text, UITextView, ScrollView,
View: NativeView, Text, MarkdownSelectableText, ScrollView,
buildMessageContentLayout, nextSettledContentWidth,
MarkdownBlockContent: ({ block, renderBlock }: { block: unknown; renderBlock: (block: unknown) => React.ReactNode }) => renderBlock(block),
HighlightedCodeText: ({ text }: { text: string }) => React.createElement('code', null, text),
Expand Down
45 changes: 24 additions & 21 deletions apps/mobile/src/session/BotGroupMessageText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
import { botGroupBodyParts } from '@cindy/maker-shared/botGroupPresentation';
import type { BotGroupAuthorKind } from '@cindy/maker-shared/botGroupChat';
import { useCallback, useMemo, useState, type ReactNode } from 'react';
import { ScrollView, StyleSheet, View, useWindowDimensions, type LayoutChangeEvent } from 'react-native';
import { Platform, ScrollView, StyleSheet, View, useWindowDimensions, type LayoutChangeEvent } from 'react-native';
import { splitBotGroupMentionSegments } from '@cindy/maker-shared/botGroupMentions';
import { Text } from '@/components/AppText';
import { MarkdownSelectableText, MarkdownSelectableSpan } from './MarkdownSelectableText';
import { fontWeight, monoFont, useThemedStyles, type ThemeColors } from '@/theme';
import { lineHeight, radius, spacing, typeScale } from '@/theme/tokens';
import { parseMobileMarkdown, type MobileMarkdownBlock, type MobileMarkdownInline } from './messageMarkdown';
Expand All @@ -18,25 +19,26 @@ import { buildMobileMarkdownTableColumnWidths } from './messageTableLayout';
type Styles = ReturnType<typeof makeStyles>;

function renderInlines(inlines: readonly MobileMarkdownInline[], styles: Styles): ReactNode[] {
const Span = Platform.OS === 'ios' ? MarkdownSelectableSpan : Text;
return inlines.map((inline, index) => {
// 强调里的代码等:外层套一层带强调样式的 Text,嵌套 Text 继承样式。强调里的链接按强调
// 纯文本显示:群聊不打开链接,不加下划线,不做出「看着能点却点不动」的样子(DESIGN.md §14.5)。
if (inline.marks?.length) {
const markStyles = { strong: styles.strong, emphasis: styles.emphasis, strikethrough: styles.strike };
return (
<Text key={index} style={inline.marks.map((mark) => markStyles[mark])}>
<Span key={index} style={inline.marks.map((mark) => markStyles[mark])}>
{inline.type === 'link' ? inline.text : renderInlines([{ ...inline, marks: undefined }], styles)}
</Text>
</Span>
);
}
switch (inline.type) {
case 'strong': return <Text key={index} style={styles.strong}>{inline.text}</Text>;
case 'emphasis': return <Text key={index} style={styles.emphasis}>{inline.text}</Text>;
case 'strikethrough': return <Text key={index} style={styles.strike}>{inline.text}</Text>;
case 'code': return <Text key={index} style={styles.inlineCode}>{inline.text}</Text>;
case 'link': return <Text key={index}>{inline.text}</Text>;
case 'image': return <Text key={index}>{inline.alt ? `[${inline.alt}]` : ''}</Text>;
default: return <Text key={index}>{inline.text}</Text>;
case 'strong': return <Span key={index} style={styles.strong}>{inline.text}</Span>;
case 'emphasis': return <Span key={index} style={styles.emphasis}>{inline.text}</Span>;
case 'strikethrough': return <Span key={index} style={styles.strike}>{inline.text}</Span>;
case 'code': return <Span key={index} style={styles.inlineCode}>{inline.text}</Span>;
case 'link': return <Span key={index}>{inline.text}</Span>;
case 'image': return <Span key={index}>{inline.alt ? `[${inline.alt}]` : ''}</Span>;
default: return <Span key={index}>{inline.text}</Span>;
}
});
}
Expand Down Expand Up @@ -64,9 +66,9 @@ function BotGroupMarkdownTable({ block, styles }: {
<View key={key} style={[styles.tableRow, header && styles.tableHeaderRow]}>
{widths.map((width, index) => (
<View key={index} style={[styles.tableCell, { width }]}>
<Text selectable style={[styles.tableText, header && styles.tableHeaderText]}>
<MarkdownSelectableText selectable style={[styles.tableText, header && styles.tableHeaderText]}>
{renderInlines(cells[index] ?? [], styles)}
</Text>
</MarkdownSelectableText>
</View>
))}
</View>
Expand All @@ -82,26 +84,26 @@ function BotGroupMarkdownTable({ block, styles }: {
function renderBlock(block: MobileMarkdownBlock, styles: Styles): ReactNode {
switch (block.type) {
case 'heading':
return <Text key={block.key} selectable style={[styles.text, styles.heading]}>{renderInlines(block.inlines, styles)}</Text>;
return <MarkdownSelectableText key={block.key} selectable style={[styles.text, styles.heading]}>{renderInlines(block.inlines, styles)}</MarkdownSelectableText>;
case 'blockquote':
return <View key={block.key} style={styles.quote}>
<Text selectable style={[styles.text, styles.quoteText]}>{renderInlines(block.inlines, styles)}</Text>
<MarkdownSelectableText selectable style={[styles.text, styles.quoteText]}>{renderInlines(block.inlines, styles)}</MarkdownSelectableText>
</View>;
case 'list_item':
return <View key={block.key} style={styles.listItem}>
<Text style={styles.text}>{block.checked === undefined ? (block.ordered ? block.marker : '•') : block.checked ? '☑' : '☐'}</Text>
<Text selectable style={[styles.text, styles.listText]}>{renderInlines(block.inlines, styles)}</Text>
<MarkdownSelectableText selectable style={[styles.text, styles.listText]}>{renderInlines(block.inlines, styles)}</MarkdownSelectableText>
</View>;
case 'code':
case 'mermaid':
case 'math':
return <View key={block.key} style={styles.codeBlock}>
<Text selectable style={styles.codeText}>{block.text}</Text>
<MarkdownSelectableText selectable style={styles.codeText}>{block.text}</MarkdownSelectableText>
</View>;
case 'table':
return <BotGroupMarkdownTable key={block.key} block={block} styles={styles} />;
default:
return <Text key={block.key} selectable style={styles.text}>{renderInlines(block.inlines, styles)}</Text>;
return <MarkdownSelectableText key={block.key} selectable style={styles.text}>{renderInlines(block.inlines, styles)}</MarkdownSelectableText>;
}
}

Expand All @@ -115,13 +117,14 @@ export function BotGroupMarkdownText({ content, authorKind }: { content: string;

/** The user's own words: plain text with `@name` shown as a mention mark. */
export function BotGroupUserText({ content, mentionLabels }: { content: string; mentionLabels: readonly string[] }) {
const Span = Platform.OS === 'ios' ? MarkdownSelectableSpan : Text;
const styles = useThemedStyles(makeStyles);
const segments = useMemo(() => splitBotGroupMentionSegments(content, mentionLabels), [content, mentionLabels]);
return <Text selectable style={styles.text}>
return <MarkdownSelectableText selectable style={styles.text}>
{segments.map((segment, index) => segment.mention
? <Text key={index} style={styles.mention}>{segment.text}</Text>
: <Text key={index}>{segment.text}</Text>)}
</Text>;
? <Span key={index} style={styles.mention}>{segment.text}</Span>
: <Span key={index}>{segment.text}</Span>)}
</MarkdownSelectableText>;
}

const makeStyles = (colors: ThemeColors) => StyleSheet.create({
Expand Down
Loading
Loading