Files
paseo/packages/app/src/components/download-toast.tsx
T
Mohamed BoudraandGitHub b97d6d13f3 Load complete chat history when reaching the top (#2481)
* 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.
2026-07-27 18:21:07 +08:00

156 lines
5.1 KiB
TypeScript

import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check, X, XCircle } from "lucide-react-native";
import { useDownloadStore, formatSpeed, formatEta, type Download } from "@/stores/download-store";
const AUTO_DISMISS_DELAY = 3000;
function getDownloadStatusText(download: Download, t: TFunction): string {
if (download.status === "downloading") {
if (download.progress) {
return `${Math.round(download.progress.percent * 100)}% · ${formatSpeed(download.progress.speed)} · ${formatEta(download.progress.eta)}`;
}
return t("common.states.starting");
}
if (download.status === "complete") return t("common.states.downloadComplete");
return download.message ?? t("common.states.downloadFailed");
}
export function DownloadToast() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const downloads = useDownloadStore((state) => state.downloads);
const activeDownloadId = useDownloadStore((state) => state.activeDownloadId);
const dismissDownload = useDownloadStore((state) => state.dismissDownload);
const dismissTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const activeDownload = activeDownloadId ? downloads.get(activeDownloadId) : null;
useEffect(() => {
if (dismissTimeoutRef.current) {
clearTimeout(dismissTimeoutRef.current);
dismissTimeoutRef.current = null;
}
if (activeDownload && activeDownload.status !== "downloading") {
dismissTimeoutRef.current = setTimeout(() => {
dismissDownload(activeDownload.id);
}, AUTO_DISMISS_DELAY);
}
return () => {
if (dismissTimeoutRef.current) {
clearTimeout(dismissTimeoutRef.current);
}
};
}, [activeDownload, dismissDownload]);
const containerStyle = useMemo(
() => [styles.container, { bottom: theme.spacing[4] + insets.bottom }],
[theme.spacing, insets.bottom],
);
const handleDismiss = useCallback(() => {
if (activeDownload) {
dismissDownload(activeDownload.id);
}
}, [activeDownload, dismissDownload]);
if (!activeDownload) {
return null;
}
return (
<View style={containerStyle} pointerEvents="box-none">
<View style={styles.toast}>
{activeDownload.status === "downloading" ? (
<LoadingSpinner size="small" color={theme.colors.foreground} />
) : null}
{activeDownload.status === "complete" ? (
<Check size={18} color={theme.colors.primary} />
) : null}
{activeDownload.status !== "downloading" && activeDownload.status !== "complete" ? (
<XCircle size={18} color={theme.colors.destructive} />
) : null}
<View style={styles.textContainer}>
<Text style={styles.fileName} numberOfLines={1}>
{activeDownload.fileName}
</Text>
<Text style={styles.status}>{getDownloadStatusText(activeDownload, t)}</Text>
{activeDownload.status === "downloading" && activeDownload.progress && (
<View style={styles.progressBar}>
<ProgressFill percent={activeDownload.progress.percent} />
</View>
)}
</View>
{activeDownload.status !== "downloading" && (
<Pressable onPress={handleDismiss} hitSlop={8} style={styles.dismiss}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
)}
</View>
</View>
);
}
function ProgressFill({ percent }: { percent: number }) {
const width: `${number}%` = `${Math.round(percent * 100)}%`;
const fillStyle = useMemo(() => [styles.progressFill, { width }], [width]);
return <View style={fillStyle} />;
}
const styles = StyleSheet.create((theme) => ({
container: {
position: "absolute",
left: theme.spacing[4],
right: theme.spacing[4],
zIndex: 1000,
},
toast: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
...theme.shadow.md,
},
textContainer: {
flex: 1,
gap: theme.spacing[1],
},
fileName: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
status: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
progressBar: {
height: 3,
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.full,
marginTop: theme.spacing[1],
overflow: "hidden",
},
progressFill: {
height: "100%",
backgroundColor: theme.colors.primary,
borderRadius: theme.borderRadius.full,
},
dismiss: {
padding: theme.spacing[1],
},
}));