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
39 changes: 39 additions & 0 deletions apps/mobile/__tests__/components/chat/action-chips.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,21 @@ describe('ActionChips (mobile)', () => {
expect(pressables.length).toBe(0)
})

it('does not render tag mutation chips as Pressable even with handler', () => {
for (const type of ['CreateTag', 'UpdateTag', 'DeleteTag']) {
let tree: any
TestRenderer.act(() => {
tree = TestRenderer.create(
<ActionChips
actions={[makeAction({ type, status: 'Success', entityId: 'tag-1' })]}
onChipClick={() => {}}
/>,
)
})
expect(findPressableByType(tree.root).length).toBe(0)
}
})

it('does not render DeleteGoal chip as Pressable even with handler', () => {
let tree: any
TestRenderer.act(() => {
Expand Down Expand Up @@ -189,6 +204,30 @@ describe('ActionChips (mobile)', () => {
expect(findPressableByType(tree.root).length).toBe(0)
})

it('renders localized labels for the new tag and reorder action types', () => {
const cases: Array<[string, string]> = [
['CreateTag', 'chat.action.createdTag'],
['UpdateTag', 'chat.action.updatedTag'],
['DeleteTag', 'chat.action.deletedTag'],
['ReorderGoals', 'chat.action.reorderedGoals'],
['ReorderHabits', 'chat.action.reorderedHabits'],
]
for (const [type, labelKey] of cases) {
let tree: any
TestRenderer.act(() => {
tree = TestRenderer.create(
<ActionChips actions={[makeAction({ type, entityName: 'Work' })]} />,
)
})
const matches = tree.root.findAll(
(node: any) =>
typeof node.props?.children === 'string' &&
node.props.children.startsWith(labelKey),
)
expect(matches.length).toBeGreaterThan(0)
}
})

it('does not render chip with null entityId as Pressable', () => {
let tree: any
TestRenderer.act(() => {
Expand Down
70 changes: 67 additions & 3 deletions apps/mobile/__tests__/components/message-bubble.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,21 @@ vi.mock('lucide-react-native', () => {
return {
Sparkles: (props: Record<string, unknown>) => React.createElement('Sparkles', props),
User: (props: Record<string, unknown>) => React.createElement('User', props),
ArrowUpRight: (props: Record<string, unknown>) =>
React.createElement('ArrowUpRight', props),
}
})

const push = vi.fn()
vi.mock('expo-router', () => ({
useRouter: () => ({ push }),
}))

vi.mock('@/components/ui/markdown', () => {
const React = require('react')
return {
Markdown: ({ children }: { children: string }) =>
React.createElement('Markdown', null, children),
}
})

Expand All @@ -76,9 +91,6 @@ vi.mock('@/components/chat/clarification-card', () => ({
vi.mock('@/components/chat/pending-operation-card', () => ({
PendingOperationCard: () => null,
}))
vi.mock('@/components/chat/format-chat-message', () => ({
formatChatMessage: (text: string) => text,
}))

function makeMessage(overrides: Partial<ChatMessage> = {}): ChatMessage {
return {
Expand Down Expand Up @@ -155,3 +167,55 @@ describe('MessageBubble trace footer (mobile)', () => {
expect(setStringAsync).toHaveBeenCalledWith('req-abc-123')
})
})

function findSurfaceLinks(root: TestTreeRoot, label: string): TestNode[] {
return root.findAll(
(node) =>
node.props != null &&
typeof node.type !== 'string' &&
typeof node.props.onPress === 'function' &&
node.props.accessibilityLabel === label,
)
}

describe('MessageBubble related-surfaces footer (mobile)', () => {
beforeEach(() => {
push.mockClear()
})

it('renders deep links for known surfaces and drops unknown ones', async () => {
let tree!: TestInstance
await TestRenderer.act(async () => {
tree = TestRenderer.create(
<MessageBubble
message={makeMessage({
role: 'ai',
relatedSurfaces: ['gamification', 'mystery'],
})}
/>,
)
})

const links = findSurfaceLinks(tree.root, 'chat.related.surface.gamification')
expect(links).toHaveLength(1)
expect(findSurfaceLinks(tree.root, 'chat.related.surface.mystery')).toHaveLength(0)

await TestRenderer.act(async () => {
links[0]?.props.onPress?.()
})
expect(push).toHaveBeenCalledWith('/achievements')
})

it('does not render the footer for user messages', async () => {
let tree!: TestInstance
await TestRenderer.act(async () => {
tree = TestRenderer.create(
<MessageBubble
message={makeMessage({ role: 'user', relatedSurfaces: ['gamification'] })}
/>,
)
})

expect(findSurfaceLinks(tree.root, 'chat.related.surface.gamification')).toHaveLength(0)
})
})
94 changes: 94 additions & 0 deletions apps/mobile/__tests__/components/ui/markdown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { isValidElement, type ReactElement } from 'react'

const TestRenderer = require('react-test-renderer')

const openURL = vi.fn((_url: string) => Promise.resolve())
vi.mock('react-native', () => ({
Linking: { openURL: (url: string) => openURL(url) },
Text: 'Text',
}))

// Capture the props the Markdown wrapper passes into react-native-marked so we
// can assert theming + exercise the safe-link renderer it provides.
const markedProps: { current: Record<string, unknown> | null } = { current: null }
vi.mock('react-native-marked', () => {
class Renderer {
getKey() {
return 'k'
}
}
return {
__esModule: true,
default: (props: Record<string, unknown>) => {
markedProps.current = props
return null
},
Renderer,
}
})

import { Markdown } from '@/components/ui/markdown'

interface LinkElement {
props: { onPress?: () => void }
}

interface CapturedRenderer {
link(children: unknown, href: string): ReactElement & LinkElement
}

function renderMarkdown(props: Parameters<typeof Markdown>[0]): Record<string, unknown> {
markedProps.current = null
TestRenderer.act(() => {
TestRenderer.create(<Markdown {...props} />)
})
if (!markedProps.current) throw new Error('Markdown did not render react-native-marked')
return markedProps.current
}

describe('mobile Markdown wrapper', () => {
beforeEach(() => {
openURL.mockClear()
})

it('passes the content through as the markdown value', () => {
const props = renderMarkdown({ children: '# Hello' })
expect(props.value).toBe('# Hello')
})

it('opens http(s) and mailto links', () => {
const props = renderMarkdown({ children: 'x' })
const renderer = props.renderer as CapturedRenderer

for (const href of ['https://orbit.app', 'http://x', 'mailto:a@b.com']) {
const element = renderer.link(['label'], href)
expect(isValidElement(element)).toBe(true)
expect(typeof element.props.onPress).toBe('function')
element.props.onPress?.()
}

expect(openURL).toHaveBeenCalledTimes(3)
expect(openURL).toHaveBeenCalledWith('https://orbit.app')
})

it('refuses to open javascript: and data: link schemes', () => {
const props = renderMarkdown({ children: 'x' })
const renderer = props.renderer as CapturedRenderer

for (const href of ['javascript:alert(1)', 'data:text/html,<script>']) {
const element = renderer.link(['label'], href)
// unsafe links render as plain text with no press handler
expect(element.props.onPress).toBeUndefined()
}
expect(openURL).not.toHaveBeenCalled()
})

it('themes text with a different color for muted descriptions', () => {
const defaultProps = renderMarkdown({ children: 'x' })
const mutedProps = renderMarkdown({ children: 'x', tone: 'muted' })
const defaultStyles = defaultProps.styles as { text: { color: string } }
const mutedStyles = mutedProps.styles as { text: { color: string } }
expect(defaultStyles.text.color).not.toBe(mutedStyles.text.color)
})
})
16 changes: 16 additions & 0 deletions apps/mobile/components/chat/action-chips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ const ACTION_LABELS: Record<string, string> = {
UpdateGoalProgress: "chat.action.updatedGoalProgress",
UpdateGoalStatus: "chat.action.updatedGoalStatus",
LinkHabitsToGoal: "chat.action.linkedGoalHabits",
create_tag: "chat.action.createdTag",
update_tag: "chat.action.updatedTag",
delete_tag: "chat.action.deletedTag",
reorder_goals: "chat.action.reorderedGoals",
reorder_habits: "chat.action.reorderedHabits",
CreateTag: "chat.action.createdTag",
UpdateTag: "chat.action.updatedTag",
DeleteTag: "chat.action.deletedTag",
ReorderGoals: "chat.action.reorderedGoals",
ReorderHabits: "chat.action.reorderedHabits",
};

const NON_NAVIGABLE_ACTION_TYPES = new Set([
Expand All @@ -47,6 +57,12 @@ const NON_NAVIGABLE_ACTION_TYPES = new Set([
"DeleteSubHabit",
"suggest_breakdown",
"SuggestBreakdown",
"create_tag",
"CreateTag",
"update_tag",
"UpdateTag",
"delete_tag",
"DeleteTag",
]);

type ChipStyleEntry = {
Expand Down
22 changes: 0 additions & 22 deletions apps/mobile/components/chat/format-chat-message.ts

This file was deleted.

43 changes: 13 additions & 30 deletions apps/mobile/components/habits/description-viewer.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { useCallback, useMemo } from "react";
import { ScrollView, Text, StyleSheet } from "react-native";
import { useCallback } from "react";
import { ScrollView, StyleSheet } from "react-native";
import { BottomSheetModal } from "@/components/bottom-sheet-modal";
import { withDrawerContentInset } from "@/components/ui/drawer-content-inset";
import { createTokensV2 } from "@/lib/theme";
import { useAppTheme } from "@/lib/use-app-theme";

type AppTokens = ReturnType<typeof createTokensV2>;
import { Markdown } from "@/components/ui/markdown";

interface DescriptionViewerProps {
open: boolean;
Expand All @@ -20,13 +17,6 @@ export function DescriptionViewer({
title,
description,
}: Readonly<DescriptionViewerProps>) {
const { currentScheme, currentTheme } = useAppTheme();
const tokens = useMemo(
() => createTokensV2(currentScheme, currentTheme),
[currentScheme, currentTheme],
);
const styles = useMemo(() => createStyles(tokens), [tokens]);

const handleClose = useCallback(() => {
onClose();
}, [onClose]);
Expand All @@ -43,25 +33,18 @@ export function DescriptionViewer({
contentContainerStyle={withDrawerContentInset(styles.scrollContent)}
showsVerticalScrollIndicator={false}
>
<Text style={styles.descriptionText}>{description}</Text>
<Markdown tone="muted">{description}</Markdown>
</ScrollView>
</BottomSheetModal>
);
}

function createStyles(tokens: AppTokens) {
return StyleSheet.create({
scrollContainer: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 20,
paddingBottom: 32,
},
descriptionText: {
fontSize: 14,
lineHeight: 22,
color: tokens.fg2,
},
});
}
const styles = StyleSheet.create({
scrollContainer: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 20,
paddingBottom: 32,
},
});
Loading
Loading