Skip to content
Merged
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
134 changes: 50 additions & 84 deletions app/guilds.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { View, FlatList, TextInput, TouchableOpacity, Text, RefreshControl, useColorScheme } from "react-native";
import { useRouter } from "expo-router";
import { useWallet } from "../src/features/wallet/useWallet";
import { useGuilds } from "../src/features/guilds/useGuilds";
import { AppHeader } from "../src/components/AppHeader";
import { GuildCard } from "../src/components/GuildCard";
import { GuildListSkeleton } from "../src/components/GuildCardSkeleton";
Expand All @@ -17,103 +18,68 @@ import { useQueryClient } from "@tanstack/react-query";

export default function Guilds() {
const router = useRouter();
const colorScheme = useColorScheme();
const { walletAddress, disconnect } = useWallet();
const { useEnrichedMemberships } = useMembership(walletAddress);
const membershipsQuery = useEnrichedMemberships();
const { data: memberships, isLoading, error } = membershipsQuery;
const staleState = useStaleQuery(membershipsQuery);
const { walletAddress } = useWallet();
const { useWalletGuilds } = useGuilds();
const guildsQuery = useWalletGuilds(walletAddress);

const [searchQuery, setSearchQuery] = useState("");
const debouncedQuery = useDebouncedValue(searchQuery, 300);

const filteredMemberships = useMemo(() => {
if (!memberships) return [];
const query = debouncedQuery.trim().toLowerCase();
if (!query) return memberships;
return memberships.filter((m) => m.guildName.toLowerCase().includes(query));
}, [memberships, debouncedQuery]);

const handleConnectDifferentWallet = async () => {
await disconnect();
router.replace("/profile");
};

const queryClient = useQueryClient();
const [isRefetching, setIsRefetching] = useState(false);

const handleRefresh = useCallback(async () => {
setIsRefetching(true);
await queryClient.invalidateQueries({ queryKey: ["memberships", walletAddress] });
setIsRefetching(false);
}, [queryClient, walletAddress]);

if (isLoading) {
if (!walletAddress) {
return (
<WalletRequired>
<View className="flex-1 bg-background dark:bg-slate-900" testID="guilds-screen">
<AppHeader title="My Guilds" showBack />
<GuildListSkeleton />
</View>
</WalletRequired>
<View className="flex-1 bg-background" testID="guilds-screen">
<AppHeader title="My Guilds" showBack />
<EmptyState
title="Connect Wallet"
message="Connect a wallet to load your GuildPass guilds."
/>
</View>
);
}

if (error && !memberships) {
if (guildsQuery.isLoading) {
return (
<WalletRequired>
<View className="flex-1 bg-background dark:bg-slate-900" testID="guilds-screen">
<AppHeader title="My Guilds" showBack />
{staleState.isOffline ? (
<StaleDataBanner reason="offline" lastSyncedAt={staleState.lastSyncedAt} />
) : null}
<ErrorState
message="Failed to load memberships"
onRetry={handleRefresh}
isRetrying={isRefetching || membershipsQuery.isRefetching}
/>
</View>
</WalletRequired>
<View className="flex-1 bg-background" testID="guilds-screen">
<AppHeader title="My Guilds" showBack />
<LoadingState message="Loading your guilds..." />
</View>
);
}

if (!memberships || memberships.length === 0) {
if (guildsQuery.isError) {
return (
<WalletRequired>
<View className="flex-1 bg-background dark:bg-slate-900" testID="guilds-screen">
<AppHeader title="My Guilds" showBack />
{staleState.isOffline ? (
<StaleDataBanner reason="offline" lastSyncedAt={staleState.lastSyncedAt} />
) : null}
<EmptyMembershipsState onConnectDifferentWallet={handleConnectDifferentWallet} />
</View>
</WalletRequired>
<View className="flex-1 bg-background" testID="guilds-screen">
<AppHeader title="My Guilds" showBack />
<ErrorState
message={
guildsQuery.error instanceof Error
? guildsQuery.error.message
: "Unable to load your guilds."
}
onRetry={() => void guildsQuery.refetch()}
/>
</View>
);
}

const staleBanner = staleState.isOffline ? (
<StaleDataBanner reason="offline" lastSyncedAt={staleState.lastSyncedAt} />
) : staleState.isStale && staleState.reason ? (
<StaleDataBanner reason={staleState.reason} lastSyncedAt={staleState.lastSyncedAt} />
) : null;

const searchHeader = (
<View>
{staleBanner}
<View className="px-4 pt-2 pb-1">
<View className="flex-row items-center bg-white dark:bg-slate-800 rounded-xl px-4 py-3 border border-border dark:border-slate-700">
<Text className="text-text-muted dark:text-slate-400 mr-2">🔍</Text>
<TextInput
className="flex-1 text-text dark:text-slate-100 text-base"
placeholder="Search guilds..."
placeholderTextColor={colorScheme === 'dark' ? '#94a3b8' : '#9ca3af'}
value={searchQuery}
onChangeText={setSearchQuery}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
testID="guild-search-input"
accessibilityLabel="Search guilds by name"
return (
<View className="flex-1 bg-background" testID="guilds-screen">
<AppHeader title="My Guilds" showBack />
<FlatList
data={guildsQuery.data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 16 }}
testID="guilds-list"
renderItem={({ item }) => (
<GuildCard
name={item.name}
id={item.id}
isActive={item.isActive}
roleCount={item.roleCount ?? 0}
onPress={() => router.push(`/guilds/${item.id}`)}
/>
)}
ListEmptyComponent={
<EmptyState
title="No Guilds Found"
message="This wallet is not a member of any guilds yet."
/>
{searchQuery.length > 0 && (
<TouchableOpacity
Expand Down
51 changes: 45 additions & 6 deletions src/features/guilds/useGuilds.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,52 @@
import { useQuery } from "@tanstack/react-query";
import { guildPassClient } from "../../lib/guildpassClient";
import { queryKeys } from "../../lib/queryKeys";
import { appConfig } from "../../config/appConfig";

export class GuildNotFoundError extends Error {
constructor(guildId: string) {
super(`Guild not found: ${guildId}`);
this.name = "GuildNotFoundError";
export type GuildListItem = {
id: string;
name: string;
isActive: boolean;
roleCount?: number;
};

export const walletGuildsQueryKey = (walletAddress: string | null | undefined) => [
"wallet-guilds",
walletAddress ?? "",
];

export const fetchGuildsByWalletAddress = async (
walletAddress: string,
): Promise<GuildListItem[]> => {
const guildsClient = guildPassClient.guilds as typeof guildPassClient.guilds & {
getGuildsByWalletAddress?: (params: { walletAddress: string }) => Promise<GuildListItem[]>;
};

if (guildsClient.getGuildsByWalletAddress) {
return guildsClient.getGuildsByWalletAddress({ walletAddress });
}

const response = await fetch(
`${appConfig.apiUrl}/guilds?walletAddress=${encodeURIComponent(walletAddress)}`,
);

if (!response.ok) {
throw new Error("Unable to load guilds for this wallet.");
}
}

const data = (await response.json()) as GuildListItem[] | { guilds?: GuildListItem[] };
return Array.isArray(data) ? data : data.guilds ?? [];
};

export const useGuilds = () => {
const useWalletGuilds = (walletAddress: string | null | undefined) => {
return useQuery({
queryKey: walletGuildsQueryKey(walletAddress),
queryFn: () => fetchGuildsByWalletAddress(walletAddress ?? ""),
enabled: !!walletAddress,
networkMode: "offlineFirst",
});
};

const useGuild = (guildId: string) => {
return useQuery({
queryKey: queryKeys.guild.byId(guildId),
Expand Down Expand Up @@ -47,9 +84,11 @@ export const useGuilds = () => {
};

return {
getGuildsByWalletAddress: useWalletGuilds,
getGuild: useGuild,
getGuildConfig: useGuildConfig,
getRoles: useRoles,
useWalletGuilds,
useGuild,
useGuildConfig,
useRoles,
Expand Down
19 changes: 19 additions & 0 deletions tests/fixtures/guild.fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ export type GuildFixture = {
isActive: boolean;
};

export type GuildListItemFixture = {
id: string;
name: string;
isActive: boolean;
roleCount?: number;
};

export type GuildConfigFixture = {
guildId: string;
requiredRoles: string[];
Expand Down Expand Up @@ -108,6 +115,18 @@ export const GUILD_DETAIL_INACTIVE_FIXTURE: GuildFixture = {
// Guild config
// ---------------------------------------------------------------------------

export const WALLET_GUILDS_FIXTURE: GuildListItemFixture[] = [
{ id: GUILD_DETAIL_FIXTURE.id, name: GUILD_DETAIL_FIXTURE.name, isActive: true, roleCount: 3 },
{
id: GUILD_DETAIL_NO_DESCRIPTION_FIXTURE.id,
name: GUILD_DETAIL_NO_DESCRIPTION_FIXTURE.name,
isActive: true,
roleCount: 0,
},
];

export const WALLET_GUILDS_EMPTY_FIXTURE: GuildListItemFixture[] = [];

export const GUILD_CONFIG_FIXTURE: GuildConfigFixture = {
guildId: "guild_abc",
requiredRoles: ["member", "admin"],
Expand Down
12 changes: 11 additions & 1 deletion tests/fixtures/sdk.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@
*/

import { vi } from "vitest";
import { GUILD_DETAIL_FIXTURE, GUILD_CONFIG_FIXTURE, ROLES_LIST_FIXTURE } from "./guild.fixtures";
import {
GUILD_DETAIL_FIXTURE,
GUILD_CONFIG_FIXTURE,
ROLES_LIST_FIXTURE,
WALLET_GUILDS_FIXTURE,
} from "./guild.fixtures";
import { MEMBERSHIP_ACTIVE_FIXTURE, USER_ROLES_FIXTURE } from "./membership.fixtures";
import { ACCESS_GRANTED_FIXTURE } from "./access.fixtures";

Expand All @@ -35,6 +40,7 @@ import { ACCESS_GRANTED_FIXTURE } from "./access.fixtures";
export type SdkMock = {
guilds: {
getGuild: ReturnType<typeof vi.fn>;
getGuildsByWalletAddress: ReturnType<typeof vi.fn>;
getGuildConfig: ReturnType<typeof vi.fn>;
};
roles: {
Expand Down Expand Up @@ -63,6 +69,7 @@ export function createSdkMock(): SdkMock {
_instance = {
guilds: {
getGuild: vi.fn().mockResolvedValue(GUILD_DETAIL_FIXTURE),
getGuildsByWalletAddress: vi.fn().mockResolvedValue(WALLET_GUILDS_FIXTURE),
getGuildConfig: vi.fn().mockResolvedValue(GUILD_CONFIG_FIXTURE),
},
roles: {
Expand All @@ -87,6 +94,9 @@ export function createSdkMock(): SdkMock {
export function resetSdkMock(): void {
if (_instance) {
_instance.guilds.getGuild.mockReset().mockResolvedValue(GUILD_DETAIL_FIXTURE);
_instance.guilds.getGuildsByWalletAddress
.mockReset()
.mockResolvedValue(WALLET_GUILDS_FIXTURE);
_instance.guilds.getGuildConfig.mockReset().mockResolvedValue(GUILD_CONFIG_FIXTURE);
_instance.roles.getRoles.mockReset().mockResolvedValue(ROLES_LIST_FIXTURE);
_instance.roles.getUserRoles.mockReset().mockResolvedValue(USER_ROLES_FIXTURE);
Expand Down
72 changes: 72 additions & 0 deletions tests/hooks/useGuilds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
GUILD_CONFIG_FIXTURE,
ROLES_LIST_FIXTURE,
ROLES_EMPTY_FIXTURE,
WALLET_GUILDS_FIXTURE,
WALLET_GUILDS_EMPTY_FIXTURE,
} from "../fixtures/guild.fixtures";

// ---------------------------------------------------------------------------
Expand All @@ -36,6 +38,10 @@ vi.mock("expo-constants", () => ({

// Import after mocks are registered
import { guildPassClient } from "../../src/lib/guildpassClient";
import {
fetchGuildsByWalletAddress,
walletGuildsQueryKey,
} from "../../src/features/guilds/useGuilds";

// ---------------------------------------------------------------------------
// Helpers
Expand Down Expand Up @@ -72,6 +78,72 @@ describe("GuildNotFoundError", () => {
});
});

// ---------------------------------------------------------------------------
// getGuildsByWalletAddress
// ---------------------------------------------------------------------------

describe("useGuilds – getGuildsByWalletAddress", () => {
let sdk: ReturnType<typeof createSdkMock>;

beforeEach(() => {
sdk = createSdkMock();
});

afterEach(() => {
resetSdkMock();
vi.clearAllMocks();
});

it("calls guildPassClient.guilds.getGuildsByWalletAddress with the correct wallet argument", async () => {
const walletAddress = "0x1234567890123456789012345678901234567890";

await fetchGuildsByWalletAddress(walletAddress);

expect(sdk.guilds.getGuildsByWalletAddress).toHaveBeenCalledTimes(1);
expect(sdk.guilds.getGuildsByWalletAddress).toHaveBeenCalledWith({ walletAddress });
});

it("returns the wallet guild list without transforming any fields", async () => {
const result = await fetchGuildsByWalletAddress(
"0x1234567890123456789012345678901234567890",
);

expect(result).toStrictEqual(WALLET_GUILDS_FIXTURE);
expect(result[0]).toMatchObject({ id: "guild_abc", name: "Alpha Guild", isActive: true });
});

it("supports an empty guild list for empty state rendering", async () => {
sdk.guilds.getGuildsByWalletAddress.mockResolvedValueOnce(WALLET_GUILDS_EMPTY_FIXTURE);

const result = await fetchGuildsByWalletAddress(
"0x0000000000000000000000000000000000000001",
);

expect(result).toStrictEqual([]);
});

it("surfaces SDK rejection as a rejected promise", async () => {
sdk.guilds.getGuildsByWalletAddress.mockRejectedValueOnce(new Error("Unable to load guilds"));

await expect(
fetchGuildsByWalletAddress("0x1234567890123456789012345678901234567890"),
).rejects.toThrow("Unable to load guilds");
});

it("documents the expected query key: ['wallet-guilds', walletAddress]", () => {
expect(walletGuildsQueryKey("0x123")).toStrictEqual(["wallet-guilds", "0x123"]);
expect(walletGuildsQueryKey(null)).toStrictEqual(["wallet-guilds", ""]);
});

it("does not call SDK when walletAddress is empty (enabled guard)", () => {
const walletAddress = "";
const shouldFetch = !!walletAddress;

expect(shouldFetch).toBe(false);
expect(sdk.guilds.getGuildsByWalletAddress).not.toHaveBeenCalled();
});
});

// ---------------------------------------------------------------------------
// getGuild
// ---------------------------------------------------------------------------
Expand Down