Skip to content
Open
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
166 changes: 113 additions & 53 deletions frontend/src/features/assistant/presentation/VoiceCallScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -104,64 +116,86 @@ export function VoiceCallScreen({
<CollapseChevron color={colors.onPrimary} />
</Pressable>

<ScrollView
ref={transcriptRef}
contentContainerStyle={[
styles.transcriptContent,
!fitsViewport && styles.transcriptContentOverflow,
]}
onContentSizeChange={onContentSizeChange}
onLayout={onLayout}
onScroll={onScroll}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
style={styles.transcript}
testID="voice-call-transcript"
>
{messages.map((message, index) => (
<View
accessibilityLabel={`${message.role === 'user' ? '你' : '助手'}:${message.text}`}
key={message.id}
style={[
styles.turn,
message.role === 'user' ? styles.turnUser : styles.turnAssistant,
index < messages.length - 1 && styles.turnPast,
]}
>
<View style={styles.transcriptWrap}>
<ScrollView
ref={transcriptRef}
contentContainerStyle={[
styles.transcriptContent,
!fitsViewport && styles.transcriptContentOverflow,
]}
onContentSizeChange={onContentSizeChange}
onLayout={onLayout}
onMomentumScrollBegin={onMomentumScrollBegin}
onMomentumScrollEnd={onMomentumScrollEnd}
onScroll={onScroll}
onScrollBeginDrag={onScrollBeginDrag}
onScrollEndDrag={onScrollEndDrag}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
style={styles.transcript}
testID="voice-call-transcript"
>
{messages.map((message, index) => (
<View
accessibilityLabel={`${message.role === 'user' ? '你' : '助手'}:${message.text}`}
key={message.id}
style={[
styles.bubble,
message.role === 'user' ? styles.bubbleUser : styles.bubbleAssistant,
styles.turn,
message.role === 'user' ? styles.turnUser : styles.turnAssistant,
index < messages.length - 1 && styles.turnPast,
]}
>
<Text
<View
style={[
styles.bubbleText,
message.role === 'user' ? styles.bubbleTextUser : styles.bubbleTextAssistant,
styles.bubble,
message.role === 'user' ? styles.bubbleUser : styles.bubbleAssistant,
]}
>
{message.text}
</Text>
{message.pending ? (
<Voiceprint
active
barCount={BUBBLE_VOICEPRINT_BARS}
barStyle={
message.role === 'user'
? [styles.speakingWaveBar, styles.speakingWaveBarUser]
: [styles.speakingWaveBar, styles.speakingWaveBarAssistant]
}
containerStyle={styles.speakingWave}
maxHeight={12}
minHeight={2}
soundLevel={soundLevel}
testID={`voice-call-speaking-wave-${message.role}`}
/>
) : null}
<Text
style={[
styles.bubbleText,
message.role === 'user' ? styles.bubbleTextUser : styles.bubbleTextAssistant,
]}
>
{message.text}
</Text>
{message.pending ? (
<Voiceprint
active
barCount={BUBBLE_VOICEPRINT_BARS}
barStyle={
message.role === 'user'
? [styles.speakingWaveBar, styles.speakingWaveBarUser]
: [styles.speakingWaveBar, styles.speakingWaveBarAssistant]
}
containerStyle={styles.speakingWave}
maxHeight={12}
minHeight={2}
soundLevel={soundLevel}
testID={`voice-call-speaking-wave-${message.role}`}
/>
) : null}
</View>
</View>
</View>
))}
</ScrollView>
))}
</ScrollView>
{hasUnseenLatest ? (
<Pressable
accessibilityLabel="查看最新"
accessibilityRole="button"
onPress={jumpToLatest}
style={({ pressed }) => [styles.latestChip, pressed && styles.buttonPressed]}
testID="voice-call-latest"
>
<Text style={styles.latestChipEyebrow}>查看最新</Text>
{latestText ? (
<Text numberOfLines={1} style={styles.latestChipText}>
{latestText}
</Text>
) : null}
</Pressable>
) : null}
</View>

<View
style={[styles.dock, { paddingBottom: Math.max(spacing.lg, insets.bottom + spacing.sm) }]}
Expand Down Expand Up @@ -489,10 +523,36 @@ const styles = StyleSheet.create({
letterSpacing: 0.4,
textAlign: 'center',
},
transcript: {
transcriptWrap: {
flex: 1,
marginTop: 56,
},
transcript: {
flex: 1,
},
latestChip: {
alignSelf: 'center',
backgroundColor: 'rgba(234,247,200,0.94)',
borderRadius: 999,
bottom: spacing.md,
maxWidth: '86%',
paddingHorizontal: 16,
paddingVertical: 10,
position: 'absolute',
zIndex: 2,
},
latestChipEyebrow: {
color: colors.text,
fontSize: 11,
fontWeight: '700',
letterSpacing: 0.4,
},
latestChipText: {
color: colors.text,
fontSize: 13,
lineHeight: 18,
marginTop: 2,
},
transcriptContent: {
flexGrow: 1,
gap: spacing.md,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import type {
LayoutChangeEvent,
NativeScrollEvent,
Expand All @@ -7,6 +7,7 @@ import type {
} from 'react-native';

export const PINNED_TO_BOTTOM_THRESHOLD = 80;
export const TRANSCRIPT_IDLE_MS = 180;

export function contentFitsViewport(contentHeight: number, viewportHeight: number): boolean {
if (viewportHeight <= 0) {
Expand All @@ -32,45 +33,143 @@ export function isPinnedToBottom({

export function usePinnedTranscriptScroll() {
const transcriptRef = useRef<ScrollView>(null);
const pinnedRef = useRef(true);
const interactingRef = useRef(false);
const followingRef = useRef(true);
const ignoreProgrammaticScrollRef = useRef(false);
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const ignoreTimerRef = useRef<ReturnType<typeof setTimeout> | 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<NativeScrollEvent>) => {
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,
Comment thread
gac0812 marked this conversation as resolved.
});
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,
};
}
Loading
Loading