diff --git a/src/api/client.ts b/src/api/client.ts index 9cd9aca..87446b1 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -2013,7 +2013,9 @@ export class XClient { return { success: false, - error: `${this.formatErrors(errors)} | fallback: ${fallback.error ?? "Unknown error"}`, + error: `${this.formatErrors(errors)} | fallback: ${ + fallback.error ?? "Unknown error" + }`, }; } @@ -3465,6 +3467,21 @@ export class XClient { return Array.from(new Set([primary, "JR2gceKucIKcVNB_9JkhsA"])); } + private async getUserRepliesQueryIds(): Promise { + const primary = await this.getQueryId("UserTweetsAndReplies"); + return Array.from(new Set([primary, "_P1zJA2kS9W1PLHKdThsrg"])); + } + + private async getUserMediaQueryIds(): Promise { + const primary = await this.getQueryId("UserMedia"); + return Array.from(new Set([primary, "YqiE3JL1KNgf9nSt-YCt0A"])); + } + + private async getUserHighlightsQueryIds(): Promise { + const primary = await this.getQueryId("UserHighlightsTweets"); + return Array.from(new Set([primary, "D-zTJ8kigVHMaLAydoXtqA"])); + } + private buildUserProfileFeatures(): Record { return { ...this.buildSearchFeatures(), @@ -4013,6 +4030,362 @@ export class XClient { return { success: false, error: firstAttempt.error }; } + /** + * Get user's tweets and replies (includes reply tweets) + * @param userId Target user's ID + * @param count Number of tweets to fetch (default 20) + * + * @note This endpoint currently returns 404 errors consistently. + * The UserTweetsAndReplies API may require: + * - Additional authentication headers + * - Different feature flags + * - Client-specific transaction IDs + * + * Working example URL (from browser): + * https://x.com/i/api/graphql/_P1zJA2kS9W1PLHKdThsrg/UserTweetsAndReplies? + * variables={"userId":"...","count":20,"includePromotedContent":true,"withCommunity":true,"withVoice":true} + * + * TODO: Investigate why this endpoint fails while UserTweets works with similar parameters. + */ + async getUserReplies( + userId: string, + count = 20 + ): Promise { + const variables = { + userId, + count, + includePromotedContent: true, + withCommunity: true, + withVoice: true, + }; + + const features = this.buildSearchFeatures(); + const fieldToggles = { + withArticlePlainText: false, + }; + + const tryOnce = async () => { + let lastError: string | undefined; + let had404 = false; + const queryIds = await this.getUserRepliesQueryIds(); + + for (const queryId of queryIds) { + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(features), + fieldToggles: JSON.stringify(fieldToggles), + }); + const url = `${X_API_BASE}/${queryId}/UserTweetsAndReplies?${params.toString()}`; + + try { + const response = await this.fetchWithTimeout(url, { + method: "GET", + headers: this.getHeaders(), + }); + + if (response.status === 404) { + had404 = true; + lastError = `HTTP ${response.status}`; + console.error( + `[DEBUG] UserTweetsAndReplies 404 for queryId: ${queryId}` + ); + continue; + } + + if (!response.ok) { + const text = await response.text(); + return { + success: false as const, + error: `HTTP ${response.status}: ${text.slice(0, 200)}`, + had404, + }; + } + + // biome-ignore lint/suspicious/noExplicitAny: X API response varies + const data = (await response.json()) as any; + + if (data.errors && data.errors.length > 0) { + return { + success: false as const, + error: data.errors + .map((e: { message: string }) => e.message) + .join(", "), + had404, + }; + } + + const instructions = + data.data?.user?.result?.timeline_v2?.timeline?.instructions || + data.data?.user?.result?.timeline?.timeline?.instructions; + + if (!instructions) { + lastError = "No instructions found in response"; + continue; + } + + const tweets = this.parseTweetsFromInstructions( + instructions, + this.quoteDepth + ); + + return { success: true as const, tweets, had404 }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { + success: false as const, + error: lastError ?? "Unknown error fetching user replies", + had404, + }; + }; + + const firstAttempt = await tryOnce(); + if (firstAttempt.success && firstAttempt.tweets.length > 0) { + return { success: true, tweets: firstAttempt.tweets }; + } + + if (firstAttempt.had404) { + await this.refreshQueryIds(); + const secondAttempt = await tryOnce(); + if (secondAttempt.success && secondAttempt.tweets.length > 0) { + return { success: true, tweets: secondAttempt.tweets }; + } + } + + return { + success: firstAttempt.success, + tweets: firstAttempt.success ? firstAttempt.tweets : undefined, + error: firstAttempt.success ? undefined : firstAttempt.error, + }; + } + + /** + * Get user's media tweets (photos and videos) + * @param userId Target user's ID + * @param count Number of tweets to fetch (default 20) + */ + async getUserMedia( + userId: string, + count = 20 + ): Promise { + const variables = { + userId, + count, + includePromotedContent: true, + withClientEventToken: false, + withBirdwatchNotes: false, + withVoice: true, + withV2Timeline: true, + }; + + const features = this.buildTimelineFeatures(); + const fieldToggles = { + withArticlePlainText: false, + }; + + const tryOnce = async () => { + let lastError: string | undefined; + let had404 = false; + const queryIds = await this.getUserMediaQueryIds(); + + for (const queryId of queryIds) { + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(features), + fieldToggles: JSON.stringify(fieldToggles), + }); + const url = `${X_API_BASE}/${queryId}/UserMedia?${params.toString()}`; + + try { + const response = await this.fetchWithTimeout(url, { + method: "GET", + headers: this.getHeaders(), + }); + + if (response.status === 404) { + had404 = true; + lastError = `HTTP ${response.status}`; + continue; + } + + if (!response.ok) { + const text = await response.text(); + return { + success: false as const, + error: `HTTP ${response.status}: ${text.slice(0, 200)}`, + had404, + }; + } + + // biome-ignore lint/suspicious/noExplicitAny: X API response varies + const data = (await response.json()) as any; + + if (data.errors && data.errors.length > 0) { + return { + success: false as const, + error: data.errors + .map((e: { message: string }) => e.message) + .join(", "), + had404, + }; + } + + const instructions = + data.data?.user?.result?.timeline_v2?.timeline?.instructions || + data.data?.user?.result?.timeline?.timeline?.instructions; + + if (!instructions) { + lastError = "No instructions found in response"; + continue; + } + + const tweets = this.parseTweetsFromInstructions( + instructions, + this.quoteDepth + ); + + return { success: true as const, tweets, had404 }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { + success: false as const, + error: lastError ?? "Unknown error fetching user media", + had404, + }; + }; + + const firstAttempt = await tryOnce(); + if (firstAttempt.success) { + return { success: true, tweets: firstAttempt.tweets }; + } + + if (firstAttempt.had404) { + await this.refreshQueryIds(); + const secondAttempt = await tryOnce(); + if (secondAttempt.success) { + return { success: true, tweets: secondAttempt.tweets }; + } + } + + return { success: false, error: firstAttempt.error }; + } + + /** + * Get user's highlighted/pinned tweets + * @param userId Target user's ID + * @param count Number of tweets to fetch (default 20) + */ + async getUserHighlights( + userId: string, + count = 20 + ): Promise { + const variables = { + userId, + count, + includePromotedContent: true, + withVoice: true, + }; + + const features = this.buildTimelineFeatures(); + const fieldToggles = { + withArticlePlainText: false, + }; + + const tryOnce = async () => { + let lastError: string | undefined; + let had404 = false; + const queryIds = await this.getUserHighlightsQueryIds(); + + for (const queryId of queryIds) { + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(features), + fieldToggles: JSON.stringify(fieldToggles), + }); + const url = `${X_API_BASE}/${queryId}/UserHighlightsTweets?${params.toString()}`; + + try { + const response = await this.fetchWithTimeout(url, { + method: "GET", + headers: this.getHeaders(), + }); + + if (response.status === 404) { + had404 = true; + lastError = `HTTP ${response.status}`; + continue; + } + + if (!response.ok) { + const text = await response.text(); + return { + success: false as const, + error: `HTTP ${response.status}: ${text.slice(0, 200)}`, + had404, + }; + } + + // biome-ignore lint/suspicious/noExplicitAny: X API response varies + const data = (await response.json()) as any; + + if (data.errors && data.errors.length > 0) { + return { + success: false as const, + error: data.errors + .map((e: { message: string }) => e.message) + .join(", "), + had404, + }; + } + + const instructions = + data.data?.user?.result?.timeline_v2?.timeline?.instructions || + data.data?.user?.result?.timeline?.timeline?.instructions; + + if (!instructions) { + lastError = "No instructions found in response"; + continue; + } + + const tweets = this.parseTweetsFromInstructions( + instructions, + this.quoteDepth + ); + + return { success: true as const, tweets, had404 }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { + success: false as const, + error: lastError ?? "Unknown error fetching user highlights", + had404, + }; + }; + + const firstAttempt = await tryOnce(); + if (firstAttempt.success) { + return { success: true, tweets: firstAttempt.tweets }; + } + + if (firstAttempt.had404) { + await this.refreshQueryIds(); + const secondAttempt = await tryOnce(); + if (secondAttempt.success) { + return { success: true, tweets: secondAttempt.tweets }; + } + } + + return { success: false, error: firstAttempt.error }; + } + /** * Like a tweet (favorite) * @param tweetId The ID of the tweet to like diff --git a/src/api/query-ids.ts b/src/api/query-ids.ts index f3e746a..dff78dc 100644 --- a/src/api/query-ids.ts +++ b/src/api/query-ids.ts @@ -37,6 +37,9 @@ export const FALLBACK_QUERY_IDS = { HomeLatestTimeline: "iOEZpOdfekFsxSlPQCQtPg", UserByScreenName: "7mjxD3-C6BxitPMVQ6w0-Q", UserTweets: "HuTx74BxAnezK1gWvYY7zg", + UserTweetsAndReplies: "_P1zJA2kS9W1PLHKdThsrg", + UserMedia: "YqiE3JL1KNgf9nSt-YCt0A", + UserHighlightsTweets: "D-zTJ8kigVHMaLAydoXtqA", Likes: "JR2gceKucIKcVNB_9JkhsA", BookmarkFoldersSlice: "i78YDd0Tza-dV4SYs58kRg", bookmarkTweetToFolder: "4KHZvvNbHNf07bsgnL9gWA", diff --git a/src/api/types.ts b/src/api/types.ts index 56cd31a..f447a8d 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -393,6 +393,9 @@ export type OperationName = | "HomeLatestTimeline" | "UserByScreenName" | "UserTweets" + | "UserTweetsAndReplies" + | "UserMedia" + | "UserHighlightsTweets" | "Likes" | "BookmarkFoldersSlice" | "bookmarkTweetToFolder" diff --git a/src/experiments/query-client.ts b/src/experiments/query-client.ts index eaf936c..6807e33 100644 --- a/src/experiments/query-client.ts +++ b/src/experiments/query-client.ts @@ -133,6 +133,12 @@ export const queryKeys = { [...queryKeys.user.all, username, "profile"] as const, tweets: (userId: string) => [...queryKeys.user.all, userId, "tweets"] as const, + replies: (userId: string) => + [...queryKeys.user.all, userId, "replies"] as const, + media: (userId: string) => + [...queryKeys.user.all, userId, "media"] as const, + highlights: (userId: string) => + [...queryKeys.user.all, userId, "highlights"] as const, likes: () => [...queryKeys.user.all, "likes"] as const, }, } as const; diff --git a/src/experiments/use-profile-query.ts b/src/experiments/use-profile-query.ts index ab9fed8..87c97ee 100644 --- a/src/experiments/use-profile-query.ts +++ b/src/experiments/use-profile-query.ts @@ -3,8 +3,8 @@ * * Features: * - Caches user profiles by username - * - Separate queries for profile, tweets, and likes - * - Lazy loading for likes (only fetched when tab is active) + * - Separate queries for profile, tweets, replies, highlights, media, and likes + * - Lazy loading for non-default tabs (only fetched when tab is activated) */ import { useQuery } from "@tanstack/react-query"; @@ -15,7 +15,12 @@ import type { TweetData, UserProfileData } from "@/api/types"; import { queryKeys } from "./query-client"; -export type ProfileTab = "tweets" | "likes"; +export type ProfileTab = + | "tweets" + | "replies" + | "highlights" + | "media" + | "likes"; interface UseProfileQueryOptions { client: XClient; @@ -49,6 +54,36 @@ interface UseProfileQueryResult { likesFetched: boolean; /** Whether a refetch is in progress */ isRefetching: boolean; + /** User's tweets and replies */ + repliesTweets: TweetData[]; + /** Whether replies are currently loading */ + isRepliesLoading: boolean; + /** Error message if replies fetch failed */ + repliesError: string | null; + /** Fetch replies (lazy, call when Replies tab is activated) */ + fetchReplies: () => void; + /** Whether replies have been fetched at least once */ + repliesFetched: boolean; + /** User's media tweets */ + mediaTweets: TweetData[]; + /** Whether media is currently loading */ + isMediaLoading: boolean; + /** Error message if media fetch failed */ + mediaError: string | null; + /** Fetch media (lazy, call when Media tab is activated) */ + fetchMedia: () => void; + /** Whether media has been fetched at least once */ + mediaFetched: boolean; + /** User's highlighted tweets */ + highlightsTweets: TweetData[]; + /** Whether highlights are currently loading */ + isHighlightsLoading: boolean; + /** Error message if highlights fetch failed */ + highlightsError: string | null; + /** Fetch highlights (lazy, call when Highlights tab is activated) */ + fetchHighlights: () => void; + /** Whether highlights have been fetched at least once */ + highlightsFetched: boolean; } export function useProfileQuery({ @@ -56,8 +91,11 @@ export function useProfileQuery({ username, isSelf = false, }: UseProfileQueryOptions): UseProfileQueryResult { - // Track if likes have been manually triggered + // Track which tabs have been manually triggered (lazy loading) const [likesEnabled, setLikesEnabled] = useState(false); + const [repliesEnabled, setRepliesEnabled] = useState(false); + const [mediaEnabled, setMediaEnabled] = useState(false); + const [highlightsEnabled, setHighlightsEnabled] = useState(false); // Profile query - fetch user data by screen name const { @@ -99,6 +137,66 @@ export function useProfileQuery({ enabled: !!profileData?.id, }); + // Replies query - only enabled when manually triggered + const { + data: repliesData, + isLoading: isRepliesLoading, + error: repliesError, + refetch: refetchReplies, + isFetched: repliesFetched, + } = useQuery({ + queryKey: queryKeys.user.replies(profileData?.id ?? ""), + queryFn: async () => { + if (!profileData?.id) return []; + const result = await client.getUserReplies(profileData.id, 20); + if (!result.success) { + throw new Error(result.error ?? "Failed to load replies"); + } + return result.tweets ?? []; + }, + enabled: !!profileData?.id && repliesEnabled, + }); + + // Media query - only enabled when manually triggered + const { + data: mediaData, + isLoading: isMediaLoading, + error: mediaError, + refetch: refetchMedia, + isFetched: mediaFetched, + } = useQuery({ + queryKey: queryKeys.user.media(profileData?.id ?? ""), + queryFn: async () => { + if (!profileData?.id) return []; + const result = await client.getUserMedia(profileData.id, 20); + if (!result.success) { + throw new Error(result.error ?? "Failed to load media"); + } + return result.tweets ?? []; + }, + enabled: !!profileData?.id && mediaEnabled, + }); + + // Highlights query - only enabled when manually triggered + const { + data: highlightsData, + isLoading: isHighlightsLoading, + error: highlightsError, + refetch: refetchHighlights, + isFetched: highlightsFetched, + } = useQuery({ + queryKey: queryKeys.user.highlights(profileData?.id ?? ""), + queryFn: async () => { + if (!profileData?.id) return []; + const result = await client.getUserHighlights(profileData.id, 20); + if (!result.success) { + throw new Error(result.error ?? "Failed to load highlights"); + } + return result.tweets ?? []; + }, + enabled: !!profileData?.id && highlightsEnabled, + }); + // Likes query - only enabled when isSelf and manually triggered const { data: likesData, @@ -127,11 +225,41 @@ export function useProfileQuery({ } }, [isSelf, likesEnabled, refetchLikes]); + // Trigger replies fetch (lazy loading) + const fetchReplies = useCallback(() => { + if (!repliesEnabled) { + setRepliesEnabled(true); + } else { + refetchReplies(); + } + }, [repliesEnabled, refetchReplies]); + + // Trigger media fetch (lazy loading) + const fetchMedia = useCallback(() => { + if (!mediaEnabled) { + setMediaEnabled(true); + } else { + refetchMedia(); + } + }, [mediaEnabled, refetchMedia]); + + // Trigger highlights fetch (lazy loading) + const fetchHighlights = useCallback(() => { + if (!highlightsEnabled) { + setHighlightsEnabled(true); + } else { + refetchHighlights(); + } + }, [highlightsEnabled, refetchHighlights]); + // Refresh all data const refresh = useCallback(() => { refetchProfile(); if (profileData?.id) { refetchTweets(); + if (repliesEnabled) refetchReplies(); + if (mediaEnabled) refetchMedia(); + if (highlightsEnabled) refetchHighlights(); } if (isSelf && likesEnabled) { refetchLikes(); @@ -139,10 +267,16 @@ export function useProfileQuery({ }, [ refetchProfile, refetchTweets, + refetchReplies, + refetchMedia, + refetchHighlights, refetchLikes, profileData?.id, isSelf, likesEnabled, + repliesEnabled, + mediaEnabled, + highlightsEnabled, ]); return { @@ -158,5 +292,21 @@ export function useProfileQuery({ fetchLikes, likesFetched, isRefetching: isProfileRefetching || isTweetsRefetching, + repliesTweets: repliesData ?? [], + isRepliesLoading, + repliesError: repliesError instanceof Error ? repliesError.message : null, + fetchReplies, + repliesFetched, + mediaTweets: mediaData ?? [], + isMediaLoading, + mediaError: mediaError instanceof Error ? mediaError.message : null, + fetchMedia, + mediaFetched, + highlightsTweets: highlightsData ?? [], + isHighlightsLoading, + highlightsError: + highlightsError instanceof Error ? highlightsError.message : null, + fetchHighlights, + highlightsFetched, }; } diff --git a/src/screens/ProfileScreen.tsx b/src/screens/ProfileScreen.tsx index 8844503..2a7592f 100644 --- a/src/screens/ProfileScreen.tsx +++ b/src/screens/ProfileScreen.tsx @@ -1,11 +1,11 @@ /** * ProfileScreen - User profile view with bio and recent tweets * Supports collapsible header when scrolling through tweets - * When viewing own profile (isSelf), shows tabs for Tweets/Likes + * Shows tabs for Tweets/Replies/Highlights/Media/Likes on all profiles */ import { useKeyboard } from "@opentui/react"; -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, useMemo } from "react"; import type { XClient } from "@/api/client"; import type { TweetData, UserData } from "@/api/types"; @@ -13,14 +13,33 @@ import type { TweetActionState } from "@/hooks/useActions"; import { Footer, type Keybinding } from "@/components/Footer"; import { PostList } from "@/components/PostList"; -import { useProfileQuery } from "@/experiments/use-profile-query"; +import { + useProfileQuery, + type ProfileTab, +} from "@/experiments/use-profile-query"; import { useUserActions } from "@/experiments/use-user-actions"; import { colors } from "@/lib/colors"; import { formatCount } from "@/lib/format"; import { openInBrowser, previewImageUrl } from "@/lib/media"; import { extractMentions, renderTextWithMentions } from "@/lib/text"; -type ProfileTab = "tweets" | "likes"; +/** + * Tab configuration for profile navigation + * + * NOTE: Replies tab is disabled due to UserTweetsAndReplies API consistently + * returning 404 errors. The endpoint requires specific authentication or + * additional parameters that are not currently known. Re-enable when the + * API issue is resolved. + * + * See: https://x.com/i/api/graphql/{queryId}/UserTweetsAndReplies + */ +const PROFILE_TABS: readonly { key: ProfileTab; label: string }[] = [ + { key: "tweets", label: "Tweets" }, + // { key: "replies", label: "Replies" }, // Disabled: API returns 404 + { key: "highlights", label: "Highlights" }, + { key: "media", label: "Media" }, + { key: "likes", label: "Likes" }, +] as const; /** * Format X's created_at date to "Joined Month Year" @@ -107,6 +126,21 @@ export function ProfileScreen({ likesError, fetchLikes, likesFetched, + repliesTweets, + isRepliesLoading, + repliesError, + fetchReplies, + repliesFetched, + mediaTweets, + isMediaLoading, + mediaError, + fetchMedia, + mediaFetched, + highlightsTweets, + isHighlightsLoading, + highlightsError, + fetchHighlights, + highlightsFetched, } = useProfileQuery({ client, username, @@ -131,9 +165,20 @@ export function ProfileScreen({ } }, [user]); - // Tab state (only used when isSelf) + // Tab state - available tabs depend on whether viewing own profile + const availableTabs = useMemo(() => { + // Likes tab only available on own profile (others' likes are private) + return PROFILE_TABS.filter((tab) => tab.key !== "likes" || isSelf); + }, [isSelf]); + const [activeTab, setActiveTab] = useState("tweets"); + // Get current tab index for arrow navigation + const activeTabIndex = useMemo( + () => availableTabs.findIndex((t) => t.key === activeTab), + [availableTabs, activeTab] + ); + // Track if header should be collapsed (when scrolled past first tweet) const [isCollapsed, setIsCollapsed] = useState(false); @@ -141,12 +186,46 @@ export function ProfileScreen({ const [mentionsMode, setMentionsMode] = useState(false); const [mentionIndex, setMentionIndex] = useState(0); - // Fetch likes when switching to likes tab for the first time + // Trigger lazy loading when switching to non-default tabs useEffect(() => { - if (isSelf && activeTab === "likes" && !likesFetched && !isLikesLoading) { - fetchLikes(); + switch (activeTab) { + case "replies": + if (!repliesFetched && !isRepliesLoading) { + fetchReplies(); + } + break; + case "highlights": + if (!highlightsFetched && !isHighlightsLoading) { + fetchHighlights(); + } + break; + case "media": + if (!mediaFetched && !isMediaLoading) { + fetchMedia(); + } + break; + case "likes": + if (isSelf && !likesFetched && !isLikesLoading) { + fetchLikes(); + } + break; } - }, [isSelf, activeTab, likesFetched, isLikesLoading, fetchLikes]); + }, [ + activeTab, + isSelf, + repliesFetched, + isRepliesLoading, + fetchReplies, + highlightsFetched, + isHighlightsLoading, + fetchHighlights, + mediaFetched, + isMediaLoading, + fetchMedia, + likesFetched, + isLikesLoading, + fetchLikes, + ]); // Reset UI state when navigating to a different profile useEffect(() => { @@ -251,25 +330,37 @@ export function ProfileScreen({ } } break; + // Tab navigation with number keys 1-5 case "1": - // Switch to tweets tab (only on own profile) - if (isSelf && activeTab !== "tweets") { - setActiveTab("tweets"); + case "2": + case "3": + case "4": + case "5": { + const targetIndex = Number.parseInt(key.name, 10) - 1; + const targetTab = availableTabs[targetIndex]; + if (targetTab && activeTab !== targetTab.key) { + setActiveTab(targetTab.key); setIsCollapsed(false); } break; - case "2": - // Switch to likes tab (only on own profile) - if (isSelf && activeTab !== "likes") { - setActiveTab("likes"); - setIsCollapsed(false); + } + // Tab navigation with arrow keys + case "left": + if (activeTabIndex > 0) { + const prevTab = availableTabs[activeTabIndex - 1]; + if (prevTab) { + setActiveTab(prevTab.key); + setIsCollapsed(false); + } } break; - case "tab": - // Cycle between tabs (only on own profile) - if (isSelf) { - setActiveTab((prev) => (prev === "tweets" ? "likes" : "tweets")); - setIsCollapsed(false); + case "right": + if (activeTabIndex < availableTabs.length - 1) { + const nextTab = availableTabs[activeTabIndex + 1]; + if (nextTab) { + setActiveTab(nextTab.key); + setIsCollapsed(false); + } } break; case "f": @@ -480,8 +571,24 @@ export function ProfileScreen({ ); - // Tab bar for own profile (Tweets | Likes) - const tabBar = isSelf && ( + // Determine if current tab is loading + const isCurrentTabLoading = (() => { + switch (activeTab) { + case "replies": + return isRepliesLoading; + case "highlights": + return isHighlightsLoading; + case "media": + return isMediaLoading; + case "likes": + return isLikesLoading; + default: + return false; + } + })(); + + // Tab bar showing all available tabs + const tabBar = ( - - {activeTab === "tweets" ? [1] Tweets : " 1 Tweets"} - - | - - {activeTab === "likes" ? [2] Likes : " 2 Likes"} - - {activeTab === "likes" && isLikesLoading && ( - (loading...) - )} + {availableTabs.map((tab, idx) => ( + + + {activeTab === tab.key ? ( + + [{idx + 1}] {tab.label} + + ) : ( + ` ${idx + 1} ${tab.label}` + )} + + {idx < availableTabs.length - 1 && | } + + ))} + {isCurrentTabLoading && (loading...)} ); @@ -521,14 +633,45 @@ export function ProfileScreen({ ); // Determine which posts to show based on active tab - const displayPosts = isSelf && activeTab === "likes" ? likedTweets : tweets; - const displayError = isSelf && activeTab === "likes" ? likesError : null; + const displayPosts = (() => { + switch (activeTab) { + case "tweets": + return tweets; + case "replies": + return repliesTweets; + case "highlights": + return highlightsTweets; + case "media": + return mediaTweets; + case "likes": + return likedTweets; + default: + return tweets; + } + })(); + + // Determine error for current tab + const displayError = (() => { + switch (activeTab) { + case "replies": + return repliesError; + case "highlights": + return highlightsError; + case "media": + return mediaError; + case "likes": + return likesError; + default: + return null; + } + })(); // Footer keybindings - show available actions based on what data exists - // Tab keybindings (1/2) are shown in the tab bar itself, not in footer + // Tab shortcuts are shown in the tab bar itself via ←/→ and 1-5 const footerBindings: Keybinding[] = [ { key: "h/Esc", label: "back" }, { key: "j/k", label: "nav" }, + { key: "←/→", label: "tabs" }, { key: "l", label: "like" }, { key: "b", label: "bkmk" }, { @@ -590,10 +733,22 @@ export function ProfileScreen({ } // Empty state message based on active tab - const emptyMessage = - isSelf && activeTab === "likes" - ? "No liked tweets" - : "No tweets to display"; + const emptyMessage = (() => { + switch (activeTab) { + case "tweets": + return "No tweets to display"; + case "replies": + return "No replies to display"; + case "highlights": + return "No highlights to display"; + case "media": + return "No media to display"; + case "likes": + return "No liked tweets"; + default: + return "No content to display"; + } + })(); return (