diff --git a/src/components/ActivityFeed.tsx b/src/components/ActivityFeed.tsx index d770dfee..48e33124 100644 --- a/src/components/ActivityFeed.tsx +++ b/src/components/ActivityFeed.tsx @@ -9,9 +9,58 @@ interface EventType { }; } +interface FetchError { + message: string; + isRateLimited: boolean; + statusCode?: number; +} + +// Custom error class for ActivityFeed errors +class ActivityFeedError extends Error { + constructor( + message: string, + public isRateLimited = false, + public statusCode?: number + ) { + super(message); + this.name = "ActivityFeedError"; + } +} + +// Type guard to validate if data is EventType[] +const isEventTypeArray = (data: unknown): data is EventType[] => { + if (!Array.isArray(data)) return false; + return data.every((item) => { + if (typeof item !== "object" || item === null) return false; + + // Validate required fields + if ( + typeof item.id !== "string" || + typeof item.type !== "string" || + typeof item.created_at !== "string" + ) { + return false; + } + + // Validate optional repo field + if (item.repo !== undefined) { + if ( + typeof item.repo !== "object" || + item.repo === null || + typeof item.repo.name !== "string" + ) { + return false; + } + } + + return true; + }); +}; + export default function ActivityFeed({ username }: { username: string }) { const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); // 🕒 time ago function const getTimeAgo = (dateString: string) => { @@ -29,16 +78,65 @@ export default function ActivityFeed({ username }: { username: string }) { const fetchEvents = async () => { try { setLoading(true); + setError(null); const res = await fetch( `https://api.github.com/users/${username}/events` ); + + // ✅ Check HTTP response success + if (!res.ok) { + const isRateLimited = res.status === 403; + const errorData = await res.json().catch(() => ({})); + const errorMessage = + typeof errorData === "object" && + errorData !== null && + "message" in errorData && + typeof errorData.message === "string" + ? errorData.message + : `HTTP ${res.status}: Failed to fetch events`; + + throw new ActivityFeedError( + isRateLimited + ? "GitHub API rate limit exceeded. Try again later." + : errorMessage, + isRateLimited, + res.status + ); + } + const data = await res.json(); + // ✅ Validate response structure matches EventType[] + if (!isEventTypeArray(data)) { + throw new ActivityFeedError( + "Invalid API response structure. Expected array of events.", + false, + 200 + ); + } + setEvents(data); - setLoading(false); } catch (err) { - console.error(err); + const fetchError: FetchError = { + message: "Failed to fetch activity. Please try again.", + isRateLimited: false, + }; + + // Handle ActivityFeedError instances + if (err instanceof ActivityFeedError) { + fetchError.message = err.message; + fetchError.isRateLimited = err.isRateLimited; + fetchError.statusCode = err.statusCode; + } else if (err instanceof Error) { + // Handle generic errors + fetchError.message = err.message || fetchError.message; + } + + setError(fetchError); + console.error("ActivityFeed fetch error:", fetchError); + setEvents([]); + } finally { setLoading(false); } }; @@ -56,9 +154,25 @@ export default function ActivityFeed({ username }: { username: string }) { {loading ? ( -

Loading...

+

Loading activity...

+ ) : error ? ( +
+

+ ⚠️ {error.message} +

+ {error.isRateLimited && ( +

+ You've hit GitHub's API rate limit. The limit resets in 1 hour. +

+ )} + {error.statusCode === 404 && ( +

+ User not found. Please check the username. +

+ )} +
) : events.length === 0 ? ( -

No activity found

+

No activity found

) : ( events.slice(0, 10).map((event) => (