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: consolidate feed API

* update changelog

+71 -99
+1
CHANGELOG.md
··· 4 4 5 5 ### 2026-04-07 6 6 7 + - Image (single or gallery) & video player with blob downloading 7 8 - Group notifications by reason & post, with clickable links to subjects and posts 8 9 9 10 ### 2026-04-06
+3
docs/tasks/14-drafts.md
··· 10 10 11 11 - [ ] Media attachments in drafts (requires local blob caching + re-upload on submit) 12 12 - [ ] Thread builder (compose multi-post threads as a single draft) 13 + 14 + --- 15 + 13 16 - Cross-device sync via AT Protocol Permissioned Data (blocked on protocol — expected summer 2026)
+7 -50
docs/tasks/15-media.md
··· 4 4 5 5 Depends on: Milestone 03 (Feeds — PostCard, EmbedContent), Milestone 06 (Settings) 6 6 7 - ## Steps 7 + Completed April 7, 2026. See [CHANGELOG](../CHANGELOG.md) for details. 8 8 9 - ### Backend - `src-tauri/src/media.rs` + `src-tauri/src/commands/media.rs` 9 + ## Parking Lot 10 10 11 - - [x] Add `DownloadDirectory` variant to `SettingsKey` enum, default to `~/Downloads` via `dirs::download_dir()` 12 - - [x] `get_download_directory()` — resolve current download path (setting or OS default), validate it exists 13 - - [x] `set_download_directory(path: String)` — validate path is a writable directory, persist to `app_settings` 14 - - [x] `download_image(url: String, filename: Option<String>)` — HTTP fetch → write to download dir, return `{ path, bytes }` 15 - - [x] `download_video(url: String, filename: Option<String>)` — fetch m3u8 manifest, resolve best variant, download TS segments, concatenate to MP4, return `{ path, bytes }` 16 - - [x] Emit `download-progress` events during video download for frontend progress UI 17 - - [x] Filename collision handling: append `_1`, `_2`, etc. if file already exists 18 - - [x] Add `dialog:default` and scoped `fs` permissions to `capabilities/default.json` 11 + - Custom video player controls (scrubber, volume, speed)? Include download? 12 + - Save to custom album/folder per account. Bigger question is if settings are scoped to 13 + user accounts? 19 14 20 - ### Frontend - Video Player (`src/components/feeds/VideoEmbed.tsx`) 21 - 22 - - [x] `VideoEmbed` component: `<video>` element with poster from `thumbnail`, native controls 23 - - [x] Lazy-load HLS.js — attach to video element only when `playlist` URL is m3u8 24 - - [x] Click-to-play: show thumbnail + centered play button overlay, start playback on click 25 - - [x] Respect `aspectRatio` from embed to prevent layout shift 26 - - [x] Render `alt` text as caption below player when present 27 - - [x] Replace `ExternalEmbed` fallback in `EmbedContent` switch for `app.bsky.embed.video#view` 28 - - [x] Download button in player controls area → invoke `download_video` command 29 - 30 - ### Frontend - Image Gallery (`src/components/feeds/ImageGallery.tsx`) 31 - 32 - - [x] Gallery overlay: glass background (`surface_container_highest` 70% + backdrop-blur 20px) 33 - - [x] Display `fullsize` image with `object-contain`, constrained to viewport 34 - - [x] `Presence` fade-in/fade-out transitions 35 - - [x] Left/right navigation arrows + position indicator for multi-image posts 36 - - [x] Keyboard: `Escape` close, `ArrowLeft`/`ArrowRight` navigate 37 - - [x] Caption panel: alt text (`body-md`), post text truncated to 2 lines with expand, author handle as link 38 - - [x] Download button in gallery toolbar → invoke `download_image` command 39 - - [x] Wire `ImageEmbed` click handler to open gallery at the clicked image index 40 - 41 - ### Frontend - Download UX 42 - 43 - - [x] Download button spinner/progress indicator while active 44 - - [x] Success toast: filename + "Open in Finder" action (via `tauri-plugin-opener`) 45 - - [x] Error toast: human-readable failure message 46 - - [x] Right-click context menu on inline images with "Save image" option 47 - 48 - ### Frontend - Settings Integration 49 - 50 - - [ ] Add "Downloads" section to Settings view between "Data" and "Danger Zone" 51 - - [ ] Path display + "Browse" button using Tauri `dialog.open({ directory: true })` 52 - - [ ] "Reset to default" link to restore `~/Downloads` 53 - 54 - ### Parking Lot 55 - 56 - - [ ] Custom video player controls (scrubber, volume, speed) 57 - - [ ] Pinch-to-zoom and swipe gestures in gallery 15 + - [ ] Pinch-to-zoom and swipe gestures in gallery (or arrow navigation) 58 16 - [ ] Download queue with concurrent downloads 59 - - [ ] Batch download (all images in a post) 60 - - [ ] Save to custom album/folder per account 17 + - [ ] Batch download (all images in a post) as zip (`{rkey}_images.zip`)
+4 -1
src/components/deck/AddColumnPanel.test.tsx
··· 5 5 const getFeedGeneratorsMock = vi.hoisted(() => vi.fn()); 6 6 const getPreferencesMock = vi.hoisted(() => vi.fn()); 7 7 8 - vi.mock("$/lib/api/feeds", () => ({ getFeedGenerators: getFeedGeneratorsMock, getPreferences: getPreferencesMock })); 8 + vi.mock( 9 + "$/lib/api/feeds", 10 + () => ({ FeedController: { getFeedGenerators: getFeedGeneratorsMock, getPreferences: getPreferencesMock } }), 11 + ); 9 12 vi.mock("@tauri-apps/plugin-log", () => ({ error: vi.fn(), info: vi.fn(), warn: vi.fn() })); 10 13 11 14 describe("AddColumnPanel", () => {
+3 -3
src/components/deck/AddColumnPanel.tsx
··· 1 1 import { ActorSuggestionList, useActorSuggestions } from "$/components/actors/actor-search"; 2 - import { getFeedGenerators, getPreferences } from "$/lib/api/feeds"; 2 + import { FeedController } from "$/lib/api/feeds"; 3 3 import type { ColumnKind } from "$/lib/api/types/columns"; 4 4 import type { SearchMode } from "$/lib/api/types/search"; 5 5 import { getFeedName } from "$/lib/feeds"; ··· 38 38 39 39 onMount(async () => { 40 40 try { 41 - const prefs = await getPreferences(); 41 + const prefs = await FeedController.getPreferences(); 42 42 setFeeds(prefs.savedFeeds); 43 43 44 44 const uris = [...new Set(prefs.savedFeeds.filter((feed) => feed.type === "feed").map((feed) => feed.value))]; 45 45 if (uris.length > 0) { 46 - const hydrated = await getFeedGenerators(uris); 46 + const hydrated = await FeedController.getFeedGenerators(uris); 47 47 setGenerators(Object.fromEntries(hydrated.feeds.map((generator) => [generator.uri, generator]))); 48 48 } 49 49 } catch (err) {
+3 -3
src/components/deck/DeckWorkspace.tsx
··· 1 1 import { useThreadOverlayNavigation } from "$/components/posts/useThreadOverlayNavigation"; 2 2 import { useAppSession } from "$/contexts/app-session"; 3 3 import { addColumn, getColumns, removeColumn, reorderColumns, updateColumn } from "$/lib/api/columns"; 4 - import { getFeedGenerators, getPreferences } from "$/lib/api/feeds"; 4 + import { FeedController } from "$/lib/api/feeds"; 5 5 import type { Column, ColumnKind, ColumnWidth } from "$/lib/api/types/columns"; 6 6 import { getFeedName } from "$/lib/feeds"; 7 7 import type { FeedGeneratorView } from "$/lib/types"; ··· 188 188 ); 189 189 190 190 try { 191 - const preferences = await getPreferences(); 191 + const preferences = await FeedController.getPreferences(); 192 192 const savedFeedTitles = Object.fromEntries( 193 193 preferences.savedFeeds.map((feed) => [feed.value, getFeedName(feed, void 0)]), 194 194 ); ··· 203 203 let generators: Record<string, FeedGeneratorView> = {}; 204 204 205 205 if (generatorUris.length > 0) { 206 - const hydrated = await getFeedGenerators(generatorUris); 206 + const hydrated = await FeedController.getFeedGenerators(generatorUris); 207 207 generators = Object.fromEntries(hydrated.feeds.map((generator) => [generator.uri, generator])); 208 208 } 209 209
+2 -2
src/components/deck/useFeedColumnState.ts
··· 1 1 import { usePostInteractions } from "$/components/posts/usePostInteractions"; 2 - import { getFeedPage } from "$/lib/api/feeds"; 2 + import { FeedController } from "$/lib/api/feeds"; 3 3 import { patchFeedItems } from "$/lib/feeds"; 4 4 import type { FeedViewPost, SavedFeedItem } from "$/lib/types"; 5 5 import * as logger from "@tauri-apps/plugin-log"; ··· 39 39 40 40 async function load(cursor: string | null = null) { 41 41 try { 42 - const page = await getFeedPage(getFeed(), cursor, PAGE_LIMIT); 42 + const page = await FeedController.getFeedPage(getFeed(), cursor, PAGE_LIMIT); 43 43 44 44 if (cursor) { 45 45 setState("items", (prev) => [...prev, ...page.feed]);
+2 -2
src/components/feeds/ComposerWindow.tsx
··· 1 1 import { useAppSession } from "$/contexts/app-session"; 2 - import { createPost } from "$/lib/api/feeds"; 2 + import { FeedController } from "$/lib/api/feeds"; 3 3 import { POST_CREATED_EVENT } from "$/lib/constants/events"; 4 4 import { emitTo } from "@tauri-apps/api/event"; 5 5 import { getCurrentWindow } from "@tauri-apps/api/window"; ··· 23 23 24 24 setPending(true); 25 25 try { 26 - await createPost(nextText, null, null); 26 + await FeedController.createPost(nextText, null, null); 27 27 await emitTo("main", POST_CREATED_EVENT, null); 28 28 await closeWindow(); 29 29 } catch (error) {
+8 -16
src/components/feeds/useFeedWorkspaceController.ts
··· 1 1 import { usePostInteractions } from "$/components/posts/usePostInteractions"; 2 2 import { DraftController } from "$/lib/api/drafts"; 3 - import { 4 - createPost, 5 - getFeedGenerators, 6 - getFeedPage, 7 - getPostThread, 8 - getPreferences, 9 - updateFeedViewPref, 10 - updateSavedFeeds, 11 - } from "$/lib/api/feeds"; 3 + import { FeedController } from "$/lib/api/feeds"; 12 4 import { POST_CREATED_EVENT } from "$/lib/constants/events"; 13 5 import { 14 6 applyFeedPreferences, ··· 101 93 102 94 async function resolvePostByUri(uri: string): Promise<PostView | null> { 103 95 try { 104 - const payload = await getPostThread(uri); 96 + const payload = await FeedController.getPostThread(uri); 105 97 const post = findPostInThread(payload.thread, uri); 106 98 if (post) { 107 99 return post; ··· 633 625 setWorkspace(reconcile(createInitialWorkspaceState())); 634 626 635 627 try { 636 - const nextPreferences = await getPreferences(); 628 + const nextPreferences = await FeedController.getPreferences(); 637 629 if (currentDid !== props.activeSession.did) { 638 630 return; 639 631 } ··· 645 637 ...new Set(nextPreferences.savedFeeds.filter((feed) => feed.type === "feed").map((feed) => feed.value)), 646 638 ]; 647 639 if (uris.length > 0) { 648 - const hydrated = await getFeedGenerators(uris); 640 + const hydrated = await FeedController.getFeedGenerators(uris); 649 641 setWorkspace( 650 642 "generators", 651 643 reconcile(Object.fromEntries(hydrated.feeds.map((generator) => [generator.uri, generator]))), ··· 681 673 } 682 674 683 675 try { 684 - const payload = await getFeedPage(feed, state.cursor, DEFAULT_LIMIT); 676 + const payload = await FeedController.getFeedPage(feed, state.cursor, DEFAULT_LIMIT); 685 677 const items = append ? [...state.items, ...payload.feed] : payload.feed; 686 678 setWorkspace("feedStates", feed.id, { 687 679 cursor: payload.cursor ?? null, ··· 851 843 852 844 setWorkspace("composer", "pending", true); 853 845 try { 854 - await createPost(text, replyTo, embed); 846 + await FeedController.createPost(text, replyTo, embed); 855 847 856 848 if (autosaveTimerId !== null) { 857 849 clearTimeout(autosaveTimerId); ··· 902 894 903 895 async function saveFeedPreferences(updatedFeeds: SavedFeedItem[]) { 904 896 try { 905 - await updateSavedFeeds(updatedFeeds); 897 + await FeedController.updateSavedFeeds(updatedFeeds); 906 898 setWorkspace("preferences", (current) => current ? { ...current, savedFeeds: updatedFeeds } : current); 907 899 } catch (error) { 908 900 props.onError(`Failed to update feeds: ${String(error)}`); ··· 960 952 setWorkspace("localPrefs", feed.value, nextPref); 961 953 962 954 try { 963 - await updateFeedViewPref(nextPref); 955 + await FeedController.updateFeedViewPref(nextPref); 964 956 setWorkspace( 965 957 "preferences", 966 958 (current) =>
+2 -2
src/components/posts/ThreadModal.tsx
··· 1 1 import { Icon } from "$/components/shared/Icon"; 2 2 import { useAppSession } from "$/contexts/app-session"; 3 - import { getPostThread } from "$/lib/api/feeds"; 3 + import { FeedController } from "$/lib/api/feeds"; 4 4 import { findRootPost, isBlockedNode, isNotFoundNode, isThreadViewPost, patchThreadNode } from "$/lib/feeds"; 5 5 import type { PostView, ThreadNode } from "$/lib/types"; 6 6 import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js"; ··· 69 69 setState({ error: null, loading: true, thread: null, uri }); 70 70 71 71 try { 72 - const payload = await getPostThread(uri); 72 + const payload = await FeedController.getPostThread(uri); 73 73 if (threadOverlay.threadUri() === uri) { 74 74 setState({ error: null, loading: false, thread: payload.thread, uri }); 75 75 }
+7 -7
src/components/posts/usePostInteractions.ts
··· 1 - import { bookmarkPost, likePost, removeBookmark, repost, unlikePost, unrepost } from "$/lib/api/feeds"; 1 + import { FeedController } from "$/lib/api/feeds"; 2 2 import { 3 3 emitBookmarkChanged, 4 4 emitPostViewUpdated, ··· 70 70 71 71 try { 72 72 if (previousLike) { 73 - await unlikePost(previousLike); 73 + await FeedController.unlikePost(previousLike); 74 74 emitPostViewUpdated({ likeCount: Math.max(0, previousLikeCount - 1), uri: post.uri, viewer: { like: null } }); 75 75 } else { 76 - const result = await likePost(post.uri, post.cid); 76 + const result = await FeedController.likePost(post.uri, post.cid); 77 77 props.patchPost(post.uri, (current) => ({ ...current, viewer: { ...current.viewer, like: result.uri } })); 78 78 emitPostViewUpdated({ likeCount: previousLikeCount + 1, uri: post.uri, viewer: { like: result.uri } }); 79 79 } ··· 120 120 121 121 try { 122 122 if (previousRepost) { 123 - await unrepost(previousRepost); 123 + await FeedController.unrepost(previousRepost); 124 124 emitPostViewUpdated({ 125 125 repostCount: Math.max(0, previousRepostCount - 1), 126 126 uri: post.uri, 127 127 viewer: { repost: null }, 128 128 }); 129 129 } else { 130 - const result = await repost(post.uri, post.cid); 130 + const result = await FeedController.repost(post.uri, post.cid); 131 131 props.patchPost(post.uri, (current) => ({ ...current, viewer: { ...current.viewer, repost: result.uri } })); 132 132 emitPostViewUpdated({ repostCount: previousRepostCount + 1, uri: post.uri, viewer: { repost: result.uri } }); 133 133 } ··· 161 161 162 162 try { 163 163 if (previousBookmarked) { 164 - await removeBookmark(post.uri); 164 + await FeedController.removeBookmark(post.uri); 165 165 } else { 166 - await bookmarkPost(post.uri, post.cid); 166 + await FeedController.bookmarkPost(post.uri, post.cid); 167 167 } 168 168 169 169 emitPostViewUpdated({ uri: post.uri, viewer: { bookmarked: !previousBookmarked } });
+29 -13
src/lib/api/feeds.ts
··· 9 9 } from "$/lib/types"; 10 10 import { invoke } from "@tauri-apps/api/core"; 11 11 12 - export function getPreferences() { 12 + function getPreferences() { 13 13 return invoke<UserPreferences>("get_preferences"); 14 14 } 15 15 16 - export async function getFeedGenerators(uris: string[]) { 16 + async function getFeedGenerators(uris: string[]) { 17 17 return parseFeedGeneratorsResponse(await invoke("get_feed_generators", { uris })); 18 18 } 19 19 20 - export async function getFeedPage(feed: SavedFeedItem, cursor: string | null, limit: number) { 20 + async function getFeedPage(feed: SavedFeedItem, cursor: string | null, limit: number) { 21 21 const command = getFeedCommand(feed); 22 22 return parseFeedResponse(await invoke(command.name, command.args(cursor, limit))); 23 23 } 24 24 25 - export async function getPostThread(uri: string) { 25 + async function getPostThread(uri: string) { 26 26 return parseThreadResponse(await invoke("get_post_thread", { uri })); 27 27 } 28 28 29 - export function createPost(text: string, replyTo: ReplyRefInput | null, embed: EmbedInput | null) { 29 + function createPost(text: string, replyTo: ReplyRefInput | null, embed: EmbedInput | null) { 30 30 return invoke<CreateRecordResult>("create_post", { embed, replyTo, text }); 31 31 } 32 32 33 - export function likePost(uri: string, cid: string) { 33 + function likePost(uri: string, cid: string) { 34 34 return invoke<CreateRecordResult>("like_post", { cid, uri }); 35 35 } 36 36 37 - export function unlikePost(likeUri: string) { 37 + function unlikePost(likeUri: string) { 38 38 return invoke("unlike_post", { likeUri }); 39 39 } 40 40 41 - export function repost(uri: string, cid: string) { 41 + function repost(uri: string, cid: string) { 42 42 return invoke<CreateRecordResult>("repost", { cid, uri }); 43 43 } 44 44 45 - export function unrepost(repostUri: string) { 45 + function unrepost(repostUri: string) { 46 46 return invoke("unrepost", { repostUri }); 47 47 } 48 48 49 - export function bookmarkPost(uri: string, cid: string) { 49 + function bookmarkPost(uri: string, cid: string) { 50 50 return invoke("bookmark_post", { cid, uri }); 51 51 } 52 52 53 - export function removeBookmark(uri: string) { 53 + function removeBookmark(uri: string) { 54 54 return invoke("remove_bookmark", { uri }); 55 55 } 56 56 57 - export function updateSavedFeeds(feeds: SavedFeedItem[]) { 57 + function updateSavedFeeds(feeds: SavedFeedItem[]) { 58 58 return invoke("update_saved_feeds", { feeds }); 59 59 } 60 60 61 - export function updateFeedViewPref(pref: FeedViewPrefItem) { 61 + function updateFeedViewPref(pref: FeedViewPrefItem) { 62 62 return invoke("update_feed_view_pref", { pref }); 63 63 } 64 + 65 + export const FeedController = { 66 + getPreferences, 67 + getFeedGenerators, 68 + getFeedPage, 69 + getPostThread, 70 + createPost, 71 + likePost, 72 + unlikePost, 73 + repost, 74 + unrepost, 75 + bookmarkPost, 76 + removeBookmark, 77 + updateSavedFeeds, 78 + updateFeedViewPref, 79 + };