BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

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

refactor: shared post component & state

* nested/overlay route for threads

+1521 -654
+59 -58
src-tauri/src/commands/mod.rs
··· 1 1 #![allow(clippy::needless_pass_by_value)] 2 + use super::auth::{self, LoginSuggestion}; 3 + use super::conversations; 4 + use super::error::Result; 5 + use super::feed::{self, CreateRecordResult, EmbedInput, FeedViewPrefItem, ReplyRefInput, UserPreferences}; 6 + use super::notifications; 7 + use super::state::{AccountSummary, AppBootstrap, AppState}; 8 + use serde_json::Value; 9 + use tauri::AppHandle; 2 10 3 11 pub mod columns; 4 12 pub mod diagnostics; ··· 6 14 pub mod search; 7 15 pub mod settings; 8 16 9 - use super::auth::{self, LoginSuggestion}; 10 - use super::conversations; 11 - use super::error::AppError; 12 - use super::feed::{self, CreateRecordResult, EmbedInput, FeedViewPrefItem, ReplyRefInput, UserPreferences}; 13 - use super::notifications; 14 - use super::state::{AccountSummary, AppBootstrap, AppState}; 15 - use serde_json::Value; 16 - use tauri::{AppHandle, State}; 17 + type State<'a> = tauri::State<'a, AppState>; 17 18 18 19 #[tauri::command] 19 - pub fn get_app_bootstrap(state: State<'_, AppState>) -> Result<AppBootstrap, AppError> { 20 + pub fn get_app_bootstrap(state: State<'_>) -> Result<AppBootstrap> { 20 21 state.snapshot() 21 22 } 22 23 23 24 #[tauri::command] 24 - pub fn list_accounts(state: State<'_, AppState>) -> Result<Vec<AccountSummary>, AppError> { 25 + pub fn list_accounts(state: State<'_>) -> Result<Vec<AccountSummary>> { 25 26 state.accounts() 26 27 } 27 28 28 29 #[tauri::command] 29 - pub async fn login(handle: String, app: AppHandle, state: State<'_, AppState>) -> Result<AccountSummary, AppError> { 30 + pub async fn login(handle: String, app: AppHandle, state: State<'_>) -> Result<AccountSummary> { 30 31 state.login(&app, handle).await 31 32 } 32 33 33 34 #[tauri::command] 34 - pub async fn logout(did: String, app: AppHandle, state: State<'_, AppState>) -> Result<(), AppError> { 35 + pub async fn logout(did: String, app: AppHandle, state: State<'_>) -> Result<()> { 35 36 state.logout(&app, &did).await 36 37 } 37 38 38 39 #[tauri::command] 39 - pub async fn switch_account(did: String, app: AppHandle, state: State<'_, AppState>) -> Result<(), AppError> { 40 + pub async fn switch_account(did: String, app: AppHandle, state: State<'_>) -> Result<()> { 40 41 state.switch_account(&app, &did).await 41 42 } 42 43 43 44 #[tauri::command] 44 - pub async fn set_active_account(did: String, app: AppHandle, state: State<'_, AppState>) -> Result<(), AppError> { 45 + pub async fn set_active_account(did: String, app: AppHandle, state: State<'_>) -> Result<()> { 45 46 state.switch_account(&app, &did).await 46 47 } 47 48 48 49 #[tauri::command] 49 - pub async fn search_login_suggestions(query: String) -> Result<Vec<LoginSuggestion>, AppError> { 50 + pub async fn search_login_suggestions(query: String) -> Result<Vec<LoginSuggestion>> { 50 51 auth::search_login_suggestions(&query).await 51 52 } 52 53 53 54 #[tauri::command] 54 - pub async fn get_preferences(state: State<'_, AppState>) -> Result<UserPreferences, AppError> { 55 + pub async fn get_preferences(state: State<'_>) -> Result<UserPreferences> { 55 56 feed::get_preferences(&state).await 56 57 } 57 58 58 59 #[tauri::command] 59 - pub async fn get_profile(actor: String, state: State<'_, AppState>) -> Result<Value, AppError> { 60 + pub async fn get_profile(actor: String, state: State<'_>) -> Result<Value> { 60 61 feed::get_profile(actor, &state).await 61 62 } 62 63 63 64 #[tauri::command] 64 - pub async fn get_feed_generators(uris: Vec<String>, state: State<'_, AppState>) -> Result<Value, AppError> { 65 + pub async fn get_feed_generators(uris: Vec<String>, state: State<'_>) -> Result<Value> { 65 66 feed::get_feed_generators(uris, &state).await 66 67 } 67 68 68 69 #[tauri::command] 69 - pub async fn get_timeline(cursor: Option<String>, limit: u32, state: State<'_, AppState>) -> Result<Value, AppError> { 70 + pub async fn get_timeline(cursor: Option<String>, limit: u32, state: State<'_>) -> Result<Value> { 70 71 feed::get_timeline(cursor, limit, &state).await 71 72 } 72 73 73 74 #[tauri::command] 74 - pub async fn get_feed( 75 - uri: String, cursor: Option<String>, limit: u32, state: State<'_, AppState>, 76 - ) -> Result<Value, AppError> { 75 + pub async fn get_feed(uri: String, cursor: Option<String>, limit: u32, state: State<'_>) -> Result<Value> { 77 76 feed::get_feed(uri, cursor, limit, &state).await 78 77 } 79 78 80 79 #[tauri::command] 81 - pub async fn get_list_feed( 82 - uri: String, cursor: Option<String>, limit: u32, state: State<'_, AppState>, 83 - ) -> Result<Value, AppError> { 80 + pub async fn get_list_feed(uri: String, cursor: Option<String>, limit: u32, state: State<'_>) -> Result<Value> { 84 81 feed::get_list_feed(uri, cursor, limit, &state).await 85 82 } 86 83 87 84 #[tauri::command] 88 - pub async fn get_post_thread(uri: String, state: State<'_, AppState>) -> Result<Value, AppError> { 85 + pub async fn get_post_thread(uri: String, state: State<'_>) -> Result<Value> { 89 86 feed::get_post_thread(uri, &state).await 90 87 } 91 88 92 89 #[tauri::command] 93 90 pub async fn get_author_feed( 94 - actor: String, cursor: Option<String>, limit: Option<u32>, state: State<'_, AppState>, 95 - ) -> Result<Value, AppError> { 91 + actor: String, cursor: Option<String>, limit: Option<u32>, state: State<'_>, 92 + ) -> Result<Value> { 96 93 feed::get_author_feed(actor, cursor, limit, &state).await 97 94 } 98 95 99 96 #[tauri::command] 100 97 pub async fn get_actor_likes( 101 - actor: String, cursor: Option<String>, limit: Option<u32>, state: State<'_, AppState>, 102 - ) -> Result<Value, AppError> { 98 + actor: String, cursor: Option<String>, limit: Option<u32>, state: State<'_>, 99 + ) -> Result<Value> { 103 100 feed::get_actor_likes(actor, cursor, limit, &state).await 104 101 } 105 102 106 103 #[tauri::command] 107 104 pub async fn create_post( 108 - text: String, reply_to: Option<ReplyRefInput>, embed: Option<EmbedInput>, state: State<'_, AppState>, 109 - ) -> Result<CreateRecordResult, AppError> { 105 + text: String, reply_to: Option<ReplyRefInput>, embed: Option<EmbedInput>, state: State<'_>, 106 + ) -> Result<CreateRecordResult> { 110 107 feed::create_post(text, reply_to, embed, &state).await 111 108 } 112 109 113 110 #[tauri::command] 114 - pub async fn like_post(uri: String, cid: String, state: State<'_, AppState>) -> Result<CreateRecordResult, AppError> { 111 + pub async fn like_post(uri: String, cid: String, state: State<'_>) -> Result<CreateRecordResult> { 115 112 feed::like_post(uri, cid, &state).await 116 113 } 117 114 118 115 #[tauri::command] 119 - pub async fn unlike_post(like_uri: String, state: State<'_, AppState>) -> Result<(), AppError> { 116 + pub async fn unlike_post(like_uri: String, state: State<'_>) -> Result<()> { 120 117 feed::unlike_post(like_uri, &state).await 121 118 } 122 119 123 120 #[tauri::command] 124 - pub async fn repost(uri: String, cid: String, state: State<'_, AppState>) -> Result<CreateRecordResult, AppError> { 121 + pub async fn repost(uri: String, cid: String, state: State<'_>) -> Result<CreateRecordResult> { 125 122 feed::repost(uri, cid, &state).await 126 123 } 127 124 128 125 #[tauri::command] 129 - pub async fn unrepost(repost_uri: String, state: State<'_, AppState>) -> Result<(), AppError> { 126 + pub async fn unrepost(repost_uri: String, state: State<'_>) -> Result<()> { 130 127 feed::unrepost(repost_uri, &state).await 131 128 } 132 129 133 130 #[tauri::command] 134 - pub async fn follow_actor(did: String, state: State<'_, AppState>) -> Result<CreateRecordResult, AppError> { 131 + pub async fn bookmark_post(uri: String, cid: String, state: State<'_>) -> Result<()> { 132 + feed::bookmark_post(uri, cid, &state).await 133 + } 134 + 135 + #[tauri::command] 136 + pub async fn remove_bookmark(uri: String, state: State<'_>) -> Result<()> { 137 + feed::remove_bookmark(uri, &state).await 138 + } 139 + 140 + #[tauri::command] 141 + pub async fn follow_actor(did: String, state: State<'_>) -> Result<CreateRecordResult> { 135 142 feed::follow_actor(did, &state).await 136 143 } 137 144 138 145 #[tauri::command] 139 - pub async fn unfollow_actor(follow_uri: String, state: State<'_, AppState>) -> Result<(), AppError> { 146 + pub async fn unfollow_actor(follow_uri: String, state: State<'_>) -> Result<()> { 140 147 feed::unfollow_actor(follow_uri, &state).await 141 148 } 142 149 143 150 #[tauri::command] 144 151 pub async fn get_followers( 145 - actor: String, cursor: Option<String>, limit: Option<u32>, state: State<'_, AppState>, 146 - ) -> Result<Value, AppError> { 152 + actor: String, cursor: Option<String>, limit: Option<u32>, state: State<'_>, 153 + ) -> Result<Value> { 147 154 feed::get_followers(actor, cursor, limit, &state).await 148 155 } 149 156 150 157 #[tauri::command] 151 - pub async fn get_follows( 152 - actor: String, cursor: Option<String>, limit: Option<u32>, state: State<'_, AppState>, 153 - ) -> Result<Value, AppError> { 158 + pub async fn get_follows(actor: String, cursor: Option<String>, limit: Option<u32>, state: State<'_>) -> Result<Value> { 154 159 feed::get_follows(actor, cursor, limit, &state).await 155 160 } 156 161 157 162 #[tauri::command] 158 - pub async fn update_saved_feeds(feeds: Vec<feed::SavedFeedItem>, state: State<'_, AppState>) -> Result<(), AppError> { 163 + pub async fn update_saved_feeds(feeds: Vec<feed::SavedFeedItem>, state: State<'_>) -> Result<()> { 159 164 feed::update_saved_feeds(feed::UpdateSavedFeedsInput { feeds }, &state).await 160 165 } 161 166 162 167 #[tauri::command] 163 - pub async fn update_feed_view_pref(pref: FeedViewPrefItem, state: State<'_, AppState>) -> Result<(), AppError> { 168 + pub async fn update_feed_view_pref(pref: FeedViewPrefItem, state: State<'_>) -> Result<()> { 164 169 feed::update_feed_view_pref(pref, &state).await 165 170 } 166 171 167 172 #[tauri::command] 168 - pub async fn list_notifications(cursor: Option<String>, state: State<'_, AppState>) -> Result<Value, AppError> { 173 + pub async fn list_notifications(cursor: Option<String>, state: State<'_>) -> Result<Value> { 169 174 notifications::list_notifications(cursor, &state).await 170 175 } 171 176 172 177 #[tauri::command] 173 - pub async fn update_seen(state: State<'_, AppState>) -> Result<(), AppError> { 178 + pub async fn update_seen(state: State<'_>) -> Result<()> { 174 179 notifications::update_seen(&state).await 175 180 } 176 181 177 182 #[tauri::command] 178 - pub async fn get_unread_count(state: State<'_, AppState>) -> Result<i64, AppError> { 183 + pub async fn get_unread_count(state: State<'_>) -> Result<i64> { 179 184 notifications::get_unread_count(&state).await 180 185 } 181 186 182 187 #[tauri::command] 183 - pub async fn list_convos( 184 - cursor: Option<String>, limit: Option<u32>, state: State<'_, AppState>, 185 - ) -> Result<Value, AppError> { 188 + pub async fn list_convos(cursor: Option<String>, limit: Option<u32>, state: State<'_>) -> Result<Value> { 186 189 conversations::list_convos(cursor, limit, &state).await 187 190 } 188 191 189 192 #[tauri::command] 190 - pub async fn get_convo_for_members(members: Vec<String>, state: State<'_, AppState>) -> Result<Value, AppError> { 193 + pub async fn get_convo_for_members(members: Vec<String>, state: State<'_>) -> Result<Value> { 191 194 conversations::get_convo_for_members(members, &state).await 192 195 } 193 196 194 197 #[tauri::command] 195 198 pub async fn get_messages( 196 - convo_id: String, cursor: Option<String>, limit: Option<u32>, state: State<'_, AppState>, 197 - ) -> Result<Value, AppError> { 199 + convo_id: String, cursor: Option<String>, limit: Option<u32>, state: State<'_>, 200 + ) -> Result<Value> { 198 201 conversations::get_messages(convo_id, cursor, limit, &state).await 199 202 } 200 203 201 204 #[tauri::command] 202 - pub async fn send_message(convo_id: String, text: String, state: State<'_, AppState>) -> Result<Value, AppError> { 205 + pub async fn send_message(convo_id: String, text: String, state: State<'_>) -> Result<Value> { 203 206 conversations::send_message(convo_id, text, &state).await 204 207 } 205 208 206 209 #[tauri::command] 207 - pub async fn update_read( 208 - convo_id: String, message_id: Option<String>, state: State<'_, AppState>, 209 - ) -> Result<(), AppError> { 210 + pub async fn update_read(convo_id: String, message_id: Option<String>, state: State<'_>) -> Result<()> { 210 211 conversations::update_read(convo_id, message_id, &state).await 211 212 }
+42
src-tauri/src/feed.rs
··· 7 7 use jacquard::api::app_bsky::actor::{ 8 8 FeedViewPref, PreferencesItem, SavedFeed, SavedFeedType, SavedFeedsPrefV2, SavedFeedsPrefV2Builder, 9 9 }; 10 + use jacquard::api::app_bsky::bookmark::create_bookmark::CreateBookmark; 11 + use jacquard::api::app_bsky::bookmark::delete_bookmark::DeleteBookmark; 10 12 use jacquard::api::app_bsky::embed::record::Record; 11 13 use jacquard::api::app_bsky::feed::get_actor_likes::GetActorLikes; 12 14 use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; ··· 708 710 .map_err(|error| { 709 711 log::error!("deleteRecord (unrepost) output error: {error}"); 710 712 AppError::validation("failed to delete record (unrepost) output") 713 + })?; 714 + 715 + Ok(()) 716 + } 717 + 718 + pub async fn bookmark_post(uri: String, cid: String, state: &AppState) -> Result<()> { 719 + let session = get_session(state).await?; 720 + let post_uri = AtUri::new(&uri).map_err(|_| AppError::validation("invalid post URI"))?; 721 + 722 + session 723 + .send(CreateBookmark::new().uri(post_uri).cid(Cid::str(&cid)).build()) 724 + .await 725 + .map_err(|error| { 726 + log::error!("createBookmark error: {error}"); 727 + AppError::validation("Could not save this post.") 728 + })? 729 + .into_output() 730 + .map_err(|error| { 731 + log::error!("createBookmark output error: {error}"); 732 + AppError::validation("Could not save this post.") 733 + })?; 734 + 735 + Ok(()) 736 + } 737 + 738 + pub async fn remove_bookmark(uri: String, state: &AppState) -> Result<()> { 739 + let session = get_session(state).await?; 740 + let post_uri = AtUri::new(&uri).map_err(|_| AppError::validation("invalid post URI"))?; 741 + 742 + session 743 + .send(DeleteBookmark::new().uri(post_uri).build()) 744 + .await 745 + .map_err(|error| { 746 + log::error!("deleteBookmark error: {error}"); 747 + AppError::validation("Could not remove this saved post.") 748 + })? 749 + .into_output() 750 + .map_err(|error| { 751 + log::error!("deleteBookmark output error: {error}"); 752 + AppError::validation("Could not remove this saved post.") 711 753 })?; 712 754 713 755 Ok(())
+2
src-tauri/src/lib.rs
··· 95 95 cmd::unlike_post, 96 96 cmd::repost, 97 97 cmd::unrepost, 98 + cmd::bookmark_post, 99 + cmd::remove_bookmark, 98 100 cmd::follow_actor, 99 101 cmd::unfollow_actor, 100 102 cmd::get_followers,
+3 -3
src/App.tsx
··· 11 11 import { MessagesPanel } from "./components/messages/MessagesPanel"; 12 12 import { NotificationsPanel } from "./components/notifications/NotificationsPanel"; 13 13 import { HeaderPanel } from "./components/panels/Header"; 14 + import { ThreadModal } from "./components/posts/ThreadModal"; 14 15 import { ProfilePanel } from "./components/profile/ProfilePanel"; 15 16 import { AppRail } from "./components/rail/AppRail"; 16 17 import { SessionSpotlight } from "./components/Session"; ··· 66 67 </section> 67 68 </main> 68 69 70 + <ThreadModal /> 69 71 <ErrorToast message={session.errorMessage} onDismiss={session.clearError} /> 70 72 </> 71 73 ); ··· 86 88 renderNotifications={() => <NotificationsPanel />} 87 89 renderProfile={(props) => <ProfilePanel actor={props.actor} />} 88 90 renderShell={AppShell} 89 - renderTimeline={({ context }) => ( 90 - <FeedWorkspace onThreadRouteChange={context.onThreadRouteChange} threadUri={context.threadUri} /> 91 - )} /> 91 + renderTimeline={() => <FeedWorkspace />} /> 92 92 }> 93 93 <> 94 94 <Show
+14 -3
src/components/deck/DeckColumn.tsx
··· 157 157 type FeedBodyContentProps = { feed: SavedFeedItem; onOpenThread: (uri: string) => void }; 158 158 159 159 function FeedBodyContent(props: FeedBodyContentProps) { 160 - const { registerSentinel, state, toggleLike, toggleRepost } = useFeedColumnState(() => props.feed); 160 + const { 161 + bookmarkPendingByUri, 162 + likePendingByUri, 163 + registerSentinel, 164 + repostPendingByUri, 165 + state, 166 + toggleBookmark, 167 + toggleLike, 168 + toggleRepost, 169 + } = useFeedColumnState(() => props.feed); 161 170 const postRefs = new Map<string, HTMLElement>(); 162 171 163 172 return ( ··· 171 180 loading: state.loading, 172 181 loadingMore: state.loadingMore, 173 182 }} 183 + bookmarkPendingByUri={bookmarkPendingByUri()} 174 184 focusedIndex={-1} 175 - likePendingByUri={state.likePendingByUri} 185 + likePendingByUri={likePendingByUri()} 176 186 likePulseUri={null} 187 + onBookmark={(post: PostView) => toggleBookmark(post)} 177 188 onFocusIndex={() => void 0} 178 189 onLike={(post: PostView) => toggleLike(post)} 179 190 onOpenThread={(uri: string) => Promise.resolve(props.onOpenThread(uri))} ··· 181 192 onReply={() => void 0} 182 193 onRepost={(post: PostView) => toggleRepost(post)} 183 194 postRefs={postRefs} 184 - repostPendingByUri={state.repostPendingByUri} 195 + repostPendingByUri={repostPendingByUri()} 185 196 repostPulseUri={null} 186 197 sentinelRef={registerSentinel} 187 198 visibleItems={state.items} />
+3 -3
src/components/deck/DeckWorkspace.tsx
··· 1 + import { useThreadOverlayNavigation } from "$/components/posts/useThreadOverlayNavigation"; 1 2 import { useAppSession } from "$/contexts/app-session"; 2 3 import { addColumn, getColumns, removeColumn, reorderColumns, updateColumn } from "$/lib/api/columns"; 3 4 import { getFeedGenerators, getPreferences } from "$/lib/api/feeds"; 4 5 import type { Column, ColumnKind, ColumnWidth } from "$/lib/api/types/columns"; 5 6 import { getFeedName } from "$/lib/feeds"; 6 7 import type { FeedGeneratorView } from "$/lib/types"; 7 - import { useNavigate } from "@solidjs/router"; 8 8 import * as logger from "@tauri-apps/plugin-log"; 9 9 import { createEffect, For, onCleanup, onMount, Show } from "solid-js"; 10 10 import { createStore, produce } from "solid-js/store"; ··· 132 132 133 133 export function DeckWorkspace() { 134 134 const session = useAppSession(); 135 - const navigate = useNavigate(); 135 + const threadOverlay = useThreadOverlayNavigation(); 136 136 let feedColumnRequest = 0; 137 137 // Module-level variable: WebKit dataTransfer.getData() returns empty string on drop, 138 138 // so we track the dragging column ID here instead. ··· 357 357 } 358 358 359 359 function handleOpenThread(uri: string) { 360 - navigate(`/timeline/thread/${encodeURIComponent(uri)}`); 360 + void threadOverlay.openThread(uri); 361 361 } 362 362 363 363 createEffect(() => {
+25 -91
src/components/deck/useFeedColumnState.ts
··· 1 - import { getFeedPage, likePost, repost, unlikePost, unrepost } from "$/lib/api/feeds"; 2 - import type { FeedViewPost, PostView, SavedFeedItem } from "$/lib/types"; 1 + import { usePostInteractions } from "$/components/posts/usePostInteractions"; 2 + import { getFeedPage } from "$/lib/api/feeds"; 3 + import { patchFeedItems } from "$/lib/feeds"; 4 + import type { FeedViewPost, SavedFeedItem } from "$/lib/types"; 3 5 import * as logger from "@tauri-apps/plugin-log"; 4 6 import { onCleanup, onMount } from "solid-js"; 5 7 import { createStore } from "solid-js/store"; ··· 7 9 const PAGE_LIMIT = 20; 8 10 9 11 export type FeedColumnState = { 12 + bookmarkPendingByUri: Record<string, boolean>; 10 13 cursor: string | null; 11 14 error: string | null; 12 15 items: FeedViewPost[]; 13 - likePendingByUri: Record<string, boolean>; 14 16 loading: boolean; 15 17 loadingMore: boolean; 16 - repostPendingByUri: Record<string, boolean>; 17 18 }; 18 19 19 20 export function useFeedColumnState(getFeed: () => SavedFeedItem) { 20 21 const [state, setState] = createStore<FeedColumnState>({ 22 + bookmarkPendingByUri: {}, 21 23 cursor: null, 22 24 error: null, 23 25 items: [], 24 - likePendingByUri: {}, 25 26 loading: true, 26 27 loadingMore: false, 27 - repostPendingByUri: {}, 28 + }); 29 + const interactions = usePostInteractions({ 30 + onError(message) { 31 + logger.error(message); 32 + }, 33 + patchPost(uri, updater) { 34 + setState("items", (items) => patchFeedItems(items, uri, updater)); 35 + }, 28 36 }); 29 37 30 38 let observer: IntersectionObserver | undefined; ··· 63 71 await load(null); 64 72 } 65 73 66 - async function toggleLike(post: PostView) { 67 - if (state.likePendingByUri[post.uri]) return; 68 - setState("likePendingByUri", post.uri, true); 69 - 70 - try { 71 - const likeUri = post.viewer?.like; 72 - if (likeUri) { 73 - await unlikePost(likeUri); 74 - setState("items", (items) => 75 - items.map((item) => { 76 - if (item.post.uri !== post.uri) return item; 77 - return { 78 - ...item, 79 - post: { 80 - ...item.post, 81 - likeCount: (item.post.likeCount ?? 1) - 1, 82 - viewer: { ...item.post.viewer, like: undefined }, 83 - }, 84 - }; 85 - })); 86 - } else { 87 - const result = await likePost(post.uri, post.cid); 88 - setState("items", (items) => 89 - items.map((item) => { 90 - if (item.post.uri !== post.uri) return item; 91 - return { 92 - ...item, 93 - post: { 94 - ...item.post, 95 - likeCount: (item.post.likeCount ?? 0) + 1, 96 - viewer: { ...item.post.viewer, like: result.uri }, 97 - }, 98 - }; 99 - })); 100 - } 101 - } catch (err) { 102 - logger.error(`Like toggle failed: ${String(err)}`); 103 - } finally { 104 - setState("likePendingByUri", post.uri, false); 105 - } 106 - } 107 - 108 - async function toggleRepost(post: PostView) { 109 - if (state.repostPendingByUri[post.uri]) return; 110 - setState("repostPendingByUri", post.uri, true); 111 - 112 - try { 113 - const repostUri = post.viewer?.repost; 114 - if (repostUri) { 115 - await unrepost(repostUri); 116 - setState("items", (items) => 117 - items.map((item) => { 118 - if (item.post.uri !== post.uri) return item; 119 - return { 120 - ...item, 121 - post: { 122 - ...item.post, 123 - repostCount: (item.post.repostCount ?? 1) - 1, 124 - viewer: { ...item.post.viewer, repost: undefined }, 125 - }, 126 - }; 127 - })); 128 - } else { 129 - const result = await repost(post.uri, post.cid); 130 - setState("items", (items) => 131 - items.map((item) => { 132 - if (item.post.uri !== post.uri) return item; 133 - return { 134 - ...item, 135 - post: { 136 - ...item.post, 137 - repostCount: (item.post.repostCount ?? 0) + 1, 138 - viewer: { ...item.post.viewer, repost: result.uri }, 139 - }, 140 - }; 141 - })); 142 - } 143 - } catch (err) { 144 - logger.error(`Repost toggle failed: ${String(err)}`); 145 - } finally { 146 - setState("repostPendingByUri", post.uri, false); 147 - } 148 - } 149 - 150 74 function registerSentinel(element: HTMLDivElement) { 151 75 observer?.disconnect(); 152 76 ··· 170 94 observer?.disconnect(); 171 95 }); 172 96 173 - return { refresh, registerSentinel, state, toggleLike, toggleRepost }; 97 + return { 98 + bookmarkPendingByUri: interactions.bookmarkPendingByUri, 99 + likePendingByUri: interactions.likePendingByUri, 100 + refresh, 101 + registerSentinel, 102 + repostPendingByUri: interactions.repostPendingByUri, 103 + state, 104 + toggleBookmark: interactions.toggleBookmark, 105 + toggleLike: interactions.toggleLike, 106 + toggleRepost: interactions.toggleRepost, 107 + }; 174 108 }
+2
src/components/feeds/FeedContent.test.tsx
··· 27 27 loading: false, 28 28 loadingMore: false, 29 29 }, 30 + bookmarkPendingByUri: {}, 30 31 likePendingByUri: {}, 31 32 likePulseUri: null, 33 + onBookmark: vi.fn(async () => {}), 32 34 onFocusIndex: vi.fn(), 33 35 onLike: vi.fn(async () => {}), 34 36 onOpenThread: vi.fn(async () => {}),
+7 -3
src/components/feeds/FeedContent.tsx
··· 31 31 props: { 32 32 activeFeedId: string; 33 33 activeFeedState: FeedState | undefined; 34 + bookmarkPendingByUri: Record<string, boolean>; 34 35 focusedIndex: number; 35 36 likePendingByUri: Record<string, boolean>; 36 37 likePulseUri: string | null; 37 38 onFocusIndex: (index: number) => void; 38 - onLike: (post: PostView) => Promise<void>; 39 - onOpenThread: (uri: string) => Promise<void>; 39 + onBookmark: (post: PostView) => Promise<void> | void; 40 + onLike: (post: PostView) => Promise<void> | void; 41 + onOpenThread: (uri: string) => Promise<void> | void; 40 42 onQuote: (post: PostView) => void; 41 43 onReply: (post: PostView, root: PostView) => void; 42 - onRepost: (post: PostView) => Promise<void>; 44 + onRepost: (post: PostView) => Promise<void> | void; 43 45 postRefs: Map<string, HTMLElement>; 44 46 repostPendingByUri: Record<string, boolean>; 45 47 repostPulseUri: string | null; ··· 53 55 <For each={props.visibleItems}> 54 56 {(item, index) => ( 55 57 <PostCard 58 + bookmarkPending={!!props.bookmarkPendingByUri[item.post.uri]} 56 59 focused={props.focusedIndex === index()} 57 60 item={item} 58 61 likePending={!!props.likePendingByUri[item.post.uri]} 62 + onBookmark={() => void props.onBookmark(item.post)} 59 63 onFocus={() => props.onFocusIndex(index())} 60 64 onLike={() => void props.onLike(item.post)} 61 65 onOpenThread={() => void props.onOpenThread(item.post.uri)}
+6 -4
src/components/feeds/FeedPane.tsx
··· 44 44 <FeedContent 45 45 activeFeedId={props.controller.activeFeed().id} 46 46 activeFeedState={props.controller.activeFeedState()} 47 + bookmarkPendingByUri={props.controller.bookmarkPendingByUri()} 47 48 focusedIndex={props.controller.workspace.focusedIndex} 48 - likePendingByUri={props.controller.workspace.likePendingByUri} 49 - likePulseUri={props.controller.workspace.likePulseUri} 49 + likePendingByUri={props.controller.likePendingByUri()} 50 + likePulseUri={props.controller.likePulseUri()} 51 + onBookmark={props.controller.toggleBookmark} 50 52 onFocusIndex={props.controller.setFocusedIndex} 51 53 onLike={props.controller.toggleLike} 52 54 onOpenThread={props.controller.openThread} ··· 54 56 onReply={props.controller.openReplyComposer} 55 57 onRepost={props.controller.toggleRepost} 56 58 postRefs={props.controller.postRefs} 57 - repostPendingByUri={props.controller.workspace.repostPendingByUri} 58 - repostPulseUri={props.controller.workspace.repostPulseUri} 59 + repostPendingByUri={props.controller.repostPendingByUri()} 60 + repostPulseUri={props.controller.repostPulseUri()} 59 61 sentinelRef={props.controller.registerSentinel} 60 62 visibleItems={props.controller.visibleItems()} /> 61 63 </div>
+8 -2
src/components/feeds/FeedWorkspace.test.tsx
··· 1 1 import { AppTestProviders } from "$/test/providers"; 2 + import { HashRouter, Route } from "@solidjs/router"; 2 3 import { fireEvent, render, screen } from "@solidjs/testing-library"; 3 4 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 4 5 import { FeedWorkspace } from "./FeedWorkspace"; ··· 80 81 invokeMock.mockReset(); 81 82 listenMock.mockReset(); 82 83 listenMock.mockResolvedValue(() => {}); 84 + globalThis.location.hash = "#/timeline"; 83 85 84 86 Object.defineProperty(globalThis, "IntersectionObserver", { 85 87 configurable: true, ··· 126 128 const { container } = render(() => ( 127 129 <AppTestProviders 128 130 session={{ activeDid: ACTIVE_SESSION.did, activeHandle: ACTIVE_SESSION.handle, activeSession: ACTIVE_SESSION }}> 129 - <FeedWorkspace onThreadRouteChange={vi.fn()} threadUri={null} /> 131 + <HashRouter> 132 + <Route path="/timeline" component={() => <FeedWorkspace />} /> 133 + </HashRouter> 130 134 </AppTestProviders> 131 135 )); 132 136 ··· 174 178 render(() => ( 175 179 <AppTestProviders 176 180 session={{ activeDid: ACTIVE_SESSION.did, activeHandle: ACTIVE_SESSION.handle, activeSession: ACTIVE_SESSION }}> 177 - <FeedWorkspace onThreadRouteChange={vi.fn()} threadUri={null} /> 181 + <HashRouter> 182 + <Route path="/timeline" component={() => <FeedWorkspace />} /> 183 + </HashRouter> 178 184 </AppTestProviders> 179 185 )); 180 186
+5 -23
src/components/feeds/FeedWorkspace.tsx
··· 1 + import { useThreadOverlayNavigation } from "$/components/posts/useThreadOverlayNavigation"; 1 2 import { useAppSession } from "$/contexts/app-session"; 2 3 import { FeedComposer } from "./FeedComposer"; 3 4 import { SavedFeedsDrawer } from "./FeedDrawer"; 4 5 import { FeedPane } from "./FeedPane"; 5 6 import { FeedWorkspaceSidebar } from "./FeedWorkspaceSidebar"; 6 - import { ThreadPanel } from "./ThreadPanel"; 7 - import { type FeedWorkspaceProps, useFeedWorkspaceController } from "./useFeedWorkspaceController"; 7 + import { useFeedWorkspaceController } from "./useFeedWorkspaceController"; 8 8 9 - type FeedWorkspaceRouteProps = Pick<FeedWorkspaceProps, "onThreadRouteChange" | "threadUri">; 10 - 11 - export function FeedWorkspace(props: FeedWorkspaceRouteProps) { 9 + export function FeedWorkspace() { 12 10 const session = useAppSession(); 11 + const threadOverlay = useThreadOverlayNavigation(); 13 12 const activeSession = () => { 14 13 if (!session.activeSession) { 15 14 throw new Error("FeedWorkspace requires an active session"); ··· 20 19 const controller = useFeedWorkspaceController({ 21 20 activeSession: activeSession(), 22 21 onError: session.reportError, 23 - get onThreadRouteChange() { 24 - return props.onThreadRouteChange; 25 - }, 26 - get threadUri() { 27 - return props.threadUri; 28 - }, 22 + onOpenThread: (uri) => void threadOverlay.openThread(uri), 29 23 }); 30 24 31 25 return ( ··· 51 45 onReorderPinned={controller.reorderPinnedFeeds} 52 46 onSelectFeed={controller.switchFeed} 53 47 onUnpinFeed={controller.unpinFeed} /> 54 - 55 - <ThreadPanel 56 - activeUri={props.threadUri} 57 - error={controller.workspace.thread.error} 58 - loading={controller.workspace.thread.loading} 59 - onClose={() => props.onThreadRouteChange(null)} 60 - onLike={(post) => void controller.toggleLike(post)} 61 - onOpenThread={(uri) => void controller.openThread(uri)} 62 - onQuote={(post) => controller.openQuoteComposer(post)} 63 - onReply={(post, root) => controller.openReplyComposer(post, root)} 64 - onRepost={(post) => void controller.toggleRepost(post)} 65 - thread={controller.workspace.thread.data} /> 66 48 67 49 <FeedComposer 68 50 activeAvatar={session.activeAvatar}
+34 -7
src/components/feeds/PostCard.test.tsx
··· 1 - import { fireEvent, render, screen } from "@solidjs/testing-library"; 1 + import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 2 2 import { describe, expect, it, vi } from "vitest"; 3 3 import { PostCard } from "./PostCard"; 4 4 ··· 25 25 expect(screen.getByText("#solid")).toBeInTheDocument(); 26 26 }); 27 27 28 - it("opens the thread when Enter is pressed on the card", async () => { 28 + it("opens the thread from the primary region on click and Enter", async () => { 29 29 const onOpenThread = vi.fn(); 30 30 render(() => <PostCard post={createPost()} onOpenThread={onOpenThread} />); 31 31 32 - await new Promise((resolve) => { 33 - fireEvent.keyDown(screen.getByRole("article"), { key: "Enter" }); 34 - resolve(void 0); 35 - }); 32 + const primaryRegion = screen.getByRole("button", { name: "Open thread" }); 33 + fireEvent.click(primaryRegion); 34 + fireEvent.keyDown(primaryRegion, { key: "Enter" }); 35 + 36 + expect(onOpenThread).toHaveBeenCalledTimes(2); 37 + }); 38 + 39 + it("does not open the thread when clicking the author link or an action button", () => { 40 + const onOpenThread = vi.fn(); 41 + const onLike = vi.fn(); 42 + render(() => <PostCard post={createPost()} onLike={onLike} onOpenThread={onOpenThread} />); 43 + 44 + fireEvent.click(screen.getByRole("link", { name: "Alice" })); 45 + fireEvent.click(screen.getByRole("button", { name: "4" })); 46 + 47 + expect(onOpenThread).not.toHaveBeenCalled(); 48 + expect(onLike).toHaveBeenCalledTimes(1); 49 + }); 36 50 37 - expect(onOpenThread).toHaveBeenCalledTimes(1); 51 + it("opens the shared menu from the overflow trigger and from right click", async () => { 52 + Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(void 0) } }); 53 + 54 + render(() => <PostCard post={createPost()} onOpenThread={vi.fn()} />); 55 + 56 + fireEvent.click(screen.getByRole("button", { name: "More actions" })); 57 + expect(screen.getByRole("menu", { name: "Post actions" })).toBeInTheDocument(); 58 + 59 + fireEvent.pointerDown(document.body); 60 + await waitFor(() => expect(screen.queryByRole("menu", { name: "Post actions" })).not.toBeInTheDocument()); 61 + 62 + fireEvent.contextMenu(screen.getByRole("article")); 63 + expect(screen.getByRole("menu", { name: "Post actions" })).toBeInTheDocument(); 64 + expect(screen.getByRole("menuitem", { name: "Copy post link" })).toBeInTheDocument(); 38 65 }); 39 66 40 67 it("shows reply context when the feed item is a reply", () => {
+199 -46
src/components/feeds/PostCard.tsx
··· 1 + import { ContextMenu, type ContextMenuAnchor, type ContextMenuItem } from "$/components/shared/ContextMenu"; 1 2 import { Icon } from "$/components/shared/Icon"; 2 3 import { 4 + buildPublicPostUrl, 3 5 formatRelativeTime, 4 6 getAvatarLabel, 5 7 getDisplayName, ··· 12 14 import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; 13 15 import type { FeedViewPost, ImagesEmbedView, PostView, ProfileViewBasic } from "$/lib/types"; 14 16 import { formatCount } from "$/lib/utils/text"; 15 - import { createMemo, For, Match, Show, Switch } from "solid-js"; 17 + import { createMemo, createSignal, For, type JSX, Match, Show, Switch } from "solid-js"; 16 18 import { Motion } from "solid-motionone"; 17 19 18 20 type PostCardProps = { 21 + bookmarkPending?: boolean; 19 22 focused?: boolean; 20 23 item?: FeedViewPost; 21 24 likePending?: boolean; 25 + onBookmark?: () => void; 22 26 onFocus?: () => void; 23 27 onLike?: () => void; 24 28 onOpenThread?: () => void; ··· 36 40 export function PostCard(props: PostCardProps) { 37 41 const authorName = createMemo(() => getDisplayName(props.post.author)); 38 42 const createdAt = createMemo(() => formatRelativeTime(getPostCreatedAt(props.post))); 43 + const isBookmarked = createMemo(() => !!props.post.viewer?.bookmarked); 39 44 const isLiked = createMemo(() => !!props.post.viewer?.like); 40 45 const isReposted = createMemo(() => !!props.post.viewer?.repost); 46 + const likeCount = createMemo(() => formatCount(props.post.likeCount)); 47 + const replyCount = createMemo(() => formatCount(props.post.replyCount)); 48 + const repostCount = createMemo(() => formatCount(props.post.repostCount)); 49 + const profileHref = createMemo(() => buildProfileRoute(getProfileRouteActor(props.post.author))); 41 50 const reasonLabel = createMemo(() => { 42 51 const reason = props.item?.reason; 43 52 if (!reason || reason.$type !== "app.bsky.feed.defs#reasonRepost") { ··· 58 67 } 59 68 60 69 return "Reply in thread"; 70 + }); 71 + const [menuAnchor, setMenuAnchor] = createSignal<ContextMenuAnchor | null>(null); 72 + const [menuOpen, setMenuOpen] = createSignal(false); 73 + let menuTriggerRef: HTMLButtonElement | undefined; 74 + 75 + const menuItems = createMemo<ContextMenuItem[]>(() => { 76 + const items: ContextMenuItem[] = []; 77 + 78 + if (props.onReply) { 79 + items.push({ icon: "i-ri-chat-1-line", label: "Reply", onSelect: props.onReply }); 80 + } 81 + 82 + if (props.onQuote) { 83 + items.push({ icon: "i-ri-chat-quote-line", label: "Quote", onSelect: props.onQuote }); 84 + } 85 + 86 + if (props.onLike) { 87 + items.push({ 88 + icon: isLiked() ? "i-ri-heart-3-fill" : "i-ri-heart-3-line", 89 + label: isLiked() ? "Unlike" : "Like", 90 + onSelect: props.onLike, 91 + }); 92 + } 93 + 94 + if (props.onRepost) { 95 + items.push({ 96 + icon: isReposted() ? "i-ri-repeat-2-fill" : "i-ri-repeat-2-line", 97 + label: isReposted() ? "Undo repost" : "Repost", 98 + onSelect: props.onRepost, 99 + }); 100 + } 101 + 102 + if (props.onBookmark) { 103 + items.push({ 104 + icon: isBookmarked() ? "i-ri-bookmark-fill" : "i-ri-bookmark-line", 105 + label: isBookmarked() ? "Unsave" : "Save", 106 + onSelect: props.onBookmark, 107 + }); 108 + } 109 + 110 + items.push({ 111 + icon: "i-ri-link-m", 112 + label: "Copy post link", 113 + onSelect: () => void navigator.clipboard?.writeText(buildPublicPostUrl(props.post)), 114 + }); 115 + 116 + if (props.onOpenThread) { 117 + items.push({ icon: "i-ri-node-tree", label: "Open thread", onSelect: props.onOpenThread }); 118 + } 119 + 120 + return items; 61 121 }); 62 122 63 - const likeCount = createMemo(() => formatCount(props.post.likeCount)); 64 - const replyCount = createMemo(() => formatCount(props.post.replyCount)); 65 - const repostCount = createMemo(() => formatCount(props.post.repostCount)); 66 - const profileHref = createMemo(() => buildProfileRoute(getProfileRouteActor(props.post.author))); 67 - const articleClickable = createMemo(() => props.showActions === false && !!props.onOpenThread); 123 + function closeMenu() { 124 + setMenuOpen(false); 125 + setMenuAnchor(null); 126 + } 127 + 128 + function openMenuFromTrigger(element: HTMLButtonElement) { 129 + setMenuAnchor({ kind: "element", rect: element.getBoundingClientRect() }); 130 + setMenuOpen(true); 131 + } 132 + 133 + function openMenuFromPointer(event: MouseEvent) { 134 + event.preventDefault(); 135 + setMenuAnchor({ kind: "point", x: event.clientX, y: event.clientY }); 136 + setMenuOpen(true); 137 + } 68 138 69 139 return ( 70 140 <article 71 141 ref={(element) => props.registerRef?.(element)} 72 142 class="group min-w-0 overflow-hidden rounded-3xl bg-white/2.5 px-5 py-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)] transition duration-150 ease-out hover:bg-white/4 max-[760px]:px-4 max-[760px]:py-4 max-[520px]:rounded-3xl max-[520px]:px-3.5" 73 143 classList={{ 74 - "cursor-pointer": articleClickable(), 75 144 "bg-[linear-gradient(135deg,rgba(125,175,255,0.11),rgba(0,115,222,0.06))] shadow-[inset_0_0_0_1px_rgba(125,175,255,0.22),0_0_0_1px_rgba(125,175,255,0.08)]": 76 145 !!props.focused, 77 146 }} 78 147 role="article" 79 - tabIndex={0} 80 - onClick={() => { 81 - if (articleClickable()) { 82 - props.onOpenThread?.(); 148 + onContextMenu={(event) => { 149 + if (menuItems().length === 0 || isInteractiveTarget(event.target)) { 83 150 return; 84 151 } 85 152 86 - props.onFocus?.(); 87 - }} 88 - onFocus={() => props.onFocus?.()} 89 - onKeyDown={(event) => { 90 - if (event.key === "Enter") { 91 - props.onOpenThread?.(); 92 - } 153 + openMenuFromPointer(event); 93 154 }}> 94 155 <Show when={reasonLabel()}> 95 156 <div class="mb-3 flex items-center gap-2 text-xs font-medium tracking-[0.04em] text-primary"> ··· 110 171 </a> 111 172 112 173 <div class="min-w-0 flex-1"> 113 - <header class="mb-2 flex flex-wrap items-center gap-x-2 gap-y-1"> 114 - <a 115 - class="wrap-break-word text-base font-semibold tracking-[-0.01em] text-on-surface no-underline transition hover:text-primary" 116 - href={`#${profileHref()}`} 117 - onClick={(event) => event.stopPropagation()}> 118 - {authorName()} 119 - </a> 120 - <a 121 - class="break-all text-xs text-on-surface-variant no-underline transition hover:text-primary" 122 - href={`#${profileHref()}`} 123 - onClick={(event) => event.stopPropagation()}> 124 - @{props.post.author.handle.replace(/^@/, "")} 125 - </a> 126 - <span class="text-xs text-on-surface-variant">{createdAt()}</span> 127 - </header> 174 + <PostPrimaryRegion onFocus={props.onFocus} onOpenThread={props.onOpenThread}> 175 + <PostHeader 176 + authorName={authorName()} 177 + createdAt={createdAt()} 178 + profileHref={profileHref()} 179 + post={props.post} /> 128 180 129 - <Show when={getPostText(props.post)}> 130 - {(text) => ( 131 - <p class="m-0 whitespace-pre-wrap wrap-break-word text-base leading-[1.65] text-on-secondary-container"> 132 - <LinkifiedText text={text()} /> 133 - </p> 134 - )} 135 - </Show> 181 + <Show when={getPostText(props.post)}> 182 + {(text) => ( 183 + <p class="m-0 whitespace-pre-wrap wrap-break-word text-base leading-[1.65] text-on-secondary-container"> 184 + <LinkifiedText text={text()} /> 185 + </p> 186 + )} 187 + </Show> 136 188 137 - <PostEmbeds post={props.post} /> 189 + <PostEmbeds post={props.post} /> 190 + </PostPrimaryRegion> 138 191 139 192 <Show when={props.showActions !== false}> 140 193 <PostActions 194 + bookmarkPending={!!props.bookmarkPending} 195 + isBookmarked={isBookmarked()} 141 196 isLiked={isLiked()} 142 197 isReposted={isReposted()} 143 198 likeCount={likeCount()} 144 199 likePending={!!props.likePending} 200 + menuOpen={menuOpen()} 145 201 pulseLike={!!props.pulseLike} 146 202 pulseRepost={!!props.pulseRepost} 147 203 replyCount={replyCount()} 148 204 repostCount={repostCount()} 149 205 repostPending={!!props.repostPending} 206 + triggerRef={(element) => { 207 + menuTriggerRef = element; 208 + }} 209 + onBookmark={props.onBookmark} 150 210 onLike={props.onLike} 211 + onMenuOpen={openMenuFromTrigger} 151 212 onOpenThread={props.onOpenThread} 152 213 onQuote={props.onQuote} 153 214 onReply={props.onReply} ··· 155 216 </Show> 156 217 </div> 157 218 </div> 219 + 220 + <ContextMenu 221 + anchor={menuAnchor()} 222 + items={menuItems()} 223 + label="Post actions" 224 + open={menuOpen()} 225 + returnFocusTo={menuTriggerRef} 226 + onClose={closeMenu} /> 158 227 </article> 159 228 ); 160 229 } 161 230 231 + function PostHeader(props: { authorName: string; createdAt: string; post: PostView; profileHref: string }) { 232 + return ( 233 + <header class="mb-2 flex flex-wrap items-center gap-x-2 gap-y-1"> 234 + <a 235 + class="wrap-break-word text-base font-semibold tracking-[-0.01em] text-on-surface no-underline transition hover:text-primary" 236 + href={`#${props.profileHref}`} 237 + onClick={(event) => event.stopPropagation()}> 238 + {props.authorName} 239 + </a> 240 + <a 241 + class="break-all text-xs text-on-surface-variant no-underline transition hover:text-primary" 242 + href={`#${props.profileHref}`} 243 + onClick={(event) => event.stopPropagation()}> 244 + @{props.post.author.handle.replace(/^@/, "")} 245 + </a> 246 + <span class="text-xs text-on-surface-variant">{props.createdAt}</span> 247 + </header> 248 + ); 249 + } 250 + 251 + function PostPrimaryRegion(props: { children: JSX.Element; onFocus?: () => void; onOpenThread?: () => void }) { 252 + const interactive = () => !!props.onOpenThread; 253 + 254 + return ( 255 + <div 256 + class="min-w-0 rounded-2xl outline-none transition duration-150 ease-out" 257 + classList={{ 258 + "cursor-pointer hover:bg-white/2 focus-visible:bg-white/3 focus-visible:ring-1 focus-visible:ring-primary/30": 259 + interactive(), 260 + }} 261 + aria-label={interactive() ? "Open thread" : undefined} 262 + role={interactive() ? "button" : undefined} 263 + tabIndex={interactive() ? 0 : undefined} 264 + onClick={() => props.onOpenThread?.()} 265 + onFocus={() => props.onFocus?.()} 266 + onKeyDown={(event) => { 267 + if ((event.key === "Enter" || event.key === " ") && props.onOpenThread) { 268 + event.preventDefault(); 269 + props.onOpenThread(); 270 + } 271 + }}> 272 + {props.children} 273 + </div> 274 + ); 275 + } 276 + 162 277 function PostActions( 163 278 props: { 279 + bookmarkPending: boolean; 280 + isBookmarked: boolean; 164 281 isLiked: boolean; 165 282 isReposted: boolean; 166 283 likeCount: string; 167 284 likePending: boolean; 285 + menuOpen: boolean; 168 286 pulseLike: boolean; 169 287 pulseRepost: boolean; 170 288 replyCount: string; 171 289 repostCount: string; 172 290 repostPending: boolean; 291 + triggerRef: (element: HTMLButtonElement) => void; 292 + onBookmark?: () => void; 173 293 onLike?: () => void; 294 + onMenuOpen: (element: HTMLButtonElement) => void; 174 295 onOpenThread?: () => void; 175 296 onQuote?: () => void; 176 297 onReply?: () => void; ··· 196 317 label={props.repostCount} 197 318 pulse={props.pulseRepost} 198 319 onClick={props.onRepost} /> 320 + <ActionButton 321 + active={props.isBookmarked} 322 + busy={props.bookmarkPending} 323 + icon="i-ri-bookmark-line" 324 + iconActive="i-ri-bookmark-fill" 325 + label={props.isBookmarked ? "Saved" : "Save"} 326 + onClick={props.onBookmark} /> 199 327 <ActionButton icon="i-ri-chat-quote-line" label="Quote" onClick={props.onQuote} /> 200 328 <ActionButton icon="i-ri-node-tree" label="Thread" onClick={props.onOpenThread} /> 329 + <button 330 + aria-label="More actions" 331 + ref={(element) => props.triggerRef(element)} 332 + aria-expanded={props.menuOpen} 333 + aria-haspopup="menu" 334 + class="inline-flex items-center justify-center rounded-full border-0 bg-transparent px-3 py-2 text-xs text-on-surface-variant transition duration-150 ease-out hover:-translate-y-px hover:bg-white/5 hover:text-primary max-[520px]:px-2.5" 335 + type="button" 336 + onClick={(event) => { 337 + event.stopPropagation(); 338 + props.onMenuOpen(event.currentTarget); 339 + }}> 340 + <Icon aria-hidden="true" iconClass="i-ri-more-fill" /> 341 + </button> 201 342 </footer> 202 343 ); 203 344 } ··· 236 377 classList={{ "text-primary": !!props.active }} 237 378 type="button" 238 379 disabled={props.busy} 239 - onClick={() => props.onClick?.()}> 380 + onClick={(event) => { 381 + event.stopPropagation(); 382 + props.onClick?.(); 383 + }}> 240 384 <Motion.span 241 385 class="flex items-center" 242 386 animate={{ scale: props.pulse ? [1, 1.3, 1] : 1 }} ··· 304 448 class="grid min-w-0 gap-3 overflow-hidden rounded-2xl bg-black/30 p-3 text-inherit no-underline shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)] transition duration-150 ease-out hover:bg-black/40" 305 449 href={props.uri} 306 450 rel="noreferrer" 307 - target="_blank"> 451 + target="_blank" 452 + onClick={(event) => event.stopPropagation()}> 308 453 <Show when={props.thumb}> 309 454 {(thumb) => <img class="max-h-64 w-full rounded-2xl object-cover" src={thumb()} alt="" />} 310 455 </Show> ··· 329 474 330 475 function QuoteEmbed(props: { author: ProfileViewBasic | null; text?: unknown; title: string }) { 331 476 const preview = createMemo(() => (typeof props.text === "string" ? props.text : "")); 332 - const title = () => props.title; 333 477 334 478 return ( 335 479 <div class="rounded-2xl bg-black/30 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"> 336 - <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{title()}</p> 480 + <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.title}</p> 337 481 <Show when={props.author}> 338 482 {(author) => ( 339 483 <p class="mt-2 wrap-break-word text-sm font-semibold text-on-surface"> ··· 359 503 {(part) => ( 360 504 <Switch fallback={<span class="wrap-anywhere">{part}</span>}> 361 505 <Match when={/^https?:\/\//i.test(part)}> 362 - <a class="break-all text-primary no-underline hover:underline" href={part} rel="noreferrer" target="_blank"> 506 + <a 507 + class="break-all text-primary no-underline hover:underline" 508 + href={part} 509 + rel="noreferrer" 510 + target="_blank" 511 + onClick={(event) => event.stopPropagation()}> 363 512 {part} 364 513 </a> 365 514 </Match> ··· 371 520 </For> 372 521 ); 373 522 } 523 + 524 + function isInteractiveTarget(target: EventTarget | null) { 525 + return target instanceof Element && !!target.closest("a, button, input, textarea, select, [role='menuitem']"); 526 + }
-194
src/components/feeds/ThreadPanel.tsx
··· 1 - import { Icon } from "$/components/shared/Icon"; 2 - import { findRootPost, isBlockedNode, isNotFoundNode, isThreadViewPost } from "$/lib/feeds"; 3 - import type { PostView, ThreadNode } from "$/lib/types"; 4 - import { createMemo, For, Match, Show, Switch } from "solid-js"; 5 - import { Motion, Presence } from "solid-motionone"; 6 - import { PostCard } from "./PostCard"; 7 - 8 - type ThreadPanelProps = { 9 - activeUri: string | null; 10 - error: string | null; 11 - loading: boolean; 12 - onClose: () => void; 13 - onLike: (post: PostView) => void; 14 - onQuote: (post: PostView) => void; 15 - onReply: (post: PostView, root: PostView) => void; 16 - onRepost: (post: PostView) => void; 17 - onOpenThread: (uri: string) => void; 18 - thread: ThreadNode | null; 19 - }; 20 - 21 - export function ThreadPanel(props: ThreadPanelProps) { 22 - const rootPost = createMemo(() => findRootPost(props.thread)); 23 - 24 - return ( 25 - <Presence> 26 - <Show when={props.activeUri}> 27 - <Motion.aside 28 - class="fixed inset-y-0 right-0 z-40 grid w-full max-w-136 grid-rows-[auto_minmax(0,1fr)] overflow-hidden bg-[rgba(12,12,12,0.92)] px-5 pb-6 pt-5 backdrop-blur-[22px] shadow-[-28px_0_50px_rgba(0,0,0,0.35)]" 29 - initial={{ opacity: 0, x: 30 }} 30 - animate={{ opacity: 1, x: 0 }} 31 - exit={{ opacity: 0, x: 36 }} 32 - transition={{ duration: 0.22 }}> 33 - <ThreadPanelHeader onClose={props.onClose} /> 34 - <div class="min-h-0 overflow-y-auto overscroll-contain pb-1"> 35 - <ThreadPanelLoading loading={props.loading} /> 36 - 37 - <Show when={!props.loading && props.error}> 38 - {(message) => ( 39 - <div class="rounded-3xl bg-[rgba(138,31,31,0.2)] p-4 text-sm text-error shadow-[inset_0_0_0_1px_rgba(255,128,128,0.2)]"> 40 - {message()} 41 - </div> 42 - )} 43 - </Show> 44 - 45 - <Show when={!props.loading && props.thread && !props.error && rootPost()}> 46 - {(root) => ( 47 - <div class="grid gap-4"> 48 - <ThreadNodeView 49 - activeUri={props.activeUri} 50 - node={props.thread!} 51 - rootPost={root()} 52 - onLike={props.onLike} 53 - onOpenThread={props.onOpenThread} 54 - onQuote={props.onQuote} 55 - onReply={props.onReply} 56 - onRepost={props.onRepost} /> 57 - </div> 58 - )} 59 - </Show> 60 - </div> 61 - </Motion.aside> 62 - </Show> 63 - </Presence> 64 - ); 65 - } 66 - 67 - function ThreadPanelHeader(props: { onClose: () => void }) { 68 - return ( 69 - <header class="sticky top-0 z-10 mb-4 flex items-center justify-between rounded-3xl bg-[rgba(14,14,14,0.9)] px-4 py-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 70 - <div> 71 - <p class="m-0 text-base font-semibold text-on-surface">Thread</p> 72 - <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">Nested replies</p> 73 - </div> 74 - <button 75 - class="inline-flex h-10 w-10 items-center justify-center rounded-xl border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:bg-white/5 hover:text-on-surface" 76 - type="button" 77 - onClick={() => props.onClose()}> 78 - <Icon aria-hidden="true" iconClass="i-ri-close-line" /> 79 - </button> 80 - </header> 81 - ); 82 - } 83 - 84 - function ThreadPanelLoading(props: { loading: boolean }) { 85 - return ( 86 - <Show when={props.loading}> 87 - <div class="grid gap-3"> 88 - <SkeletonThreadCard /> 89 - <SkeletonThreadCard /> 90 - </div> 91 - </Show> 92 - ); 93 - } 94 - 95 - function ThreadNodeView( 96 - props: { 97 - activeUri: string | null; 98 - node: ThreadNode; 99 - onLike: (post: PostView) => void; 100 - onOpenThread: (uri: string) => void; 101 - onQuote: (post: PostView) => void; 102 - onReply: (post: PostView, root: PostView) => void; 103 - onRepost: (post: PostView) => void; 104 - rootPost: PostView; 105 - }, 106 - ) { 107 - const node = createMemo(() => (isThreadViewPost(props.node) ? props.node : null)); 108 - 109 - return ( 110 - <Switch> 111 - <Match when={isBlockedNode(props.node)}> 112 - <StateCard label="Blocked post" meta={isBlockedNode(props.node) ? props.node.uri : ""} /> 113 - </Match> 114 - <Match when={isNotFoundNode(props.node)}> 115 - <StateCard label="Post not found" meta={isNotFoundNode(props.node) ? props.node.uri : ""} /> 116 - </Match> 117 - <Match when={node()}> 118 - {(threadNode) => ( 119 - <div class="grid gap-4"> 120 - <Show when={threadNode().parent}> 121 - {(parent) => ( 122 - <div class="rounded-3xl bg-white/3 p-3"> 123 - <ThreadNodeView 124 - activeUri={props.activeUri} 125 - node={parent()} 126 - rootPost={props.rootPost} 127 - onLike={props.onLike} 128 - onOpenThread={props.onOpenThread} 129 - onQuote={props.onQuote} 130 - onReply={props.onReply} 131 - onRepost={props.onRepost} /> 132 - </div> 133 - )} 134 - </Show> 135 - 136 - <PostCard 137 - focused={threadNode().post.uri === props.activeUri} 138 - post={threadNode().post} 139 - onLike={() => props.onLike(threadNode().post)} 140 - onOpenThread={() => props.onOpenThread(threadNode().post.uri)} 141 - onQuote={() => props.onQuote(threadNode().post)} 142 - onReply={() => props.onReply(threadNode().post, props.rootPost)} 143 - onRepost={() => props.onRepost(threadNode().post)} /> 144 - 145 - <Show when={threadNode().replies?.length}> 146 - <div class="grid gap-4 rounded-3xl bg-white/3 p-3"> 147 - <For each={threadNode().replies}> 148 - {(reply) => ( 149 - <ThreadNodeView 150 - activeUri={props.activeUri} 151 - node={reply} 152 - rootPost={props.rootPost} 153 - onLike={props.onLike} 154 - onOpenThread={props.onOpenThread} 155 - onQuote={props.onQuote} 156 - onReply={props.onReply} 157 - onRepost={props.onRepost} /> 158 - )} 159 - </For> 160 - </div> 161 - </Show> 162 - </div> 163 - )} 164 - </Match> 165 - </Switch> 166 - ); 167 - } 168 - 169 - function StateCard(props: { label: string; meta: string }) { 170 - return ( 171 - <div class="rounded-3xl bg-white/3 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 172 - <p class="m-0 text-sm font-semibold text-on-surface">{props.label}</p> 173 - <p class="mt-1 text-xs text-on-surface-variant">{props.meta}</p> 174 - </div> 175 - ); 176 - } 177 - 178 - function SkeletonThreadCard() { 179 - return ( 180 - <div class="rounded-3xl bg-white/3 p-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 181 - <div class="flex gap-3"> 182 - <div class="skeleton-block h-11 w-11 rounded-full" /> 183 - <div class="min-w-0 flex-1"> 184 - <div class="skeleton-block h-4 w-40 rounded-full" /> 185 - <div class="mt-3 grid gap-2"> 186 - <div class="skeleton-block h-3.5 w-full rounded-full" /> 187 - <div class="skeleton-block h-3.5 w-[82%] rounded-full" /> 188 - <div class="skeleton-block h-3.5 w-[68%] rounded-full" /> 189 - </div> 190 - </div> 191 - </div> 192 - </div> 193 - ); 194 - }
+1 -15
src/components/feeds/types.ts
··· 1 - import type { 2 - FeedGeneratorView, 3 - FeedViewPost, 4 - FeedViewPrefItem, 5 - PostView, 6 - ThreadNode, 7 - UserPreferences, 8 - } from "$/lib/types"; 1 + import type { FeedGeneratorView, FeedViewPost, FeedViewPrefItem, PostView, UserPreferences } from "$/lib/types"; 9 2 10 3 export type FeedState = { 11 4 cursor: string | null; ··· 24 17 text: string; 25 18 }; 26 19 27 - export type ThreadState = { data: ThreadNode | null; error: string | null; loading: boolean; uri: string | null }; 28 - 29 20 export type FeedWorkspaceState = { 30 21 activeFeedId: string | null; 31 22 composer: ComposerState; ··· 33 24 feedScrollTops: Record<string, number>; 34 25 focusedIndex: number; 35 26 generators: Record<string, FeedGeneratorView>; 36 - likePendingByUri: Record<string, boolean>; 37 - likePulseUri: string | null; 38 27 localPrefs: Record<string, FeedViewPrefItem>; 39 28 preferences: UserPreferences | null; 40 - repostPendingByUri: Record<string, boolean>; 41 - repostPulseUri: string | null; 42 29 showFeedsDrawer: boolean; 43 - thread: ThreadState; 44 30 };
+14 -128
src/components/feeds/useFeedWorkspaceController.ts
··· 1 + import { usePostInteractions } from "$/components/posts/usePostInteractions"; 1 2 import { 2 3 createPost, 3 4 getFeedGenerators, 4 5 getFeedPage, 5 - getPostThread, 6 6 getPreferences, 7 - likePost, 8 - repost, 9 - unlikePost, 10 - unrepost, 11 7 updateFeedViewPref, 12 8 updateSavedFeeds, 13 9 } from "$/lib/api/feeds"; ··· 19 15 getFeedName, 20 16 getReplyRootPost, 21 17 patchFeedItems, 22 - patchThreadNode, 23 18 toStrongRef, 24 19 } from "$/lib/feeds"; 25 20 import type { ActiveSession, EmbedInput, FeedViewPrefItem, PostView, ReplyRefInput, SavedFeedItem } from "$/lib/types"; ··· 33 28 buildLocalPrefs, 34 29 createDefaultFeedPref, 35 30 createDefaultFeedState, 36 - createDefaultThreadState, 37 31 createInitialWorkspaceState, 38 32 DEFAULT_TIMELINE, 39 33 getFeedScrollTop, ··· 46 40 export type FeedWorkspaceProps = { 47 41 activeSession: ActiveSession; 48 42 onError: (message: string) => void; 49 - onThreadRouteChange: (uri: string | null) => void; 50 - threadUri: string | null; 43 + onOpenThread: (uri: string) => void; 51 44 }; 52 45 53 46 const DEFAULT_LIMIT = 30; 54 47 55 48 export function useFeedWorkspaceController(props: FeedWorkspaceProps) { 56 49 const [workspace, setWorkspace] = createStore<FeedWorkspaceState>(createInitialWorkspaceState()); 50 + const interactions = usePostInteractions({ onError: props.onError, patchPost }); 51 + const toggleBookmark = interactions.toggleBookmark; 52 + const toggleLike = interactions.toggleLike; 53 + const toggleRepost = interactions.toggleRepost; 57 54 58 55 let scroller: HTMLDivElement | undefined; 59 56 let sentinel: HTMLDivElement | undefined; ··· 125 122 } 126 123 }); 127 124 }); 128 - }); 129 - 130 - createEffect(() => { 131 - const uri = props.threadUri; 132 - if (!uri) { 133 - if (workspace.thread.uri || workspace.thread.data || workspace.thread.error || workspace.thread.loading) { 134 - setWorkspace("thread", reconcile(createDefaultThreadState())); 135 - } 136 - return; 137 - } 138 - 139 - if (workspace.thread.uri === uri && (workspace.thread.data || workspace.thread.error || workspace.thread.loading)) { 140 - return; 141 - } 142 - 143 - void loadThread(uri); 144 125 }); 145 126 146 127 createEffect(() => { ··· 393 374 } 394 375 } 395 376 396 - async function loadThread(uri: string) { 397 - setWorkspace("thread", { data: null, error: null, loading: true, uri }); 398 - 399 - try { 400 - const payload = await getPostThread(uri); 401 - if (props.threadUri === uri) { 402 - setWorkspace("thread", { data: payload.thread, error: null, loading: false, uri }); 403 - } 404 - } catch (error) { 405 - if (props.threadUri === uri) { 406 - setWorkspace("thread", { data: null, error: String(error), loading: false, uri }); 407 - } 408 - props.onError(`Failed to open thread: ${String(error)}`); 409 - } 410 - } 411 - 412 377 function switchFeed(feedId: string) { 413 378 const current = activeFeed(); 414 379 if (current && scroller) { ··· 423 388 setWorkspace("showFeedsDrawer", false); 424 389 } 425 390 426 - async function openThread(uri: string) { 427 - if (props.threadUri === uri) { 428 - await loadThread(uri); 429 - return; 430 - } 431 - 432 - props.onThreadRouteChange(uri); 391 + function openThread(uri: string) { 392 + props.onOpenThread(uri); 433 393 } 434 394 435 395 function openComposer() { ··· 498 458 try { 499 459 await createPost(text, replyTo, embed); 500 460 resetComposer(); 501 - props.onThreadRouteChange(null); 502 461 await refreshActiveFeed(); 503 462 } catch (error) { 504 463 props.onError(`Failed to create post: ${String(error)}`); ··· 519 478 } 520 479 } 521 480 522 - async function toggleLike(post: PostView) { 523 - setWorkspace("likePendingByUri", post.uri, true); 524 - try { 525 - if (post.viewer?.like) { 526 - await unlikePost(post.viewer.like); 527 - patchPost( 528 - post.uri, 529 - (current) => ({ 530 - ...current, 531 - likeCount: Math.max(0, (current.likeCount ?? 0) - 1), 532 - viewer: { ...current.viewer, like: null }, 533 - }), 534 - ); 535 - } else { 536 - const result = await likePost(post.uri, post.cid); 537 - patchPost( 538 - post.uri, 539 - (current) => ({ 540 - ...current, 541 - likeCount: (current.likeCount ?? 0) + 1, 542 - viewer: { ...current.viewer, like: result.uri }, 543 - }), 544 - ); 545 - triggerLikePulse(post.uri); 546 - } 547 - } catch (error) { 548 - props.onError(`Failed to update like: ${String(error)}`); 549 - } finally { 550 - setWorkspace("likePendingByUri", post.uri, false); 551 - } 552 - } 553 - 554 - async function toggleRepost(post: PostView) { 555 - setWorkspace("repostPendingByUri", post.uri, true); 556 - try { 557 - if (post.viewer?.repost) { 558 - await unrepost(post.viewer.repost); 559 - patchPost( 560 - post.uri, 561 - (current) => ({ 562 - ...current, 563 - repostCount: Math.max(0, (current.repostCount ?? 0) - 1), 564 - viewer: { ...current.viewer, repost: null }, 565 - }), 566 - ); 567 - } else { 568 - const result = await repost(post.uri, post.cid); 569 - patchPost( 570 - post.uri, 571 - (current) => ({ 572 - ...current, 573 - repostCount: (current.repostCount ?? 0) + 1, 574 - viewer: { ...current.viewer, repost: result.uri }, 575 - }), 576 - ); 577 - triggerRepostPulse(post.uri); 578 - } 579 - } catch (error) { 580 - props.onError(`Failed to update repost: ${String(error)}`); 581 - } finally { 582 - setWorkspace("repostPendingByUri", post.uri, false); 583 - } 584 - } 585 - 586 481 function patchPost(uri: string, updater: (post: PostView) => PostView) { 587 482 for (const [feedId, state] of Object.entries(workspace.feedStates)) { 588 483 if (!state) { ··· 591 486 592 487 setWorkspace("feedStates", feedId, "items", patchFeedItems(state.items, uri, updater)); 593 488 } 594 - 595 - const currentThread = workspace.thread.data; 596 - if (currentThread) { 597 - setWorkspace("thread", "data", patchThreadNode(currentThread, uri, updater)); 598 - } 599 - } 600 - 601 - function triggerLikePulse(uri: string) { 602 - setWorkspace("likePulseUri", uri); 603 - globalThis.setTimeout(() => setWorkspace("likePulseUri", (current) => (current === uri ? null : current)), 320); 604 - } 605 - 606 - function triggerRepostPulse(uri: string) { 607 - setWorkspace("repostPulseUri", uri); 608 - globalThis.setTimeout(() => setWorkspace("repostPulseUri", (current) => (current === uri ? null : current)), 320); 609 489 } 610 490 611 491 async function saveFeedPreferences(updatedFeeds: SavedFeedItem[]) { ··· 717 597 submitPost, 718 598 switchFeed, 719 599 toggleFeedsDrawer, 600 + toggleBookmark, 720 601 toggleLike, 721 602 toggleRepost, 722 603 unpinFeed, 723 604 visibleItems, 724 605 workspace, 606 + bookmarkPendingByUri: interactions.bookmarkPendingByUri, 607 + likePendingByUri: interactions.likePendingByUri, 608 + likePulseUri: interactions.likePulseUri, 609 + repostPendingByUri: interactions.repostPendingByUri, 610 + repostPulseUri: interactions.repostPulseUri, 725 611 }; 726 612 } 727 613
+1 -8
src/components/feeds/workspace-state.ts
··· 1 1 import type { FeedViewPrefItem, FeedViewPrefs, SavedFeedItem, UserPreferences } from "$/lib/types"; 2 - import type { FeedState, FeedWorkspaceState, ThreadState } from "./types"; 2 + import type { FeedState, FeedWorkspaceState } from "./types"; 3 3 4 4 export const DEFAULT_TIMELINE: SavedFeedItem = { id: "following", type: "timeline", value: "following", pinned: true }; 5 5 6 6 export function createDefaultFeedState(): FeedState { 7 7 return { cursor: null, error: null, items: [], loading: false, loadingMore: false }; 8 8 } 9 - 10 - export const createDefaultThreadState = (): ThreadState => ({ data: null, error: null, loading: false, uri: null }); 11 9 12 10 export const createDefaultFeedPref = (feed: SavedFeedItem): FeedViewPrefItem => ({ 13 11 feed: feed.value, ··· 26 24 feedScrollTops: {}, 27 25 focusedIndex: 0, 28 26 generators: {}, 29 - likePendingByUri: {}, 30 - likePulseUri: null, 31 27 localPrefs: {}, 32 28 preferences: null, 33 - repostPendingByUri: {}, 34 - repostPulseUri: null, 35 29 showFeedsDrawer: false, 36 - thread: createDefaultThreadState(), 37 30 }; 38 31 } 39 32
+71
src/components/posts/ThreadModal.test.tsx
··· 1 + import { AppTestProviders } from "$/test/providers"; 2 + import { HashRouter, Route } from "@solidjs/router"; 3 + import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 4 + import { describe, expect, it, vi } from "vitest"; 5 + import { ThreadModal } from "./ThreadModal"; 6 + 7 + const invokeMock = vi.hoisted(() => vi.fn()); 8 + 9 + vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); 10 + 11 + function createThreadPayload() { 12 + return { 13 + thread: { 14 + $type: "app.bsky.feed.defs#threadViewPost", 15 + post: { 16 + author: { did: "did:plc:alice", handle: "alice.test", displayName: "Alice" }, 17 + cid: "cid-post", 18 + indexedAt: "2026-03-28T12:00:00.000Z", 19 + likeCount: 4, 20 + record: { createdAt: "2026-03-28T12:00:00.000Z", text: "Thread root" }, 21 + replyCount: 2, 22 + repostCount: 1, 23 + uri: "at://did:plc:alice/app.bsky.feed.post/123", 24 + viewer: {}, 25 + }, 26 + replies: [], 27 + }, 28 + }; 29 + } 30 + 31 + describe("ThreadModal", () => { 32 + it("opens from the thread query param on top of the current route and closes without changing the base path", async () => { 33 + invokeMock.mockImplementation((command: string) => { 34 + if (command === "get_post_thread") { 35 + return Promise.resolve(createThreadPayload()); 36 + } 37 + 38 + throw new Error(`unexpected invoke: ${command}`); 39 + }); 40 + 41 + globalThis.location.hash = "#/profile/alice?thread=at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2F123"; 42 + 43 + render(() => ( 44 + <AppTestProviders 45 + session={{ 46 + activeDid: "did:plc:alice", 47 + activeHandle: "alice.test", 48 + activeSession: { did: "did:plc:alice", handle: "alice.test" }, 49 + }}> 50 + <HashRouter> 51 + <Route 52 + path="/profile/:actor" 53 + component={() => ( 54 + <> 55 + <div data-testid="profile-screen">profile underneath</div> 56 + <ThreadModal /> 57 + </> 58 + )} /> 59 + </HashRouter> 60 + </AppTestProviders> 61 + )); 62 + 63 + expect(await screen.findByText("Thread root")).toBeInTheDocument(); 64 + expect(screen.getByTestId("profile-screen")).toBeInTheDocument(); 65 + 66 + fireEvent.click(screen.getByRole("button", { name: "Close thread" })); 67 + 68 + await waitFor(() => expect(globalThis.location.hash).toBe("#/profile/alice")); 69 + expect(screen.queryByText("Thread root")).not.toBeInTheDocument(); 70 + }); 71 + });
+308
src/components/posts/ThreadModal.tsx
··· 1 + import { Icon } from "$/components/shared/Icon"; 2 + import { useAppSession } from "$/contexts/app-session"; 3 + import { getPostThread } from "$/lib/api/feeds"; 4 + import { findRootPost, isBlockedNode, isNotFoundNode, isThreadViewPost, patchThreadNode } from "$/lib/feeds"; 5 + import type { PostView, ThreadNode } from "$/lib/types"; 6 + import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js"; 7 + import { createStore } from "solid-js/store"; 8 + import { Motion, Presence } from "solid-motionone"; 9 + import { PostCard } from "../feeds/PostCard"; 10 + import { usePostInteractions } from "./usePostInteractions"; 11 + import { useThreadOverlayNavigation } from "./useThreadOverlayNavigation"; 12 + 13 + type ThreadModalState = { error: string | null; loading: boolean; thread: ThreadNode | null; uri: string | null }; 14 + 15 + function createThreadModalState(): ThreadModalState { 16 + return { error: null, loading: false, thread: null, uri: null }; 17 + } 18 + 19 + export function ThreadModal() { 20 + const session = useAppSession(); 21 + const threadOverlay = useThreadOverlayNavigation(); 22 + const [state, setState] = createStore<ThreadModalState>(createThreadModalState()); 23 + const rootPost = createMemo(() => findRootPost(state.thread)); 24 + const interactions = usePostInteractions({ 25 + onError: session.reportError, 26 + patchPost(uri, updater) { 27 + const current = state.thread; 28 + if (!current) { 29 + return; 30 + } 31 + 32 + setState("thread", patchThreadNode(current, uri, updater)); 33 + }, 34 + }); 35 + 36 + createEffect(() => { 37 + const uri = threadOverlay.threadUri(); 38 + if (!uri) { 39 + if (state.uri || state.thread || state.error || state.loading) { 40 + setState(createThreadModalState()); 41 + } 42 + return; 43 + } 44 + 45 + if (state.uri === uri && (state.loading || state.thread || state.error)) { 46 + return; 47 + } 48 + 49 + void loadThread(uri); 50 + }); 51 + 52 + createEffect(() => { 53 + if (!threadOverlay.threadUri()) { 54 + return; 55 + } 56 + 57 + const handleKeyDown = (event: KeyboardEvent) => { 58 + if (event.key === "Escape") { 59 + event.preventDefault(); 60 + void threadOverlay.closeThread(); 61 + } 62 + }; 63 + 64 + globalThis.addEventListener("keydown", handleKeyDown); 65 + onCleanup(() => globalThis.removeEventListener("keydown", handleKeyDown)); 66 + }); 67 + 68 + async function loadThread(uri: string) { 69 + setState({ error: null, loading: true, thread: null, uri }); 70 + 71 + try { 72 + const payload = await getPostThread(uri); 73 + if (threadOverlay.threadUri() === uri) { 74 + setState({ error: null, loading: false, thread: payload.thread, uri }); 75 + } 76 + } catch (error) { 77 + if (threadOverlay.threadUri() === uri) { 78 + setState({ error: String(error), loading: false, thread: null, uri }); 79 + } 80 + session.reportError(`Failed to open thread: ${String(error)}`); 81 + } 82 + } 83 + 84 + return ( 85 + <Presence> 86 + <Show when={threadOverlay.threadUri()}> 87 + <div class="fixed inset-0 z-50"> 88 + <Motion.button 89 + class="absolute inset-0 border-0 bg-surface-container-highest/70 backdrop-blur-xl" 90 + type="button" 91 + aria-label="Close thread" 92 + initial={{ opacity: 0 }} 93 + animate={{ opacity: 1 }} 94 + exit={{ opacity: 0 }} 95 + transition={{ duration: 0.2 }} 96 + onClick={() => void threadOverlay.closeThread()} /> 97 + <Motion.aside 98 + class="absolute inset-y-0 right-0 grid w-full max-w-136 grid-rows-[auto_minmax(0,1fr)] overflow-hidden bg-[rgba(12,12,12,0.92)] px-5 pb-6 pt-5 shadow-[-28px_0_50px_rgba(0,0,0,0.35)] backdrop-blur-[22px]" 99 + initial={{ opacity: 0, x: 30 }} 100 + animate={{ opacity: 1, x: 0 }} 101 + exit={{ opacity: 0, x: 36 }} 102 + transition={{ duration: 0.22 }}> 103 + <ThreadModalHeader onClose={() => void threadOverlay.closeThread()} /> 104 + <ThreadModalBody 105 + activeUri={threadOverlay.threadUri()} 106 + bookmarkPendingByUri={interactions.bookmarkPendingByUri()} 107 + error={state.error} 108 + likePendingByUri={interactions.likePendingByUri()} 109 + loading={state.loading} 110 + onBookmark={(post) => void interactions.toggleBookmark(post)} 111 + onLike={(post) => void interactions.toggleLike(post)} 112 + onOpenThread={(uri) => void threadOverlay.openThread(uri)} 113 + onRepost={(post) => void interactions.toggleRepost(post)} 114 + repostPendingByUri={interactions.repostPendingByUri()} 115 + rootPost={rootPost()} 116 + thread={state.thread} /> 117 + </Motion.aside> 118 + </div> 119 + </Show> 120 + </Presence> 121 + ); 122 + } 123 + 124 + function ThreadModalBody( 125 + props: { 126 + activeUri: string | null; 127 + bookmarkPendingByUri: Record<string, boolean>; 128 + error: string | null; 129 + likePendingByUri: Record<string, boolean>; 130 + loading: boolean; 131 + onBookmark: (post: PostView) => void; 132 + onLike: (post: PostView) => void; 133 + onOpenThread: (uri: string) => void; 134 + onRepost: (post: PostView) => void; 135 + repostPendingByUri: Record<string, boolean>; 136 + rootPost: PostView | null; 137 + thread: ThreadNode | null; 138 + }, 139 + ) { 140 + return ( 141 + <div class="min-h-0 overflow-y-auto overscroll-contain pb-1"> 142 + <ThreadModalLoading loading={props.loading} /> 143 + 144 + <Show when={!props.loading && props.error}> 145 + {(message) => ( 146 + <div class="rounded-3xl bg-[rgba(138,31,31,0.2)] p-4 text-sm text-error shadow-[inset_0_0_0_1px_rgba(255,128,128,0.2)]"> 147 + {message()} 148 + </div> 149 + )} 150 + </Show> 151 + 152 + <Show when={!props.loading && props.thread && !props.error && props.rootPost}> 153 + {(root) => ( 154 + <div class="grid gap-4"> 155 + <ThreadNodeView 156 + activeUri={props.activeUri} 157 + bookmarkPendingByUri={props.bookmarkPendingByUri} 158 + likePendingByUri={props.likePendingByUri} 159 + node={props.thread!} 160 + onBookmark={props.onBookmark} 161 + onLike={props.onLike} 162 + onOpenThread={props.onOpenThread} 163 + onRepost={props.onRepost} 164 + repostPendingByUri={props.repostPendingByUri} 165 + rootPost={root()} /> 166 + </div> 167 + )} 168 + </Show> 169 + </div> 170 + ); 171 + } 172 + 173 + function ThreadModalHeader(props: { onClose: () => void }) { 174 + return ( 175 + <header class="sticky top-0 z-10 mb-4 flex items-center justify-between rounded-3xl bg-[rgba(14,14,14,0.9)] px-4 py-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 176 + <div> 177 + <p class="m-0 text-base font-semibold text-on-surface">Thread</p> 178 + <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">Nested replies</p> 179 + </div> 180 + <button 181 + class="inline-flex h-10 w-10 items-center justify-center rounded-xl border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:bg-white/5 hover:text-on-surface" 182 + type="button" 183 + onClick={() => props.onClose()}> 184 + <Icon aria-hidden="true" iconClass="i-ri-close-line" /> 185 + </button> 186 + </header> 187 + ); 188 + } 189 + 190 + function ThreadModalLoading(props: { loading: boolean }) { 191 + return ( 192 + <Show when={props.loading}> 193 + <div class="grid gap-3"> 194 + <SkeletonThreadCard /> 195 + <SkeletonThreadCard /> 196 + </div> 197 + </Show> 198 + ); 199 + } 200 + 201 + function ThreadNodeView( 202 + props: { 203 + activeUri: string | null; 204 + bookmarkPendingByUri: Record<string, boolean>; 205 + likePendingByUri: Record<string, boolean>; 206 + node: ThreadNode; 207 + onBookmark: (post: PostView) => void; 208 + onLike: (post: PostView) => void; 209 + onOpenThread: (uri: string) => void; 210 + onRepost: (post: PostView) => void; 211 + repostPendingByUri: Record<string, boolean>; 212 + rootPost: PostView; 213 + }, 214 + ) { 215 + const node = createMemo(() => (isThreadViewPost(props.node) ? props.node : null)); 216 + 217 + return ( 218 + <Switch> 219 + <Match when={isBlockedNode(props.node)}> 220 + <StateCard label="Blocked post" meta={isBlockedNode(props.node) ? props.node.uri : ""} /> 221 + </Match> 222 + <Match when={isNotFoundNode(props.node)}> 223 + <StateCard label="Post not found" meta={isNotFoundNode(props.node) ? props.node.uri : ""} /> 224 + </Match> 225 + <Match when={node()}> 226 + {(threadNode) => ( 227 + <div class="grid gap-4"> 228 + <Show when={threadNode().parent}> 229 + {(parent) => ( 230 + <div class="rounded-3xl bg-white/3 p-3"> 231 + <ThreadNodeView 232 + activeUri={props.activeUri} 233 + bookmarkPendingByUri={props.bookmarkPendingByUri} 234 + likePendingByUri={props.likePendingByUri} 235 + node={parent()} 236 + onBookmark={props.onBookmark} 237 + onLike={props.onLike} 238 + onOpenThread={props.onOpenThread} 239 + onRepost={props.onRepost} 240 + repostPendingByUri={props.repostPendingByUri} 241 + rootPost={props.rootPost} /> 242 + </div> 243 + )} 244 + </Show> 245 + 246 + <PostCard 247 + bookmarkPending={!!props.bookmarkPendingByUri[threadNode().post.uri]} 248 + focused={threadNode().post.uri === props.activeUri} 249 + likePending={!!props.likePendingByUri[threadNode().post.uri]} 250 + onBookmark={() => props.onBookmark(threadNode().post)} 251 + onLike={() => props.onLike(threadNode().post)} 252 + onOpenThread={() => props.onOpenThread(threadNode().post.uri)} 253 + onRepost={() => props.onRepost(threadNode().post)} 254 + post={threadNode().post} 255 + repostPending={!!props.repostPendingByUri[threadNode().post.uri]} /> 256 + 257 + <Show when={threadNode().replies?.length}> 258 + <div class="grid gap-4 rounded-3xl bg-white/3 p-3"> 259 + <For each={threadNode().replies}> 260 + {(reply) => ( 261 + <ThreadNodeView 262 + activeUri={props.activeUri} 263 + bookmarkPendingByUri={props.bookmarkPendingByUri} 264 + likePendingByUri={props.likePendingByUri} 265 + node={reply} 266 + onBookmark={props.onBookmark} 267 + onLike={props.onLike} 268 + onOpenThread={props.onOpenThread} 269 + onRepost={props.onRepost} 270 + repostPendingByUri={props.repostPendingByUri} 271 + rootPost={props.rootPost} /> 272 + )} 273 + </For> 274 + </div> 275 + </Show> 276 + </div> 277 + )} 278 + </Match> 279 + </Switch> 280 + ); 281 + } 282 + 283 + function StateCard(props: { label: string; meta: string }) { 284 + return ( 285 + <div class="rounded-3xl bg-white/3 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 286 + <p class="m-0 text-sm font-semibold text-on-surface">{props.label}</p> 287 + <p class="mt-1 text-xs text-on-surface-variant">{props.meta}</p> 288 + </div> 289 + ); 290 + } 291 + 292 + function SkeletonThreadCard() { 293 + return ( 294 + <div class="rounded-3xl bg-white/3 p-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 295 + <div class="flex gap-3"> 296 + <div class="skeleton-block h-11 w-11 rounded-full" /> 297 + <div class="min-w-0 flex-1"> 298 + <div class="skeleton-block h-4 w-40 rounded-full" /> 299 + <div class="mt-3 grid gap-2"> 300 + <div class="skeleton-block h-3.5 w-full rounded-full" /> 301 + <div class="skeleton-block h-3.5 w-[82%] rounded-full" /> 302 + <div class="skeleton-block h-3.5 w-[68%] rounded-full" /> 303 + </div> 304 + </div> 305 + </div> 306 + </div> 307 + ); 308 + }
+206
src/components/posts/usePostInteractions.ts
··· 1 + import { bookmarkPost, likePost, removeBookmark, repost, unlikePost, unrepost } from "$/lib/api/feeds"; 2 + import { 3 + emitBookmarkChanged, 4 + emitPostViewUpdated, 5 + type PostViewUpdateDetail, 6 + subscribePostViewUpdated, 7 + } from "$/lib/post-events"; 8 + import type { PostView } from "$/lib/types"; 9 + import { onCleanup, onMount } from "solid-js"; 10 + import { createStore } from "solid-js/store"; 11 + 12 + type InteractionState = { 13 + bookmarkPendingByUri: Record<string, boolean>; 14 + likePendingByUri: Record<string, boolean>; 15 + likePulseUri: string | null; 16 + repostPendingByUri: Record<string, boolean>; 17 + repostPulseUri: string | null; 18 + }; 19 + 20 + type UsePostInteractionsProps = { 21 + onError: (message: string) => void; 22 + patchPost: (uri: string, updater: (post: PostView) => PostView) => void; 23 + }; 24 + 25 + export function usePostInteractions(props: UsePostInteractionsProps) { 26 + const [state, setState] = createStore<InteractionState>({ 27 + bookmarkPendingByUri: {}, 28 + likePendingByUri: {}, 29 + likePulseUri: null, 30 + repostPendingByUri: {}, 31 + repostPulseUri: null, 32 + }); 33 + 34 + onMount(() => { 35 + const dispose = subscribePostViewUpdated((detail) => { 36 + props.patchPost(detail.uri, (current) => applyEventPatch(current, detail)); 37 + }); 38 + onCleanup(dispose); 39 + }); 40 + 41 + async function toggleLike(post: PostView) { 42 + if (state.likePendingByUri[post.uri]) { 43 + return; 44 + } 45 + 46 + setState("likePendingByUri", post.uri, true); 47 + const previousLike = post.viewer?.like ?? null; 48 + const previousLikeCount = post.likeCount ?? 0; 49 + 50 + if (previousLike) { 51 + props.patchPost( 52 + post.uri, 53 + (current) => ({ 54 + ...current, 55 + likeCount: Math.max(0, (current.likeCount ?? 0) - 1), 56 + viewer: { ...current.viewer, like: null }, 57 + }), 58 + ); 59 + } else { 60 + props.patchPost( 61 + post.uri, 62 + (current) => ({ 63 + ...current, 64 + likeCount: (current.likeCount ?? 0) + 1, 65 + viewer: { ...current.viewer, like: "optimistic-like" }, 66 + }), 67 + ); 68 + triggerPulse("likePulseUri", post.uri); 69 + } 70 + 71 + try { 72 + if (previousLike) { 73 + await unlikePost(previousLike); 74 + emitPostViewUpdated({ likeCount: Math.max(0, previousLikeCount - 1), uri: post.uri, viewer: { like: null } }); 75 + } else { 76 + const result = await likePost(post.uri, post.cid); 77 + props.patchPost(post.uri, (current) => ({ ...current, viewer: { ...current.viewer, like: result.uri } })); 78 + emitPostViewUpdated({ likeCount: previousLikeCount + 1, uri: post.uri, viewer: { like: result.uri } }); 79 + } 80 + } catch (error) { 81 + props.patchPost( 82 + post.uri, 83 + (current) => ({ ...current, likeCount: previousLikeCount, viewer: { ...current.viewer, like: previousLike } }), 84 + ); 85 + props.onError(`Failed to update like: ${String(error)}`); 86 + } finally { 87 + setState("likePendingByUri", post.uri, false); 88 + } 89 + } 90 + 91 + async function toggleRepost(post: PostView) { 92 + if (state.repostPendingByUri[post.uri]) { 93 + return; 94 + } 95 + 96 + setState("repostPendingByUri", post.uri, true); 97 + const previousRepost = post.viewer?.repost ?? null; 98 + const previousRepostCount = post.repostCount ?? 0; 99 + 100 + if (previousRepost) { 101 + props.patchPost( 102 + post.uri, 103 + (current) => ({ 104 + ...current, 105 + repostCount: Math.max(0, (current.repostCount ?? 0) - 1), 106 + viewer: { ...current.viewer, repost: null }, 107 + }), 108 + ); 109 + } else { 110 + props.patchPost( 111 + post.uri, 112 + (current) => ({ 113 + ...current, 114 + repostCount: (current.repostCount ?? 0) + 1, 115 + viewer: { ...current.viewer, repost: "optimistic-repost" }, 116 + }), 117 + ); 118 + triggerPulse("repostPulseUri", post.uri); 119 + } 120 + 121 + try { 122 + if (previousRepost) { 123 + await unrepost(previousRepost); 124 + emitPostViewUpdated({ 125 + repostCount: Math.max(0, previousRepostCount - 1), 126 + uri: post.uri, 127 + viewer: { repost: null }, 128 + }); 129 + } else { 130 + const result = await repost(post.uri, post.cid); 131 + props.patchPost(post.uri, (current) => ({ ...current, viewer: { ...current.viewer, repost: result.uri } })); 132 + emitPostViewUpdated({ repostCount: previousRepostCount + 1, uri: post.uri, viewer: { repost: result.uri } }); 133 + } 134 + } catch (error) { 135 + props.patchPost( 136 + post.uri, 137 + (current) => ({ 138 + ...current, 139 + repostCount: previousRepostCount, 140 + viewer: { ...current.viewer, repost: previousRepost }, 141 + }), 142 + ); 143 + props.onError(`Failed to update repost: ${String(error)}`); 144 + } finally { 145 + setState("repostPendingByUri", post.uri, false); 146 + } 147 + } 148 + 149 + async function toggleBookmark(post: PostView) { 150 + if (state.bookmarkPendingByUri[post.uri]) { 151 + return; 152 + } 153 + 154 + setState("bookmarkPendingByUri", post.uri, true); 155 + const previousBookmarked = !!post.viewer?.bookmarked; 156 + 157 + props.patchPost( 158 + post.uri, 159 + (current) => ({ ...current, viewer: { ...current.viewer, bookmarked: !previousBookmarked } }), 160 + ); 161 + 162 + try { 163 + if (previousBookmarked) { 164 + await removeBookmark(post.uri); 165 + } else { 166 + await bookmarkPost(post.uri, post.cid); 167 + } 168 + 169 + emitPostViewUpdated({ uri: post.uri, viewer: { bookmarked: !previousBookmarked } }); 170 + emitBookmarkChanged({ bookmarked: !previousBookmarked, cid: post.cid, uri: post.uri }); 171 + } catch (error) { 172 + props.patchPost( 173 + post.uri, 174 + (current) => ({ ...current, viewer: { ...current.viewer, bookmarked: previousBookmarked } }), 175 + ); 176 + props.onError(`Failed to update save: ${String(error)}`); 177 + } finally { 178 + setState("bookmarkPendingByUri", post.uri, false); 179 + } 180 + } 181 + 182 + function triggerPulse(key: "likePulseUri" | "repostPulseUri", uri: string) { 183 + setState(key, uri); 184 + globalThis.setTimeout(() => setState(key, (current) => (current === uri ? null : current)), 320); 185 + } 186 + 187 + return { 188 + bookmarkPendingByUri: () => state.bookmarkPendingByUri, 189 + likePendingByUri: () => state.likePendingByUri, 190 + likePulseUri: () => state.likePulseUri, 191 + repostPendingByUri: () => state.repostPendingByUri, 192 + repostPulseUri: () => state.repostPulseUri, 193 + toggleBookmark, 194 + toggleLike, 195 + toggleRepost, 196 + }; 197 + } 198 + 199 + function applyEventPatch(post: PostView, detail: PostViewUpdateDetail): PostView { 200 + return { 201 + ...post, 202 + likeCount: detail.likeCount ?? post.likeCount, 203 + repostCount: detail.repostCount ?? post.repostCount, 204 + viewer: detail.viewer ? { ...post.viewer, ...detail.viewer } : post.viewer, 205 + }; 206 + }
+24
src/components/posts/useThreadOverlayNavigation.ts
··· 1 + import { buildThreadOverlayRoute, getThreadOverlayUri } from "$/lib/feeds"; 2 + import { useLocation, useNavigate } from "@solidjs/router"; 3 + import { createMemo } from "solid-js"; 4 + 5 + export function useThreadOverlayNavigation() { 6 + const location = useLocation(); 7 + const navigate = useNavigate(); 8 + 9 + const threadUri = createMemo(() => getThreadOverlayUri(location.search)); 10 + 11 + function openThread(uri: string) { 12 + return navigate(buildThreadOverlayRoute(location.pathname, location.search, uri)); 13 + } 14 + 15 + function closeThread() { 16 + return navigate(buildThreadOverlayRoute(location.pathname, location.search, null)); 17 + } 18 + 19 + function buildThreadHref(uri: string | null) { 20 + return buildThreadOverlayRoute(location.pathname, location.search, uri); 21 + } 22 + 23 + return { buildThreadHref, closeThread, openThread, threadUri }; 24 + }
+35 -5
src/components/profile/ProfileFeed.tsx
··· 1 1 import type { ProfileTab } from "$/lib/profile"; 2 - import type { FeedViewPost } from "$/lib/types"; 2 + import type { FeedViewPost, PostView } from "$/lib/types"; 3 3 import { For, Match, Show, Switch } from "solid-js"; 4 4 import { PostCard } from "../feeds/PostCard"; 5 5 import { Icon } from "../shared/Icon"; 6 6 import { tabLabel } from "./profile-state"; 7 7 8 - function ProfilePostList(props: { items: FeedViewPost[]; onOpenThread: (uri: string) => void }) { 8 + function ProfilePostList( 9 + props: { 10 + bookmarkPendingByUri: Record<string, boolean>; 11 + items: FeedViewPost[]; 12 + likePendingByUri: Record<string, boolean>; 13 + onBookmark: (post: PostView) => void; 14 + onLike: (post: PostView) => void; 15 + onOpenThread: (uri: string) => void; 16 + onRepost: (post: PostView) => void; 17 + repostPendingByUri: Record<string, boolean>; 18 + }, 19 + ) { 9 20 return ( 10 21 <div class="grid gap-3"> 11 22 <For each={props.items}> 12 23 {(item) => ( 13 24 <PostCard 25 + bookmarkPending={!!props.bookmarkPendingByUri[item.post.uri]} 26 + likePending={!!props.likePendingByUri[item.post.uri]} 27 + onBookmark={() => props.onBookmark(item.post)} 28 + onLike={() => props.onLike(item.post)} 14 29 post={item.post} 15 30 item={item} 16 - showActions={false} 17 - onOpenThread={() => props.onOpenThread(item.post.uri)} /> 31 + onOpenThread={() => props.onOpenThread(item.post.uri)} 32 + onRepost={() => props.onRepost(item.post)} 33 + repostPending={!!props.repostPendingByUri[item.post.uri]} /> 18 34 )} 19 35 </For> 20 36 </div> ··· 73 89 export function ProfileFeedSection( 74 90 props: { 75 91 activeTab: ProfileTab; 92 + bookmarkPendingByUri: Record<string, boolean>; 76 93 cursor: string | null; 77 94 error: string | null; 78 95 items: FeedViewPost[]; 96 + likePendingByUri: Record<string, boolean>; 79 97 loading: boolean; 80 98 loadingMore: boolean; 99 + onBookmark: (post: PostView) => void; 100 + onLike: (post: PostView) => void; 81 101 onLoadMore: () => void; 82 102 onOpenThread: (uri: string) => void; 103 + onRepost: (post: PostView) => void; 104 + repostPendingByUri: Record<string, boolean>; 83 105 }, 84 106 ) { 85 107 return ( ··· 93 115 </Match> 94 116 95 117 <Match when={props.items.length > 0}> 96 - <ProfilePostList items={props.items} onOpenThread={props.onOpenThread} /> 118 + <ProfilePostList 119 + bookmarkPendingByUri={props.bookmarkPendingByUri} 120 + items={props.items} 121 + likePendingByUri={props.likePendingByUri} 122 + onBookmark={props.onBookmark} 123 + onLike={props.onLike} 124 + onOpenThread={props.onOpenThread} 125 + onRepost={props.onRepost} 126 + repostPendingByUri={props.repostPendingByUri} /> 97 127 </Match> 98 128 99 129 <Match when={props.cursor}>
+12
src/components/profile/ProfilePanel.test.tsx
··· 17 17 const getProfileMock = vi.hoisted(() => vi.fn()); 18 18 const navigateMock = vi.hoisted(() => vi.fn()); 19 19 const unfollowActorMock = vi.hoisted(() => vi.fn()); 20 + const threadOverlayMock = vi.hoisted(() => ({ 21 + buildThreadHref: vi.fn(( 22 + uri: string | null, 23 + ) => (uri ? `/profile/bob.test?thread=${encodeURIComponent(uri)}` : "/profile/bob.test")), 24 + closeThread: vi.fn(), 25 + openThread: vi.fn(), 26 + threadUri: vi.fn(() => null), 27 + })); 20 28 21 29 vi.mock( 22 30 "$/lib/api/profile", ··· 44 52 ); 45 53 46 54 vi.mock("@solidjs/router", () => ({ useNavigate: () => navigateMock })); 55 + vi.mock( 56 + "$/components/posts/useThreadOverlayNavigation", 57 + () => ({ useThreadOverlayNavigation: () => threadOverlayMock }), 58 + ); 47 59 48 60 function deferred<T>() { 49 61 let resolve!: (value: T) => void;
+20 -4
src/components/profile/ProfilePanel.tsx
··· 1 1 import { DiagnosticsPanel } from "$/components/deck/DiagnosticsPanel"; 2 + import { usePostInteractions } from "$/components/posts/usePostInteractions"; 3 + import { useThreadOverlayNavigation } from "$/components/posts/useThreadOverlayNavigation"; 2 4 import { ProfileSkeleton } from "$/components/ProfileSkeleton"; 3 5 import { useAppSession } from "$/contexts/app-session"; 4 6 import { ··· 12 14 } from "$/lib/api/profile"; 13 15 import { buildMessagesRoute } from "$/lib/conversations"; 14 16 import { queueExplorerTarget } from "$/lib/explorer-navigation"; 15 - import { buildThreadRoute } from "$/lib/feeds"; 17 + import { patchFeedItems } from "$/lib/feeds"; 16 18 import { buildProfileRoute, filterProfileFeed, getProfileRouteActor, type ProfileTab } from "$/lib/profile"; 17 19 import type { ActorListResponse, FeedResponse, FeedViewPost, ProfileViewBasic } from "$/lib/types"; 18 20 import { clamp } from "$/lib/utils/numbers"; ··· 34 36 export function ProfilePanel(props: { actor: string | null; embedded?: boolean }) { 35 37 const navigate = useNavigate(); 36 38 const session = useAppSession(); 39 + const threadOverlay = useThreadOverlayNavigation(); 37 40 const [state, setState] = createStore<ProfilePanelState>(createProfilePanelState()); 38 41 let requestSequence = 0; 42 + const interactions = usePostInteractions({ 43 + onError: session.reportError, 44 + patchPost(uri, updater) { 45 + setState("authorFeed", "items", (current) => patchFeedItems(current, uri, updater)); 46 + setState("likesFeed", "items", (current) => patchFeedItems(current, uri, updater)); 47 + }, 48 + }); 39 49 40 50 const activeActor = createMemo(() => props.actor?.trim() || session.activeHandle || session.activeDid || ""); 41 51 const activeProfile = createMemo(() => state.profile); ··· 51 61 const joinedLabel = createMemo(() => formatJoinedDate(activeProfile()?.createdAt)); 52 62 const pinnedPostHref = createMemo(() => { 53 63 const uri = activeProfile()?.pinnedPost?.uri; 54 - return uri ? buildThreadRoute(uri) : null; 64 + return uri ? threadOverlay.buildThreadHref(uri) : null; 55 65 }); 56 66 const profileBadges = createMemo(() => { 57 67 const profile = activeProfile(); ··· 222 232 } 223 233 224 234 function openThread(uri: string) { 225 - navigate(buildThreadRoute(uri)); 235 + void threadOverlay.openThread(uri); 226 236 } 227 237 228 238 function openExplorerTarget(target: string) { ··· 425 435 fallback={ 426 436 <ProfileFeedSection 427 437 activeTab={state.activeTab} 438 + bookmarkPendingByUri={interactions.bookmarkPendingByUri()} 428 439 cursor={activeFeedState().cursor} 429 440 error={activeFeedState().error} 430 441 items={visibleItems()} 442 + likePendingByUri={interactions.likePendingByUri()} 431 443 loading={activeFeedState().loading} 432 444 loadingMore={activeFeedState().loadingMore} 445 + onBookmark={(post) => void interactions.toggleBookmark(post)} 446 + onLike={(post) => void interactions.toggleLike(post)} 433 447 onLoadMore={handleLoadMore} 434 - onOpenThread={openThread} /> 448 + onOpenThread={openThread} 449 + onRepost={(post) => void interactions.toggleRepost(post)} 450 + repostPendingByUri={interactions.repostPendingByUri()} /> 435 451 }> 436 452 <div class="px-3 pb-4 max-[520px]:px-2"> 437 453 <DiagnosticsPanel did={profile().did} embedded onOpenExplorerTarget={openExplorerTarget} />
+17
src/components/saved/SavedPostsPanel.test.tsx
··· 1 + import { emitBookmarkChanged } from "$/lib/post-events"; 1 2 import { AppTestProviders } from "$/test/providers"; 2 3 import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 3 4 import { beforeEach, describe, expect, it, vi } from "vitest"; ··· 160 161 expect(listSavedPostsMock).toHaveBeenNthCalledWith(3, "like", 50, 0, "rust"); 161 162 expect(screen.getByText((_, element) => element?.textContent === "rust like")).toBeInTheDocument(); 162 163 }, 5000); 164 + 165 + it("patches mounted bookmark results when a bookmark is removed elsewhere", async () => { 166 + listSavedPostsMock.mockResolvedValueOnce({ 167 + nextOffset: null, 168 + posts: [createPost("bookmark", "1", "bookmark to remove")], 169 + total: 1, 170 + }); 171 + 172 + renderPanel(); 173 + 174 + expect(await screen.findByText("bookmark to remove")).toBeInTheDocument(); 175 + 176 + emitBookmarkChanged({ bookmarked: false, cid: "cid-1", uri: "at://did:plc:author:1/app.bsky.feed.post/1" }); 177 + 178 + await waitFor(() => expect(screen.queryByText("bookmark to remove")).not.toBeInTheDocument()); 179 + }); 163 180 });
+28
src/components/saved/SavedPostsPanel.tsx
··· 7 7 import { getSyncStatus, listSavedPosts, syncPosts } from "$/lib/api/search"; 8 8 import type { LocalPostResult, SavedPostSource, SyncStatus } from "$/lib/api/search"; 9 9 import { formatRelativeTime } from "$/lib/feeds"; 10 + import { subscribeBookmarkChanged } from "$/lib/post-events"; 10 11 import { normalizeError } from "$/lib/utils/text"; 11 12 import * as logger from "@tauri-apps/plugin-log"; 12 13 import { createEffect, createMemo, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"; ··· 147 148 }); 148 149 149 150 onCleanup(() => clearTimeout(debounceTimer)); 151 + 152 + createEffect(() => { 153 + const dispose = subscribeBookmarkChanged((detail) => { 154 + setState("tabs", "bookmark", "items", (items) => updateBookmarkResults(items, detail.uri, detail.bookmarked)); 155 + setState( 156 + "searchTabs", 157 + "bookmark", 158 + "items", 159 + (items) => updateBookmarkResults(items, detail.uri, detail.bookmarked), 160 + ); 161 + setState("tabs", "bookmark", "total", (current) => adjustBookmarkTotal(current, detail.bookmarked)); 162 + setState("searchTabs", "bookmark", "total", (current) => adjustBookmarkTotal(current, detail.bookmarked)); 163 + }); 164 + onCleanup(dispose); 165 + }); 150 166 151 167 async function refreshForDid(did: string | null) { 152 168 if (did === activeDid) { ··· 393 409 : loadBrowseTab(activeTab(), { append: true }))} /> 394 410 </article> 395 411 ); 412 + } 413 + 414 + function updateBookmarkResults(items: LocalPostResult[], uri: string, bookmarked: boolean) { 415 + if (bookmarked) { 416 + return items; 417 + } 418 + 419 + return items.filter((item) => item.uri !== uri); 420 + } 421 + 422 + function adjustBookmarkTotal(total: number, bookmarked: boolean) { 423 + return bookmarked ? total : Math.max(0, total - 1); 396 424 } 397 425 398 426 function SavedPostsHeader(
+56
src/components/shared/ContextMenu.test.tsx
··· 1 + import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 2 + import { createSignal } from "solid-js"; 3 + import { describe, expect, it, vi } from "vitest"; 4 + import { ContextMenu, type ContextMenuAnchor } from "./ContextMenu"; 5 + 6 + function TestMenu(props: { anchor: ContextMenuAnchor | null; onClose?: () => void }) { 7 + const [open, setOpen] = createSignal(true); 8 + const [triggerRef, setTriggerRef] = createSignal<HTMLButtonElement>(); 9 + 10 + return ( 11 + <div> 12 + <button ref={setTriggerRef}>Trigger</button> 13 + <ContextMenu 14 + anchor={props.anchor} 15 + items={[{ label: "First action", onSelect: vi.fn() }, { label: "Second action", onSelect: vi.fn() }]} 16 + label="Test menu" 17 + open={open()} 18 + returnFocusTo={triggerRef()} 19 + onClose={() => { 20 + props.onClose?.(); 21 + setOpen(false); 22 + }} /> 23 + </div> 24 + ); 25 + } 26 + 27 + describe("ContextMenu", () => { 28 + it("renders menuitems and closes on outside pointerdown", async () => { 29 + const onClose = vi.fn(); 30 + render(() => <TestMenu anchor={{ kind: "point", x: 100, y: 80 }} onClose={onClose} />); 31 + 32 + expect(screen.getByRole("menu", { name: "Test menu" })).toBeInTheDocument(); 33 + fireEvent.pointerDown(document.body); 34 + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); 35 + }); 36 + 37 + it("supports keyboard navigation and escape close", async () => { 38 + const onClose = vi.fn(); 39 + render(() => <TestMenu anchor={{ kind: "point", x: 100, y: 80 }} onClose={onClose} />); 40 + 41 + const menu = screen.getByRole("menu", { name: "Test menu" }); 42 + await waitFor(() => expect(screen.getByRole("menuitem", { name: "First action" })).toHaveFocus()); 43 + fireEvent.keyDown(menu, { key: "ArrowDown" }); 44 + await waitFor(() => expect(screen.getByRole("menuitem", { name: "Second action" })).toHaveFocus()); 45 + 46 + fireEvent.keyDown(menu, { key: "Escape" }); 47 + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); 48 + }); 49 + 50 + it("returns focus to the trigger after closing", async () => { 51 + render(() => <TestMenu anchor={{ kind: "point", x: 100, y: 80 }} />); 52 + 53 + fireEvent.pointerDown(document.body); 54 + await waitFor(() => expect(screen.getByRole("button", { name: "Trigger" })).toHaveFocus()); 55 + }); 56 + });
+219
src/components/shared/ContextMenu.tsx
··· 1 + import { Icon } from "$/components/shared/Icon"; 2 + import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"; 3 + import { Portal } from "solid-js/web"; 4 + 5 + export type ContextMenuAnchor = { kind: "element"; rect: DOMRect } | { kind: "point"; x: number; y: number }; 6 + 7 + export type ContextMenuItem = { 8 + disabled?: boolean; 9 + icon?: string; 10 + label: string; 11 + onSelect: () => void; 12 + tone?: "default" | "danger"; 13 + }; 14 + 15 + type ContextMenuProps = { 16 + anchor: ContextMenuAnchor | null; 17 + items: ContextMenuItem[]; 18 + label: string; 19 + onClose: () => void; 20 + open: boolean; 21 + returnFocusTo?: HTMLElement | null; 22 + }; 23 + 24 + const MENU_MARGIN = 8; 25 + 26 + export function ContextMenu(props: ContextMenuProps) { 27 + const [activeIndex, setActiveIndex] = createSignal(-1); 28 + const [menuStyle, setMenuStyle] = createSignal<Record<string, string>>({}); 29 + let menuRef: HTMLDivElement | undefined; 30 + let previousOpen = false; 31 + const enabledItems = createMemo(() => props.items.filter((item) => !item.disabled)); 32 + 33 + createEffect(() => { 34 + if (!props.open || !props.anchor || !menuRef) { 35 + return; 36 + } 37 + 38 + const anchor = props.anchor; 39 + const initialIndex = props.items.findIndex((item) => !item.disabled); 40 + queueMicrotask(() => { 41 + positionMenu(anchor, initialIndex); 42 + }); 43 + }); 44 + 45 + createEffect(() => { 46 + if (props.open) { 47 + previousOpen = true; 48 + return; 49 + } 50 + 51 + if (previousOpen) { 52 + props.returnFocusTo?.focus(); 53 + previousOpen = false; 54 + setActiveIndex(-1); 55 + } 56 + }); 57 + 58 + createEffect(() => { 59 + if (!props.open) { 60 + return; 61 + } 62 + 63 + const handlePointerDown = (event: PointerEvent) => { 64 + if (menuRef?.contains(event.target as Node)) { 65 + return; 66 + } 67 + 68 + props.onClose(); 69 + }; 70 + 71 + const handleKeyDown = (event: KeyboardEvent) => { 72 + if (event.key === "Escape") { 73 + event.preventDefault(); 74 + props.onClose(); 75 + } 76 + }; 77 + 78 + globalThis.addEventListener("pointerdown", handlePointerDown, true); 79 + globalThis.addEventListener("keydown", handleKeyDown); 80 + onCleanup(() => { 81 + globalThis.removeEventListener("pointerdown", handlePointerDown, true); 82 + globalThis.removeEventListener("keydown", handleKeyDown); 83 + }); 84 + }); 85 + 86 + function focusItem(index: number) { 87 + const nextIndex = index < 0 ? props.items.findIndex((item) => !item.disabled) : index; 88 + if (nextIndex < 0) { 89 + return; 90 + } 91 + 92 + setActiveIndex(nextIndex); 93 + queueMicrotask(() => { 94 + menuRef?.querySelectorAll<HTMLButtonElement>("[role='menuitem']")[nextIndex]?.focus(); 95 + }); 96 + } 97 + 98 + function moveFocus(direction: 1 | -1) { 99 + const items = props.items; 100 + if (enabledItems().length === 0) { 101 + return; 102 + } 103 + 104 + let index = activeIndex(); 105 + for (let offset = 0; offset < items.length; offset += 1) { 106 + index = (index + direction + items.length) % items.length; 107 + if (!items[index]?.disabled) { 108 + focusItem(index); 109 + return; 110 + } 111 + } 112 + } 113 + 114 + function positionMenu(anchor: ContextMenuAnchor, initialIndex: number) { 115 + if (!menuRef) { 116 + return; 117 + } 118 + 119 + const width = menuRef.offsetWidth; 120 + const height = menuRef.offsetHeight; 121 + const viewportWidth = globalThis.innerWidth; 122 + const viewportHeight = globalThis.innerHeight; 123 + const preferredLeft = anchor.kind === "point" ? anchor.x : anchor.rect.right - width; 124 + const preferredTop = anchor.kind === "point" ? anchor.y : anchor.rect.bottom + 8; 125 + const fallbackTop = anchor.kind === "point" ? anchor.y - height : anchor.rect.top - height - 8; 126 + const left = clamp(preferredLeft, MENU_MARGIN, viewportWidth - width - MENU_MARGIN); 127 + const top = preferredTop + height > viewportHeight - MENU_MARGIN 128 + ? clamp(fallbackTop, MENU_MARGIN, viewportHeight - height - MENU_MARGIN) 129 + : clamp(preferredTop, MENU_MARGIN, viewportHeight - height - MENU_MARGIN); 130 + 131 + setMenuStyle({ left: `${left}px`, top: `${top}px` }); 132 + focusItem(initialIndex); 133 + } 134 + 135 + return ( 136 + <Portal> 137 + <Show when={props.open && props.anchor}> 138 + <div class="fixed inset-0 z-60"> 139 + <div 140 + ref={(element) => { 141 + menuRef = element; 142 + }} 143 + class="fixed min-w-48 rounded-2xl bg-surface-container-high/95 p-1.5 shadow-[0_16px_50px_rgba(0,0,0,0.45),inset_0_0_0_1px_rgba(255,255,255,0.05)] backdrop-blur-[20px]" 144 + role="menu" 145 + aria-label={props.label} 146 + style={menuStyle()} 147 + onKeyDown={(event) => { 148 + switch (event.key) { 149 + case "ArrowDown": { 150 + event.preventDefault(); 151 + moveFocus(1); 152 + break; 153 + } 154 + case "ArrowUp": { 155 + event.preventDefault(); 156 + moveFocus(-1); 157 + break; 158 + } 159 + case "Home": { 160 + event.preventDefault(); 161 + focusItem(props.items.findIndex((item) => !item.disabled)); 162 + break; 163 + } 164 + case "End": { 165 + event.preventDefault(); 166 + focusItem(findLastEnabledIndex(props.items)); 167 + break; 168 + } 169 + default: { 170 + break; 171 + } 172 + } 173 + }} 174 + onPointerDown={(event) => event.stopPropagation()}> 175 + <For each={props.items}> 176 + {(item, index) => ( 177 + <button 178 + type="button" 179 + role="menuitem" 180 + tabIndex={index() === activeIndex() ? 0 : -1} 181 + class="flex w-full items-center gap-2 rounded-xl border-0 bg-transparent px-3 py-2.5 text-left text-sm text-on-surface transition duration-150 hover:bg-white/6 disabled:cursor-not-allowed disabled:opacity-50" 182 + classList={{ "text-error hover:bg-[rgba(138,31,31,0.18)]": item.tone === "danger" }} 183 + disabled={item.disabled} 184 + onFocus={() => setActiveIndex(index())} 185 + onClick={() => { 186 + if (item.disabled) { 187 + return; 188 + } 189 + 190 + item.onSelect(); 191 + props.onClose(); 192 + }}> 193 + <Show when={item.icon}> 194 + {(icon) => <Icon aria-hidden="true" iconClass={icon()} class="text-base" />} 195 + </Show> 196 + <span>{item.label}</span> 197 + </button> 198 + )} 199 + </For> 200 + </div> 201 + </div> 202 + </Show> 203 + </Portal> 204 + ); 205 + } 206 + 207 + function clamp(value: number, min: number, max: number) { 208 + return Math.min(Math.max(value, min), max); 209 + } 210 + 211 + function findLastEnabledIndex(items: ContextMenuItem[]) { 212 + for (let index = items.length - 1; index >= 0; index -= 1) { 213 + if (!items[index]?.disabled) { 214 + return index; 215 + } 216 + } 217 + 218 + return -1; 219 + }
+8
src/lib/api/feeds.ts
··· 46 46 return invoke("unrepost", { repostUri }); 47 47 } 48 48 49 + export function bookmarkPost(uri: string, cid: string) { 50 + return invoke("bookmark_post", { cid, uri }); 51 + } 52 + 53 + export function removeBookmark(uri: string) { 54 + return invoke("remove_bookmark", { uri }); 55 + } 56 + 49 57 export function updateSavedFeeds(feeds: SavedFeedItem[]) { 50 58 return invoke("update_saved_feeds", { feeds }); 51 59 }
+4
src/lib/constants/events.ts
··· 1 1 export const POST_CREATED_EVENT = "composer:post-created"; 2 2 3 + export const POST_VIEW_UPDATED_EVENT = "post:view-updated"; 4 + 5 + export const BOOKMARK_CHANGED_EVENT = "post:bookmark-changed"; 6 + 3 7 export const ACCOUNT_SWITCH_EVENT = "auth:account-switched"; 4 8 5 9 export const NOTIFICATIONS_UNREAD_COUNT_EVENT = "notifications:unread-count";
+16 -3
src/lib/feeds.test.ts
··· 1 1 import { describe, expect, it } from "vitest"; 2 2 import { 3 3 applyFeedPreferences, 4 - buildThreadRoute, 4 + buildPublicPostUrl, 5 + buildThreadOverlayRoute, 5 6 decodeThreadRouteUri, 6 7 getFeedCommand, 8 + getThreadOverlayUri, 7 9 parseFeedResponse, 8 10 parseThreadResponse, 9 11 } from "./feeds"; ··· 126 128 expect(getFeedCommand(list).args("cursor-1", 30)).toEqual({ cursor: "cursor-1", limit: 30, uri: list.value }); 127 129 }); 128 130 129 - it("encodes and decodes thread routes", () => { 131 + it("encodes and decodes thread overlays", () => { 130 132 const uri = "at://did:plc:alice/app.bsky.feed.post/abc123"; 131 133 132 - expect(buildThreadRoute(uri)).toBe("/timeline/thread/at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2Fabc123"); 134 + expect(buildThreadOverlayRoute("/profile/alice", "", uri)).toBe( 135 + "/profile/alice?thread=at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2Fabc123", 136 + ); 137 + expect(buildThreadOverlayRoute("/profile/alice", "?foo=bar", uri)).toBe( 138 + "/profile/alice?foo=bar&thread=at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2Fabc123", 139 + ); 140 + expect(buildThreadOverlayRoute("/profile/alice", "?foo=bar&thread=old", null)).toBe("/profile/alice?foo=bar"); 141 + expect(getThreadOverlayUri("?thread=at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2Fabc123")).toBe(uri); 133 142 expect(decodeThreadRouteUri("at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2Fabc123")).toBe(uri); 134 143 expect(decodeThreadRouteUri(uri)).toBe(uri); 135 144 expect(decodeThreadRouteUri("https%3A%2F%2Fexample.com")).toBeNull(); 145 + }); 146 + 147 + it("builds public post urls from handles and post rkeys", () => { 148 + expect(buildPublicPostUrl(createFeedItem().post)).toBe("https://bsky.app/profile/alice.test/post/1"); 136 149 }); 137 150 138 151 it("rejects malformed feed payloads", () => {
+27 -4
src/lib/feeds.ts
··· 20 20 } from "./types"; 21 21 22 22 export const TIMELINE_ROUTE = "/timeline"; 23 - 24 - export const THREAD_ROUTE_BASE = "/timeline/thread"; 23 + export const THREAD_QUERY_PARAM = "thread"; 25 24 26 25 export function asPostRecord(value: unknown): PostRecord { 27 26 return (asRecord(value) ?? {}) as PostRecord; ··· 367 366 } 368 367 } 369 368 370 - export function buildThreadRoute(uri: string) { 371 - return `${THREAD_ROUTE_BASE}/${encodeThreadRouteUri(uri)}`; 369 + export function getThreadOverlayUri(search: string) { 370 + return decodeThreadRouteUri(new URLSearchParams(search).get(THREAD_QUERY_PARAM)); 371 + } 372 + 373 + export function buildThreadOverlayRoute(pathname: string, search: string, uri: string | null) { 374 + const params = new URLSearchParams(search); 375 + if (uri) { 376 + params.set(THREAD_QUERY_PARAM, uri); 377 + } else { 378 + params.delete(THREAD_QUERY_PARAM); 379 + } 380 + 381 + const nextSearch = params.toString(); 382 + return nextSearch ? `${pathname}?${nextSearch}` : pathname; 383 + } 384 + 385 + export function buildPublicPostUrl(post: Pick<PostView, "author" | "uri">) { 386 + const handle = post.author.handle.replace(/^@/, "").trim(); 387 + const segments = post.uri.split("/"); 388 + const rkey = segments.at(-1)?.trim(); 389 + 390 + if (handle && rkey) { 391 + return `https://bsky.app/profile/${encodeURIComponent(handle)}/post/${encodeURIComponent(rkey)}`; 392 + } 393 + 394 + return post.uri; 372 395 }
+31
src/lib/post-events.ts
··· 1 + import { BOOKMARK_CHANGED_EVENT, POST_VIEW_UPDATED_EVENT } from "$/lib/constants/events"; 2 + import type { ViewerState } from "$/lib/types"; 3 + 4 + export type PostViewUpdateDetail = { 5 + likeCount?: number | null; 6 + repostCount?: number | null; 7 + uri: string; 8 + viewer?: Partial<ViewerState> | null; 9 + }; 10 + 11 + export type BookmarkChangedDetail = { bookmarked: boolean; cid: string; uri: string }; 12 + 13 + export function emitPostViewUpdated(detail: PostViewUpdateDetail) { 14 + globalThis.dispatchEvent(new CustomEvent<PostViewUpdateDetail>(POST_VIEW_UPDATED_EVENT, { detail })); 15 + } 16 + 17 + export function emitBookmarkChanged(detail: BookmarkChangedDetail) { 18 + globalThis.dispatchEvent(new CustomEvent<BookmarkChangedDetail>(BOOKMARK_CHANGED_EVENT, { detail })); 19 + } 20 + 21 + export function subscribePostViewUpdated(listener: (detail: PostViewUpdateDetail) => void) { 22 + const handler = (event: Event) => listener((event as CustomEvent<PostViewUpdateDetail>).detail); 23 + globalThis.addEventListener(POST_VIEW_UPDATED_EVENT, handler); 24 + return () => globalThis.removeEventListener(POST_VIEW_UPDATED_EVENT, handler); 25 + } 26 + 27 + export function subscribeBookmarkChanged(listener: (detail: BookmarkChangedDetail) => void) { 28 + const handler = (event: Event) => listener((event as CustomEvent<BookmarkChangedDetail>).detail); 29 + globalThis.addEventListener(BOOKMARK_CHANGED_EVENT, handler); 30 + return () => globalThis.removeEventListener(BOOKMARK_CHANGED_EVENT, handler); 31 + }
+9 -17
src/router.test.tsx
··· 3 3 import type { Component, ParentProps } from "solid-js"; 4 4 import { beforeEach, describe, expect, it, vi } from "vitest"; 5 5 import { buildMessagesRoute } from "./lib/conversations"; 6 - import { buildThreadRoute } from "./lib/feeds"; 7 6 import { buildProfileRoute } from "./lib/profile"; 8 7 import { AppRouter } from "./router"; 9 8 ··· 28 27 <span>{props.actor ?? "self-profile"}</span> 29 28 </div> 30 29 )); 31 - const renderTimeline = vi.fn(( 32 - props: { context: { onThreadRouteChange: (uri: string | null) => void; threadUri: string | null } }, 33 - ) => ( 34 - <div data-testid="timeline-view"> 35 - <span>{props.context.threadUri ?? "no-thread"}</span> 36 - </div> 37 - )); 30 + const renderTimeline = vi.fn(() => <div data-testid="timeline-view">timeline</div>); 38 31 39 32 const renderMessages = vi.fn((props: { memberDid: string | null }) => ( 40 33 <div data-testid="messages-view">{props.memberDid ?? "messages"}</div> ··· 67 60 listenMock.mockResolvedValue(() => {}); 68 61 }); 69 62 70 - it("renders the timeline route without a thread uri", async () => { 63 + it("renders the timeline route", async () => { 71 64 const { renderTimeline } = renderRouter("#/timeline"); 72 65 73 66 await screen.findByTestId("timeline-view"); 74 67 75 - expect(renderTimeline).toHaveBeenCalled(); 76 - expect(renderTimeline.mock.lastCall?.[0].context.threadUri).toBeNull(); 77 - expect(screen.getByText("no-thread")).toBeInTheDocument(); 68 + expect(renderTimeline).toHaveBeenCalledOnce(); 69 + expect(screen.getByText("timeline")).toBeInTheDocument(); 78 70 }); 79 71 80 - it("passes the decoded thread uri on the thread route", async () => { 81 - const threadUri = "at://did:plc:alice/app.bsky.feed.post/xyz"; 82 - const { renderTimeline } = renderRouter(`#${buildThreadRoute(threadUri)}`); 72 + it("renders the timeline route with query params intact", async () => { 73 + const { renderTimeline } = renderRouter( 74 + "#/timeline?thread=at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2Fxyz", 75 + ); 83 76 84 77 await screen.findByTestId("timeline-view"); 85 78 86 - expect(renderTimeline.mock.lastCall?.[0].context.threadUri).toBe(threadUri); 87 - expect(screen.getByText(threadUri)).toBeInTheDocument(); 79 + expect(renderTimeline).toHaveBeenCalledOnce(); 88 80 }); 89 81 90 82 it("renders the standalone composer route", async () => {
+4 -32
src/router.tsx
··· 1 1 import { useAppSession } from "$/contexts/app-session"; 2 2 import { useAppShellUi } from "$/contexts/app-shell-ui"; 3 - import { HashRouter, Navigate, Route, useLocation, useNavigate, useParams } from "@solidjs/router"; 3 + import { HashRouter, Navigate, Route, useLocation, useParams } from "@solidjs/router"; 4 4 import type { RouteSectionProps } from "@solidjs/router"; 5 5 import { type Component, createEffect, type JSX, type ParentProps, Show } from "solid-js"; 6 6 import { Dynamic } from "solid-js/web"; ··· 10 10 import { SearchPanel } from "./components/search/SearchPanel"; 11 11 import { SettingsPanel } from "./components/settings/SettingsPanel"; 12 12 import { decodeMessagesRouteMemberDid } from "./lib/conversations"; 13 - import { buildThreadRoute, decodeThreadRouteUri, TIMELINE_ROUTE } from "./lib/feeds"; 13 + import { TIMELINE_ROUTE } from "./lib/feeds"; 14 14 import { decodeProfileRouteActor } from "./lib/profile"; 15 15 16 - type TTimelineRouteProps = { context: { onThreadRouteChange: (uri: string | null) => void; threadUri: string | null } }; 17 16 type TMessagesRouteProps = { memberDid: string | null }; 18 17 type TProfileRouteProps = { actor: string | null }; 19 18 ··· 26 25 renderNotifications: () => JSX.Element; 27 26 renderProfile: Component<TProfileRouteProps>; 28 27 renderShell: Component<AppShellProps>; 29 - renderTimeline: Component<TTimelineRouteProps>; 28 + renderTimeline: () => JSX.Element; 30 29 }; 31 30 32 31 export function AppRouter(props: AppRouterProps) { ··· 65 64 66 65 const AuthRoute = () => <PublicOnlyRoute redirectHref={TIMELINE_ROUTE}>{props.renderAuth()}</PublicOnlyRoute>; 67 66 68 - const TimelineRoute = () => <TimelineRouteView renderTimeline={props.renderTimeline} threadUri={null} />; 69 - 70 - const ThreadRoute = () => { 71 - const params = useParams<{ threadUri: string }>(); 72 - const threadUri = () => decodeThreadRouteUri(params.threadUri); 73 - 74 - return ( 75 - <Show when={threadUri()} keyed fallback={<Navigate href={TIMELINE_ROUTE} />}> 76 - {(uri) => <TimelineRouteView renderTimeline={props.renderTimeline} threadUri={uri} />} 77 - </Show> 78 - ); 79 - }; 67 + const TimelineRoute = () => <ProtectedRouteView>{props.renderTimeline()}</ProtectedRouteView>; 80 68 81 69 const SearchRoute = () => ( 82 70 <ProtectedRouteView> ··· 155 143 <Route path="/" component={IndexRoute} /> 156 144 <Route path="/auth" component={AuthRoute} /> 157 145 <Route path="/timeline" component={TimelineRoute} /> 158 - <Route path="/timeline/thread/:threadUri" component={ThreadRoute} /> 159 146 <Route path="/profile" component={ProfileRoute} /> 160 147 <Route path="/profile/:actor" component={ActorProfileRoute} /> 161 148 <Route path="/composer" component={ComposerRoute} /> ··· 169 156 <Route path="/settings" component={SettingsRoute} /> 170 157 <Route path="*404" component={NotFoundRoute} /> 171 158 </HashRouter> 172 - ); 173 - } 174 - 175 - function TimelineRouteView(props: { renderTimeline: AppRouterProps["renderTimeline"]; threadUri: string | null }) { 176 - const navigate = useNavigate(); 177 - 178 - return ( 179 - <ProtectedRouteView> 180 - <Dynamic 181 - component={props.renderTimeline} 182 - context={{ 183 - onThreadRouteChange: (uri: string | null) => navigate(uri ? buildThreadRoute(uri) : TIMELINE_ROUTE), 184 - threadUri: props.threadUri, 185 - }} /> 186 - </ProtectedRouteView> 187 159 ); 188 160 } 189 161
+1 -1
tsconfig.json
··· 19 19 "noFallthroughCasesInSwitch": true, 20 20 "types": ["vite/client", "@testing-library/jest-dom"], 21 21 "baseUrl": "src", 22 - "ignoreDeprecations": "6.0", 22 + "ignoreDeprecations": "5.0", 23 23 "paths": { "$/*": ["./*"] } 24 24 }, 25 25 "include": ["src"],