mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs: post actions

+1200 -4
+303
docs/BUGS.md
··· 1 + --- 2 + title: Bugs (Post Actions & Notifications — Fix Specification) 3 + updated: 2026-03-17 4 + --- 5 + 6 + ## Checklist 7 + 8 + - [ ] [1. Post Thread Screen](#1-post-thread-screen) 9 + - [ ] [2. Post Tap Navigation](#2-post-tap-navigation) 10 + - [ ] [3. Avatar Tap Navigation](#3-avatar-tap-navigation) 11 + - [ ] [4. Quoted Post Tap Navigation](#4-quoted-post-tap-navigation) 12 + - [ ] [5. Notification Tap Navigation](#5-notification-tap-navigation) 13 + - [ ] [6. Viewer State on Own Posts](#6-viewer-state-on-own-posts) 14 + - [ ] [7. Saved Posts Screen — Render Actual Posts](#7-saved-posts-screen--render-actual-posts) 15 + - [ ] [8. Saved Posts — Accessible from Profile](#8-saved-posts--accessible-from-profile) 16 + - [ ] [9. Saved Posts — Long Press for Local, Tap for Menu](#9-saved-posts--long-press-for-local-tap-for-menu) 17 + - [ ] [10. Saved Posts — Show Save Counts](#10-saved-posts--show-save-counts) 18 + - [ ] [11. Failed Action Snackbar with Revert](#11-failed-action-snackbar-with-revert) 19 + - [ ] [12. Delete Post — Remove from Feed](#12-delete-post--remove-from-feed) 20 + 21 + ## 1. Post Thread Screen 22 + 23 + **Status:** Missing — no `/post` route or screen exists. 24 + 25 + **Problem:** Multiple features navigate to `/post?uri=...` or `/post/{uri}`, but the 26 + route is not defined in `app_router.dart` and no screen exists. This breaks: 27 + 28 + - Notification taps (like, repost, reply, mention, quote) 29 + - Saved post "open" action 30 + - Any future post deeplink 31 + 32 + **Fix:** 33 + 34 + - Create `lib/features/feed/presentation/post_thread_screen.dart`. 35 + - Use `designs/thread.html` as a reference for the UI. 36 + - Use the Bluesky `getPostThread` API to fetch the thread (parent chain + replies). 37 + - Display the focused post with full content plus its parent posts above and replies 38 + below, each rendered as `PostCardWithActions`. 39 + - Register route in `app_router.dart`: 40 + - Path: `/post` with query param `uri` (e.g. `/post?uri=at://...`). 41 + - Handle loading, error, and blocked/not-found thread states. 42 + 43 + **Files:** 44 + 45 + - New: `lib/features/feed/presentation/post_thread_screen.dart` 46 + - New: `lib/features/feed/data/post_thread_repository.dart` (wraps `getPostThread`) 47 + - Edit: `lib/core/router/app_router.dart` — add `/post` route 48 + 49 + ## 2. Post Tap Navigation 50 + 51 + **Status:** Broken — tapping a post body does nothing. 52 + 53 + **Problem:** `PostCard` is not wrapped in a tap detector. The only interactive elements 54 + are the action bar buttons and embedded media. Users expect tapping a post to open it. 55 + 56 + **Fix:** 57 + 58 + - Wrap the post content area (header + text + embed, excluding the action bar) in an 59 + `InkWell` that navigates to `/post?uri={postUri}`. 60 + - The action bar itself should NOT trigger navigation — only the content area above it. 61 + 62 + **Files:** 63 + 64 + - Edit: `lib/features/feed/presentation/widgets/post_card.dart` — wrap content in 65 + `InkWell` with navigation callback 66 + - Edit: `lib/features/feed/presentation/widgets/post_card_with_actions.dart` — pass 67 + `onTap` callback through to `PostCard` 68 + 69 + ## 3. Avatar Tap Navigation 70 + 71 + **Status:** Broken — tapping a post author's avatar does nothing. 72 + 73 + **Problem:** The avatar `CircleAvatar` in `PostCard._buildHeader` is not tappable. 74 + Users expect tapping an avatar to navigate to that user's profile. 75 + 76 + **Fix:** 77 + 78 + - Wrap the `CircleAvatar` in `_buildHeader` with a `GestureDetector` that navigates to 79 + `/profile/view?actor={author.did}`. 80 + - Requires passing the navigation callback or `BuildContext` with router access. 81 + 82 + **Files:** 83 + 84 + - Edit: `lib/features/feed/presentation/widgets/post_card.dart` — `_buildHeader` 85 + (line ~55) 86 + 87 + ## 4. Quoted Post Tap Navigation 88 + 89 + **Status:** Incorrect — tapping a quoted post navigates to the quoted author's profile 90 + instead of the quoted post. 91 + 92 + **Problem:** `PostCard._buildQuotedRecord` (line 342-346) navigates to 93 + `/profile/view?actor={quoted.author.did}`. It should open the quoted post in the thread 94 + screen. 95 + 96 + **Fix:** 97 + 98 + - Change the `onTap` in `_buildQuotedRecord` to navigate to 99 + `/post?uri={quoted.uri}` instead of the author's profile. 100 + 101 + **Files:** 102 + 103 + - Edit: `lib/features/feed/presentation/widgets/post_card.dart` — `_buildQuotedRecord` 104 + (line ~342) 105 + 106 + ## 5. Notification Tap Navigation 107 + 108 + **Status:** Broken — tapping non-follow notifications crashes or does nothing (route 109 + doesn't exist). 110 + 111 + **Problem:** `notification_list_item.dart:263` pushes `/post?uri=...` but the route is 112 + undefined. This is blocked by [#1](#1-post-thread-screen). 113 + 114 + **Fix:** 115 + 116 + - Once the `/post` route exists ([#1](#1-post-thread-screen)), notification taps will 117 + work. Verify the URI encoding is consistent (`Uri.encodeComponent` vs query param). 118 + - Current code: `context.push('/post?uri=${Uri.encodeComponent(uri.toString())}')` 119 + - Ensure the route handler decodes this correctly. 120 + - For like/repost notifications, `notification.uri` may point to the *liker's record*, 121 + not the original post. Verify that `reasonSubject` (the post that was liked) is used 122 + instead when appropriate. 123 + 124 + **Files:** 125 + 126 + - Edit: `lib/features/notifications/presentation/widgets/notification_list_item.dart` 127 + — `_onTap` (line ~256). May need to use `notification.reasonSubject` for like/repost 128 + notifications instead of `notification.uri`. 129 + 130 + ## 6. Viewer State on Own Posts 131 + 132 + **Status:** Broken — current user's liked/reposted/saved posts don't show as active in 133 + the feed. 134 + 135 + **Problem:** `PostCardWithActions` initializes `PostActionCubit` from `viewer.like` and 136 + `viewer.repost` (lines 35-36), which correctly reflects the API state. However: 137 + 138 + - After the user likes a post, scrolls away, and scrolls back, the cubit is recreated 139 + from the stale `FeedViewPost` data (the original API response), losing the local 140 + optimistic state. 141 + - Saved state works correctly because `SavedPostsCubit` is global and checks the DB. 142 + 143 + **Fix:** 144 + 145 + - Maintain a lightweight in-memory cache (e.g. `Map<String, PostActionState>`) in a 146 + higher-level provider that `PostActionCubit` reads from on creation and writes to on 147 + state changes. This way, scrolling away and back preserves the user's actions within 148 + the session. 149 + - Alternatively, store the `likeUri`/`repostUri` in the feed bloc state so it survives 150 + cubit recreation. 151 + 152 + **Files:** 153 + 154 + - New or edit: A post action cache/provider (could be a simple `ChangeNotifier` or 155 + cubit at the feed level) 156 + - Edit: `lib/features/feed/presentation/widgets/post_card_with_actions.dart` — read 157 + from cache on cubit creation 158 + - Edit: `lib/features/feed/cubit/post_action_cubit.dart` — write to cache on state 159 + changes 160 + 161 + ## 7. Saved Posts Screen — Render Actual Posts 162 + 163 + **Status:** Incomplete — saved posts screen shows metadata cards, not the actual post 164 + content. 165 + 166 + **Problem:** `_SavedPostCard` in `saved_posts_screen.dart` shows a generic "Saved Post" 167 + `ListTile` with a date and action buttons. The full post JSON is stored in the DB 168 + (`postJson` column) but is never deserialized and rendered. 169 + 170 + **Fix:** 171 + 172 + - Deserialize `savedPost.postJson` back into a `PostView` and render it with 173 + `PostCardWithActions` (or a read-only variant). 174 + - The "open" button should navigate to `/post?uri={postUri}` (once [#1](#1-post-thread-screen) exists). 175 + - Keep swipe-to-dismiss for unsaving. 176 + 177 + **Files:** 178 + 179 + - Edit: `lib/features/feed/presentation/saved_posts_screen.dart` — replace 180 + `_SavedPostCard` with actual post rendering 181 + - The route in `_openPost` (line 186) currently uses path-style 182 + `/post/${Uri.encodeComponent(...)}` but should use query-style 183 + `/post?uri=${Uri.encodeComponent(...)}` to match the route definition. 184 + 185 + ## 8. Saved Posts — Accessible from Profile 186 + 187 + **Status:** Incorrect location — saved posts are behind Settings, not on profiles. 188 + 189 + **Problem:** The saved posts link is in `settings_screen.dart` (line 56-61). The 190 + requirement is that it should be accessible from profiles. 191 + 192 + **Fix:** 193 + 194 + - Add a "Saved Posts" button/tab on the current user's own profile screen. 195 + - Keep (or remove) the Settings entry as a secondary access point. 196 + 197 + **Files:** 198 + 199 + - Edit: profile screen (add saved posts navigation for the current user's profile) 200 + - Optionally edit: `lib/features/settings/presentation/settings_screen.dart` 201 + 202 + ## 9. Saved Posts — Long Press for Local, Tap for Menu 203 + 204 + **Status:** Missing — only tap-to-toggle exists, no long press or menu. 205 + 206 + **Problem:** The bookmark button in `PostActionBar` only has `onTap` (line 82). The 207 + requirement is: 208 + 209 + - **Long press** → save/unsave locally (instant, different icon color) 210 + - **Normal press** → show menu with options: save/remove locally, save/remove from 211 + cloud (ATProto) 212 + 213 + Cloud save is not yet implemented, but the menu structure should be in place. 214 + 215 + **Fix:** 216 + 217 + - Add `onLongPress` to the bookmark `_ActionButton` in `PostActionBar`. 218 + - Long press: toggle local save immediately (current behavior), use a distinct color 219 + (e.g. amber/gold for local saves vs primary for cloud). 220 + - Normal press: show a bottom sheet with options: 221 + - "Save locally" / "Remove local save" 222 + - "Save to Bluesky" / "Remove from Bluesky" (disabled/placeholder until cloud is 223 + implemented) 224 + - Update `SavedPostsState` to distinguish local vs cloud saves. 225 + 226 + **Files:** 227 + 228 + - Edit: `lib/features/feed/presentation/widgets/post_action_bar.dart` — add 229 + `onLongPress`, show menu on tap 230 + - Edit: `lib/features/feed/cubit/saved_posts_cubit.dart` — support save type 231 + distinction 232 + - Edit: `lib/core/database/tables.dart` — add `saveType` column (local/cloud/both) 233 + with migration 234 + 235 + ## 10. Saved Posts — Show Save Counts 236 + 237 + **Status:** Missing — hardcoded to `0` and hidden. 238 + 239 + **Problem:** `PostActionBar` line 80: `count: 0` for the bookmark button. Save counts 240 + are never fetched or displayed. 241 + 242 + **Fix:** 243 + 244 + - The Bluesky API provides `PostView.bookmarkCount` (nullable `int`). Pass this value 245 + through to `PostActionBar` instead of the hardcoded `0`. 246 + - Wire it up the same way `likeCount`/`repostCount` are: read from `post.bookmarkCount` 247 + in `PostCardWithActions` and pass to `PostActionBar`. 248 + 249 + **Files:** 250 + 251 + - Edit: `lib/features/feed/presentation/widgets/post_card_with_actions.dart` — read 252 + `post.bookmarkCount ?? 0` and pass to action bar 253 + - Edit: `lib/features/feed/presentation/widgets/post_action_bar.dart` — use the passed 254 + count instead of hardcoded `0` 255 + 256 + ## 11. Failed Action Snackbar with Revert 257 + 258 + **Status:** Partially implemented — rollback works but snackbar is basic. 259 + 260 + **Problem:** `PostActionCubit` correctly reverts optimistic updates on failure and shows 261 + a snackbar via `BlocListener` in `post_card_with_actions.dart` (lines 55-64). However: 262 + 263 + - The snackbar has no retry action button. 264 + - There's no visual indication during the loading state (the icon just sits there while 265 + `isLoadingLike`/`isLoadingRepost` is true). 266 + 267 + **Fix:** 268 + 269 + - Add a "Retry" `SnackBarAction` to the error snackbar. 270 + - Show a subtle loading indicator on the action button while the network call is 271 + in-flight (e.g., replace the icon with a small spinner, or dim it). The 272 + `isLoadingLike`/`isLoadingRepost` fields already exist in state. 273 + 274 + **Files:** 275 + 276 + - Edit: `lib/features/feed/presentation/widgets/post_card_with_actions.dart` — add 277 + retry action to snackbar 278 + - Edit: `lib/features/feed/presentation/widgets/post_action_bar.dart` — show loading 279 + state visually on like/repost buttons 280 + 281 + ## 12. Delete Post — Remove from Feed 282 + 283 + **Status:** Incomplete — post is deleted on the server but remains visible in the feed. 284 + 285 + **Problem:** `PostActionCubit.deletePost()` (line 186-193) calls the API to delete but 286 + does not remove the post from the feed list. The deleted post card remains visible until 287 + the user refreshes. 288 + 289 + **Fix:** 290 + 291 + - After successful deletion, notify the parent feed bloc/cubit to remove the post from 292 + its list. 293 + - This could be done via a callback, a shared event bus, or by having the feed bloc 294 + listen for deletion events. 295 + - Show a confirmation snackbar: "Post deleted". 296 + 297 + **Files:** 298 + 299 + - Edit: `lib/features/feed/cubit/post_action_cubit.dart` — emit a "deleted" state or 300 + invoke a callback 301 + - Edit: feed bloc/cubit — handle post removal from list 302 + - Edit: `lib/features/feed/presentation/widgets/post_card_with_actions.dart` — wire up 303 + deletion callback
-4
docs/TODO.md
··· 14 14 - Instead of tabs, navigating to a record through dev tools should instead show 15 15 - A drawer on Tablet 16 16 - Cards (stacked) on Mobile, with swipe to go back 17 - - Post interactions are off -> selecting a quoted post opens that user's profile. 18 - - Tapping a post doesn't doesn't go to the post 19 - - Tapping a user's avatar in a post doesn't go to their profile 20 - - We need to add a post screen that renders a thread 21 17 22 18 ## Enhancements 23 19
+897
docs/designs/thread.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> 6 + <title>Thread - Lazurite</title> 7 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 8 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 9 + <link href="https://fonts.googleapis.com/css2?family=Lora:wght@400;500;600;700&display=swap" rel="stylesheet" /> 10 + <link 11 + href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap" 12 + rel="stylesheet" /> 13 + <link rel="stylesheet" href="styles.css" /> 14 + <style> 15 + .thread-container { 16 + padding-bottom: 88px; 17 + } 18 + 19 + /* Thread line connecting parent posts to the focused post */ 20 + .thread-line-wrapper { 21 + position: relative; 22 + } 23 + 24 + .thread-line-wrapper::after { 25 + content: ""; 26 + position: absolute; 27 + left: 39px; /* center of avatar (16px pad + 24px half of 48px avatar) - 1px */ 28 + top: 0; 29 + bottom: 0; 30 + width: 2px; 31 + background-color: var(--border); 32 + } 33 + 34 + .thread-line-wrapper .post-card { 35 + position: relative; 36 + z-index: 1; 37 + } 38 + 39 + /* Parent post: smaller, muted, with thread line below */ 40 + .thread-parent { 41 + border-bottom: none; 42 + padding-bottom: 0; 43 + } 44 + 45 + .thread-parent .post-content { 46 + color: var(--text-secondary); 47 + } 48 + 49 + .thread-parent .post-actions { 50 + display: none; 51 + } 52 + 53 + .thread-parent-connector { 54 + padding: 0 16px 0 39px; 55 + height: 16px; 56 + position: relative; 57 + } 58 + 59 + .thread-parent-connector::before { 60 + content: ""; 61 + position: absolute; 62 + left: 39px; 63 + top: 0; 64 + bottom: 0; 65 + width: 2px; 66 + background-color: var(--border); 67 + } 68 + 69 + /* Focused post: the main post in the thread */ 70 + .thread-focused { 71 + padding: 16px; 72 + border-bottom: 1px solid var(--border); 73 + background-color: var(--bg); 74 + } 75 + 76 + .thread-focused .post-header { 77 + display: flex; 78 + gap: 12px; 79 + margin-bottom: 16px; 80 + } 81 + 82 + .thread-focused .post-content { 83 + font-size: 17px; 84 + line-height: 1.6; 85 + color: var(--text-primary); 86 + margin-bottom: 16px; 87 + } 88 + 89 + .thread-focused-timestamp { 90 + font-size: 14px; 91 + color: var(--text-muted); 92 + padding-bottom: 16px; 93 + border-bottom: 1px solid var(--border); 94 + margin-bottom: 12px; 95 + } 96 + 97 + .thread-focused-stats { 98 + display: flex; 99 + gap: 20px; 100 + padding-bottom: 12px; 101 + border-bottom: 1px solid var(--border); 102 + margin-bottom: 4px; 103 + } 104 + 105 + .thread-stat { 106 + font-size: 14px; 107 + color: var(--text-secondary); 108 + } 109 + 110 + .thread-stat strong { 111 + font-weight: 600; 112 + color: var(--text-primary); 113 + } 114 + 115 + .thread-focused .post-actions { 116 + padding-top: 8px; 117 + justify-content: space-around; 118 + } 119 + 120 + .thread-focused .post-action svg { 121 + width: 22px; 122 + height: 22px; 123 + } 124 + 125 + .thread-focused .post-action { 126 + font-size: 0; /* hide counts in focused action bar, they're shown in stats */ 127 + } 128 + 129 + /* Active action states */ 130 + .post-action.active-like { 131 + color: var(--accent-error); 132 + } 133 + 134 + .post-action.active-repost { 135 + color: var(--accent-success); 136 + } 137 + 138 + .post-action.active-bookmark { 139 + color: var(--accent-primary); 140 + } 141 + 142 + /* Replies section */ 143 + .thread-replies-header { 144 + padding: 12px 16px 8px; 145 + font-size: 13px; 146 + font-weight: 600; 147 + color: var(--text-muted); 148 + text-transform: uppercase; 149 + letter-spacing: 0.5px; 150 + border-bottom: 1px solid var(--border); 151 + } 152 + 153 + /* Nested reply indentation via thread line */ 154 + .thread-reply { 155 + position: relative; 156 + } 157 + 158 + .thread-reply-nested { 159 + padding-left: 32px; 160 + position: relative; 161 + } 162 + 163 + .thread-reply-nested::before { 164 + content: ""; 165 + position: absolute; 166 + left: 39px; 167 + top: 0; 168 + height: 28px; 169 + width: 2px; 170 + background-color: var(--border); 171 + } 172 + 173 + /* Reply composer fixed at bottom, above nav */ 174 + .thread-reply-bar { 175 + position: fixed; 176 + bottom: 76px; 177 + left: 50%; 178 + transform: translateX(-50%); 179 + width: 100%; 180 + max-width: 414px; 181 + background-color: var(--surface); 182 + border-top: 1px solid var(--border); 183 + padding: 10px 16px; 184 + display: flex; 185 + align-items: center; 186 + gap: 12px; 187 + z-index: 90; 188 + } 189 + 190 + .thread-reply-bar .avatar { 191 + width: 32px; 192 + height: 32px; 193 + font-size: 12px; 194 + } 195 + 196 + .thread-reply-input { 197 + flex: 1; 198 + border: 1px solid var(--border); 199 + border-radius: 20px; 200 + padding: 8px 16px; 201 + font-size: 14px; 202 + background-color: var(--bg); 203 + color: var(--text-primary); 204 + outline: none; 205 + font-family: inherit; 206 + } 207 + 208 + .thread-reply-input::placeholder { 209 + color: var(--text-muted); 210 + } 211 + 212 + .thread-reply-input:focus { 213 + border-color: var(--accent-primary); 214 + } 215 + 216 + .thread-reply-send { 217 + width: 36px; 218 + height: 36px; 219 + border-radius: 50%; 220 + border: none; 221 + background-color: var(--accent-primary); 222 + color: white; 223 + cursor: pointer; 224 + display: flex; 225 + align-items: center; 226 + justify-content: center; 227 + transition: background-color 0.2s ease; 228 + flex-shrink: 0; 229 + } 230 + 231 + .thread-reply-send:hover { 232 + background-color: var(--accent-primary-hover); 233 + } 234 + 235 + .thread-reply-send svg { 236 + width: 18px; 237 + height: 18px; 238 + } 239 + 240 + /* Quoted post embed */ 241 + .thread-quote { 242 + margin-top: 12px; 243 + border: 1px solid var(--border); 244 + border-radius: 12px; 245 + padding: 12px; 246 + } 247 + 248 + .thread-quote-header { 249 + display: flex; 250 + align-items: center; 251 + gap: 8px; 252 + margin-bottom: 8px; 253 + } 254 + 255 + .thread-quote-avatar { 256 + width: 20px; 257 + height: 20px; 258 + border-radius: 50%; 259 + background-color: var(--surface-variant); 260 + display: flex; 261 + align-items: center; 262 + justify-content: center; 263 + font-size: 9px; 264 + font-weight: 600; 265 + color: var(--text-secondary); 266 + } 267 + 268 + .thread-quote-author { 269 + font-size: 13px; 270 + font-weight: 600; 271 + color: var(--text-primary); 272 + } 273 + 274 + .thread-quote-text { 275 + font-size: 13px; 276 + color: var(--text-secondary); 277 + line-height: 1.4; 278 + } 279 + 280 + /* Loading & error states */ 281 + .thread-loading { 282 + display: flex; 283 + flex-direction: column; 284 + align-items: center; 285 + justify-content: center; 286 + padding: 64px 24px; 287 + gap: 16px; 288 + } 289 + 290 + .thread-spinner { 291 + width: 32px; 292 + height: 32px; 293 + border: 3px solid var(--border); 294 + border-top-color: var(--accent-primary); 295 + border-radius: 50%; 296 + animation: spin 0.8s linear infinite; 297 + } 298 + 299 + @keyframes spin { 300 + to { 301 + transform: rotate(360deg); 302 + } 303 + } 304 + 305 + .thread-error { 306 + display: flex; 307 + flex-direction: column; 308 + align-items: center; 309 + padding: 64px 24px; 310 + text-align: center; 311 + gap: 12px; 312 + } 313 + 314 + .thread-error-icon { 315 + width: 48px; 316 + height: 48px; 317 + color: var(--text-muted); 318 + } 319 + 320 + .thread-error-title { 321 + font-size: 16px; 322 + font-weight: 600; 323 + color: var(--text-primary); 324 + } 325 + 326 + .thread-error-text { 327 + font-size: 14px; 328 + color: var(--text-secondary); 329 + max-width: 260px; 330 + } 331 + 332 + /* Extra bottom padding to account for reply bar */ 333 + .thread-container { 334 + padding-bottom: 140px; 335 + } 336 + 337 + /* Back button */ 338 + .header-back { 339 + background: none; 340 + border: none; 341 + color: var(--text-primary); 342 + cursor: pointer; 343 + padding: 8px; 344 + margin-left: -8px; 345 + display: flex; 346 + align-items: center; 347 + } 348 + 349 + .header-back svg { 350 + width: 24px; 351 + height: 24px; 352 + } 353 + 354 + .header-left { 355 + display: flex; 356 + align-items: center; 357 + gap: 8px; 358 + } 359 + </style> 360 + </head> 361 + <body> 362 + <div class="mobile-container"> 363 + <!-- Header --> 364 + <header class="header"> 365 + <div class="header-left"> 366 + <button class="header-back" title="Back"> 367 + <svg 368 + viewBox="0 0 24 24" 369 + fill="none" 370 + stroke="currentColor" 371 + stroke-width="2" 372 + stroke-linecap="round" 373 + stroke-linejoin="round"> 374 + <line x1="19" y1="12" x2="5" y2="12" /> 375 + <polyline points="12 19 5 12 12 5" /> 376 + </svg> 377 + </button> 378 + <h1 class="header-title" style="font-family: var(--font-heading)">Thread</h1> 379 + </div> 380 + <button class="header-action"> 381 + <svg 382 + viewBox="0 0 24 24" 383 + width="20" 384 + height="20" 385 + fill="none" 386 + stroke="currentColor" 387 + stroke-width="2" 388 + stroke-linecap="round" 389 + stroke-linejoin="round"> 390 + <circle cx="12" cy="12" r="1" /> 391 + <circle cx="19" cy="12" r="1" /> 392 + <circle cx="5" cy="12" r="1" /> 393 + </svg> 394 + </button> 395 + </header> 396 + 397 + <div class="thread-container"> 398 + <!-- Parent Post (with thread line) --> 399 + <div class="thread-line-wrapper"> 400 + <article class="post-card thread-parent"> 401 + <div class="post-header"> 402 + <div class="avatar">BJ</div> 403 + <div class="post-author"> 404 + <div class="post-author-name">Bob Johnson</div> 405 + <div class="post-author-handle">@bob.bsky.social &middot; <span class="post-timestamp">6h</span></div> 406 + </div> 407 + </div> 408 + <div class="post-content"> 409 + What are everyone's thoughts on the latest changes to the AT Protocol? Particularly interested in how 410 + federation is being handled. 411 + </div> 412 + </article> 413 + 414 + <div class="thread-parent-connector"></div> 415 + </div> 416 + 417 + <!-- Focused Post (the main post being viewed) --> 418 + <article class="thread-focused"> 419 + <div class="post-header"> 420 + <div class="avatar">AS</div> 421 + <div class="post-author"> 422 + <div class="post-author-name">Alice Smith</div> 423 + <div class="post-author-handle">@alice.bsky.social</div> 424 + </div> 425 + </div> 426 + 427 + <div class="post-content"> 428 + The federation work has been really impressive! The PDS model means you truly own your data. I've been 429 + running my own server for a week now and it's been rock solid. 430 + <br /><br /> 431 + Key things I've noticed:<br /> 432 + &bull; Identity is portable across servers<br /> 433 + &bull; The relay infrastructure scales well<br /> 434 + &bull; App views make custom feeds possible 435 + </div> 436 + 437 + <!-- Quoted post embed --> 438 + <div class="thread-quote"> 439 + <div class="thread-quote-header"> 440 + <div class="thread-quote-avatar">AT</div> 441 + <span class="thread-quote-author">AT Protocol &middot; @atproto.com</span> 442 + </div> 443 + <div class="thread-quote-text"> 444 + Federation is now live! Self-host your PDS and take control of your social data. Read more at 445 + atproto.com/blog/federation 446 + </div> 447 + </div> 448 + 449 + <div style="height: 16px"></div> 450 + 451 + <div class="thread-focused-timestamp">2:34 PM &middot; Mar 17, 2026</div> 452 + 453 + <div class="thread-focused-stats"> 454 + <span class="thread-stat"><strong>24</strong> replies</span> 455 + <span class="thread-stat"><strong>12</strong> reposts</span> 456 + <span class="thread-stat"><strong>156</strong> likes</span> 457 + <span class="thread-stat"><strong>8</strong> saves</span> 458 + </div> 459 + 460 + <div class="post-actions"> 461 + <!-- Reply --> 462 + <button class="post-action"> 463 + <svg 464 + viewBox="0 0 24 24" 465 + fill="none" 466 + stroke="currentColor" 467 + stroke-width="2" 468 + stroke-linecap="round" 469 + stroke-linejoin="round"> 470 + <path 471 + d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" /> 472 + </svg> 473 + </button> 474 + <!-- Repost (active) --> 475 + <button class="post-action active-repost"> 476 + <svg 477 + viewBox="0 0 24 24" 478 + fill="none" 479 + stroke="currentColor" 480 + stroke-width="2" 481 + stroke-linecap="round" 482 + stroke-linejoin="round"> 483 + <polyline points="17 1 21 5 17 9" /> 484 + <path d="M3 11V9a4 4 0 0 1 4-4h14" /> 485 + <polyline points="7 23 3 19 7 15" /> 486 + <path d="M21 13v2a4 4 0 0 1-4 4H3" /> 487 + </svg> 488 + </button> 489 + <!-- Like (active) --> 490 + <button class="post-action active-like"> 491 + <svg viewBox="0 0 24 24" fill="currentColor" stroke="none"> 492 + <path 493 + d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 494 + </svg> 495 + </button> 496 + <!-- Bookmark (active) --> 497 + <button class="post-action active-bookmark"> 498 + <svg viewBox="0 0 24 24" fill="currentColor" stroke="none"> 499 + <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" /> 500 + </svg> 501 + </button> 502 + <!-- Share --> 503 + <button class="post-action"> 504 + <svg 505 + viewBox="0 0 24 24" 506 + fill="none" 507 + stroke="currentColor" 508 + stroke-width="2" 509 + stroke-linecap="round" 510 + stroke-linejoin="round"> 511 + <path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" /> 512 + <polyline points="16 6 12 2 8 6" /> 513 + <line x1="12" y1="2" x2="12" y2="15" /> 514 + </svg> 515 + </button> 516 + </div> 517 + </article> 518 + 519 + <!-- Replies --> 520 + <div class="thread-replies-header">Replies</div> 521 + 522 + <!-- Reply 1 --> 523 + <article class="post-card thread-reply"> 524 + <div class="post-header"> 525 + <div class="avatar">CW</div> 526 + <div class="post-author"> 527 + <div class="post-author-name">Carol White</div> 528 + <div class="post-author-handle">@carol.dev &middot; <span class="post-timestamp">4h</span></div> 529 + </div> 530 + </div> 531 + <div class="post-content"> 532 + This is spot on! The portability aspect is what sold me. Being able to migrate my entire social presence 533 + without losing followers is a game changer. 534 + </div> 535 + <div class="post-actions"> 536 + <button class="post-action"> 537 + <svg 538 + viewBox="0 0 24 24" 539 + fill="none" 540 + stroke="currentColor" 541 + stroke-width="2" 542 + stroke-linecap="round" 543 + stroke-linejoin="round"> 544 + <path 545 + d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" /> 546 + </svg> 547 + 3 548 + </button> 549 + <button class="post-action"> 550 + <svg 551 + viewBox="0 0 24 24" 552 + fill="none" 553 + stroke="currentColor" 554 + stroke-width="2" 555 + stroke-linecap="round" 556 + stroke-linejoin="round"> 557 + <polyline points="17 1 21 5 17 9" /> 558 + <path d="M3 11V9a4 4 0 0 1 4-4h14" /> 559 + <polyline points="7 23 3 19 7 15" /> 560 + <path d="M21 13v2a4 4 0 0 1-4 4H3" /> 561 + </svg> 562 + 1 563 + </button> 564 + <button class="post-action active-like"> 565 + <svg viewBox="0 0 24 24" fill="currentColor" stroke="none"> 566 + <path 567 + d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 568 + </svg> 569 + 18 570 + </button> 571 + <button class="post-action"> 572 + <svg 573 + viewBox="0 0 24 24" 574 + fill="none" 575 + stroke="currentColor" 576 + stroke-width="2" 577 + stroke-linecap="round" 578 + stroke-linejoin="round"> 579 + <path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" /> 580 + <polyline points="16 6 12 2 8 6" /> 581 + <line x1="12" y1="2" x2="12" y2="15" /> 582 + </svg> 583 + </button> 584 + </div> 585 + </article> 586 + 587 + <!-- Reply 2 (with nested sub-reply) --> 588 + <article class="post-card thread-reply"> 589 + <div class="post-header"> 590 + <div class="avatar">DM</div> 591 + <div class="post-author"> 592 + <div class="post-author-name">David Miller</div> 593 + <div class="post-author-handle">@davidm.bsky.social &middot; <span class="post-timestamp">3h</span></div> 594 + </div> 595 + </div> 596 + <div class="post-content"> 597 + Have you run into any performance issues with your PDS? I'm considering self-hosting but worried about the 598 + resource requirements. 599 + </div> 600 + <div class="post-actions"> 601 + <button class="post-action"> 602 + <svg 603 + viewBox="0 0 24 24" 604 + fill="none" 605 + stroke="currentColor" 606 + stroke-width="2" 607 + stroke-linecap="round" 608 + stroke-linejoin="round"> 609 + <path 610 + d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" /> 611 + </svg> 612 + 1 613 + </button> 614 + <button class="post-action"> 615 + <svg 616 + viewBox="0 0 24 24" 617 + fill="none" 618 + stroke="currentColor" 619 + stroke-width="2" 620 + stroke-linecap="round" 621 + stroke-linejoin="round"> 622 + <polyline points="17 1 21 5 17 9" /> 623 + <path d="M3 11V9a4 4 0 0 1 4-4h14" /> 624 + <polyline points="7 23 3 19 7 15" /> 625 + <path d="M21 13v2a4 4 0 0 1-4 4H3" /> 626 + </svg> 627 + </button> 628 + <button class="post-action"> 629 + <svg 630 + viewBox="0 0 24 24" 631 + fill="none" 632 + stroke="currentColor" 633 + stroke-width="2" 634 + stroke-linecap="round" 635 + stroke-linejoin="round"> 636 + <path 637 + d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 638 + </svg> 639 + 5 640 + </button> 641 + <button class="post-action"> 642 + <svg 643 + viewBox="0 0 24 24" 644 + fill="none" 645 + stroke="currentColor" 646 + stroke-width="2" 647 + stroke-linecap="round" 648 + stroke-linejoin="round"> 649 + <path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" /> 650 + <polyline points="16 6 12 2 8 6" /> 651 + <line x1="12" y1="2" x2="12" y2="15" /> 652 + </svg> 653 + </button> 654 + </div> 655 + </article> 656 + 657 + <!-- Reply 3: Author's own reply in thread --> 658 + <article class="post-card thread-reply thread-reply-nested"> 659 + <div class="post-header"> 660 + <div class="avatar">AS</div> 661 + <div class="post-author"> 662 + <div class="post-author-name">Alice Smith</div> 663 + <div class="post-author-handle">@alice.bsky.social &middot; <span class="post-timestamp">2h</span></div> 664 + </div> 665 + </div> 666 + <div class="post-content"> 667 + Runs great on a small VPS! I'm using a 2GB instance and it handles everything smoothly. The docs have a good 668 + guide on minimal setups. 669 + </div> 670 + <div class="post-actions"> 671 + <button class="post-action"> 672 + <svg 673 + viewBox="0 0 24 24" 674 + fill="none" 675 + stroke="currentColor" 676 + stroke-width="2" 677 + stroke-linecap="round" 678 + stroke-linejoin="round"> 679 + <path 680 + d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" /> 681 + </svg> 682 + </button> 683 + <button class="post-action"> 684 + <svg 685 + viewBox="0 0 24 24" 686 + fill="none" 687 + stroke="currentColor" 688 + stroke-width="2" 689 + stroke-linecap="round" 690 + stroke-linejoin="round"> 691 + <polyline points="17 1 21 5 17 9" /> 692 + <path d="M3 11V9a4 4 0 0 1 4-4h14" /> 693 + <polyline points="7 23 3 19 7 15" /> 694 + <path d="M21 13v2a4 4 0 0 1-4 4H3" /> 695 + </svg> 696 + </button> 697 + <button class="post-action"> 698 + <svg 699 + viewBox="0 0 24 24" 700 + fill="none" 701 + stroke="currentColor" 702 + stroke-width="2" 703 + stroke-linecap="round" 704 + stroke-linejoin="round"> 705 + <path 706 + d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 707 + </svg> 708 + 12 709 + </button> 710 + <button class="post-action"> 711 + <svg 712 + viewBox="0 0 24 24" 713 + fill="none" 714 + stroke="currentColor" 715 + stroke-width="2" 716 + stroke-linecap="round" 717 + stroke-linejoin="round"> 718 + <path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" /> 719 + <polyline points="16 6 12 2 8 6" /> 720 + <line x1="12" y1="2" x2="12" y2="15" /> 721 + </svg> 722 + </button> 723 + </div> 724 + </article> 725 + 726 + <!-- Reply 4 --> 727 + <article class="post-card thread-reply"> 728 + <div class="post-header"> 729 + <div class="avatar">EL</div> 730 + <div class="post-author"> 731 + <div class="post-author-name">Eva Lee</div> 732 + <div class="post-author-handle">@eva.bsky.social &middot; <span class="post-timestamp">1h</span></div> 733 + </div> 734 + </div> 735 + <div class="post-content"> 736 + The custom feeds through app views are my favourite part. Being able to build your own algorithmic timeline 737 + without depending on the platform is so empowering. 738 + <a href="#" class="post-facet-hashtag">#atproto</a> <a href="#" class="post-facet-hashtag">#openweb</a> 739 + </div> 740 + <div class="post-actions"> 741 + <button class="post-action"> 742 + <svg 743 + viewBox="0 0 24 24" 744 + fill="none" 745 + stroke="currentColor" 746 + stroke-width="2" 747 + stroke-linecap="round" 748 + stroke-linejoin="round"> 749 + <path 750 + d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" /> 751 + </svg> 752 + 2 753 + </button> 754 + <button class="post-action"> 755 + <svg 756 + viewBox="0 0 24 24" 757 + fill="none" 758 + stroke="currentColor" 759 + stroke-width="2" 760 + stroke-linecap="round" 761 + stroke-linejoin="round"> 762 + <polyline points="17 1 21 5 17 9" /> 763 + <path d="M3 11V9a4 4 0 0 1 4-4h14" /> 764 + <polyline points="7 23 3 19 7 15" /> 765 + <path d="M21 13v2a4 4 0 0 1-4 4H3" /> 766 + </svg> 767 + 4 768 + </button> 769 + <button class="post-action"> 770 + <svg 771 + viewBox="0 0 24 24" 772 + fill="none" 773 + stroke="currentColor" 774 + stroke-width="2" 775 + stroke-linecap="round" 776 + stroke-linejoin="round"> 777 + <path 778 + d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 779 + </svg> 780 + 31 781 + </button> 782 + <button class="post-action"> 783 + <svg 784 + viewBox="0 0 24 24" 785 + fill="none" 786 + stroke="currentColor" 787 + stroke-width="2" 788 + stroke-linecap="round" 789 + stroke-linejoin="round"> 790 + <path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" /> 791 + <polyline points="16 6 12 2 8 6" /> 792 + <line x1="12" y1="2" x2="12" y2="15" /> 793 + </svg> 794 + </button> 795 + </div> 796 + </article> 797 + </div> 798 + 799 + <!-- Reply Composer Bar --> 800 + <div class="thread-reply-bar"> 801 + <div class="avatar" style="width: 32px; height: 32px; font-size: 12px">JD</div> 802 + <input type="text" class="thread-reply-input" placeholder="Reply to Alice..." /> 803 + <button class="thread-reply-send" title="Send reply"> 804 + <svg 805 + viewBox="0 0 24 24" 806 + fill="none" 807 + stroke="currentColor" 808 + stroke-width="2" 809 + stroke-linecap="round" 810 + stroke-linejoin="round"> 811 + <line x1="22" y1="2" x2="11" y2="13" /> 812 + <polygon points="22 2 15 22 11 13 2 9 22 2" /> 813 + </svg> 814 + </button> 815 + </div> 816 + 817 + <!-- Bottom Navigation --> 818 + <nav class="nav-bar"> 819 + <a href="home.html" class="nav-item"> 820 + <svg 821 + viewBox="0 0 24 24" 822 + fill="none" 823 + stroke="currentColor" 824 + stroke-width="2" 825 + stroke-linecap="round" 826 + stroke-linejoin="round"> 827 + <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /> 828 + <polyline points="9 22 9 12 15 12 15 22" /> 829 + </svg> 830 + <span>Home</span> 831 + </a> 832 + 833 + <a href="search.html" class="nav-item"> 834 + <svg 835 + viewBox="0 0 24 24" 836 + fill="none" 837 + stroke="currentColor" 838 + stroke-width="2" 839 + stroke-linecap="round" 840 + stroke-linejoin="round"> 841 + <circle cx="11" cy="11" r="8" /> 842 + <line x1="21" y1="21" x2="16.65" y2="16.65" /> 843 + </svg> 844 + <span>Search</span> 845 + </a> 846 + 847 + <a href="notifications.html" class="nav-item"> 848 + <svg 849 + viewBox="0 0 24 24" 850 + fill="none" 851 + stroke="currentColor" 852 + stroke-width="2" 853 + stroke-linecap="round" 854 + stroke-linejoin="round"> 855 + <path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" /> 856 + <path d="M13.73 21a2 2 0 0 1-3.46 0" /> 857 + </svg> 858 + <span>Alerts</span> 859 + </a> 860 + 861 + <a href="messages.html" class="nav-item"> 862 + <svg 863 + viewBox="0 0 24 24" 864 + fill="none" 865 + stroke="currentColor" 866 + stroke-width="2" 867 + stroke-linecap="round" 868 + stroke-linejoin="round"> 869 + <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" /> 870 + </svg> 871 + <span>Chat</span> 872 + </a> 873 + 874 + <a href="profile.html" class="nav-item"> 875 + <svg 876 + viewBox="0 0 24 24" 877 + fill="none" 878 + stroke="currentColor" 879 + stroke-width="2" 880 + stroke-linecap="round" 881 + stroke-linejoin="round"> 882 + <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /> 883 + <circle cx="12" cy="7" r="4" /> 884 + </svg> 885 + <span>Profile</span> 886 + </a> 887 + </nav> 888 + </div> 889 + 890 + <script> 891 + if (localStorage.getItem("theme")) { 892 + const t = localStorage.getItem("theme"); 893 + if (t !== "light") document.documentElement.setAttribute("data-theme", t); 894 + } 895 + </script> 896 + </body> 897 + </html>