* fix(chat): load older timeline history consistently Pagination depended on scroll events, so short or compacted initial pages could never request older history. Reevaluate edge visibility as layout and history change, and standardize loading indicators on the canonical app spinner. * fix(chat): keep older history loading reliably Use authoritative timeline cursor progress so merged rows cannot stall pagination. Wait for bottom anchoring before evaluating the history edge, and re-arm retries on a fresh upward gesture without automatic failure loops.
143 lines
4.1 KiB
TypeScript
143 lines
4.1 KiB
TypeScript
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|
import { useMemo } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Pressable, View } from "react-native";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import { Mic, MicOff, Square } from "lucide-react-native";
|
|
import { FOOTER_HEIGHT } from "@/constants/layout";
|
|
import { useVoiceTelemetry } from "@/contexts/voice-context";
|
|
import { VolumeMeter } from "./volume-meter";
|
|
|
|
interface RealtimeVoiceOverlayProps {
|
|
isMuted: boolean;
|
|
isSwitching: boolean;
|
|
onToggleMute: () => void;
|
|
onStop: () => void;
|
|
}
|
|
|
|
const OVERLAY_BUTTON_SIZE = 44;
|
|
const OVERLAY_VERTICAL_PADDING = (FOOTER_HEIGHT - OVERLAY_BUTTON_SIZE) / 2;
|
|
|
|
export function RealtimeVoiceOverlay({
|
|
isMuted,
|
|
isSwitching,
|
|
onToggleMute,
|
|
onStop,
|
|
}: RealtimeVoiceOverlayProps) {
|
|
const { theme } = useUnistyles();
|
|
const { t } = useTranslation();
|
|
const { volume, isSpeaking } = useVoiceTelemetry();
|
|
const muteButtonStyle = useMemo(
|
|
() => [
|
|
styles.actionButton,
|
|
styles.muteButton,
|
|
isMuted ? styles.muteButtonMuted : undefined,
|
|
isSwitching ? styles.buttonDisabled : undefined,
|
|
],
|
|
[isMuted, isSwitching],
|
|
);
|
|
const stopButtonStyle = useMemo(
|
|
() => [styles.actionButton, styles.stopButton, isSwitching ? styles.buttonDisabled : undefined],
|
|
[isSwitching],
|
|
);
|
|
return (
|
|
<View style={styles.container}>
|
|
<View style={styles.meterContainer}>
|
|
<VolumeMeter
|
|
volume={volume}
|
|
isMuted={isMuted}
|
|
isSpeaking={isSpeaking}
|
|
orientation="horizontal"
|
|
/>
|
|
</View>
|
|
|
|
<View style={styles.actionsContainer}>
|
|
<Pressable
|
|
onPress={onToggleMute}
|
|
disabled={isSwitching}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={
|
|
isMuted ? t("realtimeVoice.actions.unmute") : t("realtimeVoice.actions.mute")
|
|
}
|
|
style={muteButtonStyle}
|
|
>
|
|
{isMuted ? (
|
|
<MicOff size={theme.iconSize.lg} color={theme.colors.palette.white} strokeWidth={2.5} />
|
|
) : (
|
|
<Mic size={theme.iconSize.lg} color={theme.colors.foreground} strokeWidth={2.5} />
|
|
)}
|
|
</Pressable>
|
|
|
|
<Pressable
|
|
onPress={onStop}
|
|
disabled={isSwitching}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t("realtimeVoice.actions.stop")}
|
|
style={stopButtonStyle}
|
|
>
|
|
{isSwitching ? (
|
|
<LoadingSpinner size="small" color={theme.colors.palette.white} />
|
|
) : (
|
|
<Square
|
|
size={theme.iconSize.lg}
|
|
color={theme.colors.palette.white}
|
|
fill={theme.colors.palette.white}
|
|
strokeWidth={2.5}
|
|
/>
|
|
)}
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
container: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
width: "100%",
|
|
height: FOOTER_HEIGHT,
|
|
borderRadius: theme.borderRadius["2xl"],
|
|
justifyContent: "space-between",
|
|
paddingHorizontal: theme.spacing[4],
|
|
paddingVertical: OVERLAY_VERTICAL_PADDING,
|
|
backgroundColor: theme.colors.surface1,
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.border,
|
|
},
|
|
meterContainer: {
|
|
flex: 1,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
actionsContainer: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
actionButton: {
|
|
width: OVERLAY_BUTTON_SIZE,
|
|
height: OVERLAY_BUTTON_SIZE,
|
|
borderRadius: theme.borderRadius.full,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
muteButton: {
|
|
backgroundColor: theme.colors.surface0,
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.border,
|
|
},
|
|
muteButtonMuted: {
|
|
backgroundColor: theme.colors.palette.red[600],
|
|
borderColor: theme.colors.palette.red[800],
|
|
},
|
|
stopButton: {
|
|
backgroundColor: theme.colors.palette.red[600],
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.palette.red[800],
|
|
},
|
|
buttonDisabled: {
|
|
opacity: 0.5,
|
|
},
|
|
}));
|