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.
- package/LICENSE +23 -0
- package/README.md +5 -0
- package/package.json +43 -0
- package/src/api/actions.ts +16 -0
- package/src/api/client.test.ts +3370 -0
- package/src/api/client.ts +4319 -0
- package/src/api/query-ids.json +11 -0
- package/src/api/query-ids.test.ts +118 -0
- package/src/api/query-ids.ts +59 -0
- package/src/api/runtime-query-ids.test.ts +926 -0
- package/src/api/runtime-query-ids.ts +389 -0
- package/src/api/types.ts +581 -0
- package/src/app.tsx +664 -0
- package/src/auth/browser-detect.ts +150 -0
- package/src/auth/browser-picker.ts +118 -0
- package/src/auth/check.test.preload.ts +94 -0
- package/src/auth/check.test.ts +388 -0
- package/src/auth/check.ts +220 -0
- package/src/auth/cookies.test.ts +529 -0
- package/src/auth/cookies.ts +299 -0
- package/src/auth/manual-entry.ts +88 -0
- package/src/auth/session.ts +30 -0
- package/src/components/ErrorBanner.tsx +172 -0
- package/src/components/Footer.tsx +90 -0
- package/src/components/Header.tsx +57 -0
- package/src/components/NotificationItem.test.ts +252 -0
- package/src/components/NotificationItem.tsx +80 -0
- package/src/components/NotificationList.test.ts +328 -0
- package/src/components/NotificationList.tsx +157 -0
- package/src/components/PostCard.tsx +186 -0
- package/src/components/PostList.tsx +232 -0
- package/src/components/QuotedPostCard.tsx +55 -0
- package/src/components/ReplyPreviewCard.tsx +80 -0
- package/src/components/ThreadView.prototype.tsx +533 -0
- package/src/components/Toast.tsx +28 -0
- package/src/config/loader.ts +69 -0
- package/src/config/types.ts +27 -0
- package/src/contexts/ModalContext.tsx +227 -0
- package/src/experiments/TimelineScreenExperimental.tsx +202 -0
- package/src/experiments/index.tsx +43 -0
- package/src/experiments/query-client.ts +132 -0
- package/src/experiments/use-bookmark-mutation.ts +342 -0
- package/src/experiments/use-bookmarks-query.ts +166 -0
- package/src/experiments/use-notifications-query.ts +368 -0
- package/src/experiments/use-post-detail-query.ts +187 -0
- package/src/experiments/use-profile-query.ts +162 -0
- package/src/experiments/use-timeline-query.ts +201 -0
- package/src/hooks/.gitkeep +0 -0
- package/src/hooks/useActions.ts +354 -0
- package/src/hooks/useBookmarkFolders.ts +70 -0
- package/src/hooks/useBookmarks.ts +111 -0
- package/src/hooks/useCountdown.ts +75 -0
- package/src/hooks/useListNavigation.test.ts +273 -0
- package/src/hooks/useListNavigation.ts +118 -0
- package/src/hooks/useNavigation.test.ts +340 -0
- package/src/hooks/useNavigation.ts +103 -0
- package/src/hooks/useNotifications.test.ts +377 -0
- package/src/hooks/useNotifications.ts +117 -0
- package/src/hooks/usePaginatedData.ts +217 -0
- package/src/hooks/usePostDetail.ts +137 -0
- package/src/hooks/useThread.prototype.ts +314 -0
- package/src/hooks/useTimeline.ts +136 -0
- package/src/hooks/useUserProfile.ts +142 -0
- package/src/index.tsx +304 -0
- package/src/lib/colors.ts +41 -0
- package/src/lib/format.ts +69 -0
- package/src/lib/media.ts +464 -0
- package/src/lib/result.ts +6 -0
- package/src/lib/text.tsx +76 -0
- package/src/modals/BookmarkFolderSelector.tsx +260 -0
- package/src/modals/ExitConfirmationModal.tsx +131 -0
- package/src/modals/FolderPicker.tsx +281 -0
- package/src/modals/README.md +171 -0
- package/src/modals/SessionExpiredModal.tsx +47 -0
- package/src/modals/index.ts +4 -0
- package/src/screens/.gitkeep +0 -0
- package/src/screens/BookmarksScreen.tsx +168 -0
- package/src/screens/NotificationsScreen.tsx +172 -0
- package/src/screens/PostDetailScreen.tsx +976 -0
- package/src/screens/ProfileScreen.tsx +528 -0
- package/src/screens/SplashScreen.tsx +72 -0
- package/src/screens/ThreadScreen.tsx +81 -0
- package/src/screens/TimelineScreen.tsx +188 -0
- package/vendor/sweet-cookie/LICENSE +22 -0
- package/vendor/sweet-cookie/README.md +29 -0
- package/vendor/sweet-cookie/dist/index.d.ts +3 -0
- package/vendor/sweet-cookie/dist/index.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/index.js +2 -0
- package/vendor/sweet-cookie/dist/index.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chrome.d.ts +10 -0
- package/vendor/sweet-cookie/dist/providers/chrome.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chrome.js +27 -0
- package/vendor/sweet-cookie/dist/providers/chrome.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/crypto.d.ts +11 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/crypto.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/crypto.js +100 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/crypto.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.d.ts +25 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.js +104 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/shared.d.ts +10 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/shared.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/shared.js +293 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/shared.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.d.ts +10 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.js +26 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteLinux.d.ts +7 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteLinux.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteLinux.js +51 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteLinux.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteMac.d.ts +10 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteMac.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteMac.js +118 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteMac.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteWindows.d.ts +7 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteWindows.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteWindows.js +38 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteWindows.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/linuxPaths.d.ts +5 -0
- package/vendor/sweet-cookie/dist/providers/chromium/linuxPaths.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/linuxPaths.js +33 -0
- package/vendor/sweet-cookie/dist/providers/chromium/linuxPaths.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/macosKeychain.d.ts +24 -0
- package/vendor/sweet-cookie/dist/providers/chromium/macosKeychain.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/macosKeychain.js +30 -0
- package/vendor/sweet-cookie/dist/providers/chromium/macosKeychain.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/paths.d.ts +11 -0
- package/vendor/sweet-cookie/dist/providers/chromium/paths.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/paths.js +43 -0
- package/vendor/sweet-cookie/dist/providers/chromium/paths.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsMasterKey.d.ts +8 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsMasterKey.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsMasterKey.js +41 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsMasterKey.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsPaths.d.ts +8 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsPaths.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsPaths.js +53 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsPaths.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edge.d.ts +8 -0
- package/vendor/sweet-cookie/dist/providers/edge.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edge.js +27 -0
- package/vendor/sweet-cookie/dist/providers/edge.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteLinux.d.ts +7 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteLinux.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteLinux.js +53 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteLinux.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteMac.d.ts +8 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteMac.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteMac.js +60 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteMac.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteWindows.d.ts +7 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteWindows.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteWindows.js +38 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteWindows.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/firefoxSqlite.d.ts +6 -0
- package/vendor/sweet-cookie/dist/providers/firefoxSqlite.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/firefoxSqlite.js +257 -0
- package/vendor/sweet-cookie/dist/providers/firefoxSqlite.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/inline.d.ts +8 -0
- package/vendor/sweet-cookie/dist/providers/inline.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/inline.js +71 -0
- package/vendor/sweet-cookie/dist/providers/inline.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/safariBinaryCookies.d.ts +6 -0
- package/vendor/sweet-cookie/dist/providers/safariBinaryCookies.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/safariBinaryCookies.js +173 -0
- package/vendor/sweet-cookie/dist/providers/safariBinaryCookies.js.map +1 -0
- package/vendor/sweet-cookie/dist/public.d.ts +26 -0
- package/vendor/sweet-cookie/dist/public.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/public.js +197 -0
- package/vendor/sweet-cookie/dist/public.js.map +1 -0
- package/vendor/sweet-cookie/dist/types.d.ts +127 -0
- package/vendor/sweet-cookie/dist/types.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/types.js +2 -0
- package/vendor/sweet-cookie/dist/types.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/base64.d.ts +2 -0
- package/vendor/sweet-cookie/dist/util/base64.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/base64.js +18 -0
- package/vendor/sweet-cookie/dist/util/base64.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/exec.d.ts +8 -0
- package/vendor/sweet-cookie/dist/util/exec.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/exec.js +110 -0
- package/vendor/sweet-cookie/dist/util/exec.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/expire.d.ts +2 -0
- package/vendor/sweet-cookie/dist/util/expire.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/expire.js +32 -0
- package/vendor/sweet-cookie/dist/util/expire.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/fs.d.ts +2 -0
- package/vendor/sweet-cookie/dist/util/fs.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/fs.js +13 -0
- package/vendor/sweet-cookie/dist/util/fs.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/hostMatch.d.ts +2 -0
- package/vendor/sweet-cookie/dist/util/hostMatch.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/hostMatch.js +7 -0
- package/vendor/sweet-cookie/dist/util/hostMatch.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/nodeSqlite.d.ts +5 -0
- package/vendor/sweet-cookie/dist/util/nodeSqlite.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/nodeSqlite.js +58 -0
- package/vendor/sweet-cookie/dist/util/nodeSqlite.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/origins.d.ts +2 -0
- package/vendor/sweet-cookie/dist/util/origins.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/origins.js +27 -0
- package/vendor/sweet-cookie/dist/util/origins.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/runtime.d.ts +2 -0
- package/vendor/sweet-cookie/dist/util/runtime.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/runtime.js +8 -0
- package/vendor/sweet-cookie/dist/util/runtime.js.map +1 -0
- package/vendor/sweet-cookie/package.json +40 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Modal System
|
|
2
|
+
|
|
3
|
+
Type-safe modal management using React Context with discriminated unions.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
### Opening a Modal
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import { useModal } from "@/contexts/ModalContext";
|
|
11
|
+
|
|
12
|
+
function MyComponent() {
|
|
13
|
+
const { openModal, closeModal } = useModal();
|
|
14
|
+
|
|
15
|
+
const handleClick = () => {
|
|
16
|
+
openModal("exit-confirmation", {
|
|
17
|
+
onConfirm: () => console.log("Confirmed!"),
|
|
18
|
+
onCancel: closeModal,
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Available Modal Types
|
|
25
|
+
|
|
26
|
+
| Type | Props | Description |
|
|
27
|
+
| -------------------------- | ------------------------------------------------ | ------------------------------ |
|
|
28
|
+
| `folder-picker` | `client`, `tweet`, `onSelect`, `onClose` | Move bookmark to folder |
|
|
29
|
+
| `bookmark-folder-selector` | `client`, `currentFolder`, `onSelect`, `onClose` | Select folder to view |
|
|
30
|
+
| `exit-confirmation` | `onConfirm`, `onCancel` | Confirm app exit |
|
|
31
|
+
| `session-expired` | none | Terminal session expired state |
|
|
32
|
+
|
|
33
|
+
### Context Values
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
const {
|
|
37
|
+
openModal, // Open a modal with type-safe props
|
|
38
|
+
closeModal, // Close current modal (no-op for session-expired)
|
|
39
|
+
isModalOpen, // Boolean for keyboard gating
|
|
40
|
+
activeModal, // Current modal state (for advanced use)
|
|
41
|
+
} = useModal();
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Adding a New Modal
|
|
45
|
+
|
|
46
|
+
### 1. Create the modal component
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
// src/modals/MyNewModal.tsx
|
|
50
|
+
import { useKeyboard } from "@opentui/react";
|
|
51
|
+
|
|
52
|
+
interface MyNewModalProps {
|
|
53
|
+
someData: string;
|
|
54
|
+
onConfirm: () => void;
|
|
55
|
+
onClose: () => void;
|
|
56
|
+
focused?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function MyNewModal({ someData, onConfirm, onClose, focused = true }: MyNewModalProps) {
|
|
60
|
+
useKeyboard((key) => {
|
|
61
|
+
if (!focused) return; // Always guard with focused
|
|
62
|
+
|
|
63
|
+
if (key.name === "escape") onClose();
|
|
64
|
+
if (key.name === "return") onConfirm();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<box style={{ /* modal styling */ }}>
|
|
69
|
+
{/* Modal content */}
|
|
70
|
+
</box>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 2. Add types to ModalContext
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
// src/contexts/ModalContext.tsx
|
|
79
|
+
|
|
80
|
+
// Add props interface
|
|
81
|
+
export interface MyNewModalProps {
|
|
82
|
+
someData: string;
|
|
83
|
+
onConfirm: () => void;
|
|
84
|
+
onClose: () => void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Add to ModalType union
|
|
88
|
+
export type ModalType =
|
|
89
|
+
| "folder-picker"
|
|
90
|
+
| "bookmark-folder-selector"
|
|
91
|
+
| "exit-confirmation"
|
|
92
|
+
| "session-expired"
|
|
93
|
+
| "my-new-modal"; // Add here
|
|
94
|
+
|
|
95
|
+
// Add to ModalPropsMap
|
|
96
|
+
export interface ModalPropsMap {
|
|
97
|
+
// ... existing entries
|
|
98
|
+
"my-new-modal": MyNewModalProps;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Add to ModalState union
|
|
102
|
+
export type ModalState =
|
|
103
|
+
// ... existing entries
|
|
104
|
+
| { type: "my-new-modal"; props: MyNewModalProps }
|
|
105
|
+
| null;
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### 3. Add to ModalRenderer switch
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
// In ModalRenderer component
|
|
112
|
+
case "my-new-modal":
|
|
113
|
+
return (
|
|
114
|
+
<ModalWrapper>
|
|
115
|
+
<MyNewModal {...activeModal.props} focused={true} />
|
|
116
|
+
</ModalWrapper>
|
|
117
|
+
);
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### 4. Export from index
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
// src/modals/index.ts
|
|
124
|
+
export { MyNewModal } from "./MyNewModal";
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Important Patterns
|
|
128
|
+
|
|
129
|
+
### Keyboard Gating
|
|
130
|
+
|
|
131
|
+
Screens must include `!isModalOpen` in their `focused` prop to prevent keyboard events from propagating when modals are open:
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
<TimelineScreen
|
|
135
|
+
focused={currentView === "timeline" && !showSplash && !isModalOpen}
|
|
136
|
+
/>
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Callback Closures
|
|
140
|
+
|
|
141
|
+
Callbacks passed to `openModal` capture state at call time. This is intentional - the modal operates on the data that was current when it opened:
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
openModal("folder-picker", {
|
|
145
|
+
tweet: selectedPost, // Captured value
|
|
146
|
+
onSelect: async (folderId) => {
|
|
147
|
+
// selectedPost here is the captured value
|
|
148
|
+
await client.moveBookmarkToFolder(selectedPost.id, folderId);
|
|
149
|
+
closeModal();
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Terminal Modals
|
|
155
|
+
|
|
156
|
+
`session-expired` is a terminal modal - `closeModal()` is a no-op when it's active. The only way out is to exit the app.
|
|
157
|
+
|
|
158
|
+
## File Structure
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
src/
|
|
162
|
+
├── contexts/
|
|
163
|
+
│ └── ModalContext.tsx # Context, Provider, useModal, ModalRenderer
|
|
164
|
+
├── modals/
|
|
165
|
+
│ ├── README.md # This file
|
|
166
|
+
│ ├── index.ts # Re-exports
|
|
167
|
+
│ ├── FolderPicker.tsx
|
|
168
|
+
│ ├── BookmarkFolderSelector.tsx
|
|
169
|
+
│ ├── ExitConfirmationModal.tsx
|
|
170
|
+
│ └── SessionExpiredModal.tsx
|
|
171
|
+
```
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useKeyboard, useRenderer } from "@opentui/react";
|
|
2
|
+
|
|
3
|
+
import { colors } from "@/lib/colors";
|
|
4
|
+
|
|
5
|
+
export function SessionExpiredModal() {
|
|
6
|
+
const renderer = useRenderer();
|
|
7
|
+
|
|
8
|
+
useKeyboard((key) => {
|
|
9
|
+
if (key.name === "return" || key.name === "q" || key.name === "escape") {
|
|
10
|
+
renderer.destroy();
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<box
|
|
16
|
+
style={{
|
|
17
|
+
position: "absolute",
|
|
18
|
+
top: 0,
|
|
19
|
+
left: 0,
|
|
20
|
+
width: "100%",
|
|
21
|
+
height: "100%",
|
|
22
|
+
justifyContent: "center",
|
|
23
|
+
alignItems: "center",
|
|
24
|
+
}}
|
|
25
|
+
>
|
|
26
|
+
<box
|
|
27
|
+
style={{
|
|
28
|
+
borderStyle: "single",
|
|
29
|
+
padding: 2,
|
|
30
|
+
flexDirection: "column",
|
|
31
|
+
alignItems: "center",
|
|
32
|
+
gap: 1,
|
|
33
|
+
}}
|
|
34
|
+
backgroundColor={colors.selectedBg}
|
|
35
|
+
borderColor={colors.error}
|
|
36
|
+
>
|
|
37
|
+
<text fg={colors.error}>Session Expired</text>
|
|
38
|
+
<text fg="#a8a8a8">Your tokens are no longer valid.</text>
|
|
39
|
+
<box style={{ marginTop: 1, flexDirection: "row" }}>
|
|
40
|
+
<text fg="#6b6b6b">Press </text>
|
|
41
|
+
<text fg="#ffffff">Enter</text>
|
|
42
|
+
<text fg="#6b6b6b"> to quit</text>
|
|
43
|
+
</box>
|
|
44
|
+
</box>
|
|
45
|
+
</box>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BookmarksScreen - Displays the user's bookmarked posts
|
|
3
|
+
* Includes error handling with ErrorBanner for rate limits, auth expiry, etc.
|
|
4
|
+
* Supports viewing all bookmarks or a specific folder.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useKeyboard } from "@opentui/react";
|
|
8
|
+
import { useEffect } from "react";
|
|
9
|
+
|
|
10
|
+
import type { XClient } from "@/api/client";
|
|
11
|
+
import type { BookmarkFolder, TweetData } from "@/api/types";
|
|
12
|
+
import type { TweetActionState } from "@/hooks/useActions";
|
|
13
|
+
|
|
14
|
+
import { ErrorBanner } from "@/components/ErrorBanner";
|
|
15
|
+
import { PostList } from "@/components/PostList";
|
|
16
|
+
import { useBookmarksQuery } from "@/experiments";
|
|
17
|
+
import { colors } from "@/lib/colors";
|
|
18
|
+
|
|
19
|
+
interface BookmarksScreenProps {
|
|
20
|
+
client: XClient;
|
|
21
|
+
focused?: boolean;
|
|
22
|
+
/** Currently selected folder (null = all bookmarks) */
|
|
23
|
+
selectedFolder?: BookmarkFolder | null;
|
|
24
|
+
/** Called when user wants to open the folder picker (press 'f') */
|
|
25
|
+
onFolderPickerOpen?: () => void;
|
|
26
|
+
onPostCountChange?: (count: number) => void;
|
|
27
|
+
onHasMoreChange?: (hasMore: boolean) => void;
|
|
28
|
+
onPostSelect?: (post: TweetData) => void;
|
|
29
|
+
/** Called when user presses 'l' to toggle like */
|
|
30
|
+
onLike?: (post: TweetData) => void;
|
|
31
|
+
/** Called when user presses 'b' to toggle bookmark */
|
|
32
|
+
onBookmark?: (post: TweetData) => void;
|
|
33
|
+
/** Get current action state for a tweet */
|
|
34
|
+
getActionState?: (tweetId: string) => TweetActionState;
|
|
35
|
+
/** Initialize action state from API data */
|
|
36
|
+
initActionState?: (
|
|
37
|
+
tweetId: string,
|
|
38
|
+
liked: boolean,
|
|
39
|
+
bookmarked: boolean
|
|
40
|
+
) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface ScreenHeaderProps {
|
|
44
|
+
folderName?: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function ScreenHeader({ folderName }: ScreenHeaderProps) {
|
|
48
|
+
const title = folderName ?? "All Bookmarks";
|
|
49
|
+
return (
|
|
50
|
+
<box
|
|
51
|
+
style={{
|
|
52
|
+
flexShrink: 0,
|
|
53
|
+
paddingLeft: 1,
|
|
54
|
+
paddingRight: 1,
|
|
55
|
+
paddingBottom: 1,
|
|
56
|
+
flexDirection: "row",
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
<text fg={colors.primary}>
|
|
60
|
+
<b>{title}</b>
|
|
61
|
+
</text>
|
|
62
|
+
<text fg={colors.dim}> (f to switch folders)</text>
|
|
63
|
+
</box>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function BookmarksScreen({
|
|
68
|
+
client,
|
|
69
|
+
focused = false,
|
|
70
|
+
selectedFolder,
|
|
71
|
+
onFolderPickerOpen,
|
|
72
|
+
onPostCountChange,
|
|
73
|
+
onHasMoreChange,
|
|
74
|
+
onPostSelect,
|
|
75
|
+
onLike,
|
|
76
|
+
onBookmark,
|
|
77
|
+
getActionState,
|
|
78
|
+
initActionState,
|
|
79
|
+
}: BookmarksScreenProps) {
|
|
80
|
+
const {
|
|
81
|
+
posts,
|
|
82
|
+
isLoading,
|
|
83
|
+
isFetchingNextPage,
|
|
84
|
+
hasNextPage,
|
|
85
|
+
error,
|
|
86
|
+
refresh,
|
|
87
|
+
fetchNextPage,
|
|
88
|
+
} = useBookmarksQuery({ client, folderId: selectedFolder?.id });
|
|
89
|
+
|
|
90
|
+
// Report post count to parent
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
onPostCountChange?.(posts.length);
|
|
93
|
+
}, [posts.length, onPostCountChange]);
|
|
94
|
+
|
|
95
|
+
// Report hasMore state to parent
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
onHasMoreChange?.(hasNextPage);
|
|
98
|
+
}, [hasNextPage, onHasMoreChange]);
|
|
99
|
+
|
|
100
|
+
// Handle keyboard shortcuts
|
|
101
|
+
useKeyboard((key) => {
|
|
102
|
+
if (!focused) return;
|
|
103
|
+
|
|
104
|
+
if (key.name === "r") {
|
|
105
|
+
refresh();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Open folder picker with 'f'
|
|
109
|
+
if (key.name === "f") {
|
|
110
|
+
onFolderPickerOpen?.();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const folderName = selectedFolder?.name ?? null;
|
|
115
|
+
|
|
116
|
+
if (isLoading) {
|
|
117
|
+
return (
|
|
118
|
+
<box style={{ flexDirection: "column", height: "100%" }}>
|
|
119
|
+
<ScreenHeader folderName={folderName} />
|
|
120
|
+
<box style={{ padding: 2, flexGrow: 1 }}>
|
|
121
|
+
<text fg={colors.muted}>Loading bookmarks...</text>
|
|
122
|
+
</box>
|
|
123
|
+
</box>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (error) {
|
|
128
|
+
return (
|
|
129
|
+
<box style={{ flexDirection: "column", height: "100%" }}>
|
|
130
|
+
<ScreenHeader folderName={folderName} />
|
|
131
|
+
<ErrorBanner error={error} onRetry={refresh} retryDisabled={false} />
|
|
132
|
+
</box>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (posts.length === 0) {
|
|
137
|
+
return (
|
|
138
|
+
<box style={{ flexDirection: "column", height: "100%" }}>
|
|
139
|
+
<ScreenHeader folderName={folderName} />
|
|
140
|
+
<box style={{ padding: 2, flexGrow: 1 }}>
|
|
141
|
+
<text fg={colors.muted}>
|
|
142
|
+
{selectedFolder
|
|
143
|
+
? "No bookmarks in this folder. Press r to refresh."
|
|
144
|
+
: "No bookmarks yet. Press r to refresh."}
|
|
145
|
+
</text>
|
|
146
|
+
</box>
|
|
147
|
+
</box>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<box style={{ flexDirection: "column", height: "100%" }}>
|
|
153
|
+
<ScreenHeader folderName={folderName} />
|
|
154
|
+
<PostList
|
|
155
|
+
posts={posts}
|
|
156
|
+
focused={focused}
|
|
157
|
+
onPostSelect={onPostSelect}
|
|
158
|
+
onLike={onLike}
|
|
159
|
+
onBookmark={onBookmark}
|
|
160
|
+
getActionState={getActionState}
|
|
161
|
+
initActionState={initActionState}
|
|
162
|
+
onLoadMore={fetchNextPage}
|
|
163
|
+
loadingMore={isFetchingNextPage}
|
|
164
|
+
hasMore={hasNextPage}
|
|
165
|
+
/>
|
|
166
|
+
</box>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NotificationsScreen - Displays the user's notifications
|
|
3
|
+
* Uses TanStack Query for data fetching with background polling
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useKeyboard } from "@opentui/react";
|
|
7
|
+
import { useEffect } from "react";
|
|
8
|
+
|
|
9
|
+
import type { XClient } from "@/api/client";
|
|
10
|
+
import type { NotificationData } from "@/api/types";
|
|
11
|
+
|
|
12
|
+
import { ErrorBanner } from "@/components/ErrorBanner";
|
|
13
|
+
import { NotificationList } from "@/components/NotificationList";
|
|
14
|
+
import { useNotificationsQuery } from "@/experiments/use-notifications-query";
|
|
15
|
+
import { colors } from "@/lib/colors";
|
|
16
|
+
|
|
17
|
+
interface NotificationsScreenProps {
|
|
18
|
+
client: XClient;
|
|
19
|
+
focused?: boolean;
|
|
20
|
+
onNotificationCountChange?: (count: number) => void;
|
|
21
|
+
onUnreadCountChange?: (count: number) => void;
|
|
22
|
+
onNotificationSelect?: (notification: NotificationData) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ScreenHeaderProps {
|
|
26
|
+
unreadCount: number;
|
|
27
|
+
isRefetching: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ScreenHeader({ unreadCount, isRefetching }: ScreenHeaderProps) {
|
|
31
|
+
return (
|
|
32
|
+
<box
|
|
33
|
+
style={{
|
|
34
|
+
flexShrink: 0,
|
|
35
|
+
paddingLeft: 1,
|
|
36
|
+
paddingRight: 1,
|
|
37
|
+
paddingBottom: 1,
|
|
38
|
+
flexDirection: "row",
|
|
39
|
+
justifyContent: "space-between",
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
<box style={{ flexDirection: "row" }}>
|
|
43
|
+
<text fg={colors.primary}>
|
|
44
|
+
<b>Notifications</b>
|
|
45
|
+
</text>
|
|
46
|
+
{unreadCount > 0 && <text fg={colors.error}> ({unreadCount} new)</text>}
|
|
47
|
+
</box>
|
|
48
|
+
{isRefetching && (
|
|
49
|
+
<text fg={colors.muted}>
|
|
50
|
+
<i>syncing...</i>
|
|
51
|
+
</text>
|
|
52
|
+
)}
|
|
53
|
+
</box>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function NewNotificationsBanner({ count }: { count: number }) {
|
|
58
|
+
return (
|
|
59
|
+
<box
|
|
60
|
+
style={{
|
|
61
|
+
flexShrink: 0,
|
|
62
|
+
paddingLeft: 1,
|
|
63
|
+
paddingRight: 1,
|
|
64
|
+
paddingTop: 0,
|
|
65
|
+
paddingBottom: 1,
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
<box
|
|
69
|
+
style={{
|
|
70
|
+
backgroundColor: colors.primary,
|
|
71
|
+
paddingLeft: 2,
|
|
72
|
+
paddingRight: 2,
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
<text fg="#000000">
|
|
76
|
+
<b>
|
|
77
|
+
{count} new notification{count > 1 ? "s" : ""} — Press r
|
|
78
|
+
</b>
|
|
79
|
+
</text>
|
|
80
|
+
</box>
|
|
81
|
+
</box>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function NotificationsScreen({
|
|
86
|
+
client,
|
|
87
|
+
focused = false,
|
|
88
|
+
onNotificationCountChange,
|
|
89
|
+
onUnreadCountChange,
|
|
90
|
+
onNotificationSelect,
|
|
91
|
+
}: NotificationsScreenProps) {
|
|
92
|
+
const {
|
|
93
|
+
notifications,
|
|
94
|
+
unreadCount,
|
|
95
|
+
isLoading,
|
|
96
|
+
isFetchingNextPage,
|
|
97
|
+
hasNextPage,
|
|
98
|
+
error,
|
|
99
|
+
fetchNextPage,
|
|
100
|
+
refresh,
|
|
101
|
+
newNotificationsCount,
|
|
102
|
+
isRefetching,
|
|
103
|
+
} = useNotificationsQuery({ client });
|
|
104
|
+
|
|
105
|
+
// Report counts to parent
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
onNotificationCountChange?.(notifications.length);
|
|
108
|
+
}, [notifications.length, onNotificationCountChange]);
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
onUnreadCountChange?.(unreadCount);
|
|
112
|
+
}, [unreadCount, onUnreadCountChange]);
|
|
113
|
+
|
|
114
|
+
// Handle keyboard shortcuts for refresh
|
|
115
|
+
useKeyboard((key) => {
|
|
116
|
+
if (!focused) return;
|
|
117
|
+
|
|
118
|
+
if (key.name === "r") {
|
|
119
|
+
refresh();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (isLoading) {
|
|
124
|
+
return (
|
|
125
|
+
<box style={{ flexDirection: "column", height: "100%" }}>
|
|
126
|
+
<ScreenHeader unreadCount={0} isRefetching={false} />
|
|
127
|
+
<box style={{ padding: 2, flexGrow: 1 }}>
|
|
128
|
+
<text fg={colors.muted}>Loading notifications...</text>
|
|
129
|
+
</box>
|
|
130
|
+
</box>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (error) {
|
|
135
|
+
return (
|
|
136
|
+
<box style={{ flexDirection: "column", height: "100%" }}>
|
|
137
|
+
<ScreenHeader unreadCount={0} isRefetching={isRefetching} />
|
|
138
|
+
<ErrorBanner error={error} onRetry={refresh} retryDisabled={false} />
|
|
139
|
+
</box>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (notifications.length === 0) {
|
|
144
|
+
return (
|
|
145
|
+
<box style={{ flexDirection: "column", height: "100%" }}>
|
|
146
|
+
<ScreenHeader unreadCount={0} isRefetching={isRefetching} />
|
|
147
|
+
<box style={{ padding: 2, flexGrow: 1 }}>
|
|
148
|
+
<text fg={colors.muted}>
|
|
149
|
+
No notifications yet. Press r to refresh.
|
|
150
|
+
</text>
|
|
151
|
+
</box>
|
|
152
|
+
</box>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<box style={{ flexDirection: "column", height: "100%" }}>
|
|
158
|
+
<ScreenHeader unreadCount={unreadCount} isRefetching={isRefetching} />
|
|
159
|
+
{newNotificationsCount > 0 && (
|
|
160
|
+
<NewNotificationsBanner count={newNotificationsCount} />
|
|
161
|
+
)}
|
|
162
|
+
<NotificationList
|
|
163
|
+
notifications={notifications}
|
|
164
|
+
focused={focused}
|
|
165
|
+
onNotificationSelect={onNotificationSelect}
|
|
166
|
+
onLoadMore={fetchNextPage}
|
|
167
|
+
loadingMore={isFetchingNextPage}
|
|
168
|
+
hasMore={hasNextPage}
|
|
169
|
+
/>
|
|
170
|
+
</box>
|
|
171
|
+
);
|
|
172
|
+
}
|