xfeed 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +5 -0
  3. package/package.json +43 -0
  4. package/src/api/actions.ts +16 -0
  5. package/src/api/client.test.ts +3370 -0
  6. package/src/api/client.ts +4319 -0
  7. package/src/api/query-ids.json +11 -0
  8. package/src/api/query-ids.test.ts +118 -0
  9. package/src/api/query-ids.ts +59 -0
  10. package/src/api/runtime-query-ids.test.ts +926 -0
  11. package/src/api/runtime-query-ids.ts +389 -0
  12. package/src/api/types.ts +581 -0
  13. package/src/app.tsx +664 -0
  14. package/src/auth/browser-detect.ts +150 -0
  15. package/src/auth/browser-picker.ts +118 -0
  16. package/src/auth/check.test.preload.ts +94 -0
  17. package/src/auth/check.test.ts +388 -0
  18. package/src/auth/check.ts +220 -0
  19. package/src/auth/cookies.test.ts +529 -0
  20. package/src/auth/cookies.ts +299 -0
  21. package/src/auth/manual-entry.ts +88 -0
  22. package/src/auth/session.ts +30 -0
  23. package/src/components/ErrorBanner.tsx +172 -0
  24. package/src/components/Footer.tsx +90 -0
  25. package/src/components/Header.tsx +57 -0
  26. package/src/components/NotificationItem.test.ts +252 -0
  27. package/src/components/NotificationItem.tsx +80 -0
  28. package/src/components/NotificationList.test.ts +328 -0
  29. package/src/components/NotificationList.tsx +157 -0
  30. package/src/components/PostCard.tsx +186 -0
  31. package/src/components/PostList.tsx +232 -0
  32. package/src/components/QuotedPostCard.tsx +55 -0
  33. package/src/components/ReplyPreviewCard.tsx +80 -0
  34. package/src/components/ThreadView.prototype.tsx +533 -0
  35. package/src/components/Toast.tsx +28 -0
  36. package/src/config/loader.ts +69 -0
  37. package/src/config/types.ts +27 -0
  38. package/src/contexts/ModalContext.tsx +227 -0
  39. package/src/experiments/TimelineScreenExperimental.tsx +202 -0
  40. package/src/experiments/index.tsx +43 -0
  41. package/src/experiments/query-client.ts +132 -0
  42. package/src/experiments/use-bookmark-mutation.ts +342 -0
  43. package/src/experiments/use-bookmarks-query.ts +166 -0
  44. package/src/experiments/use-notifications-query.ts +368 -0
  45. package/src/experiments/use-post-detail-query.ts +187 -0
  46. package/src/experiments/use-profile-query.ts +162 -0
  47. package/src/experiments/use-timeline-query.ts +201 -0
  48. package/src/hooks/.gitkeep +0 -0
  49. package/src/hooks/useActions.ts +354 -0
  50. package/src/hooks/useBookmarkFolders.ts +70 -0
  51. package/src/hooks/useBookmarks.ts +111 -0
  52. package/src/hooks/useCountdown.ts +75 -0
  53. package/src/hooks/useListNavigation.test.ts +273 -0
  54. package/src/hooks/useListNavigation.ts +118 -0
  55. package/src/hooks/useNavigation.test.ts +340 -0
  56. package/src/hooks/useNavigation.ts +103 -0
  57. package/src/hooks/useNotifications.test.ts +377 -0
  58. package/src/hooks/useNotifications.ts +117 -0
  59. package/src/hooks/usePaginatedData.ts +217 -0
  60. package/src/hooks/usePostDetail.ts +137 -0
  61. package/src/hooks/useThread.prototype.ts +314 -0
  62. package/src/hooks/useTimeline.ts +136 -0
  63. package/src/hooks/useUserProfile.ts +142 -0
  64. package/src/index.tsx +304 -0
  65. package/src/lib/colors.ts +41 -0
  66. package/src/lib/format.ts +69 -0
  67. package/src/lib/media.ts +464 -0
  68. package/src/lib/result.ts +6 -0
  69. package/src/lib/text.tsx +76 -0
  70. package/src/modals/BookmarkFolderSelector.tsx +260 -0
  71. package/src/modals/ExitConfirmationModal.tsx +131 -0
  72. package/src/modals/FolderPicker.tsx +281 -0
  73. package/src/modals/README.md +171 -0
  74. package/src/modals/SessionExpiredModal.tsx +47 -0
  75. package/src/modals/index.ts +4 -0
  76. package/src/screens/.gitkeep +0 -0
  77. package/src/screens/BookmarksScreen.tsx +168 -0
  78. package/src/screens/NotificationsScreen.tsx +172 -0
  79. package/src/screens/PostDetailScreen.tsx +976 -0
  80. package/src/screens/ProfileScreen.tsx +528 -0
  81. package/src/screens/SplashScreen.tsx +72 -0
  82. package/src/screens/ThreadScreen.tsx +81 -0
  83. package/src/screens/TimelineScreen.tsx +188 -0
  84. package/vendor/sweet-cookie/LICENSE +22 -0
  85. package/vendor/sweet-cookie/README.md +29 -0
  86. package/vendor/sweet-cookie/dist/index.d.ts +3 -0
  87. package/vendor/sweet-cookie/dist/index.d.ts.map +1 -0
  88. package/vendor/sweet-cookie/dist/index.js +2 -0
  89. package/vendor/sweet-cookie/dist/index.js.map +1 -0
  90. package/vendor/sweet-cookie/dist/providers/chrome.d.ts +10 -0
  91. package/vendor/sweet-cookie/dist/providers/chrome.d.ts.map +1 -0
  92. package/vendor/sweet-cookie/dist/providers/chrome.js +27 -0
  93. package/vendor/sweet-cookie/dist/providers/chrome.js.map +1 -0
  94. package/vendor/sweet-cookie/dist/providers/chromeSqlite/crypto.d.ts +11 -0
  95. package/vendor/sweet-cookie/dist/providers/chromeSqlite/crypto.d.ts.map +1 -0
  96. package/vendor/sweet-cookie/dist/providers/chromeSqlite/crypto.js +100 -0
  97. package/vendor/sweet-cookie/dist/providers/chromeSqlite/crypto.js.map +1 -0
  98. package/vendor/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.d.ts +25 -0
  99. package/vendor/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.d.ts.map +1 -0
  100. package/vendor/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.js +104 -0
  101. package/vendor/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.js.map +1 -0
  102. package/vendor/sweet-cookie/dist/providers/chromeSqlite/shared.d.ts +10 -0
  103. package/vendor/sweet-cookie/dist/providers/chromeSqlite/shared.d.ts.map +1 -0
  104. package/vendor/sweet-cookie/dist/providers/chromeSqlite/shared.js +293 -0
  105. package/vendor/sweet-cookie/dist/providers/chromeSqlite/shared.js.map +1 -0
  106. package/vendor/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.d.ts +10 -0
  107. package/vendor/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.d.ts.map +1 -0
  108. package/vendor/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.js +26 -0
  109. package/vendor/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.js.map +1 -0
  110. package/vendor/sweet-cookie/dist/providers/chromeSqliteLinux.d.ts +7 -0
  111. package/vendor/sweet-cookie/dist/providers/chromeSqliteLinux.d.ts.map +1 -0
  112. package/vendor/sweet-cookie/dist/providers/chromeSqliteLinux.js +51 -0
  113. package/vendor/sweet-cookie/dist/providers/chromeSqliteLinux.js.map +1 -0
  114. package/vendor/sweet-cookie/dist/providers/chromeSqliteMac.d.ts +10 -0
  115. package/vendor/sweet-cookie/dist/providers/chromeSqliteMac.d.ts.map +1 -0
  116. package/vendor/sweet-cookie/dist/providers/chromeSqliteMac.js +118 -0
  117. package/vendor/sweet-cookie/dist/providers/chromeSqliteMac.js.map +1 -0
  118. package/vendor/sweet-cookie/dist/providers/chromeSqliteWindows.d.ts +7 -0
  119. package/vendor/sweet-cookie/dist/providers/chromeSqliteWindows.d.ts.map +1 -0
  120. package/vendor/sweet-cookie/dist/providers/chromeSqliteWindows.js +38 -0
  121. package/vendor/sweet-cookie/dist/providers/chromeSqliteWindows.js.map +1 -0
  122. package/vendor/sweet-cookie/dist/providers/chromium/linuxPaths.d.ts +5 -0
  123. package/vendor/sweet-cookie/dist/providers/chromium/linuxPaths.d.ts.map +1 -0
  124. package/vendor/sweet-cookie/dist/providers/chromium/linuxPaths.js +33 -0
  125. package/vendor/sweet-cookie/dist/providers/chromium/linuxPaths.js.map +1 -0
  126. package/vendor/sweet-cookie/dist/providers/chromium/macosKeychain.d.ts +24 -0
  127. package/vendor/sweet-cookie/dist/providers/chromium/macosKeychain.d.ts.map +1 -0
  128. package/vendor/sweet-cookie/dist/providers/chromium/macosKeychain.js +30 -0
  129. package/vendor/sweet-cookie/dist/providers/chromium/macosKeychain.js.map +1 -0
  130. package/vendor/sweet-cookie/dist/providers/chromium/paths.d.ts +11 -0
  131. package/vendor/sweet-cookie/dist/providers/chromium/paths.d.ts.map +1 -0
  132. package/vendor/sweet-cookie/dist/providers/chromium/paths.js +43 -0
  133. package/vendor/sweet-cookie/dist/providers/chromium/paths.js.map +1 -0
  134. package/vendor/sweet-cookie/dist/providers/chromium/windowsMasterKey.d.ts +8 -0
  135. package/vendor/sweet-cookie/dist/providers/chromium/windowsMasterKey.d.ts.map +1 -0
  136. package/vendor/sweet-cookie/dist/providers/chromium/windowsMasterKey.js +41 -0
  137. package/vendor/sweet-cookie/dist/providers/chromium/windowsMasterKey.js.map +1 -0
  138. package/vendor/sweet-cookie/dist/providers/chromium/windowsPaths.d.ts +8 -0
  139. package/vendor/sweet-cookie/dist/providers/chromium/windowsPaths.d.ts.map +1 -0
  140. package/vendor/sweet-cookie/dist/providers/chromium/windowsPaths.js +53 -0
  141. package/vendor/sweet-cookie/dist/providers/chromium/windowsPaths.js.map +1 -0
  142. package/vendor/sweet-cookie/dist/providers/edge.d.ts +8 -0
  143. package/vendor/sweet-cookie/dist/providers/edge.d.ts.map +1 -0
  144. package/vendor/sweet-cookie/dist/providers/edge.js +27 -0
  145. package/vendor/sweet-cookie/dist/providers/edge.js.map +1 -0
  146. package/vendor/sweet-cookie/dist/providers/edgeSqliteLinux.d.ts +7 -0
  147. package/vendor/sweet-cookie/dist/providers/edgeSqliteLinux.d.ts.map +1 -0
  148. package/vendor/sweet-cookie/dist/providers/edgeSqliteLinux.js +53 -0
  149. package/vendor/sweet-cookie/dist/providers/edgeSqliteLinux.js.map +1 -0
  150. package/vendor/sweet-cookie/dist/providers/edgeSqliteMac.d.ts +8 -0
  151. package/vendor/sweet-cookie/dist/providers/edgeSqliteMac.d.ts.map +1 -0
  152. package/vendor/sweet-cookie/dist/providers/edgeSqliteMac.js +60 -0
  153. package/vendor/sweet-cookie/dist/providers/edgeSqliteMac.js.map +1 -0
  154. package/vendor/sweet-cookie/dist/providers/edgeSqliteWindows.d.ts +7 -0
  155. package/vendor/sweet-cookie/dist/providers/edgeSqliteWindows.d.ts.map +1 -0
  156. package/vendor/sweet-cookie/dist/providers/edgeSqliteWindows.js +38 -0
  157. package/vendor/sweet-cookie/dist/providers/edgeSqliteWindows.js.map +1 -0
  158. package/vendor/sweet-cookie/dist/providers/firefoxSqlite.d.ts +6 -0
  159. package/vendor/sweet-cookie/dist/providers/firefoxSqlite.d.ts.map +1 -0
  160. package/vendor/sweet-cookie/dist/providers/firefoxSqlite.js +257 -0
  161. package/vendor/sweet-cookie/dist/providers/firefoxSqlite.js.map +1 -0
  162. package/vendor/sweet-cookie/dist/providers/inline.d.ts +8 -0
  163. package/vendor/sweet-cookie/dist/providers/inline.d.ts.map +1 -0
  164. package/vendor/sweet-cookie/dist/providers/inline.js +71 -0
  165. package/vendor/sweet-cookie/dist/providers/inline.js.map +1 -0
  166. package/vendor/sweet-cookie/dist/providers/safariBinaryCookies.d.ts +6 -0
  167. package/vendor/sweet-cookie/dist/providers/safariBinaryCookies.d.ts.map +1 -0
  168. package/vendor/sweet-cookie/dist/providers/safariBinaryCookies.js +173 -0
  169. package/vendor/sweet-cookie/dist/providers/safariBinaryCookies.js.map +1 -0
  170. package/vendor/sweet-cookie/dist/public.d.ts +26 -0
  171. package/vendor/sweet-cookie/dist/public.d.ts.map +1 -0
  172. package/vendor/sweet-cookie/dist/public.js +197 -0
  173. package/vendor/sweet-cookie/dist/public.js.map +1 -0
  174. package/vendor/sweet-cookie/dist/types.d.ts +127 -0
  175. package/vendor/sweet-cookie/dist/types.d.ts.map +1 -0
  176. package/vendor/sweet-cookie/dist/types.js +2 -0
  177. package/vendor/sweet-cookie/dist/types.js.map +1 -0
  178. package/vendor/sweet-cookie/dist/util/base64.d.ts +2 -0
  179. package/vendor/sweet-cookie/dist/util/base64.d.ts.map +1 -0
  180. package/vendor/sweet-cookie/dist/util/base64.js +18 -0
  181. package/vendor/sweet-cookie/dist/util/base64.js.map +1 -0
  182. package/vendor/sweet-cookie/dist/util/exec.d.ts +8 -0
  183. package/vendor/sweet-cookie/dist/util/exec.d.ts.map +1 -0
  184. package/vendor/sweet-cookie/dist/util/exec.js +110 -0
  185. package/vendor/sweet-cookie/dist/util/exec.js.map +1 -0
  186. package/vendor/sweet-cookie/dist/util/expire.d.ts +2 -0
  187. package/vendor/sweet-cookie/dist/util/expire.d.ts.map +1 -0
  188. package/vendor/sweet-cookie/dist/util/expire.js +32 -0
  189. package/vendor/sweet-cookie/dist/util/expire.js.map +1 -0
  190. package/vendor/sweet-cookie/dist/util/fs.d.ts +2 -0
  191. package/vendor/sweet-cookie/dist/util/fs.d.ts.map +1 -0
  192. package/vendor/sweet-cookie/dist/util/fs.js +13 -0
  193. package/vendor/sweet-cookie/dist/util/fs.js.map +1 -0
  194. package/vendor/sweet-cookie/dist/util/hostMatch.d.ts +2 -0
  195. package/vendor/sweet-cookie/dist/util/hostMatch.d.ts.map +1 -0
  196. package/vendor/sweet-cookie/dist/util/hostMatch.js +7 -0
  197. package/vendor/sweet-cookie/dist/util/hostMatch.js.map +1 -0
  198. package/vendor/sweet-cookie/dist/util/nodeSqlite.d.ts +5 -0
  199. package/vendor/sweet-cookie/dist/util/nodeSqlite.d.ts.map +1 -0
  200. package/vendor/sweet-cookie/dist/util/nodeSqlite.js +58 -0
  201. package/vendor/sweet-cookie/dist/util/nodeSqlite.js.map +1 -0
  202. package/vendor/sweet-cookie/dist/util/origins.d.ts +2 -0
  203. package/vendor/sweet-cookie/dist/util/origins.d.ts.map +1 -0
  204. package/vendor/sweet-cookie/dist/util/origins.js +27 -0
  205. package/vendor/sweet-cookie/dist/util/origins.js.map +1 -0
  206. package/vendor/sweet-cookie/dist/util/runtime.d.ts +2 -0
  207. package/vendor/sweet-cookie/dist/util/runtime.d.ts.map +1 -0
  208. package/vendor/sweet-cookie/dist/util/runtime.js +8 -0
  209. package/vendor/sweet-cookie/dist/util/runtime.js.map +1 -0
  210. package/vendor/sweet-cookie/package.json +40 -0
