diff --git a/app/guilds.tsx b/app/guilds.tsx
index 0e57aeb..3908401 100644
--- a/app/guilds.tsx
+++ b/app/guilds.tsx
@@ -1,7 +1,15 @@
-import { View, FlatList, TextInput, TouchableOpacity, Text, RefreshControl, useColorScheme } from "react-native";
+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 { useGuilds, type GuildListItem } from "../src/features/guilds/useGuilds";
import { AppHeader } from "../src/components/AppHeader";
import { GuildCard } from "../src/components/GuildCard";
import { GuildListSkeleton } from "../src/components/GuildCardSkeleton";
@@ -11,24 +19,116 @@ import { EmptyState } from "../src/components/EmptyState";
import { WalletRequired } from "../src/components/WalletRequired";
import { useDebouncedValue } from "../src/lib/useDebouncedValue";
import React, { useState, useMemo, useCallback } from "react";
-import { useMembership } from "../src/features/membership/useMembership";
+import { useMembership, type EnrichedMembership } from "../src/features/membership/useMembership";
import { StaleDataBanner } from "../src/components/StaleDataBanner";
-import { useStaleQuery } from "../src/features/offline/useStaleQuery";
+import { useCombinedStaleState } from "../src/features/offline/useStaleQuery";
import { useQueryClient } from "@tanstack/react-query";
+import { queryKeys } from "../src/lib/queryKeys";
+
+type GuildListRow = {
+ guildId: string;
+ guildName: string;
+ isActive: boolean;
+ roleCount: number;
+ status?: EnrichedMembership["status"];
+};
+
+function rowsFromWalletGuilds(
+ guilds: GuildListItem[],
+ memberships: EnrichedMembership[],
+): GuildListRow[] {
+ const membershipsByGuildId = new Map(
+ memberships.map((membership) => [membership.guildId, membership]),
+ );
+
+ return guilds.map((guild) => {
+ const membership = membershipsByGuildId.get(guild.id);
+ return {
+ guildId: guild.id,
+ guildName: guild.name,
+ isActive: guild.isActive,
+ roleCount: guild.roleCount ?? membership?.roleCount ?? 0,
+ status: guild.status ?? membership?.status ?? (guild.isActive ? "active" : "inactive"),
+ };
+ });
+}
+
+function rowsFromMemberships(memberships: EnrichedMembership[]): GuildListRow[] {
+ return memberships.map((membership) => ({
+ guildId: membership.guildId,
+ guildName: membership.guildName,
+ isActive: membership.isActive,
+ roleCount: membership.roleCount,
+ status: membership.status,
+ }));
+}
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 { useEnrichedMemberships } = useMembership(walletAddress);
+ const queryClient = useQueryClient();
+
const guildsQuery = useWalletGuilds(walletAddress);
+ const membershipsQuery = useEnrichedMemberships();
+ const memberships = membershipsQuery.data ?? [];
+ const staleState = useCombinedStaleState([guildsQuery, membershipsQuery]);
+
+ const [searchQuery, setSearchQuery] = useState("");
+ const [isRefetching, setIsRefetching] = useState(false);
+ const debouncedQuery = useDebouncedValue(searchQuery, 300);
+
+ const listRows = useMemo(() => {
+ const guilds = guildsQuery.data ?? [];
+ return guilds.length > 0
+ ? rowsFromWalletGuilds(guilds, memberships)
+ : rowsFromMemberships(memberships);
+ }, [guildsQuery.data, memberships]);
+
+ const filteredGuilds = useMemo(() => {
+ const query = debouncedQuery.trim().toLowerCase();
+ if (!query) return listRows;
+ return listRows.filter((guild) => guild.guildName.toLowerCase().includes(query));
+ }, [listRows, debouncedQuery]);
+
+ const handleConnectDifferentWallet = async () => {
+ await disconnect();
+ router.replace("/profile");
+ };
+
+ const handleRefresh = useCallback(async () => {
+ if (!walletAddress) return;
+
+ setIsRefetching(true);
+ try {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: queryKeys.walletGuilds.byWallet(walletAddress) }),
+ queryClient.invalidateQueries({ queryKey: queryKeys.memberships.byWallet(walletAddress) }),
+ guildsQuery.refetch(),
+ membershipsQuery.refetch(),
+ ]);
+ } finally {
+ setIsRefetching(false);
+ }
+ }, [guildsQuery, membershipsQuery, queryClient, walletAddress]);
if (!walletAddress) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ if (guildsQuery.isLoading && membershipsQuery.isLoading && listRows.length === 0) {
return (
@@ -36,17 +136,10 @@ export default function Guilds() {
-
-
-
-
);
}
- if (guildsQuery.isLoading) {
+ if (guildsQuery.isError && membershipsQuery.error && listRows.length === 0) {
return (
@@ -54,17 +147,21 @@ export default function Guilds() {
{staleState.isOffline ? (
) : null}
-
+
-
-
-
-
);
}
- if (guildsQuery.isError) {
+ if (listRows.length === 0) {
return (
@@ -93,7 +190,7 @@ export default function Guilds() {
-
- void guildsQuery.refetch()}
- />
-
- );
- }
-
- return (
-
-
- item.id}
- contentContainerStyle={{ padding: 16 }}
- testID="guilds-list"
- renderItem={({ item }) => (
- router.push(`/guilds/${item.id}`)}
- />
- )}
- ListEmptyComponent={
-
{searchQuery.length > 0 && (
);
- const isRefreshing = isRefetching || membershipsQuery.isRefetching;
+ const isRefreshing = isRefetching || guildsQuery.isRefetching || membershipsQuery.isRefetching;
+ const isShowingOfflineCache = staleState.isOffline && filteredGuilds.length > 0;
return (
item.guildId}
contentContainerStyle={{ padding: 16 }}
testID="guilds-list"
@@ -171,6 +234,8 @@ export default function Guilds() {
id={item.guildId}
isActive={item.isActive}
roleCount={item.roleCount}
+ status={item.status}
+ offlineCached={isShowingOfflineCache}
onPress={() => router.push(`/guilds/${item.guildId}`)}
/>
)}
@@ -184,4 +249,4 @@ export default function Guilds() {
);
-}
\ No newline at end of file
+}
diff --git a/app/guilds/[guildId].tsx b/app/guilds/[guildId].tsx
index 9d2267a..5a591cc 100644
--- a/app/guilds/[guildId].tsx
+++ b/app/guilds/[guildId].tsx
@@ -28,6 +28,11 @@ import type {
AccessRequirement,
PerChainRoleEligibilityResolution,
} from "../../src/features/access/roleEligibilityResolver";
+import {
+ getGuildPassStatusLabel,
+ resolveGuildPassStatus,
+ type GuildPassStatus,
+} from "../../src/features/passes/passCache";
import React from "react";
type GuildDetailRole = {
@@ -37,6 +42,42 @@ type GuildDetailRole = {
requirements?: AccessRequirement[];
};
+const MEMBERSHIP_STATUS_STYLES: Record<
+ GuildPassStatus,
+ { text: string; border: string; cachePill: string; cacheText: string }
+> = {
+ active: {
+ text: "text-success dark:text-green-400",
+ border: "border-success/30 dark:border-green-600/50",
+ cachePill: "bg-success/10",
+ cacheText: "text-success",
+ },
+ inactive: {
+ text: "text-text-muted dark:text-slate-400",
+ border: "",
+ cachePill: "bg-text-muted/10",
+ cacheText: "text-text-muted",
+ },
+ expired: {
+ text: "text-secondary",
+ border: "border-secondary/40",
+ cachePill: "bg-secondary/10",
+ cacheText: "text-secondary",
+ },
+ revoked: {
+ text: "text-error",
+ border: "border-error/40",
+ cachePill: "bg-error/10",
+ cacheText: "text-error",
+ },
+ unknown: {
+ text: "text-text-muted dark:text-slate-400",
+ border: "",
+ cachePill: "bg-text-muted/10",
+ cacheText: "text-text-muted",
+ },
+};
+
function ChainUnavailableState({
chainId,
label,
@@ -104,6 +145,10 @@ export default function GuildDetail() {
const { data: guildConfig } = guildConfigQuery;
const staleState = useCombinedStaleState([guildQuery, membershipQuery, rolesQuery]);
+ const membershipStatus = resolveGuildPassStatus(membership);
+ const membershipStatusLabel = getGuildPassStatusLabel(membershipStatus);
+ const membershipStatusStyle = MEMBERSHIP_STATUS_STYLES[membershipStatus];
+ const isShowingCachedMembership = staleState.isOffline && membership !== undefined;
const fallbackChainId = guild?.chainId ?? 1;
const detailRoles = roles as GuildDetailRole[] | undefined;
const normalizedRequirements = normalizeRoleRequirements(
@@ -125,17 +170,14 @@ export default function GuildDetail() {
enabled: !!validGuildId && !!walletAddress && !!detailRoles,
});
const availabilityByChain = React.useMemo(
- () =>
- new Map(
- chainAvailability.perChain.map((chain) => [chain.chainId, chain] as const),
- ),
+ () => new Map(chainAvailability.perChain.map((chain) => [chain.chainId, chain] as const)),
[chainAvailability.perChain],
);
const guildChainLabel =
groupedRequirements.length === 0
- ? isKnownChainId(guild?.chainId ?? 1)
- ? `${getChainDisplayName(guild?.chainId ?? 1)} (${guild?.chainId ?? 1})`
- : `Unsupported network (chain: ${guild?.chainId ?? 1})`
+ ? isKnownChainId(fallbackChainId)
+ ? `${getChainDisplayName(fallbackChainId)} (${fallbackChainId})`
+ : `Unsupported network (chain: ${fallbackChainId})`
: groupedRequirements.length === 1
? groupedRequirements[0]?.label
: `Multiple networks (${groupedRequirements.map((group) => group.label).join(", ")})`;
@@ -202,10 +244,16 @@ export default function GuildDetail() {
) : null}
-
+
{guild.name}
-
+
{guild.description || "No description provided."}
@@ -227,41 +275,45 @@ export default function GuildDetail() {
- Your Membership
+
+ Your Membership
+
Status
- {membership?.isActive ? "Active Member" : "Not a Member"}
+ {membershipStatusLabel}
+ {isShowingCachedMembership ? (
+
+
+
+ Cached offline
+
+
+
+ ) : null}
- Available Roles
+
+ Available Roles
+
{groupedRequirements.length > 0 ? (
- groupedRequirements.map((group) => (
-
-
- {group.label}
-
-
- {group.requirements.map((role) => (
- {
const availability = availabilityByChain.get(group.chainId);
const isUnavailable =
@@ -315,7 +367,9 @@ export default function GuildDetail() {
);
})
) : (
- No roles defined for this guild.
+
+ No roles defined for this guild.
+
)}
diff --git a/src/components/GuildCard.tsx b/src/components/GuildCard.tsx
index 4a9db62..7a5dedb 100644
--- a/src/components/GuildCard.tsx
+++ b/src/components/GuildCard.tsx
@@ -2,42 +2,98 @@ import { View, Text, TouchableOpacity } from "react-native";
import React from "react";
import { Card } from "./Card";
import { RoleBadge } from "./RoleBadge";
+import type { GuildPassStatus } from "../features/passes/passCache";
type GuildCardProps = {
name: string;
id: string;
isActive: boolean;
roleCount: number;
+ status?: GuildPassStatus;
+ offlineCached?: boolean;
onPress: () => void;
};
-export const GuildCard = ({ name, id, isActive, roleCount, onPress }: GuildCardProps) => {
+const STATUS_STYLES: Record<
+ GuildPassStatus,
+ { label: string; pill: string; text: string; roleTier: "default" | "premium" | "restricted" }
+> = {
+ active: {
+ label: "ACTIVE",
+ pill: "bg-success/10",
+ text: "text-success",
+ roleTier: "premium",
+ },
+ inactive: {
+ label: "INACTIVE",
+ pill: "bg-text-muted/10",
+ text: "text-text-muted",
+ roleTier: "default",
+ },
+ expired: {
+ label: "EXPIRED",
+ pill: "bg-secondary/10",
+ text: "text-secondary",
+ roleTier: "default",
+ },
+ revoked: {
+ label: "REVOKED",
+ pill: "bg-error/10",
+ text: "text-error",
+ roleTier: "restricted",
+ },
+ unknown: {
+ label: "UNKNOWN",
+ pill: "bg-text-muted/10",
+ text: "text-text-muted",
+ roleTier: "default",
+ },
+};
+
+export const GuildCard = ({
+ name,
+ id,
+ isActive,
+ roleCount,
+ status,
+ offlineCached = false,
+ onPress,
+}: GuildCardProps) => {
+ const resolvedStatus = status ?? (isActive ? "active" : "inactive");
+ const statusStyle = STATUS_STYLES[resolvedStatus];
+
return (
{name}
-
-
- {isActive ? "ACTIVE" : "INACTIVE"}
-
+
+ {statusStyle.label}
ID: {id}
•
Tap to view details
+ {offlineCached ? (
+ <>
+ •
+
+ Cached offline
+
+ >
+ ) : null}
diff --git a/src/features/guilds/useGuilds.ts b/src/features/guilds/useGuilds.ts
index c0c20c9..3339b5c 100644
--- a/src/features/guilds/useGuilds.ts
+++ b/src/features/guilds/useGuilds.ts
@@ -1,18 +1,27 @@
-import { useQuery } from "@tanstack/react-query";
+import { onlineManager, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import { guildPassClient } from "../../lib/guildpassClient";
import { appConfig } from "../../config/appConfig";
+import { queryKeys } from "../../lib/queryKeys";
+import { getCachedMembershipSummaries, type GuildPassStatus } from "../passes/passCache";
export type GuildListItem = {
id: string;
name: string;
isActive: boolean;
roleCount?: number;
+ status?: GuildPassStatus;
+ lastSyncedAt?: number;
};
-export const walletGuildsQueryKey = (walletAddress: string | null | undefined) => [
- "wallet-guilds",
- walletAddress ?? "",
-];
+export class GuildNotFoundError extends Error {
+ constructor(guildId: string) {
+ super(`Guild not found: ${guildId}`);
+ this.name = "GuildNotFoundError";
+ }
+}
+
+export const walletGuildsQueryKey = (walletAddress: string | null | undefined) =>
+ queryKeys.walletGuilds.byWallet(walletAddress ?? "");
export const fetchGuildsByWalletAddress = async (
walletAddress: string,
@@ -34,23 +43,70 @@ export const fetchGuildsByWalletAddress = async (
}
const data = (await response.json()) as GuildListItem[] | { guilds?: GuildListItem[] };
- return Array.isArray(data) ? data : data.guilds ?? [];
+ return Array.isArray(data) ? data : (data.guilds ?? []);
};
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function cachedGuildName(queryClient: QueryClient, guildId: string): string {
+ const guild = queryClient.getQueryData(queryKeys.guild.byId(guildId));
+ return isRecord(guild) && typeof guild.name === "string" ? guild.name : guildId;
+}
+
+function getCachedWalletGuilds(
+ queryClient: QueryClient,
+ walletAddress: string,
+): GuildListItem[] | undefined {
+ const summaries = getCachedMembershipSummaries(queryClient, walletAddress);
+ if (!summaries) return undefined;
+
+ return summaries.map((summary) => ({
+ id: summary.guildId,
+ name: cachedGuildName(queryClient, summary.guildId),
+ isActive: summary.isActive,
+ roleCount: summary.roleCount,
+ status: summary.status,
+ lastSyncedAt: summary.lastSyncedAt,
+ }));
+}
+
export const useGuilds = () => {
+ const queryClient = useQueryClient();
+
const useWalletGuilds = (walletAddress: string | null | undefined) => {
- return useQuery({
- queryKey: walletGuildsQueryKey(walletAddress),
- queryFn: () => fetchGuildsByWalletAddress(walletAddress ?? ""),
+ const queryKey = walletGuildsQueryKey(walletAddress);
+
+ return useQuery({
+ queryKey,
+ queryFn: async () => {
+ if (!walletAddress) return [];
+
+ const cached = queryClient.getQueryData(queryKey);
+ if (!onlineManager.isOnline()) {
+ return cached ?? getCachedWalletGuilds(queryClient, walletAddress) ?? [];
+ }
+
+ return fetchGuildsByWalletAddress(walletAddress);
+ },
enabled: !!walletAddress,
networkMode: "offlineFirst",
+ refetchOnReconnect: "always",
});
};
const useGuild = (guildId: string) => {
- return useQuery({
- queryKey: queryKeys.guild.byId(guildId),
+ const queryKey = queryKeys.guild.byId(guildId);
+
+ return useQuery({
+ queryKey,
queryFn: async () => {
+ const cached = queryClient.getQueryData(queryKey);
+ if (!onlineManager.isOnline() && cached !== undefined) {
+ return cached as any;
+ }
+
try {
return await guildPassClient.guilds.getGuild({ guildId });
} catch (error) {
@@ -62,24 +118,45 @@ export const useGuilds = () => {
},
enabled: !!guildId,
networkMode: "offlineFirst",
+ refetchOnReconnect: "always",
});
};
const useGuildConfig = (guildId: string) => {
- return useQuery({
- queryKey: queryKeys.guildConfig.byId(guildId),
- queryFn: () => guildPassClient.guilds.getGuildConfig({ guildId }),
+ const queryKey = queryKeys.guildConfig.byId(guildId);
+
+ return useQuery({
+ queryKey,
+ queryFn: async () => {
+ const cached = queryClient.getQueryData(queryKey);
+ if (!onlineManager.isOnline() && cached !== undefined) {
+ return cached as any;
+ }
+
+ return guildPassClient.guilds.getGuildConfig({ guildId });
+ },
enabled: !!guildId,
networkMode: "offlineFirst",
+ refetchOnReconnect: "always",
});
};
const useRoles = (guildId: string) => {
- return useQuery({
- queryKey: queryKeys.guildRoles.byId(guildId),
- queryFn: () => guildPassClient.roles.getRoles({ guildId }),
+ const queryKey = queryKeys.guildRoles.byId(guildId);
+
+ return useQuery({
+ queryKey,
+ queryFn: async () => {
+ const cached = queryClient.getQueryData(queryKey);
+ if (!onlineManager.isOnline() && cached !== undefined) {
+ return cached as any;
+ }
+
+ return guildPassClient.roles.getRoles({ guildId });
+ },
enabled: !!guildId,
networkMode: "offlineFirst",
+ refetchOnReconnect: "always",
});
};
diff --git a/src/features/membership/useMembership.ts b/src/features/membership/useMembership.ts
index 497f6ad..765767b 100644
--- a/src/features/membership/useMembership.ts
+++ b/src/features/membership/useMembership.ts
@@ -1,65 +1,110 @@
-import { useQuery, useQueries } from "@tanstack/react-query";
+import { onlineManager, useQuery, useQueries, useQueryClient } from "@tanstack/react-query";
import { guildPassClient } from "../../lib/guildpassClient";
import { queryKeys } from "../../lib/queryKeys";
+import {
+ getCachedMembershipSummaries,
+ normalizeMembershipForPassSummary,
+ type CachedGuildPassSummary,
+} from "../passes/passCache";
-export type NormalizedMembership = {
- guildId: string;
- isActive: boolean;
- roleCount: number;
-};
+export type NormalizedMembership = CachedGuildPassSummary;
export type EnrichedMembership = NormalizedMembership & {
guildName: string;
};
export const useMembership = (walletAddress: string | null) => {
+ const queryClient = useQueryClient();
+
const useMembershipQuery = (guildId: string) => {
- return useQuery({
- queryKey: queryKeys.membership.byWalletAndGuild(walletAddress ?? "", guildId),
- queryFn: () =>
- guildPassClient.membership.getMembership({
+ const queryKey = queryKeys.membership.byWalletAndGuild(walletAddress ?? "", guildId);
+
+ return useQuery({
+ queryKey,
+ queryFn: async () => {
+ const cached = queryClient.getQueryData(queryKey);
+ if (!onlineManager.isOnline() && cached !== undefined) {
+ return cached as any;
+ }
+
+ return guildPassClient.membership.getMembership({
walletAddress: walletAddress!,
guildId,
- }),
+ });
+ },
enabled: !!walletAddress && !!guildId,
networkMode: "offlineFirst",
+ refetchOnReconnect: "always",
});
};
const useUserRoles = (guildId: string) => {
- return useQuery({
- queryKey: queryKeys.userRoles.byWalletAndGuild(walletAddress ?? "", guildId),
- queryFn: () =>
- guildPassClient.roles.getUserRoles({
+ const queryKey = queryKeys.userRoles.byWalletAndGuild(walletAddress ?? "", guildId);
+
+ return useQuery({
+ queryKey,
+ queryFn: async () => {
+ const cached = queryClient.getQueryData(queryKey);
+ if (!onlineManager.isOnline() && cached !== undefined) {
+ return cached as any;
+ }
+
+ return guildPassClient.roles.getUserRoles({
walletAddress: walletAddress!,
guildId,
- }),
+ });
+ },
enabled: !!walletAddress && !!guildId,
networkMode: "offlineFirst",
+ refetchOnReconnect: "always",
});
};
const useMembershipsQuery = () => {
- return useQuery({
- queryKey: queryKeys.memberships.byWallet(walletAddress ?? ""),
+ const queryKey = queryKeys.memberships.byWallet(walletAddress ?? "");
+
+ return useQuery({
+ queryKey,
queryFn: async () => {
if (!walletAddress) return [];
- const { getDatabase } = await import("../../database/connection");
- const dal = await import("../../database/dal");
- const db = getDatabase();
- const rows = await dal.getMembershipsByWallet(db, walletAddress);
-
- return rows.map((row) => {
- const membership = JSON.parse(row.raw_json);
- return {
- guildId: row.guild_id,
- isActive: row.status === "active",
- roleCount: membership.roles?.length || 0,
- } satisfies NormalizedMembership;
- });
+ const cached = getCachedMembershipSummaries(queryClient, walletAddress);
+
+ if (!onlineManager.isOnline() && cached !== undefined) {
+ return cached;
+ }
+
+ try {
+ const { getDatabase } = await import("../../database/connection");
+ const dal = await import("../../database/dal");
+ const db = getDatabase();
+ const rows = await dal.getMembershipsByWallet(db, walletAddress);
+
+ if (rows.length > 0) {
+ return rows
+ .map((row) => {
+ const membership = JSON.parse(row.raw_json);
+ return normalizeMembershipForPassSummary(membership, {
+ fallbackGuildId: row.guild_id,
+ fallbackRoleCount: membership.roles?.length,
+ lastSyncedAt: new Date(row.updated_at).getTime(),
+ });
+ })
+ .filter((entry): entry is NormalizedMembership => entry !== null);
+ }
+ } catch (error) {
+ // The encrypted TanStack cache is the reliable offline source. The
+ // SQLite layer is optional here until a wallet-wide membership API
+ // exists and can keep it populated.
+ if (cached === undefined) {
+ throw error;
+ }
+ }
+
+ return cached ?? [];
},
enabled: !!walletAddress,
networkMode: "offlineFirst",
+ refetchOnReconnect: "always",
});
};
@@ -68,13 +113,25 @@ export const useMembership = (walletAddress: string | null) => {
const memberships = membershipsQuery.data ?? [];
const guildNameQueries = useQueries({
- queries: memberships.map((m) => ({
- queryKey: queryKeys.guild.byId(m.guildId),
- queryFn: () => guildPassClient.guilds.getGuild({ guildId: m.guildId }),
- enabled: !!m.guildId,
- staleTime: 1000 * 60 * 5,
- networkMode: "offlineFirst" as const,
- })),
+ queries: memberships.map((m) => {
+ const queryKey = queryKeys.guild.byId(m.guildId);
+
+ return {
+ queryKey,
+ queryFn: async () => {
+ const cached = queryClient.getQueryData(queryKey);
+ if (!onlineManager.isOnline() && cached !== undefined) {
+ return cached as any;
+ }
+
+ return guildPassClient.guilds.getGuild({ guildId: m.guildId });
+ },
+ enabled: !!m.guildId,
+ staleTime: 1000 * 60 * 5,
+ networkMode: "offlineFirst" as const,
+ refetchOnReconnect: "always" as const,
+ };
+ }),
});
const enriched = memberships.map((m, i) => {
@@ -87,6 +144,8 @@ export const useMembership = (walletAddress: string | null) => {
: undefined) ?? m.guildId,
isActive: m.isActive,
roleCount: m.roleCount,
+ status: m.status,
+ lastSyncedAt: m.lastSyncedAt,
} satisfies EnrichedMembership;
});
diff --git a/src/features/passes/passCache.ts b/src/features/passes/passCache.ts
new file mode 100644
index 0000000..b3cccbf
--- /dev/null
+++ b/src/features/passes/passCache.ts
@@ -0,0 +1,250 @@
+import type { QueryClient } from "@tanstack/react-query";
+import { queryKeys, QUERY_ROOTS } from "../../lib/queryKeys";
+
+export type GuildPassStatus = "active" | "inactive" | "expired" | "revoked" | "unknown";
+
+export type CachedGuildPassSummary = {
+ guildId: string;
+ isActive: boolean;
+ roleCount: number;
+ status: GuildPassStatus;
+ lastSyncedAt?: number;
+};
+
+export const GUILD_PASS_QUERY_ROOTS = [
+ QUERY_ROOTS.MEMBERSHIP,
+ QUERY_ROOTS.MEMBERSHIPS,
+ QUERY_ROOTS.WALLET_GUILDS,
+ QUERY_ROOTS.USER_ROLES,
+ QUERY_ROOTS.GUILD,
+ QUERY_ROOTS.GUILD_CONFIG,
+ QUERY_ROOTS.GUILD_ROLES,
+] as const;
+
+export function isGuildPassQuery(queryKey: readonly unknown[]): boolean {
+ const root = queryKey[0];
+ return typeof root === "string" && (GUILD_PASS_QUERY_ROOTS as readonly string[]).includes(root);
+}
+
+export function getGuildPassStatusLabel(status: GuildPassStatus): string {
+ switch (status) {
+ case "active":
+ return "Active Member";
+ case "expired":
+ return "Expired";
+ case "revoked":
+ return "Revoked";
+ case "inactive":
+ return "Not a Member";
+ case "unknown":
+ default:
+ return "Unknown";
+ }
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function readString(value: Record, keys: string[]): string | undefined {
+ for (const key of keys) {
+ const candidate = value[key];
+ if (typeof candidate === "string" && candidate.trim().length > 0) {
+ return candidate;
+ }
+ }
+ return undefined;
+}
+
+function isExpiredTimestamp(value: string | undefined, now: number): boolean {
+ if (!value) return false;
+ const time = new Date(value).getTime();
+ return Number.isFinite(time) && time <= now;
+}
+
+export function resolveGuildPassStatus(
+ membership: unknown,
+ now: number = Date.now(),
+): GuildPassStatus {
+ if (!isRecord(membership)) {
+ return "unknown";
+ }
+
+ const explicitStatus = readString(membership, [
+ "status",
+ "membershipStatus",
+ "state",
+ ])?.toLowerCase();
+
+ if (
+ explicitStatus === "active" ||
+ explicitStatus === "inactive" ||
+ explicitStatus === "expired" ||
+ explicitStatus === "revoked"
+ ) {
+ return explicitStatus;
+ }
+
+ if (readString(membership, ["revokedAt", "revocationDate"])) {
+ return "revoked";
+ }
+
+ if (isExpiredTimestamp(readString(membership, ["expiresAt", "expiredAt", "validUntil"]), now)) {
+ return "expired";
+ }
+
+ if (typeof membership.isActive === "boolean") {
+ return membership.isActive ? "active" : "inactive";
+ }
+
+ return "unknown";
+}
+
+function isActiveStatus(status: GuildPassStatus): boolean {
+ return status === "active";
+}
+
+function roleCountFromMembership(membership: Record): number | undefined {
+ const roles = membership.roles;
+ if (Array.isArray(roles)) {
+ return roles.length;
+ }
+
+ const roleCount = membership.roleCount;
+ if (typeof roleCount === "number" && Number.isFinite(roleCount)) {
+ return roleCount;
+ }
+
+ return undefined;
+}
+
+function roleCountFromQuery(
+ queryClient: Pick,
+ walletAddress: string,
+ guildId: string,
+): number | undefined {
+ const roles = queryClient.getQueryData(
+ queryKeys.userRoles.byWalletAndGuild(walletAddress, guildId),
+ );
+ return Array.isArray(roles) ? roles.length : undefined;
+}
+
+export function normalizeMembershipForPassSummary(
+ membership: unknown,
+ options: {
+ fallbackGuildId?: string;
+ fallbackRoleCount?: number;
+ lastSyncedAt?: number;
+ now?: number;
+ } = {},
+): CachedGuildPassSummary | null {
+ if (!isRecord(membership)) {
+ return null;
+ }
+
+ const guildId = readString(membership, ["guildId", "guild_id", "id"]) ?? options.fallbackGuildId;
+
+ if (!guildId) {
+ return null;
+ }
+
+ const status = resolveGuildPassStatus(membership, options.now);
+ const roleCount = roleCountFromMembership(membership) ?? options.fallbackRoleCount ?? 0;
+
+ return {
+ guildId,
+ isActive: isActiveStatus(status),
+ roleCount,
+ status,
+ lastSyncedAt: options.lastSyncedAt,
+ };
+}
+
+function getQueryUpdatedAt(
+ queryClient: QueryClient,
+ queryKey: readonly unknown[],
+): number | undefined {
+ return queryClient.getQueryCache().find({ queryKey })?.state.dataUpdatedAt;
+}
+
+function normalizeAggregateData(
+ queryClient: QueryClient,
+ walletAddress: string,
+): CachedGuildPassSummary[] | undefined {
+ const aggregate = queryClient.getQueryData(queryKeys.memberships.byWallet(walletAddress));
+ if (!Array.isArray(aggregate)) {
+ return undefined;
+ }
+
+ const lastSyncedAt = getQueryUpdatedAt(
+ queryClient,
+ queryKeys.memberships.byWallet(walletAddress),
+ );
+ return aggregate
+ .map((entry) =>
+ normalizeMembershipForPassSummary(entry, {
+ lastSyncedAt,
+ }),
+ )
+ .filter((entry): entry is CachedGuildPassSummary => entry !== null);
+}
+
+function deriveMembershipSummariesFromEntities(
+ queryClient: QueryClient,
+ walletAddress: string,
+): CachedGuildPassSummary[] {
+ const walletKey = walletAddress.toLowerCase();
+ const summaries = new Map();
+
+ queryClient
+ .getQueryCache()
+ .getAll()
+ .forEach((query) => {
+ const [root, queryWalletAddress, guildId] = query.queryKey;
+ if (
+ root !== QUERY_ROOTS.MEMBERSHIP ||
+ typeof queryWalletAddress !== "string" ||
+ queryWalletAddress.toLowerCase() !== walletKey ||
+ typeof guildId !== "string"
+ ) {
+ return;
+ }
+
+ const summary = normalizeMembershipForPassSummary(query.state.data, {
+ fallbackGuildId: guildId,
+ fallbackRoleCount: roleCountFromQuery(queryClient, queryWalletAddress, guildId),
+ lastSyncedAt: query.state.dataUpdatedAt,
+ });
+
+ if (summary) {
+ summaries.set(summary.guildId, summary);
+ }
+ });
+
+ return [...summaries.values()].sort((a, b) => a.guildId.localeCompare(b.guildId));
+}
+
+export function getCachedMembershipSummaries(
+ queryClient: QueryClient,
+ walletAddress: string,
+): CachedGuildPassSummary[] | undefined {
+ const derived = deriveMembershipSummariesFromEntities(queryClient, walletAddress);
+ if (derived.length > 0) {
+ return derived;
+ }
+
+ return normalizeAggregateData(queryClient, walletAddress);
+}
+
+export function rebuildMembershipsAggregateFromCache(
+ queryClient: QueryClient,
+ walletAddress: string,
+): CachedGuildPassSummary[] | undefined {
+ const summaries = deriveMembershipSummariesFromEntities(queryClient, walletAddress);
+ if (summaries.length === 0) {
+ return normalizeAggregateData(queryClient, walletAddress);
+ }
+
+ queryClient.setQueryData(queryKeys.memberships.byWallet(walletAddress), summaries);
+ return summaries;
+}
diff --git a/src/features/sync/reconcile.ts b/src/features/sync/reconcile.ts
index b9965b4..e8817c6 100644
--- a/src/features/sync/reconcile.ts
+++ b/src/features/sync/reconcile.ts
@@ -6,7 +6,6 @@
*/
import { isWalletScopedQueryRoot } from "../../lib/queryKeys";
-import { PERSISTABLE_QUERY_KEY_ROOTS } from "../../lib/offlineCache";
import type {
SyncCorrection,
SyncCorrectionSeverity,
@@ -15,11 +14,16 @@ import type {
SyncEntityKind,
} from "./sync.types";
-/** Query-key roots the reconciliation pass covers: derived from the offline
- * cache allowlist so the two never drift. The "access-check" root is a
- * mutation namespace, not a cached server entity, so it is excluded. */
-export const RECONCILED_QUERY_KEY_ROOTS: readonly SyncEntityKind[] =
- PERSISTABLE_QUERY_KEY_ROOTS.filter((root): root is SyncEntityKind => root !== "access-check");
+/** Query-key roots the reconciliation pass covers. Being persistable and being
+ * server-reconcilable are separate properties; aggregate/client-only roots
+ * such as "memberships" are persisted but refreshed indirectly. */
+export const RECONCILED_QUERY_KEY_ROOTS: readonly SyncEntityKind[] = [
+ "membership",
+ "user-roles",
+ "guild",
+ "guild-config",
+ "guild-roles",
+];
/**
* Parses a React Query key into a sync entity descriptor.
diff --git a/src/features/sync/syncEngine.ts b/src/features/sync/syncEngine.ts
index 3515ffa..87b44bd 100644
--- a/src/features/sync/syncEngine.ts
+++ b/src/features/sync/syncEngine.ts
@@ -20,12 +20,8 @@ import { hashKey } from "@tanstack/react-query";
import type { QueryClient } from "@tanstack/react-query";
import { computeEntityVersion, describeSyncableQuery, diffEntity } from "./reconcile";
import { prioritizeDescriptors } from "./syncPriority";
-import {
- DEFAULT_RETRY_CONFIG,
- RetryAborted,
- runWithRetry,
- type RetryConfig,
-} from "./retryPolicy";
+import { rebuildMembershipsAggregateFromCache } from "../passes/passCache";
+import { DEFAULT_RETRY_CONFIG, RetryAborted, runWithRetry, type RetryConfig } from "./retryPolicy";
import type {
SyncCorrection,
SyncEntityDescriptor,
@@ -198,15 +194,23 @@ export function createSyncEngine(deps: SyncEngineDeps): SyncEngine {
const corrections: SyncCorrection[] = [];
const errors: SyncRunError[] = [];
const metaEntries: Record = {};
+ const walletsToRefresh = new Set();
let entitiesUpdated = 0;
let abortedOffline = false;
results.forEach((result, index) => {
+ const descriptor = descriptors[index];
if (result.status === "fulfilled") {
if (result.value === null) return; // entity vanished mid-pass
corrections.push(...result.value.corrections);
metaEntries[result.value.metaKey] = result.value.meta;
if (result.value.updated) entitiesUpdated += 1;
+ if (
+ descriptor.walletAddress &&
+ (descriptor.kind === "membership" || descriptor.kind === "user-roles")
+ ) {
+ walletsToRefresh.add(descriptor.walletAddress);
+ }
} else if (result.reason instanceof RetryAborted) {
// Connectivity vanished mid-pass. Not an entity error: the entity was
// never disproven, so it keeps its existing meta and is left for the
@@ -225,6 +229,9 @@ export function createSyncEngine(deps: SyncEngineDeps): SyncEngine {
// forward progress instead of being discarded wholesale.
syncStore.getState().recordEntityMetaBatch(metaEntries);
syncStore.getState().addCorrections(corrections);
+ walletsToRefresh.forEach((walletAddress) => {
+ rebuildMembershipsAggregateFromCache(queryClient, walletAddress);
+ });
const finishedAt = now();
if (errors.length > 0) {
diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts
index e8aa9c4..2a49a9d 100644
--- a/src/lib/queryKeys.ts
+++ b/src/lib/queryKeys.ts
@@ -3,6 +3,7 @@ import { walletScopedQueryRoots } from "./walletScopedCache";
export const QUERY_ROOTS = {
MEMBERSHIP: "membership",
MEMBERSHIPS: "memberships",
+ WALLET_GUILDS: "wallet-guilds",
USER_ROLES: "user-roles",
GUILD: "guild",
GUILD_CONFIG: "guild-config",
@@ -36,6 +37,10 @@ export const queryKeys = {
all: ["memberships"] as const,
byWallet: (walletAddress: string) => ["memberships", walletAddress] as const,
},
+ walletGuilds: {
+ all: ["wallet-guilds"] as const,
+ byWallet: (walletAddress: string) => ["wallet-guilds", walletAddress] as const,
+ },
userRoles: {
all: ["user-roles"] as const,
byWalletAndGuild: (walletAddress: string, guildId: string) =>
@@ -55,10 +60,12 @@ export const queryKeys = {
export const PERSISTABLE_QUERY_ROOTS: readonly QueryRoot[] = [
QUERY_ROOTS.MEMBERSHIP,
QUERY_ROOTS.MEMBERSHIPS,
+ QUERY_ROOTS.WALLET_GUILDS,
QUERY_ROOTS.USER_ROLES,
QUERY_ROOTS.GUILD,
QUERY_ROOTS.GUILD_CONFIG,
QUERY_ROOTS.GUILD_ROLES,
+ QUERY_ROOTS.ACCESS_CHECK,
];
export function isPersistableQuery(queryKey: readonly unknown[]): boolean {
diff --git a/src/lib/walletScopedCache.ts b/src/lib/walletScopedCache.ts
index e807061..a5defb6 100644
--- a/src/lib/walletScopedCache.ts
+++ b/src/lib/walletScopedCache.ts
@@ -1,6 +1,11 @@
import type { QueryClient } from "@tanstack/react-query";
-export const walletScopedQueryRoots = new Set(["membership", "memberships", "user-roles"]);
+export const walletScopedQueryRoots = new Set([
+ "membership",
+ "memberships",
+ "wallet-guilds",
+ "user-roles",
+]);
const walletScopedMutationRoots = new Set(["access-check"]);
function isWalletScopedQuery(queryKey: readonly unknown[]): boolean {
diff --git a/tests/GuildCard.test.tsx b/tests/GuildCard.test.tsx
new file mode 100644
index 0000000..4998131
--- /dev/null
+++ b/tests/GuildCard.test.tsx
@@ -0,0 +1,33 @@
+import React from "react";
+import TestRenderer from "react-test-renderer";
+import { describe, expect, it, vi } from "vitest";
+import { GuildCard } from "../src/components/GuildCard";
+
+vi.mock("react-native", () => ({
+ View: "View",
+ Text: "Text",
+ TouchableOpacity: "TouchableOpacity",
+}));
+
+describe("GuildCard", () => {
+ it("labels cached revoked passes distinctly while offline", () => {
+ const renderer = TestRenderer.create(
+ {}}
+ />,
+ );
+
+ const button = renderer.root.findByProps({ accessibilityRole: "button" });
+ expect(button.props.accessibilityLabel).toContain("revoked");
+ expect(button.props.accessibilityLabel).toContain("cached offline");
+
+ expect(JSON.stringify(renderer.toJSON())).toContain("REVOKED");
+ expect(renderer.root.findByProps({ testID: "guild-card-offline-cache" })).toBeDefined();
+ });
+});
diff --git a/tests/passCache.test.ts b/tests/passCache.test.ts
new file mode 100644
index 0000000..b59800f
--- /dev/null
+++ b/tests/passCache.test.ts
@@ -0,0 +1,128 @@
+import { describe, expect, it } from "vitest";
+import { QueryClient, dehydrate, hydrate } from "@tanstack/react-query";
+import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
+import {
+ getCachedMembershipSummaries,
+ rebuildMembershipsAggregateFromCache,
+ resolveGuildPassStatus,
+} from "../src/features/passes/passCache";
+import { isPersistableQuery, PERSISTED_QUERY_CACHE_KEY } from "../src/lib/offlineCache";
+import { queryKeys } from "../src/lib/queryKeys";
+import {
+ MEMBERSHIP_ACTIVE_FIXTURE,
+ TEST_WALLET_ADDRESS,
+ USER_ROLES_FIXTURE,
+} from "./fixtures/membership.fixtures";
+
+function createMemoryStorage() {
+ const store = new Map();
+
+ return {
+ getItem: async (key: string) => store.get(key) ?? null,
+ setItem: async (key: string, value: string) => {
+ store.set(key, value);
+ },
+ removeItem: async (key: string) => {
+ store.delete(key);
+ },
+ };
+}
+
+describe("pass cache", () => {
+ it("restores a persisted wallet pass list after an app restart", async () => {
+ const storage = createMemoryStorage();
+ const persister = createAsyncStoragePersister({
+ storage,
+ key: PERSISTED_QUERY_CACHE_KEY,
+ throttleTime: 0,
+ });
+ const sourceClient = new QueryClient();
+ const membershipsKey = queryKeys.memberships.byWallet(TEST_WALLET_ADDRESS);
+
+ sourceClient.setQueryData(membershipsKey, [
+ {
+ guildId: "guild_abc",
+ isActive: true,
+ roleCount: 2,
+ status: "active",
+ },
+ ]);
+
+ await persister.persistClient({
+ timestamp: Date.now(),
+ buster: "",
+ clientState: dehydrate(sourceClient, {
+ shouldDehydrateQuery: (query) =>
+ query.state.status === "success" && isPersistableQuery(query.queryKey),
+ }),
+ });
+
+ const restoredClient = new QueryClient();
+ const persisted = await persister.restoreClient();
+ expect(persisted).toBeDefined();
+ hydrate(restoredClient, persisted!.clientState);
+
+ expect(getCachedMembershipSummaries(restoredClient, TEST_WALLET_ADDRESS)).toStrictEqual([
+ {
+ guildId: "guild_abc",
+ isActive: true,
+ roleCount: 2,
+ status: "active",
+ lastSyncedAt: expect.any(Number),
+ },
+ ]);
+ });
+
+ it("rebuilds the aggregate pass list from refreshed membership and role entities", () => {
+ const queryClient = new QueryClient();
+ queryClient.setQueryData(queryKeys.memberships.byWallet(TEST_WALLET_ADDRESS), [
+ {
+ guildId: "guild_abc",
+ isActive: true,
+ roleCount: 2,
+ status: "active",
+ },
+ ]);
+ queryClient.setQueryData(
+ queryKeys.membership.byWalletAndGuild(TEST_WALLET_ADDRESS, "guild_abc"),
+ {
+ ...MEMBERSHIP_ACTIVE_FIXTURE,
+ status: "revoked",
+ isActive: false,
+ },
+ );
+ queryClient.setQueryData(
+ queryKeys.userRoles.byWalletAndGuild(TEST_WALLET_ADDRESS, "guild_abc"),
+ [],
+ );
+
+ const summaries = rebuildMembershipsAggregateFromCache(queryClient, TEST_WALLET_ADDRESS);
+
+ expect(summaries).toStrictEqual([
+ {
+ guildId: "guild_abc",
+ isActive: false,
+ roleCount: 0,
+ status: "revoked",
+ lastSyncedAt: expect.any(Number),
+ },
+ ]);
+ expect(
+ queryClient.getQueryData(queryKeys.memberships.byWallet(TEST_WALLET_ADDRESS)),
+ ).toStrictEqual(summaries);
+ });
+
+ it("classifies expired and revoked cached passes distinctly", () => {
+ expect(resolveGuildPassStatus({ ...MEMBERSHIP_ACTIVE_FIXTURE, status: "revoked" })).toBe(
+ "revoked",
+ );
+ expect(
+ resolveGuildPassStatus({
+ ...MEMBERSHIP_ACTIVE_FIXTURE,
+ expiresAt: "2020-01-01T00:00:00.000Z",
+ }),
+ ).toBe("expired");
+ expect(resolveGuildPassStatus(MEMBERSHIP_ACTIVE_FIXTURE)).toBe("active");
+ expect(USER_ROLES_FIXTURE).toHaveLength(2);
+ });
+});
diff --git a/tests/sync/syncCoordinator.integration.test.ts b/tests/sync/syncCoordinator.integration.test.ts
index e40ad0c..6dcc2f3 100644
--- a/tests/sync/syncCoordinator.integration.test.ts
+++ b/tests/sync/syncCoordinator.integration.test.ts
@@ -371,12 +371,8 @@ describe("sync under intermittent connectivity", () => {
await pass;
// And the full order still has every tier-1 entity ahead of every tier-2.
- const lastTierOne = order.findLastIndex(
- (k) => k === "membership" || k === "user-roles",
- );
- const firstTierTwo = order.findIndex(
- (k) => k !== "membership" && k !== "user-roles",
- );
+ const lastTierOne = order.findLastIndex((k) => k === "membership" || k === "user-roles");
+ const firstTierTwo = order.findIndex((k) => k !== "membership" && k !== "user-roles");
expect(lastTierOne).toBeLessThan(firstTierTwo);
});
@@ -485,10 +481,17 @@ describe("sync under intermittent connectivity", () => {
expect(summary?.status).toBe("completed");
expect(summary?.errors).toStrictEqual([]);
- // The unreconcilable entry is skipped, not counted and not destroyed.
+ // The unreconcilable entry is skipped, not counted, and refreshed from
+ // reconciled membership/role entities rather than left stale.
expect(summary?.entitiesChecked).toBe(2);
expect(queryClient.getQueryData(["memberships", TEST_WALLET_ADDRESS])).toStrictEqual([
- { guildId: GUILD_ID },
+ {
+ guildId: GUILD_ID,
+ isActive: true,
+ roleCount: 2,
+ status: "active",
+ lastSyncedAt: expect.any(Number),
+ },
]);
expect(useSyncStore.getState().status).toBe("idle");
});
diff --git a/tests/sync/syncEngine.test.ts b/tests/sync/syncEngine.test.ts
index f53a80b..015bb6e 100644
--- a/tests/sync/syncEngine.test.ts
+++ b/tests/sync/syncEngine.test.ts
@@ -19,6 +19,7 @@ import {
} from "../../src/features/sync/syncEngine";
import { useSyncStore } from "../../src/features/sync/sync.store";
import { computeEntityVersion } from "../../src/features/sync/reconcile";
+import { queryKeys } from "../../src/lib/queryKeys";
import {
MEMBERSHIP_ACTIVE_FIXTURE,
TEST_WALLET_ADDRESS,
@@ -129,6 +130,41 @@ describe("sync engine – acceptance scenario: cached grant revoked server-side"
expect(useSyncStore.getState().status).toBe("idle");
expect(useSyncStore.getState().lastSyncCompletedAt).toBe(now);
});
+
+ it("refreshes the wallet pass list aggregate after reconnect reconciliation", async () => {
+ queryClient.setQueryData(queryKeys.memberships.byWallet(TEST_WALLET_ADDRESS), [
+ {
+ guildId: "guild_abc",
+ isActive: true,
+ roleCount: 2,
+ status: "active",
+ },
+ ]);
+ const engine = createSyncEngine({
+ queryClient,
+ fetchers: makeFetchers({
+ membership: vi.fn().mockResolvedValue({ ...MEMBERSHIP_REVOKED, status: "revoked" }),
+ "user-roles": vi.fn().mockResolvedValue([]),
+ }),
+ syncStore: useSyncStore,
+ sleep: async () => {},
+ isOnline: () => true,
+ });
+
+ await engine.runReconciliation();
+
+ expect(
+ queryClient.getQueryData(queryKeys.memberships.byWallet(TEST_WALLET_ADDRESS)),
+ ).toStrictEqual([
+ {
+ guildId: "guild_abc",
+ isActive: false,
+ roleCount: 0,
+ status: "revoked",
+ lastSyncedAt: expect.any(Number),
+ },
+ ]);
+ });
});
describe("sync engine – behaviour", () => {