diff --git a/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx b/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx index c697a2c4..158a0f76 100644 --- a/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx +++ b/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx @@ -56,8 +56,20 @@ export function VoiceCallScreen({ onTogglePause, }: VoiceCallScreenProps) { const insets = useSafeAreaInsets(); - const { fitsViewport, onContentSizeChange, onLayout, onScroll, transcriptRef } = - usePinnedTranscriptScroll(); + const { + fitsViewport, + hasUnseenLatest, + jumpToLatest, + onContentSizeChange, + onLayout, + onMomentumScrollBegin, + onMomentumScrollEnd, + onScroll, + onScrollBeginDrag, + onScrollEndDrag, + transcriptRef, + } = usePinnedTranscriptScroll(); + const latestText = messages[messages.length - 1]?.text; const [scale] = useState(() => new Animated.Value(1)); useEffect(() => { @@ -104,64 +116,86 @@ export function VoiceCallScreen({ - - {messages.map((message, index) => ( - + + + {messages.map((message, index) => ( - - {message.text} - - {message.pending ? ( - - ) : null} + + {message.text} + + {message.pending ? ( + + ) : null} + - - ))} - + ))} + + {hasUnseenLatest ? ( + [styles.latestChip, pressed && styles.buttonPressed]} + testID="voice-call-latest" + > + 查看最新 + {latestText ? ( + + {latestText} + + ) : null} + + ) : null} + (null); - const pinnedRef = useRef(true); + const interactingRef = useRef(false); + const followingRef = useRef(true); + const ignoreProgrammaticScrollRef = useRef(false); + const idleTimerRef = useRef | null>(null); + const ignoreTimerRef = useRef | null>(null); const viewportHeightRef = useRef(0); const contentHeightRef = useRef(0); const [fitsViewport, setFitsViewport] = useState(true); + const [hasUnseenLatest, setHasUnseenLatest] = useState(false); const syncFits = () => { const fits = contentFitsViewport(contentHeightRef.current, viewportHeightRef.current); setFitsViewport((current) => (current === fits ? current : fits)); }; + const clearIdleTimer = () => { + if (idleTimerRef.current == null) { + return; + } + clearTimeout(idleTimerRef.current); + idleTimerRef.current = null; + }; + + const clearIgnoreTimer = () => { + if (ignoreTimerRef.current === null) { + return; + } + clearTimeout(ignoreTimerRef.current); + ignoreTimerRef.current = null; + }; + + const setFollowing = (next: boolean) => { + followingRef.current = next; + if (next) { + setHasUnseenLatest(false); + } + }; + + const followLatest = () => { + if (interactingRef.current) { + return; + } + setFollowing(true); + ignoreProgrammaticScrollRef.current = true; + transcriptRef.current?.scrollToEnd({ animated: true }); + clearIgnoreTimer(); + ignoreTimerRef.current = setTimeout(() => { + ignoreProgrammaticScrollRef.current = false; + ignoreTimerRef.current = null; + }, TRANSCRIPT_IDLE_MS); + }; + + const markInteracting = () => { + interactingRef.current = true; + clearIdleTimer(); + }; + + const markIdle = () => { + interactingRef.current = false; + if (followingRef.current) { + followLatest(); + } + }; + + useEffect(() => { + return () => { + clearIdleTimer(); + clearIgnoreTimer(); + }; + }, []); + const onLayout = (event: LayoutChangeEvent) => { viewportHeightRef.current = event.nativeEvent.layout.height; syncFits(); }; + const onScrollBeginDrag = () => { + markInteracting(); + }; + + const onScrollEndDrag = () => { + clearIdleTimer(); + idleTimerRef.current = setTimeout(markIdle, TRANSCRIPT_IDLE_MS); + }; + + const onMomentumScrollBegin = () => { + markInteracting(); + }; + + const onMomentumScrollEnd = () => { + clearIdleTimer(); + markIdle(); + }; + const onScroll = (event: NativeSyntheticEvent) => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; viewportHeightRef.current = layoutMeasurement.height; contentHeightRef.current = contentSize.height; - pinnedRef.current = isPinnedToBottom({ + const atBottom = isPinnedToBottom({ contentHeight: contentSize.height, offsetY: contentOffset.y, viewportHeight: layoutMeasurement.height, }); + if (ignoreProgrammaticScrollRef.current) { + return; + } + setFollowing(atBottom); }; const onContentSizeChange = (_width: number, height: number) => { contentHeightRef.current = height; syncFits(); - if (pinnedRef.current) { - transcriptRef.current?.scrollToEnd({ animated: true }); + if (interactingRef.current || !followingRef.current) { + if (!followingRef.current) { + setHasUnseenLatest(true); + } + return; } + followLatest(); + }; + + const jumpToLatest = () => { + interactingRef.current = false; + followLatest(); }; return { fitsViewport, + hasUnseenLatest, + jumpToLatest, onContentSizeChange, onLayout, + onMomentumScrollBegin, + onMomentumScrollEnd, onScroll, + onScrollBeginDrag, + onScrollEndDrag, transcriptRef, }; } diff --git a/frontend/tests/unit/features/assistant/presentation/VoiceCallScreen.test.tsx b/frontend/tests/unit/features/assistant/presentation/VoiceCallScreen.test.tsx index 83cd5d0a..ed3c6405 100644 --- a/frontend/tests/unit/features/assistant/presentation/VoiceCallScreen.test.tsx +++ b/frontend/tests/unit/features/assistant/presentation/VoiceCallScreen.test.tsx @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, jest } from '@jest/globals'; -import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import { act, fireEvent, render, screen, within } from '@testing-library/react-native'; import { Platform, StyleSheet } from 'react-native'; import { RadialGradient } from 'react-native-svg'; @@ -252,6 +252,48 @@ describe('VoiceCallScreen', () => { }, }); expect(screen.getByLabelText('你:明天下午三点开会')).toBeTruthy(); + expect(screen.queryByLabelText('查看最新')).toBeNull(); + } finally { + Platform.OS = original; + } + }, + ); + + it.each(['ios', 'android'] as const)( + 'shows a jump-to-latest chip on %s after scrolling away from new turns', + (os) => { + const original = Platform.OS; + Platform.OS = os; + try { + renderScreen({ + messages: [ + { id: 'u1', role: 'user', text: '明天下午三点开会' }, + { id: 'a1', role: 'assistant', text: '好,已经记下了' }, + { id: 'u2', role: 'user', text: '改到四点' }, + { id: 'a2', role: 'assistant', text: '已改到四点' }, + ], + status: 'paused', + title: '已暂停,点击圆圈继续', + }); + const transcript = screen.getByTestId('voice-call-transcript'); + fireEvent(transcript, 'layout', { + nativeEvent: { layout: { height: 400, width: 390, x: 0, y: 0 } }, + }); + fireEvent(transcript, 'scrollBeginDrag'); + fireEvent.scroll(transcript, { + nativeEvent: { + contentOffset: { x: 0, y: 0 }, + contentSize: { height: 2000, width: 390 }, + layoutMeasurement: { height: 400, width: 390 }, + }, + }); + fireEvent(transcript, 'contentSizeChange', 390, 2300); + expect(screen.getByLabelText('查看最新')).toBeTruthy(); + expect( + within(screen.getByTestId('voice-call-latest')).getByText('已改到四点'), + ).toBeTruthy(); + fireEvent.press(screen.getByLabelText('查看最新')); + expect(screen.queryByLabelText('查看最新')).toBeNull(); } finally { Platform.OS = original; } diff --git a/frontend/tests/unit/features/assistant/presentation/usePinnedTranscriptScroll.test.ts b/frontend/tests/unit/features/assistant/presentation/usePinnedTranscriptScroll.test.ts index d9a2686c..696850ff 100644 --- a/frontend/tests/unit/features/assistant/presentation/usePinnedTranscriptScroll.test.ts +++ b/frontend/tests/unit/features/assistant/presentation/usePinnedTranscriptScroll.test.ts @@ -1,12 +1,23 @@ -import { describe, expect, it, jest } from '@jest/globals'; +import { afterEach, describe, expect, it, jest } from '@jest/globals'; import { act, renderHook } from '@testing-library/react-native'; -import type { LayoutChangeEvent, ScrollView } from 'react-native'; +import type { + LayoutChangeEvent, + NativeScrollEvent, + NativeSyntheticEvent, + ScrollView, +} from 'react-native'; +import { Platform } from 'react-native'; import { + PINNED_TO_BOTTOM_THRESHOLD, + TRANSCRIPT_IDLE_MS, contentFitsViewport, + isPinnedToBottom, usePinnedTranscriptScroll, } from '../../../../../src/features/assistant/presentation/usePinnedTranscriptScroll'; +const originalOs = Platform.OS; + function layoutEvent(height: number): LayoutChangeEvent { return { nativeEvent: { @@ -15,6 +26,24 @@ function layoutEvent(height: number): LayoutChangeEvent { } as LayoutChangeEvent; } +function scrollEvent({ + contentHeight, + offsetY, + viewportHeight, +}: { + contentHeight: number; + offsetY: number; + viewportHeight: number; +}): NativeSyntheticEvent { + return { + nativeEvent: { + contentOffset: { x: 0, y: offsetY }, + contentSize: { height: contentHeight, width: 390 }, + layoutMeasurement: { height: viewportHeight, width: 390 }, + }, + } as NativeSyntheticEvent; +} + describe('contentFitsViewport', () => { it('treats an unmeasured viewport as fitting', () => { expect(contentFitsViewport(800, 0)).toBe(true); @@ -28,8 +57,42 @@ describe('contentFitsViewport', () => { }); }); +describe('isPinnedToBottom', () => { + it('pins short content and the true bottom', () => { + expect(isPinnedToBottom({ contentHeight: 200, offsetY: 0, viewportHeight: 400 })).toBe(true); + expect(isPinnedToBottom({ contentHeight: 2000, offsetY: 1600, viewportHeight: 400 })).toBe( + true, + ); + }); + + it('unpins once the user scrolls past the threshold', () => { + const viewportHeight = 400; + const contentHeight = 2000; + const bottomOffset = contentHeight - viewportHeight; + expect( + isPinnedToBottom({ + contentHeight, + offsetY: bottomOffset - PINNED_TO_BOTTOM_THRESHOLD, + viewportHeight, + }), + ).toBe(true); + expect( + isPinnedToBottom({ + contentHeight, + offsetY: bottomOffset - PINNED_TO_BOTTOM_THRESHOLD - 1, + viewportHeight, + }), + ).toBe(false); + }); +}); + describe('usePinnedTranscriptScroll', () => { - it('keeps short content pinned to the dock and does not overflow', () => { + afterEach(() => { + Platform.OS = originalOs; + jest.useRealTimers(); + }); + + it('follows the latest turn when the user is not scrolling', () => { const { result } = renderHook(() => usePinnedTranscriptScroll()); const scrollToEnd = jest.fn(); result.current.transcriptRef.current = { scrollToEnd } as unknown as ScrollView; @@ -40,13 +103,12 @@ describe('usePinnedTranscriptScroll', () => { }); expect(result.current.fitsViewport).toBe(true); + expect(result.current.hasUnseenLatest).toBe(false); expect(scrollToEnd).toHaveBeenCalledWith({ animated: true }); }); it('lets a tall transcript overflow so the user can scroll up', () => { const { result } = renderHook(() => usePinnedTranscriptScroll()); - const scrollToEnd = jest.fn(); - result.current.transcriptRef.current = { scrollToEnd } as unknown as ScrollView; act(() => { result.current.onLayout(layoutEvent(400)); @@ -54,6 +116,136 @@ describe('usePinnedTranscriptScroll', () => { }); expect(result.current.fitsViewport).toBe(false); + }); + + it.each(['ios', 'android'] as const)( + 'does not follow the latest turn on %s while the user is dragging', + (os) => { + Platform.OS = os; + const { result } = renderHook(() => usePinnedTranscriptScroll()); + const scrollToEnd = jest.fn(); + result.current.transcriptRef.current = { scrollToEnd } as unknown as ScrollView; + + act(() => { + result.current.onScrollBeginDrag(); + result.current.onMomentumScrollBegin(); + result.current.onContentSizeChange(390, 2200); + }); + + expect(scrollToEnd).not.toHaveBeenCalled(); + expect(result.current.hasUnseenLatest).toBe(false); + }, + ); + + it.each(['ios', 'android'] as const)( + 'follows the latest turn on %s after the user stops at the bottom', + (os) => { + Platform.OS = os; + jest.useFakeTimers(); + const { result } = renderHook(() => usePinnedTranscriptScroll()); + const scrollToEnd = jest.fn(); + result.current.transcriptRef.current = { scrollToEnd } as unknown as ScrollView; + + act(() => { + result.current.onScrollBeginDrag(); + result.current.onContentSizeChange(390, 2000); + }); + expect(scrollToEnd).not.toHaveBeenCalled(); + + act(() => { + result.current.onMomentumScrollEnd(); + }); + expect(scrollToEnd).toHaveBeenCalledWith({ animated: true }); + }, + ); + + it('keeps earlier turns in view and flags unseen latest after scrolling up', () => { + Platform.OS = 'ios'; + const { result } = renderHook(() => usePinnedTranscriptScroll()); + const scrollToEnd = jest.fn(); + result.current.transcriptRef.current = { scrollToEnd } as unknown as ScrollView; + + act(() => { + result.current.onScrollBeginDrag(); + result.current.onScroll( + scrollEvent({ contentHeight: 2000, offsetY: 0, viewportHeight: 400 }), + ); + result.current.onContentSizeChange(390, 2200); + }); + + expect(scrollToEnd).not.toHaveBeenCalled(); + expect(result.current.hasUnseenLatest).toBe(true); + + act(() => { + result.current.jumpToLatest(); + }); expect(scrollToEnd).toHaveBeenCalledWith({ animated: true }); + expect(result.current.hasUnseenLatest).toBe(false); + }); + + it('clears unseen latest after the user scrolls back to the bottom', () => { + Platform.OS = 'ios'; + const { result } = renderHook(() => usePinnedTranscriptScroll()); + const scrollToEnd = jest.fn(); + result.current.transcriptRef.current = { scrollToEnd } as unknown as ScrollView; + + act(() => { + result.current.onScroll( + scrollEvent({ contentHeight: 2000, offsetY: 0, viewportHeight: 400 }), + ); + result.current.onContentSizeChange(390, 2200); + }); + expect(result.current.hasUnseenLatest).toBe(true); + + act(() => { + result.current.onScroll( + scrollEvent({ contentHeight: 2200, offsetY: 1800, viewportHeight: 400 }), + ); + }); + expect(result.current.hasUnseenLatest).toBe(false); + expect(scrollToEnd).not.toHaveBeenCalled(); + }); + + it('uses the drag-end idle timer when momentum does not follow', () => { + Platform.OS = 'ios'; + jest.useFakeTimers(); + const { result } = renderHook(() => usePinnedTranscriptScroll()); + const scrollToEnd = jest.fn(); + result.current.transcriptRef.current = { scrollToEnd } as unknown as ScrollView; + + act(() => { + result.current.onScrollBeginDrag(); + result.current.onScrollEndDrag(); + result.current.onContentSizeChange(390, 2100); + }); + expect(scrollToEnd).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(TRANSCRIPT_IDLE_MS); + }); + expect(scrollToEnd).toHaveBeenCalledWith({ animated: true }); + }); + + it('ignores programmatic scroll so follow-latest does not unpin itself', () => { + Platform.OS = 'ios'; + jest.useFakeTimers(); + const { result } = renderHook(() => usePinnedTranscriptScroll()); + const scrollToEnd = jest.fn(); + result.current.transcriptRef.current = { scrollToEnd } as unknown as ScrollView; + + act(() => { + result.current.onLayout(layoutEvent(400)); + result.current.onContentSizeChange(390, 2000); + }); + expect(scrollToEnd).toHaveBeenCalledTimes(1); + + act(() => { + result.current.onScroll( + scrollEvent({ contentHeight: 2000, offsetY: 0, viewportHeight: 400 }), + ); + result.current.onContentSizeChange(390, 2100); + }); + expect(scrollToEnd).toHaveBeenCalledTimes(2); + expect(result.current.hasUnseenLatest).toBe(false); }); });