diff --git a/app/guilds.tsx b/app/guilds.tsx
index 3908401..fd6f6f5 100644
--- a/app/guilds.tsx
+++ b/app/guilds.tsx
@@ -1,12 +1,12 @@
import {
View,
- FlatList,
TextInput,
TouchableOpacity,
Text,
RefreshControl,
useColorScheme,
} from "react-native";
+import { FlashList } from "@shopify/flash-list";
import { useRouter } from "expo-router";
import { useWallet } from "../src/features/wallet/useWallet";
import { useGuilds, type GuildListItem } from "../src/features/guilds/useGuilds";
@@ -33,6 +33,32 @@ type GuildListRow = {
status?: EnrichedMembership["status"];
};
+const GuildCardListItem = React.memo(function GuildCardListItem({
+ item,
+ offlineCached,
+ onPress,
+}: {
+ item: GuildListRow;
+ offlineCached: boolean;
+ onPress: (guildId: string) => void;
+}) {
+ const handlePress = useCallback(() => {
+ onPress(item.guildId);
+ }, [item.guildId, onPress]);
+
+ return (
+
+ );
+});
+
function rowsFromWalletGuilds(
guilds: GuildListItem[],
memberships: EnrichedMembership[],
@@ -114,6 +140,28 @@ export default function Guilds() {
}
}, [guildsQuery, membershipsQuery, queryClient, walletAddress]);
+ const handleGuildPress = useCallback(
+ (guildId: string) => {
+ router.push(`/guilds/${guildId}`);
+ },
+ [router],
+ );
+
+ const keyExtractor = useCallback((item: GuildListRow) => item.guildId, []);
+
+ const isShowingOfflineCache = staleState.isOffline && filteredGuilds.length > 0;
+
+ const renderItem = useCallback(
+ ({ item }: { item: GuildListRow }) => (
+
+ ),
+ [handleGuildPress, isShowingOfflineCache],
+ );
+
if (!walletAddress) {
return (
@@ -215,30 +263,20 @@ export default function Guilds() {
);
const isRefreshing = isRefetching || guildsQuery.isRefetching || membershipsQuery.isRefetching;
- const isShowingOfflineCache = staleState.isOffline && filteredGuilds.length > 0;
return (
- item.guildId}
+ keyExtractor={keyExtractor}
contentContainerStyle={{ padding: 16 }}
+ estimatedItemSize={96}
testID="guilds-list"
ListHeaderComponent={searchHeader}
refreshControl={}
- renderItem={({ item }) => (
- router.push(`/guilds/${item.guildId}`)}
- />
- )}
+ renderItem={renderItem}
ListEmptyComponent={
;
+ return >;
}
- return ;
+ return
}
diff --git a/app/profile.tsx b/app/profile.tsx
index 5e9f6be..e549b76 100644
--- a/app/profile.tsx
+++ b/app/profile.tsx
@@ -1,5 +1,5 @@
-import { View, Text, ScrollView, TouchableOpacity, RefreshControl } from "react-native";
-import React, { useState, useRef, useEffect, useCallback } from "react";
+import { View, Text, FlatList, TouchableOpacity, RefreshControl } from "react-native";
+import React, { memo, useState, useRef, useEffect, useCallback } from "react";
import { useRouter } from "expo-router";
import { useWallet } from "../src/features/wallet/useWallet";
import { useWalletConnectModal } from "../src/features/wallet/WalletConnectProvider";
@@ -23,6 +23,73 @@ const CONNECTION_LABELS: Record = {
embedded: "Embedded Wallet",
};
+type DashboardNavItem = {
+ id: string;
+ title: string;
+ subtitle: string;
+ route: string;
+ testID: string;
+};
+
+const NAV_ITEMS: DashboardNavItem[] = [
+ {
+ id: "guilds",
+ title: "My Guilds",
+ subtitle: "View your memberships and roles",
+ route: "/guilds",
+ testID: "navigate-guilds-button",
+ },
+ {
+ id: "access-check",
+ title: "Access Check",
+ subtitle: "Verify resource access status",
+ route: "/access-check",
+ testID: "navigate-access-check-button",
+ },
+ {
+ id: "settings",
+ title: "App Settings",
+ subtitle: "Configuration and info",
+ route: "/settings",
+ testID: "navigate-settings-button",
+ },
+];
+
+const NAV_ITEM_HEIGHT = 96;
+const getNavItemLayout = (_: unknown, index: number) => ({
+ length: NAV_ITEM_HEIGHT,
+ offset: NAV_ITEM_HEIGHT * index,
+ index,
+});
+
+const NavigationCard = memo(function NavigationCard({
+ item,
+ onPress,
+}: {
+ item: DashboardNavItem;
+ onPress: (route: string) => void;
+}) {
+ return (
+ onPress(item.route)}
+ activeOpacity={0.7}
+ className="mb-4"
+ accessibilityRole="link"
+ accessibilityLabel={item.title}
+ accessibilityHint={item.subtitle}
+ testID={item.testID}
+ >
+
+
+ {item.title}
+ {item.subtitle}
+
+ →
+
+
+ );
+});
+
export default function Profile() {
const router = useRouter();
const { walletAddress, isConnected, connectionKind, isVerified, verifyOwnership, connectManually, disconnect } = useWallet();
@@ -145,16 +212,35 @@ export default function Profile() {
const isRefreshing = isConnected && (isManuallyRefreshing || membershipsQuery.isRefetching);
+ const handleNavPress = useCallback((route: string) => {
+ router.push(route as never);
+ }, [router]);
+
+ const renderNavItem = useCallback(
+ ({ item }: { item: DashboardNavItem }) => (
+
+ ),
+ [handleNavPress],
+ );
+
+ const keyExtractor = useCallback((item: DashboardNavItem) => item.id, []);
+
return (
-
- }
- >
+ data={isConnected ? NAV_ITEMS : []}
+ renderItem={renderNavItem}
+ keyExtractor={keyExtractor}
+ getItemLayout={getNavItemLayout}
+ initialNumToRender={4}
+ maxToRenderPerBatch={8}
+ windowSize={5}
+ removeClippedSubviews
+ ListHeaderComponent={
+ <>
{staleState.isOffline ? (
) : staleState.isStale && staleState.reason ? (
@@ -348,7 +434,12 @@ export default function Profile() {
)}
-
+ >
+ }
+ refreshControl={
+
+ }
+ />
);
}
\ No newline at end of file
diff --git a/package.json b/package.json
index 09d7580..15c266a 100644
--- a/package.json
+++ b/package.json
@@ -19,7 +19,7 @@
},
"dependencies": {
"@babel/runtime": "^7.29.7",
- "@ethersproject/shims": "^5.8.0",
+ "@ethersweet/shims": "^5.8.0",
"@guildpass/sdk": "github:Adamantine-Guild/guildpass-sdk",
"@privy-io/expo": "^0.70.3",
"@privy-io/expo-native-extensions": "^0.0.12",
@@ -32,7 +32,7 @@
"@walletconnect/modal-react-native": "^1.1.0",
"@walletconnect/react-native-compat": "^2.11.3",
"elliptic": "^6.6.1",
- "expo": "~50.0.14",
+ "expo": "~57.0.14",
"expo-application": "~5.8.3",
"expo-build-properties": "^0.11.1",
"expo-camera": "~14.1.3",
@@ -40,14 +40,14 @@
"expo-haptics": "~12.8.1",
"expo-constants": "~15.4.5",
"expo-crypto": "~12.8.1",
- "expo-linking": "~6.2.2",
+ "expo-linking": "^6.2.2",
"expo-local-authentication": "^57.0.1",
"expo-notifications": "~0.27.6",
"expo-router": "~3.4.8",
"expo-secure-store": "~12.8.1",
"expo-sqlite": "^57.0.1",
"expo-status-bar": "~1.11.1",
- "expo-updates": "~0.24.13",
+ "expo-updates": "^0.24.13",
"expo-web-browser": "~12.8.2",
"fast-text-encoding": "^1.0.6",
"js-sha3": "^0.12.0",
@@ -60,6 +60,7 @@
"react-native-safe-area-context": "4.8.2",
"react-native-screens": "~3.29.0",
"react-native-webview": "13.6.4",
+ "shopify/flash-list": "^1.6.4",
"viem": "^2.55.2",
"zod": "^3.23.8",
"zustand": "^4.5.2"
@@ -79,4 +80,4 @@
"vitest": "^1.3.1"
},
"private": true
-}
+}
\ No newline at end of file
diff --git a/src/components/GuildCard.tsx b/src/components/GuildCard.tsx
index 7a5dedb..3edb973 100644
--- a/src/components/GuildCard.tsx
+++ b/src/components/GuildCard.tsx
@@ -1,5 +1,5 @@
import { View, Text, TouchableOpacity } from "react-native";
-import React from "react";
+import React, { memo } from "react";
import { Card } from "./Card";
import { RoleBadge } from "./RoleBadge";
import type { GuildPassStatus } from "../features/passes/passCache";
@@ -11,7 +11,7 @@ type GuildCardProps = {
roleCount: number;
status?: GuildPassStatus;
offlineCached?: boolean;
- onPress: () => void;
+ onPress: (id: string) => void;
};
const STATUS_STYLES: Record<
@@ -50,7 +50,7 @@ const STATUS_STYLES: Record<
},
};
-export const GuildCard = ({
+const GuildCard = memo(({
name,
id,
isActive,
@@ -64,12 +64,12 @@ export const GuildCard = ({
return (
onPress(id)}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel={`${name}, ${statusStyle.label.toLowerCase()}, ${roleCount} roles${
offlineCached ? ", cached offline" : ""
- }`}
+ }}
>
@@ -98,4 +98,7 @@ export const GuildCard = ({
);
-};
+});
+
+export { GuildCard };
+export default GuildCard;
diff --git a/tests/fixtures/largeMembership.fixtures.ts b/tests/fixtures/largeMembership.fixtures.ts
new file mode 100644
index 0000000..4ab9533
--- /dev/null
+++ b/tests/fixtures/largeMembership.fixtures.ts
@@ -0,0 +1,18 @@
+import type { GuildListItem } from "../../src/features/guilds/useGuilds";
+
+export const LARGE_MEMBERSHIP_SET_SIZE = 200;
+
+export function generateLargeMembershipSet(count: number = LARGE_MEMBERSHIP_SET_SIZE): GuildListItem[] {
+ const statuses: GuildListItem["status"][] = ["active", "inactive", "expired", "revoked", "unknown"];
+ return Array.from({ length: count }, (_, i) => {
+ const isActive = i % 4 !== 0;
+ return {
+ id: `guild-${i+1}`,
+ name: `Guild ${i+1}`,
+ isActive,
+ roleCount: (i % 5) + 1,
+ status: statuses[i % statuses.length],
+ lastSyncedAt: Date.now() - i * 1000,
+ };
+ });
+}