@@ -0,0 +1,528 @@
1
+ /**
2
+ * ProfileScreen - User profile view with bio and recent tweets
3
+ * Supports collapsible header when scrolling through tweets
4
+ * When viewing own profile (isSelf), shows tabs for Tweets/Likes
5
+ */
6
+
7
+ import { useKeyboard } from "@opentui/react";
8
+ import { useState, useCallback, useEffect } from "react";
9
+
10
+ import type { XClient } from "@/api/client";
11
+ import type { TweetData, UserData } from "@/api/types";
12
+ import type { TweetActionState } from "@/hooks/useActions";
13
+
14
+ import { Footer, type Keybinding } from "@/components/Footer";
15
+ import { PostList } from "@/components/PostList";
16
+ import { useUserProfile } from "@/hooks/useUserProfile";
17
+ import { colors } from "@/lib/colors";
18
+ import { formatCount } from "@/lib/format";
19
+ import { openInBrowser, previewImageUrl } from "@/lib/media";
20
+ import { extractMentions, renderTextWithMentions } from "@/lib/text";
21
+
22
+ type ProfileTab = "tweets" | "likes";
23
+
24
+ /**
25
+ * Format X's created_at date to "Joined Month Year"
26
+ */
27
+ function formatJoinDate(createdAt: string | undefined): string | undefined {
28
+ if (!createdAt) return undefined;
29
+ try {
30
+ const date = new Date(createdAt);
31
+ if (Number.isNaN(date.getTime())) return undefined;
32
+ const month = date.toLocaleDateString("en-US", { month: "long" });
33
+ const year = date.getFullYear();
34
+ return `Joined ${month} ${year}`;
35
+ } catch {
36
+ return undefined;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Extract display domain from a URL
42
+ */
43
+ function extractDomain(url: string | undefined): string | undefined {
44
+ if (!url) return undefined;
45
+ try {
46
+ const parsed = new URL(url);
47
+ return parsed.hostname.replace(/^www\./, "");
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ interface ProfileScreenProps {
54
+ client: XClient;
55
+ username: string;
56
+ /** Current logged-in user (used to detect self-view) */
57
+ currentUser?: UserData;
58
+ focused?: boolean;
59
+ onBack?: () => void;
60
+ onPostSelect?: (post: TweetData) => void;
61
+ /** Called when user navigates to a mentioned profile */
62
+ onProfileOpen?: (username: string) => void;
63
+ /** Called when user presses 'l' to toggle like */
64
+ onLike?: (post: TweetData) => void;
65
+ /** Called when user presses 'b' to toggle bookmark */
66
+ onBookmark?: (post: TweetData) => void;
67
+ /** Get current action state for a tweet */
68
+ getActionState?: (tweetId: string) => TweetActionState;
69
+ /** Initialize action state from API data */
70
+ initActionState?: (
71
+ tweetId: string,
72
+ liked: boolean,
73
+ bookmarked: boolean
74
+ ) => void;
75
+ /** Whether to show the footer */
76
+ showFooter?: boolean;
77
+ }
78
+
79
+ export function ProfileScreen({
80
+ client,
81
+ username,
82
+ currentUser,
83
+ focused = false,
84
+ onBack,
85
+ onPostSelect,
86
+ onProfileOpen,
87
+ onLike,
88
+ onBookmark,
89
+ getActionState,
90
+ initActionState,
91
+ showFooter = true,
92
+ }: ProfileScreenProps) {
93
+ // Detect if viewing own profile
94
+ const isSelf = Boolean(
95
+ currentUser && username.toLowerCase() === currentUser.username.toLowerCase()
96
+ );
97
+
98
+ const {
99
+ user,
100
+ tweets,
101
+ loading,
102
+ error,
103
+ refresh,
104
+ likedTweets,
105
+ likesLoading,
106
+ likesError,
107
+ fetchLikes,
108
+ likesFetched,
109
+ } = useUserProfile({
110
+ client,
111
+ username,
112
+ isSelf,
113
+ });
114
+
115
+ // Tab state (only used when isSelf)
116
+ const [activeTab, setActiveTab] = useState<ProfileTab>("tweets");
117
+
118
+ // Track if header should be collapsed (when scrolled past first tweet)
119
+ const [isCollapsed, setIsCollapsed] = useState(false);
120
+
121
+ // Mentions navigation state
122
+ const [mentionsMode, setMentionsMode] = useState(false);
123
+ const [mentionIndex, setMentionIndex] = useState(0);
124
+
125
+ // Fetch likes when switching to likes tab for the first time
126
+ useEffect(() => {
127
+ if (isSelf && activeTab === "likes" && !likesFetched && !likesLoading) {
128
+ fetchLikes();
129
+ }
130
+ }, [isSelf, activeTab, likesFetched, likesLoading, fetchLikes]);
131
+
132
+ // Extract mentions from bio
133
+ const bioMentions = user?.description
134
+ ? extractMentions(user.description)
135
+ : [];
136
+ const hasMentions = bioMentions.length > 0;
137
+ const mentionCount = bioMentions.length;
138
+ const currentMention = hasMentions ? bioMentions[mentionIndex] : undefined;
139
+
140
+ const handleSelectedIndexChange = useCallback((index: number) => {
141
+ setIsCollapsed(index > 0);
142
+ }, []);
143
+
144
+ // Handle keyboard shortcuts
145
+ useKeyboard((key) => {
146
+ if (!focused) return;
147
+
148
+ // Handle mentions mode navigation
149
+ if (mentionsMode && hasMentions) {
150
+ // Exit mentions mode with escape or h
151
+ if (key.name === "escape" || key.name === "h") {
152
+ setMentionsMode(false);
153
+ return;
154
+ }
155
+ // Navigate mentions with j/k
156
+ if (key.name === "j" || key.name === "down") {
157
+ if (mentionIndex < mentionCount - 1) {
158
+ setMentionIndex((prev) => prev + 1);
159
+ }
160
+ return;
161
+ }
162
+ if (key.name === "k" || key.name === "up") {
163
+ if (mentionIndex > 0) {
164
+ setMentionIndex((prev) => prev - 1);
165
+ }
166
+ return;
167
+ }
168
+ // Open mention profile with Enter
169
+ if (key.name === "return") {
170
+ if (currentMention) {
171
+ onProfileOpen?.(currentMention);
172
+ }
173
+ return;
174
+ }
175
+ // Other keys exit mentions mode and proceed
176
+ }
177
+
178
+ switch (key.name) {
179
+ case "escape":
180
+ case "backspace":
181
+ case "h":
182
+ onBack?.();
183
+ break;
184
+ case "r":
185
+ refresh();
186
+ break;
187
+ case "a":
188
+ // Open avatar/profile photo in Quick Look
189
+ if (user?.profileImageUrl) {
190
+ previewImageUrl(user.profileImageUrl, `profile_${user.username}`);
191
+ }
192
+ break;
193
+ case "v":
194
+ // View banner image in Quick Look
195
+ if (user?.bannerImageUrl) {
196
+ previewImageUrl(user.bannerImageUrl, `banner_${user.username}`);
197
+ }
198
+ break;
199
+ case "w":
200
+ // Open website in browser
201
+ if (user?.websiteUrl) {
202
+ openInBrowser(user.websiteUrl);
203
+ }
204
+ break;
205
+ case "x":
206
+ // Open profile on x.com
207
+ if (user?.username) {
208
+ openInBrowser(`https://x.com/${user.username}`);
209
+ }
210
+ break;
211
+ case "m":
212
+ // Handle mentions: single = direct profile, multiple = enter mode
213
+ if (hasMentions && !mentionsMode) {
214
+ if (mentionCount === 1) {
215
+ // Single mention - open profile directly
216
+ const mention = bioMentions[0];
217
+ if (mention) {
218
+ onProfileOpen?.(mention);
219
+ }
220
+ } else {
221
+ // Multiple mentions - enter navigation mode
222
+ setMentionsMode(true);
223
+ setMentionIndex(0);
224
+ }
225
+ }
226
+ break;
227
+ case "1":
228
+ // Switch to tweets tab (only on own profile)
229
+ if (isSelf && activeTab !== "tweets") {
230
+ setActiveTab("tweets");
231
+ setIsCollapsed(false);
232
+ }
233
+ break;
234
+ case "2":
235
+ // Switch to likes tab (only on own profile)
236
+ if (isSelf && activeTab !== "likes") {
237
+ setActiveTab("likes");
238
+ setIsCollapsed(false);
239
+ }
240
+ break;
241
+ }
242
+ });
243
+
244
+ // Compact header for collapsed state - just name and handle
245
+ const compactHeader = user && (
246
+ <box
247
+ style={{
248
+ flexShrink: 0,
249
+ paddingLeft: 1,
250
+ paddingRight: 1,
251
+ flexDirection: "row",
252
+ }}
253
+ >
254
+ <text fg={colors.dim}>{mentionsMode ? "<- Back (Esc) | " : "<- "}</text>
255
+ {mentionsMode && <text fg={colors.primary}>Navigating mentions </text>}
256
+ {isSelf && <text fg={colors.primary}>My Profile </text>}
257
+ <text fg="#ffffff">
258
+ <b>{user.name}</b>
259
+ </text>
260
+ {user.isBlueVerified && <text fg={colors.primary}> {"\u2713"}</text>}
261
+ <text fg={colors.muted}> @{user.username}</text>
262
+ </box>
263
+ );
264
+
265
+ // Full profile header with back hint, bio, and stats
266
+ const joinDate = formatJoinDate(user?.createdAt);
267
+ const websiteDomain = extractDomain(user?.websiteUrl);
268
+
269
+ const fullHeader = user && (
270
+ <box
271
+ style={{
272
+ flexShrink: 0,
273
+ flexDirection: "column",
274
+ justifyContent: "flex-start",
275
+ marginBottom: 0,
276
+ paddingBottom: 0,
277
+ }}
278
+ >
279
+ {/* Back hint + Name + Handle on same line */}
280
+ <box style={{ paddingLeft: 1, paddingRight: 1, flexDirection: "row" }}>
281
+ <text fg={colors.dim}>{mentionsMode ? "<- Back (Esc) | " : "<- "}</text>
282
+ {mentionsMode && <text fg={colors.primary}>Navigating mentions </text>}
283
+ {isSelf && <text fg={colors.primary}>My Profile </text>}
284
+ <text fg="#ffffff">
285
+ <b>{user.name}</b>
286
+ </text>
287
+ {user.isBlueVerified && <text fg={colors.primary}> {"\u2713"}</text>}
288
+ <text fg={colors.muted}> @{user.username}</text>
289
+ </box>
290
+
291
+ {/* Bio - highlight @mentions in blue */}
292
+ {user.description && (
293
+ <box style={{ paddingLeft: 1, paddingRight: 1 }}>
294
+ {hasMentions ? (
295
+ renderTextWithMentions(
296
+ user.description.trim(),
297
+ colors.primary,
298
+ "#cccccc"
299
+ )
300
+ ) : (
301
+ <text fg="#cccccc">{user.description.trim()}</text>
302
+ )}
303
+ </box>
304
+ )}
305
+
306
+ {/* Location, Website, Join Date row */}
307
+ {(user.location || websiteDomain || joinDate) && (
308
+ <box style={{ paddingLeft: 1, paddingRight: 1, flexDirection: "row" }}>
309
+ {user.location && (
310
+ <>
311
+ <text fg={colors.muted}>{"\u{1F4CD}"} </text>
312
+ <text fg="#aaaaaa">{user.location}</text>
313
+ </>
314
+ )}
315
+ {user.location && websiteDomain && (
316
+ <text fg={colors.dim}> {"\u00B7"} </text>
317
+ )}
318
+ {websiteDomain && (
319
+ <>
320
+ <text fg={colors.muted}>{"\u{1F517}"} </text>
321
+ <text fg={colors.primary}>{websiteDomain}</text>
322
+ </>
323
+ )}
324
+ {(user.location || websiteDomain) && joinDate && (
325
+ <text fg={colors.dim}> {"\u00B7"} </text>
326
+ )}
327
+ {joinDate && (
328
+ <>
329
+ <text fg={colors.muted}>{"\u{1F4C5}"} </text>
330
+ <text fg="#aaaaaa">{joinDate}</text>
331
+ </>
332
+ )}
333
+ </box>
334
+ )}
335
+
336
+ {/* Stats */}
337
+ <box style={{ paddingLeft: 1, paddingRight: 1, flexDirection: "row" }}>
338
+ <text fg="#ffffff">{formatCount(user.followersCount)}</text>
339
+ <text fg={colors.muted}> Followers </text>
340
+ <text fg={colors.dim}>{"\u00B7"} </text>
341
+ <text fg="#ffffff">{formatCount(user.followingCount)}</text>
342
+ <text fg={colors.muted}> Following</text>
343
+ </box>
344
+
345
+ {/* Mentions section - simplified UI based on count */}
346
+ {hasMentions && (
347
+ <box
348
+ style={{ paddingLeft: 1, paddingRight: 1, flexDirection: "column" }}
349
+ >
350
+ {mentionCount === 1 ? (
351
+ // Single mention - show directly with profile shortcut
352
+ <box style={{ flexDirection: "row" }}>
353
+ <text fg={colors.dim}>Mentions: </text>
354
+ <text fg={colors.primary}>@{bioMentions[0]}</text>
355
+ <text fg={colors.dim}> (</text>
356
+ <text fg={colors.primary}>m</text>
357
+ <text fg={colors.dim}> profile)</text>
358
+ </box>
359
+ ) : mentionsMode ? (
360
+ // Multiple mentions - navigation mode active
361
+ <>
362
+ <box style={{ flexDirection: "row" }}>
363
+ <text fg={colors.dim}>Mentions ({mentionCount}): </text>
364
+ <text fg={colors.primary}>j/k</text>
365
+ <text fg={colors.dim}> navigate </text>
366
+ <text fg={colors.primary}>Enter</text>
367
+ <text fg={colors.dim}> profile</text>
368
+ </box>
369
+ {bioMentions.map((mention, idx) => {
370
+ const isSelected = idx === mentionIndex;
371
+ return (
372
+ <box key={mention} style={{ flexDirection: "row" }}>
373
+ <text fg={isSelected ? colors.primary : colors.muted}>
374
+ {isSelected ? ">" : " "} @{mention}
375
+ </text>
376
+ </box>
377
+ );
378
+ })}
379
+ </>
380
+ ) : (
381
+ // Multiple mentions - collapsed view
382
+ <box style={{ flexDirection: "row" }}>
383
+ <text fg={colors.dim}>Mentions: </text>
384
+ <text fg={colors.primary}>@{bioMentions[0]}</text>
385
+ <text fg={colors.dim}> +{mentionCount - 1} more (</text>
386
+ <text fg={colors.primary}>m</text>
387
+ <text fg={colors.dim}> to navigate)</text>
388
+ </box>
389
+ )}
390
+ </box>
391
+ )}
392
+ </box>
393
+ );
394
+
395
+ // Tab bar for own profile (Tweets | Likes)
396
+ const tabBar = isSelf && (
397
+ <box
398
+ style={{
399
+ paddingLeft: 1,
400
+ paddingRight: 1,
401
+ flexShrink: 0,
402
+ flexDirection: "row",
403
+ }}
404
+ >
405
+ <text fg={activeTab === "tweets" ? colors.primary : colors.dim}>
406
+ {activeTab === "tweets" ? <b>[1] Tweets</b> : " 1 Tweets"}
407
+ </text>
408
+ <text fg={colors.dim}> | </text>
409
+ <text fg={activeTab === "likes" ? colors.primary : colors.dim}>
410
+ {activeTab === "likes" ? <b>[2] Likes</b> : " 2 Likes"}
411
+ </text>
412
+ {activeTab === "likes" && likesLoading && (
413
+ <text fg={colors.muted}> (loading...)</text>
414
+ )}
415
+ </box>
416
+ );
417
+
418
+ // Separator
419
+ const separator = (
420
+ <box
421
+ style={{
422
+ paddingLeft: 1,
423
+ paddingRight: 1,
424
+ flexShrink: 0,
425
+ marginTop: 0,
426
+ marginBottom: 0,
427
+ paddingTop: 0,
428
+ paddingBottom: 0,
429
+ }}
430
+ >
431
+ <text fg="#444444">{"─".repeat(50)}</text>
432
+ </box>
433
+ );
434
+
435
+ // Determine which posts to show based on active tab
436
+ const displayPosts = isSelf && activeTab === "likes" ? likedTweets : tweets;
437
+ const displayError = isSelf && activeTab === "likes" ? likesError : null;
438
+
439
+ // Footer keybindings - show available actions based on what data exists
440
+ // Tab keybindings (1/2) are shown in the tab bar itself, not in footer
441
+ const footerBindings: Keybinding[] = [
442
+ { key: "h/Esc", label: "back" },
443
+ { key: "j/k", label: "nav" },
444
+ { key: "l", label: "like" },
445
+ { key: "b", label: "bkmk" },
446
+ {
447
+ key: "m",
448
+ label: mentionCount === 1 ? "@profile" : "mentions",
449
+ show: hasMentions && !mentionsMode,
450
+ },
451
+ { key: "a", label: "avatar", show: !!user?.profileImageUrl },
452
+ { key: "v", label: "banner", show: !!user?.bannerImageUrl },
453
+ { key: "w", label: "web", show: !!user?.websiteUrl },
454
+ { key: "x", label: "x.com" },
455
+ { key: "r", label: "refresh" },
456
+ ];
457
+
458
+ // Loading state
459
+ if (loading) {
460
+ return (
461
+ <box style={{ flexDirection: "column", height: "100%" }}>
462
+ <box style={{ padding: 1, flexDirection: "row" }}>
463
+ <text fg={colors.dim}>{"<- "}</text>
464
+ <text fg={colors.muted}>Loading profile...</text>
465
+ </box>
466
+ </box>
467
+ );
468
+ }
469
+
470
+ // Error state
471
+ if (error) {
472
+ return (
473
+ <box style={{ flexDirection: "column", height: "100%" }}>
474
+ <box style={{ padding: 1, flexDirection: "row" }}>
475
+ <text fg={colors.dim}>{"<- "}</text>
476
+ <text fg="#ff6666">Error: {error}</text>
477
+ </box>
478
+ </box>
479
+ );
480
+ }
481
+
482
+ // User not found
483
+ if (!user) {
484
+ return (
485
+ <box style={{ flexDirection: "column", height: "100%" }}>
486
+ <box style={{ padding: 1, flexDirection: "row" }}>
487
+ <text fg={colors.dim}>{"<- "}</text>
488
+ <text fg={colors.muted}>User not found</text>
489
+ </box>
490
+ </box>
491
+ );
492
+ }
493
+
494
+ // Empty state message based on active tab
495
+ const emptyMessage =
496
+ isSelf && activeTab === "likes"
497
+ ? "No liked tweets"
498
+ : "No tweets to display";
499
+
500
+ return (
501
+ <box style={{ flexDirection: "column", height: "100%" }}>
502
+ {isCollapsed ? compactHeader : fullHeader}
503
+ {tabBar}
504
+ {separator}
505
+ {displayError ? (
506
+ <box style={{ padding: 1, flexGrow: 1 }}>
507
+ <text fg="#ff6666">Error: {displayError}</text>
508
+ </box>
509
+ ) : displayPosts.length > 0 ? (
510
+ <PostList
511
+ posts={displayPosts}
512
+ focused={focused}
513
+ onPostSelect={onPostSelect}
514
+ onSelectedIndexChange={handleSelectedIndexChange}
515
+ onLike={onLike}
516
+ onBookmark={onBookmark}
517
+ getActionState={getActionState}
518
+ initActionState={initActionState}
519
+ />
520
+ ) : (
521
+ <box style={{ padding: 1, flexGrow: 1 }}>
522
+ <text fg={colors.muted}>{emptyMessage}</text>
523
+ </box>
524
+ )}
525
+ <Footer bindings={footerBindings} visible={showFooter} />
526
+ </box>
527
+ );
528
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * SplashScreen - Displays during initial app loading
3
+ * Shows xfeed branding with animated spinner
4
+ */
5
+
6
+ import { useEffect, useState } from "react";
7
+
8
+ import { colors } from "@/lib/colors";
9
+
10
+ // ASCII art logo
11
+ const LOGO = [
12
+ " ██╗ ██╗███████╗███████╗███████╗██████╗ ",
13
+ " ╚██╗██╔╝██╔════╝██╔════╝██╔════╝██╔══██╗",
14
+ " ╚███╔╝ █████╗ █████╗ █████╗ ██║ ██║",
15
+ " ██╔██╗ ██╔══╝ ██╔══╝ ██╔══╝ ██║ ██║",
16
+ " ██╔╝ ██╗██║ ███████╗███████╗██████╔╝",
17
+ " ╚═╝ ╚═╝╚═╝ ╚══════╝╚══════╝╚═════╝ ",
18
+ ];
19
+
20
+ const SPINNER_FRAMES = ["◢", "◣", "◤", "◥"];
21
+ const SPINNER_INTERVAL_MS = 100;
22
+
23
+ export function SplashScreen() {
24
+ const [spinnerIndex, setSpinnerIndex] = useState(0);
25
+
26
+ useEffect(() => {
27
+ const interval = setInterval(() => {
28
+ setSpinnerIndex((prev) => (prev + 1) % SPINNER_FRAMES.length);
29
+ }, SPINNER_INTERVAL_MS);
30
+
31
+ return () => clearInterval(interval);
32
+ }, []);
33
+
34
+ return (
35
+ <box
36
+ style={{
37
+ flexDirection: "column",
38
+ height: "100%",
39
+ justifyContent: "center",
40
+ alignItems: "center",
41
+ }}
42
+ >
43
+ {/* Top border */}
44
+ <text fg={colors.dim}>{"─".repeat(52)}</text>
45
+
46
+ <box style={{ marginTop: 1 }} />
47
+
48
+ {/* Logo */}
49
+ {LOGO.map((line, i) => (
50
+ <text key={i} fg="#ffffff">
51
+ {line}
52
+ </text>
53
+ ))}
54
+
55
+ <box style={{ marginTop: 1 }} />
56
+
57
+ {/* Tagline */}
58
+ <text fg={colors.dim}>{"[terminal client for X, everything app]"}</text>
59
+
60
+ <box style={{ marginTop: 1 }} />
61
+
62
+ {/* Bottom border */}
63
+ <text fg={colors.dim}>{"─".repeat(52)}</text>
64
+
65
+ {/* Spinner and loading text */}
66
+ <box style={{ marginTop: 2, flexDirection: "row" }}>
67
+ <text fg="#ffffff">{SPINNER_FRAMES[spinnerIndex]}</text>
68
+ <text fg={colors.dim}> initializing...</text>
69
+ </box>
70
+ </box>
71
+ );
72
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * ThreadScreen - Full thread view with visual hierarchy
3
+ *
4
+ * Experimental screen for Issue #80.
5
+ * Shows ancestor chain, focused tweet, and reply tree with collapse/expand.
6
+ *
7
+ * @experimental
8
+ */
9
+
10
+ import type { XClient } from "@/api/client";
11
+ import type { TweetData } from "@/api/types";
12
+
13
+ import { ThreadViewPrototype } from "@/components/ThreadView.prototype";
14
+ import { useThread } from "@/hooks/useThread.prototype";
15
+ import { colors } from "@/lib/colors";
16
+
17
+ interface ThreadScreenProps {
18
+ client: XClient;
19
+ tweet: TweetData;
20
+ focused?: boolean;
21
+ onBack?: () => void;
22
+ onSelectTweet?: (tweet: TweetData) => void;
23
+ showFooter?: boolean;
24
+ }
25
+
26
+ export function ThreadScreen({
27
+ client,
28
+ tweet,
29
+ focused = false,
30
+ onBack,
31
+ onSelectTweet,
32
+ showFooter = true,
33
+ }: ThreadScreenProps) {
34
+ const { ancestors, replyTree, loadingAncestors, loadingReplies, error } =
35
+ useThread({
36
+ client,
37
+ tweet,
38
+ maxAncestorDepth: 10,
39
+ });
40
+
41
+ // Show loading state
42
+ if (loadingAncestors || loadingReplies) {
43
+ return (
44
+ <box style={{ flexDirection: "column", height: "100%" }}>
45
+ <box style={{ paddingLeft: 1, paddingTop: 1 }}>
46
+ <text fg={colors.muted}>
47
+ Loading thread...
48
+ {loadingAncestors && " (ancestors)"}
49
+ {loadingReplies && " (replies)"}
50
+ </text>
51
+ </box>
52
+ </box>
53
+ );
54
+ }
55
+
56
+ // Show error state
57
+ if (error) {
58
+ return (
59
+ <box style={{ flexDirection: "column", height: "100%" }}>
60
+ <box style={{ paddingLeft: 1, paddingTop: 1 }}>
61
+ <text fg={colors.error}>Error: {error}</text>
62
+ </box>
63
+ <box style={{ paddingLeft: 1, paddingTop: 1 }}>
64
+ <text fg={colors.dim}>Press h or Esc to go back</text>
65
+ </box>
66
+ </box>
67
+ );
68
+ }
69
+
70
+ return (
71
+ <ThreadViewPrototype
72
+ ancestors={ancestors}
73
+ focusedTweet={tweet}
74
+ replyTree={replyTree}
75
+ focused={focused}
76
+ onBack={onBack}
77
+ onSelectTweet={onSelectTweet}
78
+ showFooter={showFooter}
79
+ />
80
+ );
81
+ }