From d9eb362d3fd225a5a492b7e8acd5a021e76725f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Jan 2026 07:30:59 +0000 Subject: [PATCH 1/2] feat: add URL navigation modal for quick tweet/profile access Add 'g' keybinding to open a modal where users can paste x.com or twitter.com URLs to navigate directly to tweets or profiles. This enables quick access to specific content without scrolling through timelines. - Create url-parser utility to parse tweet and profile URLs - Create UrlInputModal component with loading/error states - Add 'g goto' keybinding hint in footer --- src/app.tsx | 45 +++++++++ src/components/Footer.tsx | 1 + src/lib/url-parser.test.ts | 189 +++++++++++++++++++++++++++++++++++ src/lib/url-parser.ts | 125 +++++++++++++++++++++++ src/modals/UrlInputModal.tsx | 185 ++++++++++++++++++++++++++++++++++ 5 files changed, 545 insertions(+) create mode 100644 src/lib/url-parser.test.ts create mode 100644 src/lib/url-parser.ts create mode 100644 src/modals/UrlInputModal.tsx diff --git a/src/app.tsx b/src/app.tsx index 88fa1cf..4527249 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,42 @@ 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} + /> + ), + unstyled: true, + }); + + // 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 +704,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..a2b78ce --- /dev/null +++ b/src/lib/url-parser.test.ts @@ -0,0 +1,189 @@ +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..523f3e1 --- /dev/null +++ b/src/modals/UrlInputModal.tsx @@ -0,0 +1,185 @@ +/** + * 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 { 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); + + 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); + } + }; + + useDialogKeyboard((key) => { + if (isSubmitting) return; + + if (key.name === "escape") { + dismiss(); + } + }, dialogId); + + 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 + + + ); +} From 1d0739fec2523eb28741175c3408c1f613a4522b Mon Sep 17 00:00:00 2001 From: Ali Ihsan Nergiz Date: Tue, 13 Jan 2026 00:27:16 +0000 Subject: [PATCH 2/2] fix: resolve URL navigation modal issues and X API feature flag - Add post_ctas_fetch_enabled feature flag to both buildArticleFeatures and buildSearchFeatures to fix X API 400 errors - Fix dialog keyboard handling using useDialogKeyboard for proper ESC - Add paste event handling since OpenTUI Input doesn't support paste natively - Set dialog width to constrain input field within bounds - Remove unstyled option for proper dialog rendering Co-Authored-By: Claude Opus 4.5 --- src/api/client.ts | 2 ++ src/app.tsx | 6 +++-- src/lib/url-parser.test.ts | 8 ++++-- src/modals/UrlInputModal.tsx | 48 +++++++++++++++++++++++++++--------- 4 files changed, 48 insertions(+), 16 deletions(-) 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 4527249..e1bebf6 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -583,7 +583,10 @@ function AppContent({ client, user }: AppProps) { navigate("post-detail"); return { success: true }; } - return { success: false, error: tweetResult.error ?? "Tweet not found" }; + return { + success: false, + error: tweetResult.error ?? "Tweet not found", + }; }} onNavigateToProfile={async (username) => { setProfileStack((prev) => [...prev, username]); @@ -595,7 +598,6 @@ function AppContent({ client, user }: AppProps) { dialogId={ctx.dialogId} /> ), - unstyled: true, }); // Result is undefined if dismissed, otherwise navigation already happened diff --git a/src/lib/url-parser.test.ts b/src/lib/url-parser.test.ts index a2b78ce..12c6f6a 100644 --- a/src/lib/url-parser.test.ts +++ b/src/lib/url-parser.test.ts @@ -5,7 +5,9 @@ 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"); + const result = parseXUrl( + "https://x.com/doodlestein/status/2007588870662107197" + ); expect(result).toEqual({ type: "tweet", username: "doodlestein", @@ -14,7 +16,9 @@ describe("parseXUrl", () => { }); it("parses twitter.com tweet URL", () => { - const result = parseXUrl("https://twitter.com/elonmusk/status/1234567890"); + const result = parseXUrl( + "https://twitter.com/elonmusk/status/1234567890" + ); expect(result).toEqual({ type: "tweet", username: "elonmusk", diff --git a/src/modals/UrlInputModal.tsx b/src/modals/UrlInputModal.tsx index 523f3e1..0dc2e9f 100644 --- a/src/modals/UrlInputModal.tsx +++ b/src/modals/UrlInputModal.tsx @@ -14,7 +14,8 @@ import { useDialogKeyboard, type PromptContext, } from "@opentui-ui/dialog/react"; -import { useState } from "react"; +import { useAppContext } from "@opentui/react"; +import { useEffect, useState } from "react"; import { colors } from "@/lib/colors"; import { parseXUrl, type ParsedUrl } from "@/lib/url-parser"; @@ -36,9 +37,13 @@ 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 }>; + onNavigateToTweet: ( + tweetId: string + ) => Promise<{ success: boolean; error?: string }>; /** Callback to handle navigation to a profile */ - onNavigateToProfile: (username: string) => Promise<{ success: boolean; error?: string }>; + onNavigateToProfile: ( + username: string + ) => Promise<{ success: boolean; error?: string }>; } /** @@ -56,6 +61,33 @@ export function UrlInputContent({ 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(); @@ -98,16 +130,8 @@ export function UrlInputContent({ } }; - useDialogKeyboard((key) => { - if (isSubmitting) return; - - if (key.name === "escape") { - dismiss(); - } - }, dialogId); - return ( - + {/* Header */}