diff --git a/docs/tasks/05-explorer.md b/docs/tasks/05-explorer.md index b36f84f..e498a64 100644 --- a/docs/tasks/05-explorer.md +++ b/docs/tasks/05-explorer.md @@ -21,4 +21,11 @@ Spec: [explorer.md](../specs/explorer.md) - [ ] **Frontend**: breadcrumb navigation bar with `Motion` width animation on segment changes - [ ] **Frontend**: `Presence` crossfade transitions between explorer view levels - [ ] **Frontend**: keyboard shortcuts — `Backspace` up a level, `Cmd+[/]` back/forward -- [ ] **Optional**: Jetstream live-tail view with `Motion` slide-in for new records +- [ ] **Frontend**: Jetstream live-tail view with `Motion` slide-in for new records + +### Parking Lot + +These require update to the spec & more research before implementation. + +- [ ] **Frontend**: Firehose Viewer +- [ ] **Frontend**: [Spacedust](https://spacedust.microcosm.blue/) Viewer diff --git a/docs/tasks/06-search.md b/docs/tasks/06-search.md index 3e12a55..bb424bc 100644 --- a/docs/tasks/06-search.md +++ b/docs/tasks/06-search.md @@ -25,5 +25,6 @@ Spec: [search.md](../specs/search.md) - [ ] **Frontend**: search results with staggered `Motion` fade-in, highlighted keyword matches - [ ] **Frontend**: sync status indicator with animated progress bar, `Presence` fade-out on complete - [ ] **Frontend**: model download progress bar (percentage + ETA) on first launch + - Splash/Preflight route should explain what the point of this is - [ ] **Frontend**: empty state illustration when no posts synced yet - [ ] **Frontend**: `Tab` cycles search mode, `Escape` clears diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 649a8ff..2b642fe 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,7 +1,7 @@ #![allow(clippy::needless_pass_by_value)] use super::auth::{self, LoginSuggestion}; use super::error::AppError; -use super::feed::{self, CreateRecordResult, EmbedInput, ReplyRefInput, UserPreferences}; +use super::feed::{self, CreateRecordResult, EmbedInput, FeedViewPrefItem, ReplyRefInput, UserPreferences}; use super::state::{AccountSummary, AppBootstrap, AppState}; use serde_json::Value; use tauri::{AppHandle, State}; @@ -113,3 +113,8 @@ pub async fn unrepost(repost_uri: String, state: State<'_, AppState>) -> Result< pub async fn update_saved_feeds(feeds: Vec, state: State<'_, AppState>) -> Result<(), AppError> { feed::update_saved_feeds(feed::UpdateSavedFeedsInput { feeds }, &state).await } + +#[tauri::command] +pub async fn update_feed_view_pref(pref: FeedViewPrefItem, state: State<'_, AppState>) -> Result<(), AppError> { + feed::update_feed_view_pref(pref, &state).await +} diff --git a/src-tauri/src/feed.rs b/src-tauri/src/feed.rs index 7b30e81..a1cac86 100644 --- a/src-tauri/src/feed.rs +++ b/src-tauri/src/feed.rs @@ -90,6 +90,8 @@ pub struct UserPreferences { pub feed_view_prefs: Vec, } +type StoredPreferences = Vec>; + fn extract_saved_feeds(pref: &SavedFeedsPrefV2<'_>) -> Vec { pref.items .iter() @@ -118,6 +120,112 @@ fn extract_feed_view_pref(pref: &FeedViewPref<'_>) -> FeedViewPrefItem { } } +fn user_preferences_from_items(items: &[PreferencesItem<'_>]) -> UserPreferences { + let mut saved_feeds = Vec::new(); + let mut feed_view_prefs = Vec::new(); + + for item in items { + match item { + PreferencesItem::SavedFeedsPrefV2(pref) => { + saved_feeds = extract_saved_feeds(pref); + } + PreferencesItem::FeedViewPref(pref) => { + feed_view_prefs.push(extract_feed_view_pref(pref)); + } + _ => {} + } + } + + UserPreferences { saved_feeds, feed_view_prefs } +} + +async fn fetch_preference_items(state: &AppState) -> Result { + let session = get_session(state).await?; + fetch_preference_items_with_session(&session).await +} + +async fn fetch_preference_items_with_session(session: &Arc) -> Result { + let output = session + .send(GetPreferences) + .await + .map_err(|_| AppError::validation("getPreferences"))? + .into_output() + .map_err(|_| AppError::validation("getPreferences output"))?; + + Ok(output.preferences.into_iter().map(IntoStatic::into_static).collect()) +} + +async fn store_preference_items(session: &Arc, items: StoredPreferences) -> Result<()> { + session + .send(PutPreferences::new().preferences(items).build()) + .await + .map_err(|_| AppError::validation("putPreferences"))? + .into_output() + .map_err(|_| AppError::validation("putPreferences output"))?; + + Ok(()) +} + +fn build_saved_feeds_preference_item(feeds: Vec) -> PreferencesItem<'static> { + let items = feeds + .into_iter() + .map(|feed| { + SavedFeed::new() + .id(feed.id) + .r#type(match feed.r#type.as_str() { + "timeline" => SavedFeedType::Timeline, + "feed" => SavedFeedType::Feed, + "list" => SavedFeedType::List, + _ => SavedFeedType::Other(feed.r#type.into()), + }) + .value(feed.value) + .pinned(feed.pinned) + .build() + }) + .collect::>(); + + PreferencesItem::SavedFeedsPrefV2(Box::new(SavedFeedsPrefV2Builder::new().items(items).build())) +} + +fn build_feed_view_pref_item(pref: FeedViewPrefItem) -> PreferencesItem<'static> { + PreferencesItem::FeedViewPref(Box::new(FeedViewPref { + feed: pref.feed.into(), + hide_quote_posts: Some(pref.hide_quote_posts), + hide_replies: Some(pref.hide_replies), + hide_replies_by_like_count: pref.hide_replies_by_like_count, + hide_replies_by_unfollowed: Some(pref.hide_replies_by_unfollowed), + hide_reposts: Some(pref.hide_reposts), + extra_data: Default::default(), + })) +} + +fn merge_saved_feeds_preferences(preferences: StoredPreferences, feeds: Vec) -> StoredPreferences { + let mut merged = preferences + .into_iter() + .filter(|item| { + !matches!( + item, + PreferencesItem::SavedFeedsPref(_) | PreferencesItem::SavedFeedsPrefV2(_) + ) + }) + .collect::>(); + merged.push(build_saved_feeds_preference_item(feeds)); + merged +} + +fn merge_feed_view_preferences(preferences: StoredPreferences, pref: FeedViewPrefItem) -> StoredPreferences { + let feed = pref.feed.clone(); + let mut merged = preferences + .into_iter() + .filter(|item| match item { + PreferencesItem::FeedViewPref(existing) => existing.feed.as_ref() != feed.as_str(), + _ => true, + }) + .collect::>(); + merged.push(build_feed_view_pref_item(pref)); + merged +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StrongRefInput { @@ -146,30 +254,8 @@ pub struct CreateRecordResult { } pub async fn get_preferences(state: &AppState) -> Result { - let session = get_session(state).await?; - let output = session - .send(GetPreferences) - .await - .map_err(|_| AppError::validation("getPreferences"))? - .into_output() - .map_err(|_| AppError::validation("getPreferences output"))?; - - let mut saved_feeds = Vec::new(); - let mut feed_view_prefs = Vec::new(); - - for item in &output.preferences { - match item { - PreferencesItem::SavedFeedsPrefV2(pref) => { - saved_feeds = extract_saved_feeds(pref); - } - PreferencesItem::FeedViewPref(pref) => { - feed_view_prefs.push(extract_feed_view_pref(pref)); - } - _ => {} - } - } - - Ok(UserPreferences { saved_feeds, feed_view_prefs }) + let preferences = fetch_preference_items(state).await?; + Ok(user_preferences_from_items(&preferences)) } pub async fn get_feed_generators(uris: Vec, state: &AppState) -> Result { @@ -476,34 +562,105 @@ pub struct UpdateSavedFeedsInput { pub async fn update_saved_feeds(input: UpdateSavedFeedsInput, state: &AppState) -> Result<()> { let session = get_session(state).await?; + let preferences = fetch_preference_items_with_session(&session).await?; + let merged = merge_saved_feeds_preferences(preferences, input.feeds); + store_preference_items(&session, merged).await +} - let items: Vec> = input - .feeds - .into_iter() - .map(|f| { - SavedFeed::new() - .id(f.id) - .r#type(match f.r#type.as_str() { - "timeline" => SavedFeedType::Timeline, - "feed" => SavedFeedType::Feed, - "list" => SavedFeedType::List, - _ => SavedFeedType::Other(f.r#type.into()), - }) - .value(f.value) - .pinned(f.pinned) - .build() - }) - .collect(); +pub async fn update_feed_view_pref(pref: FeedViewPrefItem, state: &AppState) -> Result<()> { + let session = get_session(state).await?; + let preferences = fetch_preference_items_with_session(&session).await?; + let merged = merge_feed_view_preferences(preferences, pref); + store_preference_items(&session, merged).await +} - let saved_feeds_pref = Box::new(SavedFeedsPrefV2Builder::new().items(items).build()); - let pref_item = PreferencesItem::SavedFeedsPrefV2(saved_feeds_pref); +#[cfg(test)] +mod tests { + use super::{ + merge_feed_view_preferences, merge_saved_feeds_preferences, user_preferences_from_items, FeedViewPrefItem, + SavedFeedItem, + }; + use jacquard::api::app_bsky::actor::{AdultContentPref, FeedViewPref, PreferencesItem}; - session - .send(PutPreferences::new().preferences(vec![pref_item]).build()) - .await - .map_err(|_| AppError::validation("putPreferences"))? - .into_output() - .map_err(|_| AppError::validation("putPreferences output"))?; + fn adult_content_pref_item() -> PreferencesItem<'static> { + PreferencesItem::AdultContentPref(Box::new(AdultContentPref::new().enabled(true).build())) + } - Ok(()) + fn feed_view_pref_item(feed: &str, hide_reposts: bool) -> PreferencesItem<'static> { + PreferencesItem::FeedViewPref(Box::new(FeedViewPref { + feed: feed.to_owned().into(), + hide_quote_posts: Some(false), + hide_replies: Some(false), + hide_replies_by_like_count: None, + hide_replies_by_unfollowed: Some(true), + hide_reposts: Some(hide_reposts), + extra_data: Default::default(), + })) + } + + #[test] + fn merging_saved_feeds_preserves_other_preferences() { + let preferences = vec![adult_content_pref_item(), feed_view_pref_item("following", true)]; + let merged = merge_saved_feeds_preferences( + preferences, + vec![SavedFeedItem { + id: "following".into(), + r#type: "timeline".into(), + value: "following".into(), + pinned: true, + }], + ); + + assert!(merged + .iter() + .any(|item| matches!(item, PreferencesItem::AdultContentPref(_)))); + assert!(merged + .iter() + .any(|item| matches!(item, PreferencesItem::FeedViewPref(_)))); + + let user_preferences = user_preferences_from_items(&merged); + assert_eq!(user_preferences.saved_feeds.len(), 1); + assert_eq!(user_preferences.feed_view_prefs.len(), 1); + assert!(user_preferences.feed_view_prefs[0].hide_reposts); + } + + #[test] + fn merging_feed_view_pref_replaces_only_matching_feed() { + let preferences = vec![ + adult_content_pref_item(), + feed_view_pref_item("following", true), + feed_view_pref_item("at://feed/custom", false), + ]; + let merged = merge_feed_view_preferences( + preferences, + FeedViewPrefItem { + feed: "following".into(), + hide_replies: true, + hide_replies_by_unfollowed: false, + hide_replies_by_like_count: Some(4), + hide_reposts: false, + hide_quote_posts: true, + }, + ); + + let user_preferences = user_preferences_from_items(&merged); + assert_eq!(user_preferences.feed_view_prefs.len(), 2); + + let following = user_preferences + .feed_view_prefs + .iter() + .find(|pref| pref.feed == "following") + .expect("following pref should exist"); + assert!(!following.hide_reposts); + assert!(following.hide_quote_posts); + assert_eq!(following.hide_replies_by_like_count, Some(4)); + + let custom = user_preferences + .feed_view_prefs + .iter() + .find(|pref| pref.feed == "at://feed/custom") + .expect("custom pref should exist"); + assert!(!custom.hide_quote_posts); + assert!(!custom.hide_replies); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 42b3394..8a5f6a4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -74,7 +74,8 @@ pub fn run() { cmd::unlike_post, cmd::repost, cmd::unrepost, - cmd::update_saved_feeds + cmd::update_saved_feeds, + cmd::update_feed_view_pref ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.css b/src/App.css index 8aeae42..b27d792 100644 --- a/src/App.css +++ b/src/App.css @@ -1,4 +1,6 @@ -@import "tailwindcss"; +/* NOTE: This prevents vite from processing src-tauri */ +@import "tailwindcss" source(none); +@source "./"; @plugin "@egoist/tailwindcss-icons"; @plugin "@tailwindcss/forms"; @@ -98,3 +100,7 @@ body { transform: translateX(100%); } } + +button { + @apply cursor-pointer disabled:cursor-auto; +} diff --git a/src/App.tsx b/src/App.tsx index 1bef01f..1c91830 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -62,6 +62,7 @@ function App() { const primaryAccount = createMemo(() => activeAccount() ?? app.accounts[0] ?? null); const hasSession = createMemo(() => !!app.activeSession); const railCompact = createMemo(() => app.railCollapsed && !app.narrowViewport); + const railCondensed = createMemo(() => railCompact() || app.narrowViewport); const railColumns = createMemo(() => (railCompact() ? "5.75rem minmax(0,1fr)" : "16rem minmax(0,1fr)")); const metaLabel = createMemo(() => { if (app.bootstrapping) { @@ -209,9 +210,10 @@ function App() { activeAccount={activeAccount()} activeSession={app.activeSession} accounts={app.accounts} - collapsed={railCompact()} + collapsed={railCondensed()} hasSession={hasSession()} logoutDid={app.logoutDid} + narrow={app.narrowViewport} openSwitcher={app.showSwitcher} switchingDid={app.switchingDid} onLogout={(did) => void logout(did)} @@ -220,7 +222,7 @@ function App() { onToggleSwitcher={() => setApp("showSwitcher", (open) => !open)} />
{props.children}
diff --git a/src/components/AppRail.tsx b/src/components/AppRail.tsx index aed884a..d06bd28 100644 --- a/src/components/AppRail.tsx +++ b/src/components/AppRail.tsx @@ -8,7 +8,7 @@ import { Wordmark } from "./Wordmark"; function RailHeader(props: { collapsed: boolean; onToggleCollapse: () => void }) { return (
); diff --git a/src/components/feeds/FeedWorkspace.tsx b/src/components/feeds/FeedWorkspace.tsx index a7e05d4..38f75fc 100644 --- a/src/components/feeds/FeedWorkspace.tsx +++ b/src/components/feeds/FeedWorkspace.tsx @@ -5,6 +5,9 @@ import { getFeedCommand, getFeedName, getReplyRootPost, + parseFeedGeneratorsResponse, + parseFeedResponse, + parseThreadResponse, patchFeedItems, patchThreadNode, toStrongRef, @@ -14,12 +17,10 @@ import type { CreateRecordResult, EmbedInput, FeedGeneratorView, - FeedResponse, FeedViewPrefItem, PostView, ReplyRefInput, SavedFeedItem, - ThreadResponse, UserPreferences, } from "$/lib/types"; import { shouldIgnoreKey } from "$/lib/utils/events"; @@ -33,7 +34,19 @@ import { FeedComposer } from "./FeedComposer"; import { SavedFeedsDrawer } from "./FeedDrawer"; import { FeedPane } from "./FeedPane"; import { ThreadPanel } from "./ThreadPanel"; -import type { FeedState, FeedWorkspaceState } from "./types"; +import type { FeedWorkspaceState } from "./types"; +import { + buildLocalPrefs, + createDefaultFeedPref, + createDefaultFeedState, + createDefaultThreadState, + createInitialWorkspaceState, + DEFAULT_TIMELINE, + getNextFocusedIndex, + getNextFocusedScrollTop, + updateFeedScrollState, + upsertFeedViewPrefs, +} from "./workspace-state"; type FeedWorkspaceProps = { activeSession: ActiveSession; @@ -44,45 +57,6 @@ type FeedWorkspaceProps = { const DEFAULT_LIMIT = 30; -const DEFAULT_TIMELINE: SavedFeedItem = { id: "following", type: "timeline", value: "following", pinned: true }; - -function createDefaultFeedState(): FeedState { - return { cursor: null, error: null, items: [], loading: false, loadingMore: false, scrollTop: 0 }; -} - -function createDefaultFeedPref(feed: SavedFeedItem): FeedViewPrefItem { - return { - feed: feed.value, - hideQuotePosts: false, - hideReplies: false, - hideRepliesByLikeCount: null, - hideRepliesByUnfollowed: false, - hideReposts: false, - }; -} - -function createInitialWorkspaceState(): FeedWorkspaceState { - return { - activeFeedId: null, - composer: { open: false, pending: false, quoteTarget: null, replyRoot: null, replyTarget: null, text: "" }, - feedStates: {}, - focusedIndex: 0, - generators: {}, - likePendingByUri: {}, - likePulseUri: null, - localPrefs: {}, - preferences: null, - repostPendingByUri: {}, - repostPulseUri: null, - showFeedsDrawer: false, - thread: createDefaultThreadState(), - }; -} - -function createDefaultThreadState() { - return { data: null, error: null, loading: false, uri: null } satisfies FeedWorkspaceState["thread"]; -} - export function FeedWorkspace(props: FeedWorkspaceProps) { const [workspace, setWorkspace] = createStore(createInitialWorkspaceState()); @@ -150,7 +124,7 @@ export function FeedWorkspace(props: FeedWorkspaceProps) { void ensureFeedLoaded(feed); const nextScrollTop = workspace.feedStates[feed.id]?.scrollTop ?? 0; queueMicrotask(() => { - if (scroller) { + if (scroller && scroller.scrollTop !== nextScrollTop) { scroller.scrollTop = nextScrollTop; } }); @@ -195,16 +169,28 @@ export function FeedWorkspace(props: FeedWorkspaceProps) { lastFocusedUri = item.post.uri; queueMicrotask(() => { + if (!scroller) { + return; + } + const element = postRefs.get(item.post.uri); if (!element?.isConnected) { return; } - if (document.activeElement !== element) { - element.focus(); - } + const scrollerRect = scroller.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + const itemTop = elementRect.top - scrollerRect.top + scroller.scrollTop; + const nextScrollTop = getNextFocusedScrollTop( + scroller.scrollTop, + scroller.clientHeight, + itemTop, + element.offsetHeight, + ); - element.scrollIntoView({ block: "nearest" }); + if (nextScrollTop !== null && scroller.scrollTop !== nextScrollTop) { + scroller.scrollTop = nextScrollTop; + } }); }); @@ -263,10 +249,10 @@ export function FeedWorkspace(props: FeedWorkspaceProps) { event.preventDefault(); setWorkspace("focusedIndex", (current) => { if (event.key === "j") { - return Math.min(current + 1, items.length - 1); + return getNextFocusedIndex(current, "next", items.length); } - return Math.max(current - 1, 0); + return getNextFocusedIndex(current, "previous", items.length); }); return; } @@ -331,16 +317,13 @@ export function FeedWorkspace(props: FeedWorkspaceProps) { } setWorkspace("preferences", nextPreferences); - setWorkspace( - "localPrefs", - reconcile(Object.fromEntries(nextPreferences.feedViewPrefs.map((pref) => [pref.feed, pref]))), - ); + setWorkspace("localPrefs", reconcile(buildLocalPrefs(nextPreferences))); const uris = [ ...new Set(nextPreferences.savedFeeds.filter((feed) => feed.type === "feed").map((feed) => feed.value)), ]; if (uris.length > 0) { - const hydrated = await invoke<{ feeds: FeedGeneratorView[] }>("get_feed_generators", { uris }); + const hydrated = parseFeedGeneratorsResponse(await invoke("get_feed_generators", { uris })); setWorkspace( "generators", reconcile(Object.fromEntries(hydrated.feeds.map((generator) => [generator.uri, generator]))), @@ -375,7 +358,7 @@ export function FeedWorkspace(props: FeedWorkspaceProps) { try { const command = getFeedCommand(feed); - const payload = await invoke(command.name, command.args(state.cursor, DEFAULT_LIMIT)); + const payload = parseFeedResponse(await invoke(command.name, command.args(state.cursor, DEFAULT_LIMIT))); const items = append ? [...state.items, ...payload.feed] : payload.feed; setWorkspace("feedStates", feed.id, { cursor: payload.cursor ?? null, @@ -397,7 +380,7 @@ export function FeedWorkspace(props: FeedWorkspaceProps) { setWorkspace("thread", { data: null, error: null, loading: true, uri }); try { - const payload = await invoke("get_post_thread", { uri }); + const payload = parseThreadResponse(await invoke("get_post_thread", { uri })); if (props.threadUri === uri) { setWorkspace("thread", { data: payload.thread, error: null, loading: false, uri }); } @@ -634,7 +617,7 @@ export function FeedWorkspace(props: FeedWorkspaceProps) { return ( <> -
+
{ sentinel = element; }} - setScrollTop={(top) => - setWorkspace("feedStates", activeFeed().id, { - ...(workspace.feedStates[activeFeed().id] ?? createDefaultFeedState()), - scrollTop: top, - })} + setScrollTop={(top) => { + const feedId = activeFeed().id; + const nextState = updateFeedScrollState(workspace.feedStates[feedId], top); + if (!nextState) { + return; + } + + setWorkspace("feedStates", feedId, nextState); + }} visibleItems={visibleItems()} /> ); - function setFeedPref(key: K, value: FeedViewPrefItem[K]) { + async function setFeedPref(key: K, value: FeedViewPrefItem[K]) { const feed = activeFeed(); - setWorkspace("localPrefs", feed.value, { ...activePref(), [key]: value }); + const previousPref = activePref(); + const nextPref = { ...previousPref, [key]: value }; + + setWorkspace("localPrefs", feed.value, nextPref); + + try { + await invoke("update_feed_view_pref", { pref: nextPref }); + setWorkspace( + "preferences", + (current) => + current ? { ...current, feedViewPrefs: upsertFeedViewPrefs(current.feedViewPrefs, nextPref) } : current, + ); + } catch (error) { + setWorkspace("localPrefs", feed.value, previousPref); + props.onError(`Failed to update display filters: ${String(error)}`); + } } } function WorkspaceSidebar( props: { - activePref: UserPreferences["feedViewPrefs"][number]; + activePref: FeedViewPrefItem; drawerFeeds: SavedFeedItem[]; generators: Record; onFeedSelect: (feedId: string) => void; - onPrefChange: ( - key: K, - value: UserPreferences["feedViewPrefs"][number][K], - ) => void; + onPrefChange: (key: K, value: FeedViewPrefItem[K]) => void; }, ) { return ( -
); diff --git a/src/components/feeds/PostCard.tsx b/src/components/feeds/PostCard.tsx index 4aabc49..db5f0cc 100644 --- a/src/components/feeds/PostCard.tsx +++ b/src/components/feeds/PostCard.tsx @@ -1,6 +1,5 @@ import { Icon } from "$/components/shared/Icon"; import { - formatCount, formatRelativeTime, getAvatarLabel, getDisplayName, @@ -9,7 +8,8 @@ import { getQuotedAuthor, getQuotedText, } from "$/lib/feeds"; -import type { FeedViewPost, ImagesEmbedView, PostView } from "$/lib/types"; +import type { FeedViewPost, ImagesEmbedView, PostView, ProfileViewBasic } from "$/lib/types"; +import { formatCount } from "$/lib/utils/text"; import { createMemo, For, Match, Show, Switch } from "solid-js"; import { Motion } from "solid-motionone"; @@ -44,6 +44,10 @@ export function PostCard(props: PostCardProps) { return `${getDisplayName(reason.by)} reposted`; }); + const likeCount = createMemo(() => formatCount(props.post.likeCount)); + const replyCount = createMemo(() => formatCount(props.post.replyCount)); + const repostCount = createMemo(() => formatCount(props.post.repostCount)); + return ( props.registerRef?.(element)} @@ -99,16 +103,16 @@ export function PostCard(props: PostCardProps) { busy={!!props.likePending} icon="i-ri-heart-3-line" iconActive="i-ri-heart-3-fill" - label={formatCount(props.post.likeCount)} + label={likeCount()} pulse={!!props.pulseLike} onClick={props.onLike} /> - + @@ -167,10 +171,8 @@ function ActionButton( } function PostEmbeds(props: { post: PostView }) { - const embed = createMemo(() => props.post.embed); - return ( - + {(current) => (
@@ -204,9 +206,10 @@ function PostEmbeds(props: { post: PostView }) { } function ImageEmbed(props: { embed: ImagesEmbedView }) { + const images = createMemo(() => props.embed.images.slice(0, 4)); return (
1 }}> - + {(image) => (
{image.alt @@ -246,12 +249,13 @@ function ExternalEmbed(props: { description?: string; thumb?: string; title?: st ); } -function QuoteEmbed(props: { author: PostView["author"] | null; text?: unknown; title: string }) { +function QuoteEmbed(props: { author: ProfileViewBasic | null; text?: unknown; title: string }) { const preview = createMemo(() => (typeof props.text === "string" ? props.text : "")); + const title = () => props.title; return (
-

{props.title}

+

{title()}

{(author) => (

@@ -274,21 +278,18 @@ function LinkifiedText(props: { text: string }) { return ( - {(part) => { - if (/^https?:\/\//i.test(part)) { - return ( + {(part) => ( + {part}}> + {part} - ); - } - - if (/^[@#]/.test(part)) { - return {part}; - } - - return {part}; - }} + + + {part} + + + )} ); } diff --git a/src/components/feeds/types.ts b/src/components/feeds/types.ts index 90def2a..58ad617 100644 --- a/src/components/feeds/types.ts +++ b/src/components/feeds/types.ts @@ -16,16 +16,20 @@ export type FeedState = { scrollTop: number; }; +export type ComposerState = { + open: boolean; + pending: boolean; + quoteTarget: PostView | null; + replyRoot: PostView | null; + replyTarget: PostView | null; + text: string; +}; + +export type ThreadState = { data: ThreadNode | null; error: string | null; loading: boolean; uri: string | null }; + export type FeedWorkspaceState = { activeFeedId: string | null; - composer: { - open: boolean; - pending: boolean; - quoteTarget: PostView | null; - replyRoot: PostView | null; - replyTarget: PostView | null; - text: string; - }; + composer: ComposerState; feedStates: Record; focusedIndex: number; generators: Record; @@ -36,5 +40,5 @@ export type FeedWorkspaceState = { repostPendingByUri: Record; repostPulseUri: string | null; showFeedsDrawer: boolean; - thread: { data: ThreadNode | null; error: string | null; loading: boolean; uri: string | null }; + thread: ThreadState; }; diff --git a/src/components/feeds/workspace-state.test.ts b/src/components/feeds/workspace-state.test.ts new file mode 100644 index 0000000..24fa033 --- /dev/null +++ b/src/components/feeds/workspace-state.test.ts @@ -0,0 +1,77 @@ +import type { FeedViewPrefItem, UserPreferences } from "$/lib/types"; +import { describe, expect, it } from "vitest"; +import { + buildLocalPrefs, + createDefaultFeedPref, + createDefaultFeedState, + DEFAULT_TIMELINE, + getNextFocusedIndex, + getNextFocusedScrollTop, + updateFeedScrollState, + upsertFeedViewPrefs, +} from "./workspace-state"; + +const createFeedViewPref = (overrides: Partial = {}) => ({ + feed: "following", + hideQuotePosts: false, + hideReplies: false, + hideRepliesByLikeCount: null, + hideRepliesByUnfollowed: true, + hideReposts: false, + ...overrides, +}); + +describe("workspaceState", () => { + it("builds default timeline preferences with unfollowed replies hidden", () => { + expect(createDefaultFeedPref(DEFAULT_TIMELINE)).toEqual({ + feed: "following", + hideQuotePosts: false, + hideReplies: false, + hideRepliesByLikeCount: null, + hideRepliesByUnfollowed: true, + hideReposts: false, + }); + }); + + it("indexes feed preferences by feed id", () => { + const preferences = { + savedFeeds: [], + feedViewPrefs: [createFeedViewPref(), createFeedViewPref({ feed: "at://feed/custom", hideReposts: true })], + } satisfies UserPreferences; + + expect(buildLocalPrefs(preferences)).toEqual({ + following: createFeedViewPref(), + "at://feed/custom": createFeedViewPref({ feed: "at://feed/custom", hideReposts: true }), + }); + }); + + it("upserts a saved feed preference without dropping unrelated ones", () => { + const current = [createFeedViewPref(), createFeedViewPref({ feed: "at://feed/custom", hideReplies: true })]; + const nextPref = createFeedViewPref({ hideReposts: true, hideRepliesByLikeCount: 5 }); + + expect(upsertFeedViewPrefs(current, nextPref)).toEqual([ + createFeedViewPref({ feed: "at://feed/custom", hideReplies: true }), + createFeedViewPref({ hideReposts: true, hideRepliesByLikeCount: 5 }), + ]); + }); + + it("clamps keyboard focus movement within the rendered feed", () => { + expect(getNextFocusedIndex(0, "next", 3)).toBe(1); + expect(getNextFocusedIndex(2, "next", 3)).toBe(2); + expect(getNextFocusedIndex(0, "previous", 3)).toBe(0); + expect(getNextFocusedIndex(2, "previous", 3)).toBe(1); + expect(getNextFocusedIndex(0, "next", 0)).toBe(0); + }); + + it("avoids scroll-state writes when the scroll position is unchanged", () => { + const state = createDefaultFeedState(); + expect(updateFeedScrollState(state, 0)).toBeNull(); + expect(updateFeedScrollState(state, 48)).toEqual({ ...state, scrollTop: 48 }); + }); + + it("computes focused-post scrolling without using browser focus", () => { + expect(getNextFocusedScrollTop(120, 300, 100, 60)).toBe(84); + expect(getNextFocusedScrollTop(120, 300, 380, 80)).toBe(176); + expect(getNextFocusedScrollTop(120, 300, 180, 60)).toBeNull(); + }); +}); diff --git a/src/components/feeds/workspace-state.ts b/src/components/feeds/workspace-state.ts new file mode 100644 index 0000000..41a5d1d --- /dev/null +++ b/src/components/feeds/workspace-state.ts @@ -0,0 +1,88 @@ +import type { FeedViewPrefItem, FeedViewPrefs, SavedFeedItem, UserPreferences } from "$/lib/types"; +import type { FeedState, FeedWorkspaceState, ThreadState } from "./types"; + +export const DEFAULT_TIMELINE: SavedFeedItem = { id: "following", type: "timeline", value: "following", pinned: true }; + +export function createDefaultFeedState(): FeedState { + return { cursor: null, error: null, items: [], loading: false, loadingMore: false, scrollTop: 0 }; +} + +export const createDefaultThreadState = (): ThreadState => ({ data: null, error: null, loading: false, uri: null }); + +export const createDefaultFeedPref = (feed: SavedFeedItem): FeedViewPrefItem => ({ + feed: feed.value, + hideQuotePosts: false, + hideReplies: false, + hideRepliesByLikeCount: null, + hideRepliesByUnfollowed: true, + hideReposts: false, +}); + +export function createInitialWorkspaceState(): FeedWorkspaceState { + return { + activeFeedId: null, + composer: { open: false, pending: false, quoteTarget: null, replyRoot: null, replyTarget: null, text: "" }, + feedStates: {}, + focusedIndex: 0, + generators: {}, + likePendingByUri: {}, + likePulseUri: null, + localPrefs: {}, + preferences: null, + repostPendingByUri: {}, + repostPulseUri: null, + showFeedsDrawer: false, + thread: createDefaultThreadState(), + }; +} + +export function buildLocalPrefs(preferences: UserPreferences): Record { + return Object.fromEntries(preferences.feedViewPrefs.map((pref) => [pref.feed, pref])); +} + +export function upsertFeedViewPrefs(feedViewPrefs: FeedViewPrefs, nextPref: FeedViewPrefItem): FeedViewPrefs { + return [...feedViewPrefs.filter((pref) => pref.feed !== nextPref.feed), nextPref]; +} + +export function getNextFocusedIndex(currentIndex: number, direction: "next" | "previous", totalItems: number): number { + if (totalItems <= 0) { + return 0; + } + + if (direction === "next") { + return Math.min(currentIndex + 1, totalItems - 1); + } + + return Math.max(currentIndex - 1, 0); +} + +export function updateFeedScrollState(state: FeedState | undefined, scrollTop: number): FeedState | null { + const currentState = state ?? createDefaultFeedState(); + if (currentState.scrollTop === scrollTop) { + return null; + } + + return { ...currentState, scrollTop }; +} + +export function getNextFocusedScrollTop( + currentScrollTop: number, + viewportHeight: number, + itemTop: number, + itemHeight: number, + padding = 16, +): number | null { + const viewportTop = currentScrollTop; + const viewportBottom = currentScrollTop + viewportHeight; + const itemBottom = itemTop + itemHeight; + + if (itemTop < viewportTop + padding) { + return Math.max(0, itemTop - padding); + } + + if (itemBottom > viewportBottom - padding) { + return Math.max(0, itemBottom - viewportHeight + padding); + } + + return null; +} diff --git a/src/lib/feeds.test.ts b/src/lib/feeds.test.ts index 1591d27..0e46630 100644 --- a/src/lib/feeds.test.ts +++ b/src/lib/feeds.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { applyFeedPreferences, buildThreadRoute, decodeThreadRouteUri, getFeedCommand } from "./feeds"; +import { + applyFeedPreferences, + buildThreadRoute, + decodeThreadRouteUri, + getFeedCommand, + parseFeedResponse, + parseThreadResponse, +} from "./feeds"; import type { FeedViewPost, FeedViewPrefItem, SavedFeedItem } from "./types"; function createFeedItem(overrides: Partial = {}): FeedViewPost { @@ -92,4 +99,13 @@ describe("feed helpers", () => { expect(decodeThreadRouteUri(uri)).toBe(uri); expect(decodeThreadRouteUri("https%3A%2F%2Fexample.com")).toBeNull(); }); + + it("rejects malformed feed payloads", () => { + expect(() => parseFeedResponse({ cursor: null, feed: {} })).toThrow("feed response payload is invalid"); + expect(() => parseFeedResponse({ cursor: 42, feed: [] })).toThrow("feed response cursor is invalid"); + }); + + it("rejects malformed thread payloads", () => { + expect(() => parseThreadResponse({ thread: { nope: true } })).toThrow("thread response payload is invalid"); + }); }); diff --git a/src/lib/feeds.ts b/src/lib/feeds.ts index 47090be..db7bdb3 100644 --- a/src/lib/feeds.ts +++ b/src/lib/feeds.ts @@ -1,7 +1,9 @@ import type { BlockedPost, EmbedView, + FeedGeneratorsResponse, FeedReplyNode, + FeedResponse, FeedViewPost, FeedViewPrefItem, Maybe, @@ -12,6 +14,7 @@ import type { SavedFeedItem, StrongRefInput, ThreadNode, + ThreadResponse, ThreadViewPost, } from "./types"; @@ -31,6 +34,82 @@ export function asPostRecord(value: unknown): PostRecord { return (asRecord(value) ?? {}) as PostRecord; } +function asArray(value: unknown) { + return Array.isArray(value) ? value : null; +} + +function isProfileViewBasic(value: unknown): boolean { + const record = asRecord(value); + return !!record && typeof record.did === "string" && typeof record.handle === "string"; +} + +function isPostView(value: unknown): value is PostView { + const record = asRecord(value); + const author = asRecord(record?.author); + const postRecord = asRecord(record?.record); + + return !!record + && !!author + && !!postRecord + && typeof record.cid === "string" + && typeof record.indexedAt === "string" + && typeof record.uri === "string" + && isProfileViewBasic(author); +} + +function isFeedViewPost(value: unknown): value is FeedViewPost { + const record = asRecord(value); + return !!record && isPostView(record.post); +} + +function isThreadNode(value: unknown): value is ThreadNode { + const record = asRecord(value); + if (!record || typeof record.$type !== "string") { + return false; + } + + if (record.$type === "app.bsky.feed.defs#threadViewPost") { + return isPostView(record.post); + } + + return record.$type === "app.bsky.feed.defs#blockedPost" || record.$type === "app.bsky.feed.defs#notFoundPost"; +} + +export function parseFeedResponse(value: unknown): FeedResponse { + const record = asRecord(value); + const feed = asArray(record?.feed); + + if (!record || !feed || !feed.every((item) => isFeedViewPost(item))) { + throw new Error("feed response payload is invalid"); + } + + if (record.cursor !== undefined && record.cursor !== null && typeof record.cursor !== "string") { + throw new Error("feed response cursor is invalid"); + } + + return { cursor: (record.cursor as string | null | undefined) ?? null, feed }; +} + +export function parseThreadResponse(value: unknown): ThreadResponse { + const record = asRecord(value); + if (!record || !isThreadNode(record.thread)) { + throw new Error("thread response payload is invalid"); + } + + return { thread: record.thread }; +} + +export function parseFeedGeneratorsResponse(value: unknown): FeedGeneratorsResponse { + const record = asRecord(value); + const feeds = asArray(record?.feeds); + + if (!record || !feeds) { + throw new Error("feed generators payload is invalid"); + } + + return { feeds: feeds as FeedGeneratorsResponse["feeds"] }; +} + export function getPostText(post: PostView) { const text = post.record.text; return typeof text === "string" ? text.trim() : ""; @@ -74,18 +153,6 @@ export function formatRelativeTime(value: string) { return formatter.format(deltaSeconds, "second"); } -export function formatCount(value: Maybe) { - if (!value) { - return "0"; - } - - if (value >= 1000) { - return `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}K`; - } - - return value.toString(); -} - export function getFeedName(item: { type: string; value: string }, hydratedName?: string | null) { if (item.type === "timeline") { return item.value === "following" ? "Following" : "Timeline"; diff --git a/src/lib/types.ts b/src/lib/types.ts index d824b6a..7a1b01d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -21,7 +21,9 @@ export type FeedViewPrefItem = { hideQuotePosts: boolean; }; -export type UserPreferences = { savedFeeds: SavedFeedItem[]; feedViewPrefs: FeedViewPrefItem[] }; +export type FeedViewPrefs = Array; + +export type UserPreferences = { savedFeeds: SavedFeedItem[]; feedViewPrefs: FeedViewPrefs }; export type AuthorViewerState = { following?: string | null }; diff --git a/src/lib/utils/text.ts b/src/lib/utils/text.ts index 3f5c0c5..13b81b2 100644 --- a/src/lib/utils/text.ts +++ b/src/lib/utils/text.ts @@ -1,3 +1,17 @@ +import type { Maybe } from "$/lib/types"; + export function escapeForRegex(value: string) { return value.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); } + +export function formatCount(value: Maybe) { + if (!value) { + return "0"; + } + + if (value >= 1000) { + return `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}K`; + } + + return value.toString(); +} diff --git a/vite.config.ts b/vite.config.ts index d88d619..03793cd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,6 +11,7 @@ const test: ViteUserConfig["test"] = { server: { deps: { inline: ["@solidjs/router"] } }, ui: false, watch: false, + testTimeout: 2500, }; const host = process.env.TAURI_DEV_HOST;