diff --git a/app/guilds.tsx b/app/guilds.tsx
index 7f24507..17c335e 100644
--- a/app/guilds.tsx
+++ b/app/guilds.tsx
@@ -7,6 +7,7 @@ import { GuildCard } from "../src/components/GuildCard";
import { LoadingState } from "../src/components/LoadingState";
import { ErrorState } from "../src/components/ErrorState";
import { EmptyState } from "../src/components/EmptyState";
+import { WalletRequired } from "../src/components/WalletRequired";
import React from "react";
export default function Guilds() {
@@ -23,31 +24,33 @@ export default function Guilds() {
];
return (
-
-
- item.id}
- contentContainerStyle={{ padding: 16 }}
- testID="guilds-list"
- renderItem={({ item }) => (
- router.push(`/guilds/${item.id}`)}
- />
- )}
- ListEmptyComponent={
- {}}
- />
- }
- />
-
+
+
+
+ item.id}
+ contentContainerStyle={{ padding: 16 }}
+ testID="guilds-list"
+ renderItem={({ item }) => (
+ router.push(`/guilds/${item.id}`)}
+ />
+ )}
+ ListEmptyComponent={
+ {}}
+ />
+ }
+ />
+
+
);
}
diff --git a/app/guilds/[guildId].tsx b/app/guilds/[guildId].tsx
index 9e84d51..75a1135 100644
--- a/app/guilds/[guildId].tsx
+++ b/app/guilds/[guildId].tsx
@@ -9,6 +9,7 @@ import { ErrorState } from "../../src/components/ErrorState";
import { Card } from "../../src/components/Card";
import { RoleBadge } from "../../src/components/RoleBadge";
import { StaleDataBanner } from "../../src/components/StaleDataBanner";
+import { WalletRequired } from "../../src/components/WalletRequired";
import { useCombinedStaleState } from "../../src/features/offline/useStaleQuery";
import React from "react";
@@ -34,106 +35,96 @@ export default function GuildDetail() {
const staleState = useCombinedStaleState([guildQuery, membershipQuery, rolesQuery]);
- if (!validGuildId) {
- return ;
- }
-
- if (
- (guildPending && guildLoading) ||
- (memPending && memLoading) ||
- (rolesPending && rolesLoading)
- ) {
- return ;
- }
-
- if (guildError && !guild) {
- return (
-
- );
- }
-
- if (!guild) {
- return (
-
- );
- }
-
return (
-
-
-
- {staleState.isOffline ? (
-
- ) : staleState.isStale && staleState.reason ? (
-
- ) : null}
+
+
+ {!validGuildId ? (
+
+ ) : (guildPending && guildLoading) || (memPending && memLoading) || (rolesPending && rolesLoading) ? (
+
+ ) : guildError && !guild ? (
+
+ ) : !guild ? (
+
+ ) : (
+ <>
+
+
+ {staleState.isOffline ? (
+
+ ) : staleState.isStale && staleState.reason ? (
+
+ ) : null}
-
-
- {guild.name}
-
-
- {guild.description || "No description provided."}
-
+
+
+ {guild.name}
+
+
+ {guild.description || "No description provided."}
+
-
-
- Owner
-
- {guild.ownerAddress.substring(0, 6)}...{guild.ownerAddress.substring(38)}
-
-
-
- Chain ID
-
- {guild.chainId}
-
-
-
-
+
+
+ Owner
+
+ {guild.ownerAddress.substring(0, 6)}...{guild.ownerAddress.substring(38)}
+
+
+
+ Chain ID
+
+ {guild.chainId}
+
+
+
+
-
- Your Membership
-
-
- Status
-
- {membership?.isActive ? "Active Member" : "Not a Member"}
-
-
-
-
+
+ Your Membership
+
+
+ Status
+
+ {membership?.isActive ? "Active Member" : "Not a Member"}
+
+
+
+
-
- Available Roles
-
- {roles && roles.length > 0 ? (
- roles.map((role: { id: string; name: string }) => )
- ) : (
- No roles defined for this guild.
- )}
-
-
-
-
+
+ Available Roles
+
+ {roles && roles.length > 0 ? (
+ roles.map((role: { id: string; name: string }) => )
+ ) : (
+ No roles defined for this guild.
+ )}
+
+
+
+ >
+ )}
+
+
);
}
diff --git a/app/settings.tsx b/app/settings.tsx
index 19adb6d..ae1c48d 100644
--- a/app/settings.tsx
+++ b/app/settings.tsx
@@ -3,6 +3,7 @@ import { useWallet } from "../src/features/wallet/useWallet";
import { AppHeader } from "../src/components/AppHeader";
import { Card } from "../src/components/Card";
import { Button } from "../src/components/Button";
+import { WalletRequired } from "../src/components/WalletRequired";
import { appConfig } from "../src/config/appConfig";
import { resetAppState } from "../src/lib/resetAppState";
import React, { useState } from "react";
@@ -51,16 +52,18 @@ export default function Settings() {
Account
-
- will disconnect your current wallet address and clear any local cache.
-
-
+
+
+ will disconnect your current wallet address and clear any local cache.
+
+
+
diff --git a/src/components/WalletRequired.tsx b/src/components/WalletRequired.tsx
new file mode 100644
index 0000000..b32355b
--- /dev/null
+++ b/src/components/WalletRequired.tsx
@@ -0,0 +1,64 @@
+import { View, Text } from "react-native";
+import React, { useEffect } from "react";
+import { useRouter } from "expo-router";
+import { useWallet } from "../features/wallet/useWallet";
+import { Button } from "./Button";
+
+interface WalletRequiredProps {
+ children: React.ReactNode;
+ /**
+ * When true (default), redirects to /profile if wallet is not connected.
+ * When false, renders an inline connect-wallet prompt instead.
+ */
+ redirect?: boolean;
+}
+
+/**
+ * Route guard that ensures a wallet is connected before rendering
+ * wallet-scoped content. Redirects to /profile or shows a connect prompt.
+ */
+export function WalletRequired({
+ children,
+ redirect = true,
+}: WalletRequiredProps) {
+ const { isConnected, isHydrated } = useWallet();
+ const router = useRouter();
+
+ useEffect(() => {
+ if (isHydrated && !isConnected && redirect) {
+ router.replace("/profile");
+ }
+ }, [isHydrated, isConnected, redirect, router]);
+
+ // Wait for store hydration to avoid flash of wrong state
+ if (!isHydrated) {
+ return null;
+ }
+
+ if (!isConnected) {
+ if (redirect) {
+ return null;
+ }
+
+ return (
+
+
+ Wallet connection required
+
+
+ Please connect your wallet to access this screen.
+
+
+ );
+ }
+
+ return <>{children}>;
+}
diff --git a/tests/walletRequired.test.tsx b/tests/walletRequired.test.tsx
new file mode 100644
index 0000000..0895df7
--- /dev/null
+++ b/tests/walletRequired.test.tsx
@@ -0,0 +1,98 @@
+///
+import React from "react";
+import TestRenderer, { act } from "react-test-renderer";
+import { describe, expect, it, vi } from "vitest";
+import { Text } from "react-native";
+import { WalletRequired } from "../src/components/WalletRequired";
+
+const mockIsConnected = vi.fn();
+const mockIsHydrated = vi.fn();
+const mockReplace = vi.fn();
+
+vi.mock("react-native", () => ({
+ View: "View",
+ Text: "Text",
+ TouchableOpacity: "TouchableOpacity",
+ ActivityIndicator: "ActivityIndicator",
+}));
+
+vi.mock("../src/features/wallet/useWallet", () => ({
+ useWallet: () => ({
+ isConnected: mockIsConnected(),
+ isHydrated: mockIsHydrated(),
+ }),
+}));
+
+vi.mock("expo-router", () => ({
+ useRouter: () => ({ replace: mockReplace }),
+}));
+
+describe("WalletRequired", () => {
+ beforeEach(() => {
+ mockIsConnected.mockReset().mockReturnValue(true);
+ mockIsHydrated.mockReset().mockReturnValue(true);
+ mockReplace.mockReset();
+ });
+
+ it("renders children when wallet is connected", () => {
+ let tree: any;
+ act(() => {
+ tree = TestRenderer.create(
+
+ Protected content
+
+ );
+ });
+
+ expect(JSON.stringify(tree.toJSON())).toContain("Protected content");
+ });
+
+ it("returns null when store is not yet hydrated", () => {
+ mockIsConnected.mockReturnValue(false);
+ mockIsHydrated.mockReturnValue(false);
+
+ let tree: any;
+ act(() => {
+ tree = TestRenderer.create(
+
+ Protected content
+
+ );
+ });
+
+ expect(tree.toJSON()).toBeNull();
+ });
+
+ it("redirects to /profile when disconnected and redirect=true", () => {
+ mockIsConnected.mockReturnValue(false);
+ mockIsHydrated.mockReturnValue(true);
+
+ act(() => {
+ TestRenderer.create(
+
+ Protected content
+
+ );
+ });
+
+ expect(mockReplace).toHaveBeenCalledWith("/profile");
+ });
+
+ it("shows connect-wallet prompt when disconnected and redirect=false", () => {
+ mockIsConnected.mockReturnValue(false);
+ mockIsHydrated.mockReturnValue(true);
+
+ let renderer: any;
+ act(() => {
+ renderer = TestRenderer.create(
+
+ Protected content
+
+ );
+ });
+
+ expect(renderer.root.findByProps({ testID: "wallet-required-prompt" })).toBeDefined();
+ expect(renderer.root.findByProps({ testID: "wallet-required-connect" })).toBeDefined();
+ expect(mockReplace).not.toHaveBeenCalled();
+ });
+});