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
82 changes: 55 additions & 27 deletions app/access-check.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,14 @@ function PerChainEligibilityList({
perChainRoleEligibility,
isResolvingRoleEligibility,
roleEligibilityError,
retryingChainIds = [],
onRetryChain,
}: {
perChainRoleEligibility: PerChainRoleEligibilityResolution[];
isResolvingRoleEligibility: boolean;
roleEligibilityError?: string;
retryingChainIds?: number[];
onRetryChain?: (chainId: number) => void;
}) {
if (
perChainRoleEligibility.length === 0 &&
Expand Down Expand Up @@ -78,32 +82,51 @@ function PerChainEligibilityList({
</Text>
) : null}

{perChainRoleEligibility.map((chain) => (
<View
key={`${chain.chainId}-${chain.status}`}
className="border border-border dark:border-slate-700 rounded-xl p-3 mb-2"
testID={`per-chain-eligibility-row-${chain.chainId}`}
>
<View className="flex-row items-center justify-between">
<Text className="text-text dark:text-slate-100 font-semibold">
Chain {chain.chainId}
</Text>
<Text
className={`px-2 py-1 rounded-full border text-xs font-semibold ${statusClassName[chain.status]}`}
>
{statusCopy[chain.status]}
</Text>
{perChainRoleEligibility.map((chain) => {
const isRetrying = retryingChainIds.includes(chain.chainId);
const canRetry = chain.chainId > 0 && chain.status !== "resolved" && onRetryChain;

return (
<View
key={`${chain.chainId}-${chain.status}`}
className="border border-border dark:border-slate-700 rounded-xl p-3 mb-2"
testID={`per-chain-eligibility-row-${chain.chainId}`}
>
<View className="flex-row items-center justify-between">
<Text className="text-text dark:text-slate-100 font-semibold">
Chain {chain.chainId}
</Text>
<Text
className={`px-2 py-1 rounded-full border text-xs font-semibold ${statusClassName[chain.status]}`}
>
{statusCopy[chain.status]}
</Text>
</View>
{chain.resolvedRoles && chain.resolvedRoles.length > 0 ? (
<Text className="text-text-muted dark:text-slate-400 text-xs mt-2">
Roles: {chain.resolvedRoles.join(", ")}
</Text>
) : null}
{chain.errorMessage ? (
<Text className="text-error dark:text-red-400 text-xs mt-2">
{chain.errorMessage}
</Text>
) : null}
{canRetry ? (
<Button
title="Retry"
variant="outline"
className="mt-3 py-2 px-4"
loading={isRetrying}
disabled={isRetrying}
testID={`per-chain-eligibility-retry-${chain.chainId}`}
accessibilityLabel={`Retry chain ${chain.chainId} role eligibility`}
onPress={() => onRetryChain(chain.chainId)}
/>
) : null}
</View>
{chain.resolvedRoles && chain.resolvedRoles.length > 0 ? (
<Text className="text-text-muted dark:text-slate-400 text-xs mt-2">
Roles: {chain.resolvedRoles.join(", ")}
</Text>
) : null}
{chain.errorMessage ? (
<Text className="text-error dark:text-red-400 text-xs mt-2">{chain.errorMessage}</Text>
) : null}
</View>
))}
);
})}
</Card>
);
}
Expand Down Expand Up @@ -149,6 +172,8 @@ export default function AccessCheck() {
reset: resetAccessCheck,
perChainRoleEligibility,
isResolvingRoleEligibility,
resolvingRoleEligibilityChainIds,
retryRoleEligibilityChain,
roleEligibilityError,
} = accessCheck;
const recordCheck = useAccessHistoryStore((state) => state.recordCheck);
Expand Down Expand Up @@ -419,8 +444,7 @@ export default function AccessCheck() {
!!addressError ||
!!guildIdError ||
!!resourceIdError ||
countdown.isExpired ||
isOffline
countdown.isExpired
}
/>
{isOffline ? (
Expand Down Expand Up @@ -565,6 +589,8 @@ export default function AccessCheck() {
<PerChainEligibilityList
perChainRoleEligibility={perChainRoleEligibility}
isResolvingRoleEligibility={isResolvingRoleEligibility}
retryingChainIds={resolvingRoleEligibilityChainIds}
onRetryChain={retryRoleEligibilityChain}
roleEligibilityError={roleEligibilityError}
/>
</View>
Expand All @@ -584,6 +610,8 @@ export default function AccessCheck() {
<PerChainEligibilityList
perChainRoleEligibility={perChainRoleEligibility}
isResolvingRoleEligibility={isResolvingRoleEligibility}
retryingChainIds={resolvingRoleEligibilityChainIds}
onRetryChain={retryRoleEligibilityChain}
roleEligibilityError={roleEligibilityError}
/>
</View>
Expand Down
199 changes: 145 additions & 54 deletions app/guilds/[guildId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { useWallet } from "../../src/features/wallet/useWallet";
import { useGuilds, GuildNotFoundError } from "../../src/features/guilds/useGuilds";
import { useMembership } from "../../src/features/membership/useMembership";
import { AppHeader } from "../../src/components/AppHeader";
import { LoadingState } from "../../src/components/LoadingState";
import { GuildDetailSkeleton } from "../../src/components/GuildDetailSkeleton";
import { ErrorState } from "../../src/components/ErrorState";
import { GuildNotFoundState } from "../../src/components/GuildNotFoundState";
import { Card } from "../../src/components/Card";
import { Button } from "../../src/components/Button";
import { RoleBadge } from "../../src/components/RoleBadge";
import { WalletAddress } from "../../src/components/WalletAddress";
import {
Expand All @@ -19,9 +19,73 @@ import {
import { StaleDataBanner } from "../../src/components/StaleDataBanner";
import { WalletRequired } from "../../src/components/WalletRequired";
import { useCombinedStaleState } from "../../src/features/offline/useStaleQuery";
import { groupRoleRequirementsByChain } from "../../src/features/guilds/roleRequirements";
import {
groupRoleRequirementsByChain,
normalizeRoleRequirements,
} from "../../src/features/guilds/roleRequirements";
import { useGuildChainAvailability } from "../../src/features/guilds/useGuildChainAvailability";
import type {
AccessRequirement,
PerChainRoleEligibilityResolution,
} from "../../src/features/access/roleEligibilityResolver";
import React from "react";

type GuildDetailRole = {
id: string;
name: string;
chainId?: number;
requirements?: AccessRequirement[];
};

function ChainUnavailableState({
chainId,
label,
status,
errorMessage,
isRetrying,
onRetry,
}: {
chainId: number;
label: string;
status: PerChainRoleEligibilityResolution["status"];
errorMessage?: string;
isRetrying: boolean;
onRetry: () => void;
}) {
const isTimeout = status === "timed-out";

return (
<Card
className="border-amber-300 bg-amber-50 dark:border-amber-600 dark:bg-amber-900/30"
accessibilityRole="alert"
accessibilityLabel={`${label} role requirements unavailable`}
testID={`guild-chain-unavailable-${chainId}`}
>
<Text className="text-amber-800 dark:text-amber-300 font-bold">
{isTimeout ? "Network check timed out" : "Network unavailable"}
</Text>
<Text className="text-text dark:text-slate-100 text-sm mt-2">
{isTimeout
? `${label} did not respond before the timeout. Other networks can still be explored.`
: `${label} is unavailable right now. Other networks can still be explored.`}
</Text>
{errorMessage ? (
<Text className="text-amber-800 dark:text-amber-300 text-xs mt-2">{errorMessage}</Text>
) : null}
<Button
title="Retry"
variant="outline"
className="mt-3 py-2 px-4"
loading={isRetrying}
disabled={isRetrying}
testID={`guild-chain-retry-${chainId}`}
accessibilityLabel={`Retry ${label} role requirements`}
onPress={onRetry}
/>
</Card>
);
}

export default function GuildDetail() {
const { guildId } = useLocalSearchParams<{ guildId: string }>();
const { walletAddress } = useWallet();
Expand All @@ -34,41 +98,38 @@ export default function GuildDetail() {
const membershipQuery = useMembershipQuery(validGuildId);
const rolesQuery = useRoles(validGuildId);

const {
data: guild,
isLoading: guildLoading,
error: guildError,
isPending: guildPending,
} = guildQuery;
const { isLoading: memLoading, isPending: memPending, data: membership } = membershipQuery;
const { data: roles, isLoading: rolesLoading, isPending: rolesPending } = rolesQuery;
const { data: guild, isLoading: guildLoading, error: guildError } = guildQuery;
const { isLoading: memLoading, data: membership } = membershipQuery;
const { data: roles, isLoading: rolesLoading } = rolesQuery;
const { data: guildConfig } = guildConfigQuery;

const staleState = useCombinedStaleState([guildQuery, membershipQuery, rolesQuery]);
const groupedRequirements = groupRoleRequirementsByChain(
(roles as Array<{ id: string; name: string; chainId?: number }> | undefined) &&
guildConfig?.requirements
? (roles as Array<{ id: string; name: string; chainId?: number }> | undefined)?.map(
(role) => {
const requirement = guildConfig.requirements?.find(
(item: { id: string; name?: string; chainId: number }) =>
item.id === role.id || item.name === role.name,
);
return {
id: role.id,
name: role.name,
chainId: role.chainId ?? requirement?.chainId ?? guild?.chainId ?? 1,
};
},
)
: ((roles as Array<{ id: string; name: string; chainId?: number }> | undefined) ?? []).map(
(role) => ({
id: role.id,
name: role.name,
chainId: role.chainId ?? guild?.chainId ?? 1,
}),
),
guild?.chainId ?? 1,
const fallbackChainId = guild?.chainId ?? 1;
const detailRoles = roles as GuildDetailRole[] | undefined;
const normalizedRequirements = normalizeRoleRequirements(
detailRoles,
guildConfig?.requirements as
| {
id: string;
name?: string;
chainId: number;
}[]
| undefined,
fallbackChainId,
);
const groupedRequirements = groupRoleRequirementsByChain(normalizedRequirements, fallbackChainId);
const chainAvailability = useGuildChainAvailability({
guildId: validGuildId,
walletAddress,
roles: detailRoles,
enabled: !!validGuildId && !!walletAddress && !!detailRoles,
});
const availabilityByChain = React.useMemo(
() =>
new Map(
chainAvailability.perChain.map((chain) => [chain.chainId, chain] as const),
),
[chainAvailability.perChain],
);
const guildChainLabel =
groupedRequirements.length === 0
Expand Down Expand Up @@ -152,7 +213,6 @@ export default function GuildDetail() {
<View className="flex-row justify-between items-center mb-2">
<Text className="text-text-muted dark:text-slate-400">Owner</Text>
<WalletAddress address={guild.ownerAddress} testID="guild-owner" />
<AddressChip address={guild.ownerAddress} testID="guild-owner" />
</View>
<View className="flex-row justify-between">
<Text className="text-text-muted dark:text-slate-400">Chain ID</Text>
Expand Down Expand Up @@ -188,27 +248,58 @@ export default function GuildDetail() {
<View className="mb-6">
<Text className="text-lg font-bold text-text dark:text-slate-100 mb-3">Available Roles</Text>
{groupedRequirements.length > 0 ? (
groupedRequirements.map((group) => (
<View key={`${group.chainId}`} className="mb-4">
<Text className="text-sm font-semibold text-text-muted dark:text-slate-400 mb-2">
{group.label}
</Text>
<View
className="flex-row flex-wrap"
testID={`guild-roles-list-${group.chainId}`}
>
{group.requirements.map((role) => (
<RequirementCard
key={role.id}
chainId={role.chainId}
testID={`role-requirement-${role.id}`}
groupedRequirements.map((group) => {
const availability = availabilityByChain.get(group.chainId);
const isUnavailable =
availability?.status === "timed-out" || availability?.status === "error";
const isChecking = chainAvailability.checkingChainIds.includes(group.chainId);

return (
<View key={`${group.chainId}`} className="mb-4">
<View className="flex-row justify-between items-center mb-2">
<Text className="text-sm font-semibold text-text-muted dark:text-slate-400">
{group.label}
</Text>
{isChecking ? (
<Text
className="text-xs font-semibold text-primary"
testID={`guild-chain-checking-${group.chainId}`}
>
Checking network
</Text>
) : null}
</View>

{isUnavailable ? (
<ChainUnavailableState
chainId={group.chainId}
label={group.label}
status={availability.status}
errorMessage={availability.errorMessage}
isRetrying={isChecking}
onRetry={() => {
void chainAvailability.retryChain(group.chainId);
}}
/>
) : (
<View
className="flex-row flex-wrap"
testID={`guild-roles-list-${group.chainId}`}
>
<RoleBadge name={role.name} />
</RequirementCard>
))}
{group.requirements.map((role) => (
<RequirementCard
key={role.id}
chainId={role.chainId}
testID={`role-requirement-${role.id}`}
>
<RoleBadge name={role.name} />
</RequirementCard>
))}
</View>
)}
</View>
</View>
))
);
})
) : (
<Text className="text-text-muted dark:text-slate-400 italic">No roles defined for this guild.</Text>
)}
Expand Down
Loading