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.

feat: separate composer window + route for post creation

+234 -66
+4 -3
docs/design.md
··· 39 39 ### App Rail 40 40 41 41 - Fixed-width rail on the far left using `surface_container_lowest`. 42 - - Icons use 1.5pt strokes. 43 42 - Active icon color: `primary` (`#7dafff`); inactive: `on_surface_variant`. 44 - - No persistent labels; labels may appear on hover. 43 + - No persistent labels; labels may appear on hover when collapsed 45 44 46 45 ### Buttons 47 46 ··· 68 67 69 68 - Preserve large areas of pure `#000000` to maintain visual breathing room. 70 69 - Use `secondary_container` for chips/tags to create soft contrast. 71 - - Apply `full` roundedness to key interactive controls (for example, buttons and search bars) to balance the hard screen geometry. 70 + - Apply subtle roundedness to key interactive controls (for example, buttons and search bars) to balance the hard screen geometry. 71 + - Stick to size/weight from tailwind (`-xs`, `-lg`, etc.), only overriding/hardcoding when necessary for hierarchy or emphasis. 72 72 73 73 ### Do Not 74 74 75 75 - Use pure white (`#FFFFFF`) for long-form body copy; use `on_secondary_container` (`#c9d1dd`) to reduce eye strain. 76 76 - Use fully opaque borders. 77 77 - Stack more than three surface-container depth levels; use a backdrop-blur overlay instead. 78 + - No gradients, textures, or patterns. Avoid noise and visual clutter to maintain focus on content.
+1
docs/tasks/04-notifications.md
··· 5 5 ## Steps 6 6 7 7 - [ ] Create `src-tauri/src/notifications.rs` 8 + - `src-tauri/src/commands/notifications.rs` for Tauri commands 8 9 - [ ] `list_notifications(cursor: Option<String>)` — `app.bsky.notification.listNotifications` 9 10 - [ ] `update_seen()` — `app.bsky.notification.updateSeen` 10 11 - [ ] `get_unread_count()` — `app.bsky.notification.getUnreadCount`
+15 -14
docs/tasks/05-explorer.md
··· 2 2 3 3 Spec: [explorer.md](../specs/explorer.md) 4 4 5 - ## Steps 5 + ## Tasks 6 6 7 - - [ ] Create `src-tauri/src/explorer.rs` — Tauri commands for AT data browsing 8 - - [ ] `resolve_input(input: String)` — detect if input is at:// URI, handle, DID, or PDS URL; resolve accordingly 9 - - [ ] `describe_server(pds_url: String)` — `com.atproto.server.describeServer` 10 - - [ ] `describe_repo(did: String)` — `com.atproto.repo.describeRepo` 11 - - [ ] `list_records(did: String, collection: String, cursor: Option<String>)` — `com.atproto.repo.listRecords` 12 - - [ ] `get_record(did: String, collection: String, rkey: String)` — `com.atproto.repo.getRecord` 13 - - [ ] `export_repo_car(did: String)` — `com.atproto.sync.getRepo`, save to file 14 - - [ ] `query_labels(uri: String)` — `com.atproto.label.queryLabels` 7 + - [ ] Create `src-tauri/src/explorer.rs` for business logic 8 + - `src-tauri/src/commands/explorer.rs` - Tauri commands for AT data browsing 9 + - [ ] `resolve_input(input: String)` - detect if input is at:// URI, handle, DID, or PDS URL; resolve accordingly 10 + - [ ] `describe_server(pds_url: String)` - `com.atproto.server.describeServer` 11 + - [ ] `describe_repo(did: String)` - `com.atproto.repo.describeRepo` 12 + - [ ] `list_records(did: String, collection: String, cursor: Option<String>)` - `com.atproto.repo.listRecords` 13 + - [ ] `get_record(did: String, collection: String, rkey: String)` - `com.atproto.repo.getRecord` 14 + - [ ] `export_repo_car(did: String)` - `com.atproto.sync.getRepo`, save to file 15 + - [ ] `query_labels(uri: String)` - `com.atproto.label.queryLabels` 15 16 - [ ] Wire deep-link handler: `at://` URI → parse → call `resolve_input` → emit navigation event 16 17 - [ ] **Frontend**: explorer URL bar with input parsing, `Cmd+L` to focus 17 - - [ ] **Frontend**: PDS view — server info + hosted account list, skeleton loading 18 - - [ ] **Frontend**: repo view — collection list with record counts 19 - - [ ] **Frontend**: collection view — paginated record list 20 - - [ ] **Frontend**: record view — syntax-highlighted JSON with collapsible sections, type-specific rendering 18 + - [ ] **Frontend**: PDS view - server info + hosted account list, skeleton loading 19 + - [ ] **Frontend**: repo view - collection list with record counts 20 + - [ ] **Frontend**: collection view - paginated record list 21 + - [ ] **Frontend**: record view - syntax-highlighted JSON with collapsible sections, type-specific rendering 21 22 - [ ] **Frontend**: breadcrumb navigation bar with `Motion` width animation on segment changes 22 23 - [ ] **Frontend**: `Presence` crossfade transitions between explorer view levels 23 - - [ ] **Frontend**: keyboard shortcuts — `Backspace` up a level, `Cmd+[/]` back/forward 24 + - [ ] **Frontend**: keyboard shortcuts - `Backspace` up a level, `Cmd+[/]` back/forward 24 25 - [ ] **Frontend**: Jetstream live-tail view with `Motion` slide-in for new records 25 26 26 27 ### Parking Lot
+15 -2
docs/tasks/08-release.md
··· 1 - # Task 08: Release 1 + # 08: Release 2 2 3 - ## Steps 3 + ## Tasks 4 4 5 5 - [ ] App icon and branding 6 6 - [ ] Auto-update via `tauri-plugin-updater` pointing to GitHub Releases 7 7 - [ ] macOS code signing and notarization 8 8 - [ ] DMG packaging via `tauri build` 9 9 - [ ] Smoke test: fresh install flow, OAuth login, timeline load, search sync 10 + 11 + ## Parking Lot 12 + 13 + - Settings 14 + 1. Clear cache 15 + 2. Reset 16 + 3. Logs 17 + 4. Theme (light/dark/auto) 18 + 5. Timeline refresh interval 19 + 6. Notification preferences 20 + 7. Data export (JSON/CSV) 21 + 8. Account management (add/remove accounts) 22 + 9. About (version info, license, contributors, support links)
+1
src-tauri/Cargo.lock
··· 7134 7134 "gtk", 7135 7135 "heck 0.5.0", 7136 7136 "http", 7137 + "image", 7137 7138 "jni 0.21.1", 7138 7139 "libc", 7139 7140 "log",
+1 -1
src-tauri/Cargo.toml
··· 18 18 tauri-build = { version = "2", features = [] } 19 19 20 20 [dependencies] 21 - tauri = { version = "2", features = ["tray-icon"] } 21 + tauri = { version = "2", features = ["image-png", "tray-icon"] } 22 22 tauri-plugin-opener = "2" 23 23 tauri-plugin-global-shortcut = "2" 24 24 serde = { version = "1", features = ["derive"] }
+43 -20
src-tauri/src/tray.rs
··· 1 1 use tauri::{ 2 + image::Image, 2 3 menu::{Menu, MenuItem}, 3 - tray::{MouseButton, TrayIconBuilder, TrayIconEvent}, 4 - AppHandle, Emitter, Manager, WebviewWindow, 4 + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, 5 + AppHandle, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder, 5 6 }; 6 - use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut}; 7 + use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState}; 7 8 8 - const COMPOSER_OPEN_EVENT: &str = "composer:open"; 9 + const COMPOSER_WINDOW_LABEL: &str = "composer"; 10 + const COMPOSER_WINDOW_ROUTE: &str = "index.html#/composer"; 11 + const MAIN_WINDOW_LABEL: &str = "main"; 9 12 const MENU_NEW_POST: &str = "new_post"; 10 13 const MENU_TOGGLE_WINDOW: &str = "toggle_window"; 11 14 const MENU_QUIT: &str = "quit"; ··· 16 19 let quit_i = MenuItem::with_id(app, MENU_QUIT, "Quit", true, None::<&str>)?; 17 20 18 21 let menu = Menu::with_items(app, &[&new_post_i, &toggle_window_i, &quit_i])?; 22 + let tray_icon = Image::from_bytes(include_bytes!("../../public/tray-icon.png"))?; 19 23 20 24 let tray = TrayIconBuilder::new() 21 - .icon(app.default_window_icon().unwrap().clone()) 25 + .icon(tray_icon) 22 26 .menu(&menu) 23 27 .show_menu_on_left_click(false) 24 28 .on_menu_event(|app, event| match event.id().as_ref() { 25 29 MENU_NEW_POST => { 26 - show_window_and_emit(app, COMPOSER_OPEN_EVENT); 30 + let _ = open_composer_window(app); 27 31 } 28 32 MENU_TOGGLE_WINDOW => { 29 33 toggle_window_visibility(app); ··· 34 38 _ => {} 35 39 }) 36 40 .on_tray_icon_event(|tray, event| { 37 - if let TrayIconEvent::Click { button: MouseButton::Left, .. } = event { 38 - toggle_window_visibility(tray.app_handle()); 41 + if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event { 42 + let _ = open_composer_window(tray.app_handle()); 39 43 } 40 44 }) 41 45 .build(app)?; ··· 48 52 pub fn setup_global_shortcut(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> { 49 53 let shortcut = Shortcut::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyN); 50 54 51 - app.global_shortcut().on_shortcut(shortcut, |app, _shortcut, _event| { 52 - show_window_and_emit(app, COMPOSER_OPEN_EVENT); 55 + app.global_shortcut().on_shortcut(shortcut, |app, _, event| { 56 + if event.state == ShortcutState::Pressed { 57 + let _ = open_composer_window(app); 58 + } 53 59 })?; 54 60 55 61 Ok(()) 56 62 } 57 63 58 64 fn toggle_window_visibility(app: &AppHandle) { 59 - if let Some(window) = app.get_webview_window("main") { 65 + if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { 60 66 if is_window_visible(&window) { 61 67 let _ = window.hide(); 62 68 } else { 63 - let _ = window.unminimize(); 64 - let _ = window.show(); 65 - let _ = window.set_focus(); 69 + show_window(&window); 66 70 } 67 71 } 68 72 } ··· 71 75 window.is_visible().unwrap_or(false) && !window.is_minimized().unwrap_or(false) 72 76 } 73 77 74 - fn show_window_and_emit(app: &AppHandle, event: &str) { 75 - if let Some(window) = app.get_webview_window("main") { 76 - let _ = window.unminimize(); 77 - let _ = window.show(); 78 - let _ = window.set_focus(); 78 + fn open_composer_window(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> { 79 + if let Some(window) = app.get_webview_window(COMPOSER_WINDOW_LABEL) { 80 + show_window(&window); 81 + return Ok(()); 79 82 } 80 83 81 - let _ = app.emit(event, ()); 84 + let window = WebviewWindowBuilder::new( 85 + app, 86 + COMPOSER_WINDOW_LABEL, 87 + WebviewUrl::App(COMPOSER_WINDOW_ROUTE.into()), 88 + ) 89 + .title("New Post") 90 + .inner_size(720.0, 640.0) 91 + .min_inner_size(560.0, 420.0) 92 + .resizable(true) 93 + .center() 94 + .build()?; 95 + 96 + show_window(&window); 97 + 98 + Ok(()) 99 + } 100 + 101 + fn show_window(window: &WebviewWindow) { 102 + let _ = window.unminimize(); 103 + let _ = window.show(); 104 + let _ = window.set_focus(); 82 105 }
+5 -2
src/App.tsx
··· 12 12 import type { ParentProps } from "solid-js"; 13 13 import { AccountLedger } from "./components/account/AccountLedger"; 14 14 import { AppRail } from "./components/AppRail"; 15 + import { ComposerWindow } from "./components/feeds/ComposerWindow"; 15 16 import { FeedWorkspace } from "./components/feeds/FeedWorkspace"; 16 17 import { LoginPanel } from "./components/LoginPanel"; 17 18 import { HeaderPanel } from "./components/panels/Header"; 18 19 import { SessionSpotlight } from "./components/Session"; 19 20 import { ErrorToast } from "./components/shared/ErrorToast"; 21 + import { ACCOUNT_SWITCH_EVENT } from "./lib/constants/events"; 20 22 import type { AccountSummary, ActiveSession } from "./lib/types"; 21 23 import { AppRouter } from "./router"; 22 - 23 - const ACCOUNT_SWITCH_EVENT = "auth:account-switched"; 24 24 25 25 const RAIL_COLLAPSED_STORAGE_KEY = "lazurite:rail-collapsed"; 26 26 ··· 266 266 onSwitch={(did) => void switchAccount(did)} /> 267 267 )} 268 268 renderShell={AppShell} 269 + renderComposer={(session) => ( 270 + <ComposerWindow activeHandle={session.handle} onError={(message) => setApp("errorMessage", message)} /> 271 + )} 269 272 renderTimeline={(session, context) => ( 270 273 <FeedWorkspace 271 274 activeSession={session}
+54
src/components/feeds/ComposerWindow.tsx
··· 1 + import { createPost } from "$/lib/api/feeds"; 2 + import { POST_CREATED_EVENT } from "$/lib/constants/events"; 3 + import { emitTo } from "@tauri-apps/api/event"; 4 + import { getCurrentWindow } from "@tauri-apps/api/window"; 5 + import { createSignal } from "solid-js"; 6 + import { ComposerSurface } from "./FeedComposer"; 7 + 8 + type ComposerWindowProps = { activeHandle: string; onError: (message: string) => void }; 9 + 10 + async function closeWindow() { 11 + await getCurrentWindow().close(); 12 + } 13 + 14 + export function ComposerWindow(props: ComposerWindowProps) { 15 + const [pending, setPending] = createSignal(false); 16 + const [text, setText] = createSignal(""); 17 + 18 + async function submitPost() { 19 + const nextText = text().trim(); 20 + if (!nextText) { 21 + return; 22 + } 23 + 24 + setPending(true); 25 + try { 26 + await createPost(nextText, null, null); 27 + await emitTo("main", POST_CREATED_EVENT, null); 28 + await closeWindow(); 29 + } catch (error) { 30 + props.onError(`Failed to create post: ${String(error)}`); 31 + } finally { 32 + setPending(false); 33 + } 34 + } 35 + 36 + return ( 37 + <div class="min-h-screen bg-[radial-gradient(circle_at_top,rgba(125,175,255,0.12),transparent_32%),#000]"> 38 + <ComposerSurface 39 + activeHandle={props.activeHandle} 40 + layout="window" 41 + pending={pending()} 42 + quoteTarget={null} 43 + replyTarget={null} 44 + suggestions={[]} 45 + text={text()} 46 + onApplySuggestion={() => {}} 47 + onClearQuote={() => {}} 48 + onClearReply={() => {}} 49 + onClose={() => void closeWindow()} 50 + onSubmit={() => void submitPost()} 51 + onTextChange={setText} /> 52 + </div> 53 + ); 54 + }
+45 -8
src/components/feeds/FeedComposer.tsx
··· 43 43 onTextChange: (value: string) => void; 44 44 }; 45 45 46 + export type ComposerSurfaceProps = Omit<FeedComposerProps, "open"> & { layout?: "dialog" | "window" }; 47 + 46 48 export function FeedComposer(props: FeedComposerProps) { 47 - const count = createMemo(() => [...props.text].length); 48 - const progress = createMemo(() => Math.min(100, (count() / 300) * 100)); 49 - 50 49 return ( 51 50 <Presence> 52 51 <Show when={props.open}> ··· 60 59 type="button" 61 60 onClick={() => props.onClose()} /> 62 61 63 - <ComposerPanel count={count()} progress={progress()} {...props} /> 62 + <ComposerSurface 63 + activeHandle={props.activeHandle} 64 + layout="dialog" 65 + pending={props.pending} 66 + quoteTarget={props.quoteTarget} 67 + replyTarget={props.replyTarget} 68 + suggestions={props.suggestions} 69 + text={props.text} 70 + onApplySuggestion={props.onApplySuggestion} 71 + onClearQuote={props.onClearQuote} 72 + onClearReply={props.onClearReply} 73 + onClose={props.onClose} 74 + onSubmit={props.onSubmit} 75 + onTextChange={props.onTextChange} /> 64 76 </div> 65 77 </Show> 66 78 </Presence> 67 79 ); 68 80 } 69 81 70 - function ComposerPanel(props: FeedComposerProps & { count: number; progress: number }) { 82 + export function ComposerSurface(props: ComposerSurfaceProps) { 83 + const count = createMemo(() => [...props.text].length); 84 + const progress = createMemo(() => Math.min(100, (count() / 300) * 100)); 85 + 71 86 return ( 72 - <div class="relative z-10 flex min-h-screen items-end justify-center p-4 pt-16"> 87 + <div class={getComposerViewportClass(props.layout)}> 73 88 <Motion.section 74 - class="grid max-h-[calc(100vh-2rem)] w-full max-w-3xl grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden rounded-[1.8rem] bg-surface-container-high shadow-[0_25px_70px_rgba(0,0,0,0.7),0_0_0_1px_rgba(125,175,255,0.14)]" 89 + class={getComposerPanelClass(props.layout)} 75 90 initial={{ opacity: 0, y: 36 }} 76 91 animate={{ opacity: 1, y: 0 }} 77 92 exit={{ opacity: 0, y: 30 }} ··· 93 108 onClearQuote={props.onClearQuote} 94 109 onClearReply={props.onClearReply} 95 110 onTextChange={props.onTextChange} /> 96 - <ComposerFooter count={props.count} progress={props.progress} /> 111 + <ComposerFooter count={count()} progress={progress()} /> 97 112 </Motion.section> 98 113 </div> 99 114 ); 115 + } 116 + 117 + function getComposerViewportClass(layout: ComposerSurfaceProps["layout"]) { 118 + if (layout === "window") { 119 + return "mx-auto flex min-h-screen w-full max-w-4xl items-center justify-center p-6 max-[640px]:p-4"; 120 + } 121 + 122 + return "relative z-10 flex min-h-screen items-end justify-center p-4 pt-16"; 123 + } 124 + 125 + function getComposerPanelClass(layout: ComposerSurfaceProps["layout"]) { 126 + const classes = [ 127 + "grid w-full max-w-3xl grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden rounded-[1.8rem] bg-surface-container-high shadow-[0_25px_70px_rgba(0,0,0,0.7),0_0_0_1px_rgba(125,175,255,0.14)]", 128 + ]; 129 + 130 + if (layout === "window") { 131 + classes.push("max-h-[min(48rem,calc(100vh-3rem))]"); 132 + } else { 133 + classes.push("max-h-[calc(100vh-2rem)]"); 134 + } 135 + 136 + return classes.join(" "); 100 137 } 101 138 102 139 function ComposerHeader(
+19 -14
src/components/feeds/useFeedWorkspaceController.ts
··· 11 11 updateFeedViewPref, 12 12 updateSavedFeeds, 13 13 } from "$/lib/api/feeds"; 14 + import { POST_CREATED_EVENT } from "$/lib/constants/events"; 14 15 import { 15 16 applyFeedPreferences, 16 17 extractHandles, ··· 217 218 onMount(() => { 218 219 globalThis.addEventListener("keydown", handleGlobalKeydown); 219 220 220 - let unlistenComposer: (() => void) | undefined; 221 - void listen("composer:open", () => { 222 - openComposer(); 221 + let unlistenPostCreated: (() => void) | undefined; 222 + void listen(POST_CREATED_EVENT, () => { 223 + void refreshActiveFeed(); 223 224 }).then((dispose) => { 224 - unlistenComposer = dispose; 225 + unlistenPostCreated = dispose; 225 226 }); 226 227 227 228 onCleanup(() => { 228 229 globalThis.removeEventListener("keydown", handleGlobalKeydown); 229 - unlistenComposer?.(); 230 + unlistenPostCreated?.(); 230 231 }); 231 232 }); 232 233 ··· 498 499 await createPost(text, replyTo, embed); 499 500 resetComposer(); 500 501 props.onThreadRouteChange(null); 501 - const feed = activeFeed(); 502 - await loadFeed(feed, false); 503 - const nextScrollTops = updateFeedScrollTop(workspace.feedScrollTops, feed.id, 0); 504 - if (nextScrollTops) { 505 - setWorkspace("feedScrollTops", reconcile(nextScrollTops)); 506 - } 507 - if (scroller) { 508 - scroller.scrollTop = 0; 509 - } 502 + await refreshActiveFeed(); 510 503 } catch (error) { 511 504 props.onError(`Failed to create post: ${String(error)}`); 512 505 } finally { 513 506 setWorkspace("composer", "pending", false); 507 + } 508 + } 509 + 510 + async function refreshActiveFeed() { 511 + const feed = activeFeed(); 512 + await loadFeed(feed, false); 513 + const nextScrollTops = updateFeedScrollTop(workspace.feedScrollTops, feed.id, 0); 514 + if (nextScrollTops) { 515 + setWorkspace("feedScrollTops", reconcile(nextScrollTops)); 516 + } 517 + if (scroller) { 518 + scroller.scrollTop = 0; 514 519 } 515 520 } 516 521
+3
src/lib/constants/events.ts
··· 1 + export const POST_CREATED_EVENT = "composer:post-created"; 2 + 3 + export const ACCOUNT_SWITCH_EVENT = "auth:account-switched";
+14 -1
src/router.test.tsx
··· 11 11 12 12 function renderRouter(hash: string) { 13 13 globalThis.location.hash = hash; 14 + const renderComposer = vi.fn((currentSession: ActiveSession) => ( 15 + <div data-testid="composer-view">{currentSession.handle}</div> 16 + )); 14 17 const renderTimeline = vi.fn((currentSession: ActiveSession, context: { threadUri: string | null }) => ( 15 18 <div data-testid="timeline-view"> 16 19 <span>{currentSession.handle}</span> ··· 23 26 bootstrapping={false} 24 27 hasSession 25 28 renderAuth={() => <div>Auth</div>} 29 + renderComposer={renderComposer} 26 30 renderShell={Shell} 27 31 renderTimeline={renderTimeline} 28 32 session={session} /> 29 33 )); 30 34 31 - return { renderTimeline }; 35 + return { renderComposer, renderTimeline }; 32 36 } 33 37 34 38 describe("AppRouter", () => { ··· 50 54 51 55 expect(renderTimeline.mock.lastCall?.[1].threadUri).toBe(threadUri); 52 56 expect(screen.getByText(threadUri)).toBeInTheDocument(); 57 + }); 58 + 59 + it("renders the standalone composer route", async () => { 60 + const { renderComposer } = renderRouter("#/composer"); 61 + 62 + await screen.findByTestId("composer-view"); 63 + 64 + expect(renderComposer).toHaveBeenCalledOnce(); 65 + expect(screen.getByText(session.handle)).toBeInTheDocument(); 53 66 }); 54 67 });
+14 -1
src/router.tsx
··· 16 16 hasSession: boolean; 17 17 onLocationChange?: () => void; 18 18 renderAuth: () => JSX.Element; 19 + renderComposer: (session: ActiveSession) => JSX.Element; 19 20 renderShell: Component<ParentProps>; 20 21 renderTimeline: ( 21 22 session: ActiveSession, ··· 28 29 const RouterFrame: Component<RouteSectionProps> = (routeProps) => { 29 30 const location = useLocation(); 30 31 let previousPath = location.pathname; 32 + const standaloneComposerRoute = () => location.pathname === "/composer"; 31 33 32 34 createEffect(() => { 33 35 const nextPath = location.pathname; ··· 37 39 } 38 40 }); 39 41 40 - return <props.renderShell>{routeProps.children}</props.renderShell>; 42 + return ( 43 + <Show when={standaloneComposerRoute()} fallback={<props.renderShell>{routeProps.children}</props.renderShell>}> 44 + {routeProps.children} 45 + </Show> 46 + ); 41 47 }; 42 48 43 49 const IndexRoute = () => ( ··· 99 105 </ProtectedRouteView> 100 106 ); 101 107 108 + const ComposerRoute = () => ( 109 + <ProtectedRouteView bootstrapping={props.bootstrapping} session={props.session}> 110 + {(session) => props.renderComposer(session)} 111 + </ProtectedRouteView> 112 + ); 113 + 102 114 const ExplorerRoute = () => ( 103 115 <ProtectedRouteView bootstrapping={props.bootstrapping} session={props.session}> 104 116 {() => ( ··· 122 134 <Route path="/auth" component={AuthRoute} /> 123 135 <Route path="/timeline" component={TimelineRoute} /> 124 136 <Route path="/timeline/thread/:threadUri" component={ThreadRoute} /> 137 + <Route path="/composer" component={ComposerRoute} /> 125 138 <Route path="/search" component={SearchRoute} /> 126 139 <Route path="/notifications" component={NotificationsRoute} /> 127 140 <Route path="/explorer" component={ExplorerRoute} />