diff --git a/mobile-app/sapot-mobile-app/app/(drawer)/(tabs)/peer/[id].tsx b/mobile-app/sapot-mobile-app/app/(drawer)/(tabs)/peer/[id].tsx index 49584e00..9379e039 100644 --- a/mobile-app/sapot-mobile-app/app/(drawer)/(tabs)/peer/[id].tsx +++ b/mobile-app/sapot-mobile-app/app/(drawer)/(tabs)/peer/[id].tsx @@ -1,10 +1,10 @@ -import { usePeerService, useProfilePhoto } from "@/features/shared/hooks"; +import { useDelayedLoading, usePeerService, useProfilePhoto } from "@/features/shared/hooks"; +import { PeerProfileSkeleton } from "@/features/shared/components/peer-profile-skeleton"; import { uiLog } from "@/features/shared/core/utils/logger"; import { useLocalSearchParams } from "expo-router"; import { useEffect, useState } from "react"; import { View } from "react-native"; import { Avatar, Text, useTheme } from "react-native-paper"; -import { LoadingSpinner } from "@/features/shared/components/loading-spinner"; export default function PeerProfile() { const theme = useTheme(); @@ -14,6 +14,7 @@ export default function PeerProfile() { const [peerName, setPeerName] = useState("Unknown user"); const [username, setUsername] = useState(""); const [isLoading, setIsLoading] = useState(true); + const showSkeleton = useDelayedLoading(isLoading, { resetKey: id }); useEffect(() => { uiLog.info("[PeerProfile] mounted"); @@ -27,6 +28,9 @@ export default function PeerProfile() { let isMounted = true; const loadPeer = async () => { + setIsLoading(true); + setPeerName("Unknown user"); + setUsername(""); if (!id) { uiLog.warn("[PeerProfile] missing peer id"); if (isMounted) setIsLoading(false); @@ -62,8 +66,8 @@ export default function PeerProfile() { return ( - {isLoading ? ( - + {showSkeleton ? ( + ) : ( <> diff --git a/mobile-app/sapot-mobile-app/app/(drawer)/(tabs)/peer/__tests__/peer-profile.test.tsx b/mobile-app/sapot-mobile-app/app/(drawer)/(tabs)/peer/__tests__/peer-profile.test.tsx new file mode 100644 index 00000000..be63373d --- /dev/null +++ b/mobile-app/sapot-mobile-app/app/(drawer)/(tabs)/peer/__tests__/peer-profile.test.tsx @@ -0,0 +1,48 @@ +import { act, render } from "@testing-library/react-native"; +import type { ReactNode } from "react"; + +const mockParams = jest.fn(); +const mockUsePeerService = jest.fn(); + +jest.mock("expo-router", () => ({ useLocalSearchParams: () => mockParams() })); +jest.mock("@/features/shared/hooks", () => ({ + useDelayedLoading: (loading: boolean) => loading, + usePeerService: () => mockUsePeerService(), + useProfilePhoto: () => ({ url: null }), + useReducedMotion: () => false, +})); +jest.mock("react-native-paper", () => ({ + ...(() => { + const React = require("react"); + const { Text, View } = require("react-native"); + return { + Avatar: { + Image: (props: object) => React.createElement(View, props), + Text: ({ label, ...props }: { label: string }) => React.createElement(View, props, React.createElement(Text, null, label)), + }, + Text: ({ children, ...props }: { children: ReactNode }) => React.createElement(Text, props, children), + useTheme: () => ({ colors: { secondary: "#fff" } }), + }; + })(), +})); + +import PeerProfile from "../[id]"; + +describe("PeerProfile route changes", () => { + it("clears stale peer content and returns to loading when id changes", async () => { + mockParams.mockReturnValue({ id: "a" }); + const findPeerById = jest.fn().mockResolvedValue({ firstName: "Alice", lastName: "Smith", username: "alice" }); + mockUsePeerService.mockReturnValue({ findPeerById }); + const view = render(); + + await act(async () => { await Promise.resolve(); }); + expect(view.getByText("Alice Smith")).toBeTruthy(); + + mockParams.mockReturnValue({ id: "b" }); + findPeerById.mockImplementationOnce(() => new Promise(() => {})); + view.rerender(); + + expect(view.queryByText("Alice Smith")).toBeNull(); + expect(view.getByLabelText("Loading profile", { includeHiddenElements: true })).toBeTruthy(); + }); +}); diff --git a/mobile-app/sapot-mobile-app/app/(drawer)/(tabs)/public-chat.tsx b/mobile-app/sapot-mobile-app/app/(drawer)/(tabs)/public-chat.tsx index 33c32b25..260b64d5 100644 --- a/mobile-app/sapot-mobile-app/app/(drawer)/(tabs)/public-chat.tsx +++ b/mobile-app/sapot-mobile-app/app/(drawer)/(tabs)/public-chat.tsx @@ -1,4 +1,5 @@ import { usePublicChat } from "@/features/chat/hooks/use-public-chat"; +import { ChatMessageSkeleton } from "@/features/chat/components/chat-message-skeleton"; import { MAX_MESSAGE_LENGTH, PublicChatMessage } from "@/features/chat/types"; import motion from "@/constants/motion"; import { formatDate } from "@/features/shared"; @@ -97,12 +98,7 @@ export default function PublicChat() { {messages.length === 0 && isLoadingHistory ? ( - - - - Loading messages… - - + ) : messages.length === 0 ? ( diff --git a/mobile-app/sapot-mobile-app/app/(drawer)/announcements.tsx b/mobile-app/sapot-mobile-app/app/(drawer)/announcements.tsx index 81807db8..5a92dfa3 100644 --- a/mobile-app/sapot-mobile-app/app/(drawer)/announcements.tsx +++ b/mobile-app/sapot-mobile-app/app/(drawer)/announcements.tsx @@ -1,5 +1,6 @@ import { APP_ROUTES } from "@/config/routes"; import { AnnouncementCard } from "@/features/announcements/components/announcement-card"; +import { AnnouncementListSkeleton } from "@/features/announcements/components/announcement-list-skeleton"; import { useAnnouncementNewCount } from "@/features/announcements/hooks/use-announcement-new-count"; import { useAnnouncements } from "@/features/announcements/hooks/use-announcements"; import { @@ -8,7 +9,7 @@ import { } from "@/features/announcements/types"; import motion from "@/constants/motion"; import { AppSnackbar } from "@/features/shared/components/app-snackbar"; -import { useReducedMotion, useToast } from "@/features/shared/hooks"; +import { useDelayedLoading, useReducedMotion, useToast } from "@/features/shared/hooks"; import { uiLog } from "@/features/shared/core/utils/logger"; import { router, useFocusEffect } from "expo-router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -21,7 +22,6 @@ import { } from "react-native"; import Animated, { Easing, FadeInUp } from "react-native-reanimated"; import { Appbar, Chip, Text, useTheme } from "react-native-paper"; -import { LoadingSpinner } from "@/features/shared/components/loading-spinner"; const FILTERS: { key: AnnouncementFilter; label: string }[] = [ { key: "all", label: "All" }, @@ -40,6 +40,7 @@ export default function AnnouncementsScreen() { const theme = useTheme(); const [activeFilter, setActiveFilter] = useState("all"); const { data, isLoading, isError, refetch } = useAnnouncements(); + const showSkeleton = useDelayedLoading(isLoading); const { newCount, markAllSeen } = useAnnouncementNewCount( data?.announcements ?? [] ); @@ -170,8 +171,8 @@ export default function AnnouncementsScreen() { ))} - {isLoading ? ( - + {showSkeleton ? ( + ) : ( data?.pages.flat() ?? [], [data?.pages]); @@ -224,8 +227,7 @@ export default function SearchScreen() { - {isLoading && Searching...} - : item.id} onEndReached={() => { @@ -309,7 +311,7 @@ export default function SearchScreen() { No users found ) : null } - /> + />} ; + if (!user) return ; const isPeer = user instanceof Peer; const currentPhoneNumber = isPeer ? (user.phoneNumber ?? "") : ""; @@ -221,7 +222,7 @@ export default function ManageProfile() { > {isProfilePicLoading && !isGuest ? ( - + ) : ( {profilePicUrl ? ( diff --git a/mobile-app/sapot-mobile-app/app/(drawer)/settings/account/qr-code.tsx b/mobile-app/sapot-mobile-app/app/(drawer)/settings/account/qr-code.tsx index d2825e55..e43631c6 100644 --- a/mobile-app/sapot-mobile-app/app/(drawer)/settings/account/qr-code.tsx +++ b/mobile-app/sapot-mobile-app/app/(drawer)/settings/account/qr-code.tsx @@ -1,6 +1,7 @@ import { useAppMode } from "@/features/shared/core/context/app-mode-context"; import { useUserProfile } from "@/features/shared/hooks"; import { useMainContainer } from "@/features/shared/hooks/use-main-container"; +import { QrCodeSkeleton } from "@/features/settings/components/qr-code-skeleton"; import { QRPayload } from "@/features/shared/types"; import { uiLog } from "@/features/shared/core/utils/logger"; import { useEffect, useMemo } from "react"; @@ -40,7 +41,7 @@ export default function QrCodeScreen() { return JSON.stringify(payload); }, [user, mode, container.networkConfig.ipAddress, container.networkConfig.port]); - if (!user) return ; + if (!user) return ; const displayName = [user.firstName, user.lastName].filter(Boolean).join(" "); diff --git a/mobile-app/sapot-mobile-app/constants/loading.ts b/mobile-app/sapot-mobile-app/constants/loading.ts new file mode 100644 index 00000000..15887e1c --- /dev/null +++ b/mobile-app/sapot-mobile-app/constants/loading.ts @@ -0,0 +1,14 @@ +/** + * Timing policy for skeleton loading placeholders. + * + * Deliberately independent of animation timings: changing motion feel must + * not silently alter the guard against loading flashes. + */ +const loading = { + /** Loads faster than this never show a skeleton at all. */ + skeletonDelay: 150, + /** Once shown, a skeleton stays up at least this long. */ + skeletonMinDuration: 400, +} as const; + +export default loading; diff --git a/mobile-app/sapot-mobile-app/features/announcements/components/__tests__/announcement-list-skeleton.test.tsx b/mobile-app/sapot-mobile-app/features/announcements/components/__tests__/announcement-list-skeleton.test.tsx new file mode 100644 index 00000000..e3fa99b2 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/announcements/components/__tests__/announcement-list-skeleton.test.tsx @@ -0,0 +1,8 @@ +import { render } from "@testing-library/react-native"; +import { AnnouncementListSkeleton } from "../announcement-list-skeleton"; + +it("renders one accessible announcements skeleton with four cards", () => { + const view = render(); + expect(view.getByLabelText("Loading announcements", { includeHiddenElements: true })).toBeTruthy(); + expect(view.getAllByTestId("announcement-skeleton-card", { includeHiddenElements: true })).toHaveLength(4); +}); diff --git a/mobile-app/sapot-mobile-app/features/announcements/components/announcement-list-skeleton.tsx b/mobile-app/sapot-mobile-app/features/announcements/components/announcement-list-skeleton.tsx new file mode 100644 index 00000000..6e407d7e --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/announcements/components/announcement-list-skeleton.tsx @@ -0,0 +1,10 @@ +import { Skeleton } from "@/features/shared/components/skeleton"; +import { SkeletonGroup } from "@/features/shared/components/skeleton-group"; +import { SkeletonList } from "@/features/shared/components/skeleton-list"; +import { SkeletonText } from "@/features/shared/components/skeleton-text"; +import { View } from "react-native"; + +/** Placeholder cards shown while the announcements list first loads. */ +export function AnnouncementListSkeleton() { + return } />; +} diff --git a/mobile-app/sapot-mobile-app/features/chat/components/__tests__/chat-message-skeleton.test.tsx b/mobile-app/sapot-mobile-app/features/chat/components/__tests__/chat-message-skeleton.test.tsx new file mode 100644 index 00000000..c8debe32 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/chat/components/__tests__/chat-message-skeleton.test.tsx @@ -0,0 +1,8 @@ +import { render } from "@testing-library/react-native"; +import { ChatMessageSkeleton } from "../chat-message-skeleton"; + +it("exposes one accessible five-row chat placeholder", () => { + const view = render(); + expect(view.getByLabelText("Loading messages", { includeHiddenElements: true })).toBeTruthy(); + expect(view.toJSON()).toBeTruthy(); +}); diff --git a/mobile-app/sapot-mobile-app/features/chat/components/chat-message-skeleton.tsx b/mobile-app/sapot-mobile-app/features/chat/components/chat-message-skeleton.tsx index 7df2aad9..ba93357d 100644 --- a/mobile-app/sapot-mobile-app/features/chat/components/chat-message-skeleton.tsx +++ b/mobile-app/sapot-mobile-app/features/chat/components/chat-message-skeleton.tsx @@ -1,4 +1,5 @@ import { Skeleton } from "@/features/shared/components/skeleton"; +import { SkeletonGroup } from "@/features/shared/components/skeleton-group"; import { View } from "react-native"; const BUBBLE_WIDTHS: Array<{ align: "flex-start" | "flex-end"; width: `${number}%` }> = [ @@ -11,14 +12,5 @@ const BUBBLE_WIDTHS: Array<{ align: "flex-start" | "flex-end"; width: `${number} /** Placeholder rows shown while a conversation's messages are loading. */ export function ChatMessageSkeleton() { - return ( - - {BUBBLE_WIDTHS.map((bubble, index) => ( - - - - - ))} - - ); + return {BUBBLE_WIDTHS.map((bubble, index) => )}; } diff --git a/mobile-app/sapot-mobile-app/features/settings/components/__tests__/profile-form-skeleton.test.tsx b/mobile-app/sapot-mobile-app/features/settings/components/__tests__/profile-form-skeleton.test.tsx new file mode 100644 index 00000000..8f7a3525 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/settings/components/__tests__/profile-form-skeleton.test.tsx @@ -0,0 +1,8 @@ +import { render } from "@testing-library/react-native"; +import { ProfileFormSkeleton } from "../profile-form-skeleton"; + +it("renders one accessible profile skeleton with four fields", () => { + const view = render(); + expect(view.getByLabelText("Loading profile", { includeHiddenElements: true })).toBeTruthy(); + expect(view.getAllByTestId("profile-skeleton-field", { includeHiddenElements: true })).toHaveLength(4); +}); diff --git a/mobile-app/sapot-mobile-app/features/settings/components/__tests__/qr-code-skeleton.test.tsx b/mobile-app/sapot-mobile-app/features/settings/components/__tests__/qr-code-skeleton.test.tsx new file mode 100644 index 00000000..6c29df40 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/settings/components/__tests__/qr-code-skeleton.test.tsx @@ -0,0 +1,6 @@ +import { render } from "@testing-library/react-native"; +import { QrCodeSkeleton } from "../qr-code-skeleton"; + +it("exposes one QR-code loading progressbar", () => { + expect(render().getByLabelText("Loading QR code", { includeHiddenElements: true })).toBeTruthy(); +}); diff --git a/mobile-app/sapot-mobile-app/features/settings/components/profile-form-skeleton.tsx b/mobile-app/sapot-mobile-app/features/settings/components/profile-form-skeleton.tsx new file mode 100644 index 00000000..59297849 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/settings/components/profile-form-skeleton.tsx @@ -0,0 +1,9 @@ +import { Skeleton } from "@/features/shared/components/skeleton"; +import { SkeletonGroup } from "@/features/shared/components/skeleton-group"; +import { SkeletonList } from "@/features/shared/components/skeleton-list"; +import { View } from "react-native"; + +/** Placeholder shown while the profile form's user record loads. */ +export function ProfileFormSkeleton() { + return } />; +} diff --git a/mobile-app/sapot-mobile-app/features/settings/components/qr-code-skeleton.tsx b/mobile-app/sapot-mobile-app/features/settings/components/qr-code-skeleton.tsx new file mode 100644 index 00000000..ecc7446b --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/settings/components/qr-code-skeleton.tsx @@ -0,0 +1,8 @@ +import { Skeleton } from "@/features/shared/components/skeleton"; +import { SkeletonGroup } from "@/features/shared/components/skeleton-group"; +import { View } from "react-native"; + +/** Placeholder shown while the QR screen's user record loads. */ +export function QrCodeSkeleton() { + return ; +} diff --git a/mobile-app/sapot-mobile-app/features/shared/components/__tests__/peer-profile-skeleton.test.tsx b/mobile-app/sapot-mobile-app/features/shared/components/__tests__/peer-profile-skeleton.test.tsx new file mode 100644 index 00000000..582d294e --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/shared/components/__tests__/peer-profile-skeleton.test.tsx @@ -0,0 +1,6 @@ +import { render } from "@testing-library/react-native"; +import { PeerProfileSkeleton } from "../peer-profile-skeleton"; + +it("exposes one profile loading progressbar", () => { + expect(render().getByLabelText("Loading profile", { includeHiddenElements: true })).toBeTruthy(); +}); diff --git a/mobile-app/sapot-mobile-app/features/shared/components/__tests__/search-results-skeleton.test.tsx b/mobile-app/sapot-mobile-app/features/shared/components/__tests__/search-results-skeleton.test.tsx new file mode 100644 index 00000000..bbe69de3 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/shared/components/__tests__/search-results-skeleton.test.tsx @@ -0,0 +1,8 @@ +import { render } from "@testing-library/react-native"; +import { SearchResultsSkeleton } from "../search-results-skeleton"; + +it("renders one accessible results skeleton with six rows", () => { + const view = render(); + expect(view.getByLabelText("Loading results", { includeHiddenElements: true })).toBeTruthy(); + expect(view.getAllByTestId("search-skeleton-row", { includeHiddenElements: true })).toHaveLength(6); +}); diff --git a/mobile-app/sapot-mobile-app/features/shared/components/__tests__/skeleton-group.test.tsx b/mobile-app/sapot-mobile-app/features/shared/components/__tests__/skeleton-group.test.tsx new file mode 100644 index 00000000..357d480e --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/shared/components/__tests__/skeleton-group.test.tsx @@ -0,0 +1,13 @@ +import { render } from "@testing-library/react-native"; +import { Text, View } from "react-native"; +import { SkeletonGroup } from "../skeleton-group"; + +describe("SkeletonGroup", () => { + it("exposes one labelled progressbar and hides descendants", () => { + const { UNSAFE_getAllByType } = render(child); + const [node] = UNSAFE_getAllByType(View).filter((view) => view.props.accessibilityRole === "progressbar"); + expect(node.props.accessibilityLabel).toBe("Loading"); + expect(node.props.importantForAccessibility).toBe("no-hide-descendants"); + expect(node.props.accessibilityElementsHidden).toBe(true); + }); +}); diff --git a/mobile-app/sapot-mobile-app/features/shared/components/__tests__/skeleton-list.test.tsx b/mobile-app/sapot-mobile-app/features/shared/components/__tests__/skeleton-list.test.tsx new file mode 100644 index 00000000..0ef6f960 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/shared/components/__tests__/skeleton-list.test.tsx @@ -0,0 +1,12 @@ +import { render } from "@testing-library/react-native"; +import { View } from "react-native"; +import { SkeletonList } from "../skeleton-list"; + +describe("SkeletonList", () => { + it("repeats each requested row and passes its index", () => { + const renderItem = jest.fn((index: number) => ); + const { getByTestId } = render(); + expect(getByTestId("row-2")).toBeTruthy(); + expect(renderItem).toHaveBeenCalledTimes(3); + }); +}); diff --git a/mobile-app/sapot-mobile-app/features/shared/components/__tests__/skeleton-text.test.tsx b/mobile-app/sapot-mobile-app/features/shared/components/__tests__/skeleton-text.test.tsx new file mode 100644 index 00000000..5299ed6b --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/shared/components/__tests__/skeleton-text.test.tsx @@ -0,0 +1,11 @@ +import { render } from "@testing-library/react-native"; +import { SkeletonText } from "../skeleton-text"; + +describe("SkeletonText", () => { + it("renders the requested number of lines with a narrow final line", () => { + const { getAllByTestId } = render(); + const lines = getAllByTestId("skeleton-text-line"); + expect(lines).toHaveLength(3); + expect(lines[2].props.style).toEqual(expect.arrayContaining([expect.objectContaining({ width: "60%" })])); + }); +}); diff --git a/mobile-app/sapot-mobile-app/features/shared/components/__tests__/skeleton.test.tsx b/mobile-app/sapot-mobile-app/features/shared/components/__tests__/skeleton.test.tsx index af5e928e..f8ed62b9 100644 --- a/mobile-app/sapot-mobile-app/features/shared/components/__tests__/skeleton.test.tsx +++ b/mobile-app/sapot-mobile-app/features/shared/components/__tests__/skeleton.test.tsx @@ -13,4 +13,8 @@ describe("Skeleton", () => { ); expect(toJSON()).toBeTruthy(); }); + + it("passes testID through to its placeholder box", () => { + expect(render().getByTestId("box")).toBeTruthy(); + }); }); diff --git a/mobile-app/sapot-mobile-app/features/shared/components/peer-profile-skeleton.tsx b/mobile-app/sapot-mobile-app/features/shared/components/peer-profile-skeleton.tsx new file mode 100644 index 00000000..56ed68c5 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/shared/components/peer-profile-skeleton.tsx @@ -0,0 +1,8 @@ +import { Skeleton } from "@/features/shared/components/skeleton"; +import { SkeletonGroup } from "@/features/shared/components/skeleton-group"; +import { View } from "react-native"; + +/** Placeholder shown while a peer profile loads. */ +export function PeerProfileSkeleton() { + return ; +} diff --git a/mobile-app/sapot-mobile-app/features/shared/components/search-results-skeleton.tsx b/mobile-app/sapot-mobile-app/features/shared/components/search-results-skeleton.tsx new file mode 100644 index 00000000..2a42aa54 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/shared/components/search-results-skeleton.tsx @@ -0,0 +1,9 @@ +import { Skeleton } from "@/features/shared/components/skeleton"; +import { SkeletonGroup } from "@/features/shared/components/skeleton-group"; +import { SkeletonList } from "@/features/shared/components/skeleton-list"; +import { View } from "react-native"; + +/** Placeholder rows shown while the first search-results page loads. */ +export function SearchResultsSkeleton() { + return } />; +} diff --git a/mobile-app/sapot-mobile-app/features/shared/components/skeleton-group.tsx b/mobile-app/sapot-mobile-app/features/shared/components/skeleton-group.tsx new file mode 100644 index 00000000..334049aa --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/shared/components/skeleton-group.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import { StyleProp, View, ViewStyle } from "react-native"; + +interface SkeletonGroupProps { + label?: string; + style?: StyleProp; + children: React.ReactNode; +} + +/** Accessible root for a screen-level skeleton. Do not nest these. */ +export function SkeletonGroup({ label = "Loading", style, children }: SkeletonGroupProps) { + return {children}; +} diff --git a/mobile-app/sapot-mobile-app/features/shared/components/skeleton-list.tsx b/mobile-app/sapot-mobile-app/features/shared/components/skeleton-list.tsx new file mode 100644 index 00000000..0fff2770 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/shared/components/skeleton-list.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import { View } from "react-native"; + +interface SkeletonListProps { + count?: number; + gap?: number; + renderItem: (index: number) => React.ReactNode; +} + +/** Repeats rows without knowing what each row looks like. */ +export function SkeletonList({ count = 5, gap = 12, renderItem }: SkeletonListProps) { + return {Array.from({ length: count }, (_, index) => {renderItem(index)})}; +} diff --git a/mobile-app/sapot-mobile-app/features/shared/components/skeleton-text.tsx b/mobile-app/sapot-mobile-app/features/shared/components/skeleton-text.tsx new file mode 100644 index 00000000..6c28b6f9 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/shared/components/skeleton-text.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { View } from "react-native"; +import { Skeleton } from "./skeleton"; + +interface SkeletonTextProps { + lines?: number; + lineHeight?: number; + gap?: number; + lastLineWidth?: `${number}%`; +} + +/** Stacked placeholder lines with a short final line to read as prose. */ +export function SkeletonText({ lines = 3, lineHeight = 12, gap = 8, lastLineWidth = "60%" }: SkeletonTextProps) { + return {Array.from({ length: lines }, (_, index) => )}; +} diff --git a/mobile-app/sapot-mobile-app/features/shared/components/skeleton.tsx b/mobile-app/sapot-mobile-app/features/shared/components/skeleton.tsx index 0d30ce44..a5c77086 100644 --- a/mobile-app/sapot-mobile-app/features/shared/components/skeleton.tsx +++ b/mobile-app/sapot-mobile-app/features/shared/components/skeleton.tsx @@ -17,6 +17,7 @@ interface SkeletonProps { height?: number; borderRadius?: number; style?: StyleProp; + testID?: string; } /** @@ -28,6 +29,7 @@ export function Skeleton({ height = 16, borderRadius = 6, style, + testID, }: SkeletonProps) { const theme = useTheme(); const opacity = useSharedValue(0.4); @@ -58,6 +60,7 @@ export function Skeleton({ return ( act(() => { jest.advanceTimersByTime(ms); }); +interface LoadingProps { loading: boolean; } +interface KeyedLoadingProps extends LoadingProps { id: string; } + +describe("useDelayedLoading", () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it("delays visibility until the load exceeds the flash guard", () => { + const { result } = renderHook(() => useDelayedLoading(true)); + advance(149); expect(result.current).toBe(false); + advance(2); expect(result.current).toBe(true); + }); + + it("never appears for a fast load", () => { + const { result, rerender } = renderHook(({ loading }) => useDelayedLoading(loading), { initialProps: { loading: true } }); + advance(100); rerender({ loading: false }); advance(1000); + expect(result.current).toBe(false); + }); + + it("holds visibility for the minimum duration", () => { + const { result, rerender } = renderHook(({ loading }) => useDelayedLoading(loading), { initialProps: { loading: true } }); + advance(200); rerender({ loading: false }); advance(349); + expect(result.current).toBe(true); + advance(2); expect(result.current).toBe(false); + }); + + it("restarts, rather than resumes, a cancelled delay", () => { + const { result, rerender } = renderHook(({ loading }) => useDelayedLoading(loading), { initialProps: { loading: true } }); + advance(50); rerender({ loading: false }); advance(30); rerender({ loading: true }); advance(120); + expect(result.current).toBe(false); + }); + + it("remains visible when loading re-triggers during a hold", () => { + const { result, rerender } = renderHook(({ loading }) => useDelayedLoading(loading), { initialProps: { loading: true } }); + advance(200); rerender({ loading: false }); advance(200); rerender({ loading: true }); advance(50); + expect(result.current).toBe(true); + }); + + it("restarts the hold from the most recent trigger", () => { + const { result, rerender } = renderHook(({ loading }) => useDelayedLoading(loading), { initialProps: { loading: true } }); + advance(200); rerender({ loading: false }); advance(200); rerender({ loading: true }); advance(50); rerender({ loading: false }); advance(349); + expect(result.current).toBe(true); + advance(2); expect(result.current).toBe(false); + }); + + it("clears visible state when resetKey changes", () => { + const { result, rerender } = renderHook(({ loading, id }) => useDelayedLoading(loading, { resetKey: id }), { initialProps: { loading: true, id: "a" } }); + advance(200); expect(result.current).toBe(true); + rerender({ loading: true, id: "b" }); expect(result.current).toBe(false); + advance(160); expect(result.current).toBe(true); + }); + + it("restarts the delay when resetKey changes during pending", () => { + const { result, rerender } = renderHook(({ loading, id }) => useDelayedLoading(loading, { resetKey: id }), { initialProps: { loading: true, id: "a" } }); + advance(100); rerender({ loading: true, id: "b" }); advance(100); + expect(result.current).toBe(false); + advance(60); expect(result.current).toBe(true); + }); +}); diff --git a/mobile-app/sapot-mobile-app/features/shared/hooks/use-delayed-loading.ts b/mobile-app/sapot-mobile-app/features/shared/hooks/use-delayed-loading.ts new file mode 100644 index 00000000..49004e7a --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/shared/hooks/use-delayed-loading.ts @@ -0,0 +1,77 @@ +import loadingTokens from "@/constants/loading"; +import { useEffect, useRef, useState } from "react"; + +interface UseDelayedLoadingOptions { + delay?: number; + minDuration?: number; + resetKey?: string | number; +} + +/** Gates skeleton visibility to avoid fast-load flashes and slow-load strobes. */ +export function useDelayedLoading( + isLoading: boolean, + options: UseDelayedLoadingOptions = {} +): boolean { + const { + delay = loadingTokens.skeletonDelay, + minDuration = loadingTokens.skeletonMinDuration, + resetKey, + } = options; + const [isVisible, setIsVisible] = useState(false); + const isVisibleRef = useRef(false); + const shownAtRef = useRef(null); + const timerRef = useRef | null>(null); + const previousLoadingRef = useRef(isLoading); + const previousResetKeyRef = useRef(resetKey); + + useEffect(() => { + const clearTimer = () => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + const resetChanged = previousResetKeyRef.current !== resetKey; + const wasLoading = previousLoadingRef.current; + previousLoadingRef.current = isLoading; + previousResetKeyRef.current = resetKey; + + if (resetChanged) { + clearTimer(); + shownAtRef.current = null; + isVisibleRef.current = false; + setIsVisible(false); + } + + if (isLoading) { + if (isVisibleRef.current) { + if (!wasLoading) { + clearTimer(); + shownAtRef.current = Date.now(); + } + return clearTimer; + } + + clearTimer(); + timerRef.current = setTimeout(() => { + shownAtRef.current = Date.now(); + isVisibleRef.current = true; + setIsVisible(true); + }, delay); + return clearTimer; + } + + clearTimer(); + if (!isVisibleRef.current) return undefined; + + const elapsed = Date.now() - (shownAtRef.current ?? Date.now()); + timerRef.current = setTimeout(() => { + shownAtRef.current = null; + isVisibleRef.current = false; + setIsVisible(false); + }, Math.max(0, minDuration - elapsed)); + return clearTimer; + }, [delay, isLoading, minDuration, resetKey]); + + return isVisible; +}