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
2 changes: 2 additions & 0 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
47 changes: 47 additions & 0 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<UrlNavigationResult>({
content: (ctx) => (
<UrlInputContent
onNavigateToTweet={async (tweetId) => {
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") {
Expand Down Expand Up @@ -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 (
Expand Down
1 change: 1 addition & 0 deletions src/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
193 changes: 193 additions & 0 deletions src/lib/url-parser.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
});
Loading
Loading