diff --git a/src/api/client.ts b/src/api/client.ts index 87446b1..3cadf53 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1202,6 +1202,7 @@ export class XClient { responsive_web_grok_analyze_button_fetch_trends_enabled: false, responsive_web_grok_analyze_post_followups_enabled: false, responsive_web_jetfuel_frame: true, + post_ctas_fetch_enabled: true, responsive_web_grok_share_attachment_enabled: true, articles_preview_enabled: true, responsive_web_edit_tweet_api_enabled: true, @@ -1271,6 +1272,7 @@ export class XClient { responsive_web_grok_analyze_button_fetch_trends_enabled: false, responsive_web_grok_analyze_post_followups_enabled: true, responsive_web_jetfuel_frame: true, + post_ctas_fetch_enabled: true, responsive_web_grok_share_attachment_enabled: true, responsive_web_grok_annotations_enabled: false, articles_preview_enabled: true, diff --git a/src/app.tsx b/src/app.tsx index 88fa1cf..e1bebf6 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -31,6 +31,10 @@ import { type FolderPickerResult, } from "@/modals/FolderPicker"; import { SessionExpiredContent } from "@/modals/SessionExpiredModal"; +import { + UrlInputContent, + type UrlNavigationResult, +} from "@/modals/UrlInputModal"; import { BookmarksScreen } from "@/screens/BookmarksScreen"; import { NotificationsScreen } from "@/screens/NotificationsScreen"; import { PostDetailScreen } from "@/screens/PostDetailScreen"; @@ -562,6 +566,44 @@ function AppContent({ client, user }: AppProps) { }); }, [dialog, renderer]); + // Open URL navigation modal (go to tweet or profile by URL) + const handleUrlNavigationOpen = useCallback(async () => { + const result = await dialog.prompt({ + content: (ctx) => ( + { + const tweetResult = await client.getTweet(tweetId); + if (tweetResult.success && tweetResult.tweet) { + setPostStack((prev) => [...prev, tweetResult.tweet!]); + initState( + tweetResult.tweet.id, + tweetResult.tweet.favorited ?? false, + tweetResult.tweet.bookmarked ?? false + ); + navigate("post-detail"); + return { success: true }; + } + return { + success: false, + error: tweetResult.error ?? "Tweet not found", + }; + }} + onNavigateToProfile={async (username) => { + setProfileStack((prev) => [...prev, username]); + navigate("profile"); + return { success: true }; + }} + resolve={ctx.resolve} + dismiss={ctx.dismiss} + dialogId={ctx.dialogId} + /> + ), + }); + + // Result is undefined if dismissed, otherwise navigation already happened + if (!result) return; + }, [client, dialog, initState, navigate]); + useKeyboard((key) => { // Handle copy with 'c' - Cmd+C is intercepted by terminal if (key.name === "c") { @@ -664,6 +706,11 @@ function AppContent({ client, user }: AppProps) { setProfileStack((prev) => [...prev, user.username]); navigate("profile"); } + + // Open URL navigation modal with 'g' (go to) + if (key.name === "g") { + handleUrlNavigationOpen(); + } }); return ( diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index d062497..887042f 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -15,6 +15,7 @@ const DEFAULT_BINDINGS: Keybinding[] = [ { key: "j/k", label: "nav" }, { key: "l", label: "like" }, { key: "b", label: "bookmark" }, + { key: "g", label: "goto" }, { key: "r", label: "refresh" }, { key: "Tab", label: "switch" }, { key: "q", label: "quit" }, diff --git a/src/lib/url-parser.test.ts b/src/lib/url-parser.test.ts new file mode 100644 index 0000000..12c6f6a --- /dev/null +++ b/src/lib/url-parser.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "bun:test"; + +import { parseXUrl } from "./url-parser"; + +describe("parseXUrl", () => { + describe("tweet URLs", () => { + it("parses x.com tweet URL", () => { + const result = parseXUrl( + "https://x.com/doodlestein/status/2007588870662107197" + ); + expect(result).toEqual({ + type: "tweet", + username: "doodlestein", + tweetId: "2007588870662107197", + }); + }); + + it("parses twitter.com tweet URL", () => { + const result = parseXUrl( + "https://twitter.com/elonmusk/status/1234567890" + ); + expect(result).toEqual({ + type: "tweet", + username: "elonmusk", + tweetId: "1234567890", + }); + }); + + it("parses URL without protocol", () => { + const result = parseXUrl("x.com/user/status/123456"); + expect(result).toEqual({ + type: "tweet", + username: "user", + tweetId: "123456", + }); + }); + + it("parses www subdomain", () => { + const result = parseXUrl("https://www.x.com/user/status/123456"); + expect(result).toEqual({ + type: "tweet", + username: "user", + tweetId: "123456", + }); + }); + + it("parses mobile subdomain", () => { + const result = parseXUrl("https://mobile.twitter.com/user/status/123456"); + expect(result).toEqual({ + type: "tweet", + username: "user", + tweetId: "123456", + }); + }); + + it("handles trailing slashes and query params", () => { + const result = parseXUrl("https://x.com/user/status/123456/?s=20"); + expect(result).toEqual({ + type: "tweet", + username: "user", + tweetId: "123456", + }); + }); + }); + + describe("profile URLs", () => { + it("parses x.com profile URL", () => { + const result = parseXUrl("https://x.com/elonmusk"); + expect(result).toEqual({ + type: "profile", + username: "elonmusk", + }); + }); + + it("parses twitter.com profile URL", () => { + const result = parseXUrl("https://twitter.com/doodlestein"); + expect(result).toEqual({ + type: "profile", + username: "doodlestein", + }); + }); + + it("parses profile URL without protocol", () => { + const result = parseXUrl("x.com/testuser"); + expect(result).toEqual({ + type: "profile", + username: "testuser", + }); + }); + + it("handles profile sub-pages as profile type", () => { + expect(parseXUrl("https://x.com/user/followers")).toEqual({ + type: "profile", + username: "user", + }); + expect(parseXUrl("https://x.com/user/following")).toEqual({ + type: "profile", + username: "user", + }); + expect(parseXUrl("https://x.com/user/likes")).toEqual({ + type: "profile", + username: "user", + }); + }); + }); + + describe("reserved paths", () => { + it("rejects home URL", () => { + const result = parseXUrl("https://x.com/home"); + expect(result.type).toBe("invalid"); + }); + + it("rejects explore URL", () => { + const result = parseXUrl("https://x.com/explore"); + expect(result.type).toBe("invalid"); + }); + + it("rejects notifications URL", () => { + const result = parseXUrl("https://x.com/notifications"); + expect(result.type).toBe("invalid"); + }); + + it("rejects messages URL", () => { + const result = parseXUrl("https://x.com/messages"); + expect(result.type).toBe("invalid"); + }); + + it("rejects settings URL", () => { + const result = parseXUrl("https://x.com/settings"); + expect(result.type).toBe("invalid"); + }); + + it("rejects i (internal) URL", () => { + const result = parseXUrl("https://x.com/i/flow/login"); + expect(result.type).toBe("invalid"); + }); + + it("rejects search URL", () => { + const result = parseXUrl("https://x.com/search"); + expect(result.type).toBe("invalid"); + }); + }); + + describe("invalid URLs", () => { + it("rejects empty string", () => { + const result = parseXUrl(""); + expect(result).toEqual({ + type: "invalid", + error: "URL cannot be empty", + }); + }); + + it("rejects whitespace-only string", () => { + const result = parseXUrl(" "); + expect(result).toEqual({ + type: "invalid", + error: "URL cannot be empty", + }); + }); + + it("rejects non-X/Twitter domain", () => { + const result = parseXUrl("https://example.com/user/status/123"); + expect(result).toEqual({ + type: "invalid", + error: "Not an X or Twitter URL", + }); + }); + + it("rejects URL with no path", () => { + const result = parseXUrl("https://x.com/"); + expect(result).toEqual({ + type: "invalid", + error: "No username or tweet ID in URL", + }); + }); + + it("rejects invalid tweet ID (non-numeric)", () => { + const result = parseXUrl("https://x.com/user/status/abc123"); + expect(result).toEqual({ + type: "invalid", + error: "Invalid tweet ID", + }); + }); + + it("rejects malformed URL", () => { + const result = parseXUrl("not a url at all"); + expect(result).toEqual({ + type: "invalid", + error: "Invalid URL format", + }); + }); + }); +}); diff --git a/src/lib/url-parser.ts b/src/lib/url-parser.ts new file mode 100644 index 0000000..cfb0e29 --- /dev/null +++ b/src/lib/url-parser.ts @@ -0,0 +1,125 @@ +/** + * URL Parser - Parses x.com and twitter.com URLs to extract tweet or profile info + */ + +export type ParsedUrl = + | { type: "tweet"; username: string; tweetId: string } + | { type: "profile"; username: string } + | { type: "invalid"; error: string }; + +/** Reserved paths that are not user profiles */ +const RESERVED_PATHS = new Set([ + "home", + "explore", + "notifications", + "messages", + "settings", + "i", + "search", + "compose", + "intent", + "hashtag", + "lists", +]); + +/** + * Parse an X/Twitter URL to determine if it's a tweet or profile URL + * + * @param input - URL string (can be with or without protocol) + * @returns ParsedUrl discriminated union + */ +export function parseXUrl(input: string): ParsedUrl { + const trimmed = input.trim(); + + if (!trimmed) { + return { type: "invalid", error: "URL cannot be empty" }; + } + + // Normalize the URL - add protocol if missing + let url: URL; + try { + // Add https:// if no protocol is present + const withProtocol = trimmed.match(/^https?:\/\//i) + ? trimmed + : `https://${trimmed}`; + url = new URL(withProtocol); + } catch { + return { type: "invalid", error: "Invalid URL format" }; + } + + // Check if it's an X or Twitter domain + const hostname = url.hostname.toLowerCase(); + if ( + hostname !== "x.com" && + hostname !== "twitter.com" && + hostname !== "www.x.com" && + hostname !== "www.twitter.com" && + hostname !== "mobile.x.com" && + hostname !== "mobile.twitter.com" + ) { + return { type: "invalid", error: "Not an X or Twitter URL" }; + } + + // Get path segments (filter out empty strings from leading/trailing slashes) + const pathSegments = url.pathname.split("/").filter(Boolean); + + if (pathSegments.length === 0) { + return { type: "invalid", error: "No username or tweet ID in URL" }; + } + + const firstSegment = pathSegments[0]; + if (!firstSegment) { + return { type: "invalid", error: "No username or tweet ID in URL" }; + } + + // Check if first segment is a reserved path + if (RESERVED_PATHS.has(firstSegment.toLowerCase())) { + return { type: "invalid", error: `"${firstSegment}" is not a profile` }; + } + + const username = firstSegment; + + // Check for tweet URL: /username/status/id + if (pathSegments.length >= 3) { + const secondSegment = pathSegments[1]; + const thirdSegment = pathSegments[2]; + + if (secondSegment?.toLowerCase() === "status" && thirdSegment) { + // Validate tweet ID is numeric + if (!/^\d+$/.test(thirdSegment)) { + return { type: "invalid", error: "Invalid tweet ID" }; + } + return { type: "tweet", username, tweetId: thirdSegment }; + } + } + + // Profile URL: /username (only one segment, or any other pattern) + if (pathSegments.length === 1) { + return { type: "profile", username }; + } + + // Handle other paths like /username/followers, /username/likes, etc. + // These are treated as profile URLs + if (pathSegments.length >= 2) { + const secondSegment = pathSegments[1]?.toLowerCase(); + // Known profile sub-pages + const profileSubPages = new Set([ + "followers", + "following", + "likes", + "with_replies", + "media", + "highlights", + "articles", + "verified_followers", + "photo", + "header_photo", + ]); + if (secondSegment && profileSubPages.has(secondSegment)) { + return { type: "profile", username }; + } + } + + // For other unrecognized paths under a username, still treat as profile + return { type: "profile", username }; +} diff --git a/src/modals/UrlInputModal.tsx b/src/modals/UrlInputModal.tsx new file mode 100644 index 0000000..0dc2e9f --- /dev/null +++ b/src/modals/UrlInputModal.tsx @@ -0,0 +1,209 @@ +/** + * UrlInputModal - Modal for pasting an x.com URL to navigate to a tweet or profile + * + * Uses @opentui-ui/dialog for async dialog management. + * Features: + * - Parses x.com and twitter.com URLs + * - Supports tweet URLs (/username/status/id) and profile URLs (/username) + * - Enter to submit, Esc to cancel + * - Loading state during navigation + * - Error display on failure + */ + +import { + useDialogKeyboard, + type PromptContext, +} from "@opentui-ui/dialog/react"; +import { useAppContext } from "@opentui/react"; +import { useEffect, useState } from "react"; + +import { colors } from "@/lib/colors"; +import { parseXUrl, type ParsedUrl } from "@/lib/url-parser"; + +// Dialog colors (Catppuccin-inspired) +const dialogColors = { + bgDark: "#1e1e2e", + bgPanel: "#181825", + bgInput: "#11111b", + textPrimary: "#cdd6f4", + textSecondary: "#bac2de", + textMuted: "#6c7086", + accent: "#89b4fa", +}; + +/** Result returned when navigation is successful */ +export type UrlNavigationResult = ParsedUrl & { type: "tweet" | "profile" }; + +/** Props for UrlInputContent (used with dialog.prompt) */ +export interface UrlInputContentProps extends PromptContext { + /** Callback to handle navigation to a tweet */ + onNavigateToTweet: ( + tweetId: string + ) => Promise<{ success: boolean; error?: string }>; + /** Callback to handle navigation to a profile */ + onNavigateToProfile: ( + username: string + ) => Promise<{ success: boolean; error?: string }>; +} + +/** + * Content component for URL input dialog. + * Use with dialog.prompt(). + */ +export function UrlInputContent({ + onNavigateToTweet, + onNavigateToProfile, + resolve, + dismiss, + dialogId, +}: UrlInputContentProps) { + const [value, setValue] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + // Get keyHandler for paste event handling + const { keyHandler } = useAppContext(); + + // Handle ESC to dismiss dialog - useDialogKeyboard ensures only topmost dialog responds + useDialogKeyboard((key) => { + if (isSubmitting) return; + + if (key.name === "escape") { + dismiss(); + } + }, dialogId); + + // Handle paste events - Input component doesn't handle paste natively + useEffect(() => { + if (!keyHandler || isSubmitting) return; + + const handlePaste = (event: { text: string }) => { + setValue((prev) => prev + event.text); + if (error) setError(null); + }; + + keyHandler.on("paste", handlePaste); + return () => { + keyHandler.off("paste", handlePaste); + }; + }, [keyHandler, isSubmitting, error]); + + const handleSubmit = async () => { + const trimmed = value.trim(); + + if (!trimmed) { + setError("Please paste an X URL"); + return; + } + + const parsed = parseXUrl(trimmed); + + if (parsed.type === "invalid") { + setError(parsed.error); + return; + } + + setIsSubmitting(true); + setError(null); + + try { + if (parsed.type === "tweet") { + const result = await onNavigateToTweet(parsed.tweetId); + if (result.success) { + resolve(parsed); + } else { + setError(result.error ?? "Failed to load tweet"); + setIsSubmitting(false); + } + } else { + const result = await onNavigateToProfile(parsed.username); + if (result.success) { + resolve(parsed); + } else { + setError(result.error ?? "Failed to load profile"); + setIsSubmitting(false); + } + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to navigate"); + setIsSubmitting(false); + } + }; + + return ( + + {/* Header */} + + Go to URL + + + {/* Content */} + + {/* Input field */} + { + setValue(newValue); + if (error) setError(null); + }} + onSubmit={() => { + handleSubmit(); + }} + width={50} + height={1} + backgroundColor={dialogColors.bgInput} + textColor={dialogColors.textPrimary} + placeholderColor={dialogColors.textMuted} + cursorColor={dialogColors.accent} + /> + + {/* Helper text */} + + Supports x.com/user/status/id and x.com/user + + + {/* Error message */} + {error ? {error} : null} + + {/* Loading state */} + {isSubmitting ? ( + Loading... + ) : null} + + + {/* Footer */} + + Enter + go + Esc + cancel + + + ); +}