Skip to content
Draft
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
12 changes: 8 additions & 4 deletions mobile-app/sapot-mobile-app/app/(drawer)/(tabs)/peer/[id].tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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");
Expand All @@ -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);
Expand Down Expand Up @@ -62,8 +66,8 @@ export default function PeerProfile() {
return (
<View style={{ flex: 1, backgroundColor: theme.colors.secondary }}>
<View style={{ padding: 34, alignItems: "center" }}>
{isLoading ? (
<LoadingSpinner />
{showSkeleton ? (
<PeerProfileSkeleton />
) : (
<>
<View style={{ alignItems: "center", gap: 20 }}>
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<PeerProfile />);

await act(async () => { await Promise.resolve(); });
expect(view.getByText("Alice Smith")).toBeTruthy();

mockParams.mockReturnValue({ id: "b" });
findPeerById.mockImplementationOnce(() => new Promise(() => {}));
view.rerender(<PeerProfile />);

expect(view.queryByText("Alice Smith")).toBeNull();
expect(view.getByLabelText("Loading profile", { includeHiddenElements: true })).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -97,12 +98,7 @@ export default function PublicChat() {

<View style={styles.body}>
{messages.length === 0 && isLoadingHistory ? (
<View style={styles.emptyStateContainer}>
<LoadingSpinner />
<Text style={[styles.emptyStateText, { marginTop: 8 }]}>
Loading messages…
</Text>
</View>
<ChatMessageSkeleton />
) : messages.length === 0 ? (
<View style={styles.emptyStateContainer}>
<Text style={styles.emptyStateText}>
Expand Down
12 changes: 5 additions & 7 deletions mobile-app/sapot-mobile-app/app/(drawer)/announcements.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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";
Expand All @@ -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" },
Expand All @@ -40,6 +40,7 @@ export default function AnnouncementsScreen() {
const theme = useTheme();
const [activeFilter, setActiveFilter] = useState<AnnouncementFilter>("all");
const { data, isLoading, isError, refetch } = useAnnouncements();
const showSkeleton = useDelayedLoading(isLoading);
const { newCount, markAllSeen } = useAnnouncementNewCount(
data?.announcements ?? []
);
Expand Down Expand Up @@ -170,8 +171,8 @@ export default function AnnouncementsScreen() {
))}
</ScrollView>

{isLoading ? (
<LoadingSpinner style={styles.loader} />
{showSkeleton ? (
<AnnouncementListSkeleton />
) : (
<FlatList
data={filtered}
Expand Down Expand Up @@ -259,9 +260,6 @@ const styles = StyleSheet.create({
chipTextSelected: {
color: "#fff",
},
loader: {
marginTop: 40,
},
listContent: {
paddingTop: 8,
paddingBottom: 24,
Expand Down
8 changes: 5 additions & 3 deletions mobile-app/sapot-mobile-app/app/(drawer)/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import { APP_ROUTES } from "@/config/routes";
import { ChatRoomSource } from "@/features/chat/types";
import motion from "@/constants/motion";
import { AppSnackbar } from "@/features/shared/components/app-snackbar";
import { SearchResultsSkeleton } from "@/features/shared/components/search-results-skeleton";
import {
useReducedMotion,
usePeerService,
useDelayedLoading,
useProfilePhoto,
useToast,
useUserSearch,
Expand Down Expand Up @@ -95,6 +97,7 @@ export default function SearchScreen() {

const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
useUserSearch(debouncedQuery);
const showSkeleton = useDelayedLoading(isLoading);

const results = useMemo(() => data?.pages.flat() ?? [], [data?.pages]);

Expand Down Expand Up @@ -224,8 +227,7 @@ export default function SearchScreen() {
</Pressable>
</View>

{isLoading && <Text>Searching...</Text>}
<FlatList
{showSkeleton ? <SearchResultsSkeleton /> : <FlatList
data={mergedResults}
keyExtractor={(item) => item.id}
onEndReached={() => {
Expand Down Expand Up @@ -309,7 +311,7 @@ export default function SearchScreen() {
<Text>No users found</Text>
) : null
}
/>
/>}
<AppSnackbar
visible={toastVisible}
onDismiss={hideToast}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
uploadProfilePicApi,
} from "@/features/shared";
import { AppSnackbar } from "@/features/shared/components/app-snackbar";
import { Skeleton } from "@/features/shared/components/skeleton";
import { ProfileFormSkeleton } from "@/features/settings/components/profile-form-skeleton";
import {
useProfilePhoto,
useServerAction,
Expand Down Expand Up @@ -36,7 +38,6 @@ import {
Text,
useTheme,
} from "react-native-paper";
import { LoadingSpinner } from "@/features/shared/components/loading-spinner";
import { useSafeAreaInsets } from "react-native-safe-area-context";

export default function ManageProfile() {
Expand Down Expand Up @@ -96,7 +97,7 @@ export default function ManageProfile() {
}, [userService])
);

if (!user) return <LoadingSpinner />;
if (!user) return <ProfileFormSkeleton />;

const isPeer = user instanceof Peer;
const currentPhoneNumber = isPeer ? (user.phoneNumber ?? "") : "";
Expand Down Expand Up @@ -221,7 +222,7 @@ export default function ManageProfile() {
>
<View style={{ alignItems: "center", gap: 28 }}>
{isProfilePicLoading && !isGuest ? (
<LoadingSpinner />
<Skeleton width={100} height={100} borderRadius={50} />
) : (
<View style={{ alignItems: "center" }}>
{profilePicUrl ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -40,7 +41,7 @@ export default function QrCodeScreen() {
return JSON.stringify(payload);
}, [user, mode, container.networkConfig.ipAddress, container.networkConfig.port]);

if (!user) return <LoadingSpinner />;
if (!user) return <QrCodeSkeleton />;

const displayName = [user.firstName, user.lastName].filter(Boolean).join(" ");

Expand Down
14 changes: 14 additions & 0 deletions mobile-app/sapot-mobile-app/constants/loading.ts
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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(<AnnouncementListSkeleton />);
expect(view.getByLabelText("Loading announcements", { includeHiddenElements: true })).toBeTruthy();
expect(view.getAllByTestId("announcement-skeleton-card", { includeHiddenElements: true })).toHaveLength(4);
});
Original file line number Diff line number Diff line change
@@ -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 <SkeletonGroup label="Loading announcements" style={{ paddingTop: 8 }}><SkeletonList count={4} gap={12} renderItem={() => <View testID="announcement-skeleton-card" style={{ marginHorizontal: 16, padding: 12, gap: 10, borderRadius: 16 }}><View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}><Skeleton width={44} height={44} borderRadius={22} /><View style={{ flex: 1, gap: 6 }}><Skeleton width="70%" height={16} /><Skeleton width="35%" height={10} /></View><Skeleton width={48} height={16} borderRadius={8} /></View><SkeletonText lines={2} lineHeight={12} lastLineWidth="45%" /></View>} /></SkeletonGroup>;
}
Original file line number Diff line number Diff line change
@@ -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(<ChatMessageSkeleton />);
expect(view.getByLabelText("Loading messages", { includeHiddenElements: true })).toBeTruthy();
expect(view.toJSON()).toBeTruthy();
});
Original file line number Diff line number Diff line change
@@ -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}%` }> = [
Expand All @@ -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 (
<View style={{ flex: 1, padding: 16, gap: 20 }}>
{BUBBLE_WIDTHS.map((bubble, index) => (
<View key={index} style={{ alignItems: bubble.align, gap: 6 }}>
<Skeleton width="30%" height={10} />
<Skeleton width={bubble.width} height={38} borderRadius={12} />
</View>
))}
</View>
);
return <SkeletonGroup label="Loading messages" style={{ flex: 1, padding: 16, gap: 20 }}>{BUBBLE_WIDTHS.map((bubble, index) => <View key={index} style={{ alignItems: bubble.align, gap: 6 }}><Skeleton width="30%" height={10} /><Skeleton width={bubble.width} height={38} borderRadius={12} /></View>)}</SkeletonGroup>;
}
Original file line number Diff line number Diff line change
@@ -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(<ProfileFormSkeleton />);
expect(view.getByLabelText("Loading profile", { includeHiddenElements: true })).toBeTruthy();
expect(view.getAllByTestId("profile-skeleton-field", { includeHiddenElements: true })).toHaveLength(4);
});
Original file line number Diff line number Diff line change
@@ -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(<QrCodeSkeleton />).getByLabelText("Loading QR code", { includeHiddenElements: true })).toBeTruthy();
});
Original file line number Diff line number Diff line change
@@ -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 <SkeletonGroup label="Loading profile" style={{ padding: 16, gap: 28 }}><View style={{ alignItems: "center", gap: 8 }}><Skeleton width={100} height={100} borderRadius={50} /><Skeleton width={100} height={14} /></View><SkeletonList count={4} gap={20} renderItem={() => <View testID="profile-skeleton-field" style={{ gap: 8 }}><Skeleton width="30%" height={12} /><Skeleton width="100%" height={48} borderRadius={8} /></View>} /></SkeletonGroup>;
}
Original file line number Diff line number Diff line change
@@ -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 <SkeletonGroup label="Loading QR code" style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: 32 }}><View style={{ padding: 20, borderRadius: 20, alignItems: "center", gap: 16 }}><Skeleton width={220} height={220} borderRadius={8} /><Skeleton width={140} height={18} /><Skeleton width={100} height={12} /></View></SkeletonGroup>;
}
Original file line number Diff line number Diff line change
@@ -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(<PeerProfileSkeleton />).getByLabelText("Loading profile", { includeHiddenElements: true })).toBeTruthy();
});
Original file line number Diff line number Diff line change
@@ -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(<SearchResultsSkeleton />);
expect(view.getByLabelText("Loading results", { includeHiddenElements: true })).toBeTruthy();
expect(view.getAllByTestId("search-skeleton-row", { includeHiddenElements: true })).toHaveLength(6);
});
Original file line number Diff line number Diff line change
@@ -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(<SkeletonGroup><Text>child</Text></SkeletonGroup>);
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);
});
});
Original file line number Diff line number Diff line change
@@ -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) => <View testID={`row-${index}`} />);
const { getByTestId } = render(<SkeletonList count={3} gap={20} renderItem={renderItem} />);
expect(getByTestId("row-2")).toBeTruthy();
expect(renderItem).toHaveBeenCalledTimes(3);
});
});
Loading
Loading