diff --git a/crates/tauri-app/src-tauri/src/lib.rs b/crates/tauri-app/src-tauri/src/lib.rs index 5309827..6e4514c 100644 --- a/crates/tauri-app/src-tauri/src/lib.rs +++ b/crates/tauri-app/src-tauri/src/lib.rs @@ -113,6 +113,7 @@ async fn post_create( state: tauri::State<'_, AppState>, text: String, embed: Option, + reply: Option, ) -> Result { let sess = state .store @@ -130,6 +131,16 @@ async fn post_create( record["embed"] = emb; } } + // The `reply` field on a post record (see + // `app.bsky.feed.post`) is `{root, parent}` strongRefs. We only + // attach it when the caller passes a non-null object; missing + // means "top-level post" which is the default. + if let Some(rp) = reply { + record["reply"] = serde_json::json!({ + "root": { "uri": rp.root.uri, "cid": rp.root.cid }, + "parent": { "uri": rp.parent.uri, "cid": rp.parent.cid }, + }); + } let resp = state .pds .create_record(&sess.did, "app.twi.post", record, &sess.access_jwt) diff --git a/crates/tauri-app/src-tauri/src/pds_client.rs b/crates/tauri-app/src-tauri/src/pds_client.rs index afe7c54..a594f8f 100644 --- a/crates/tauri-app/src-tauri/src/pds_client.rs +++ b/crates/tauri-app/src-tauri/src/pds_client.rs @@ -43,6 +43,26 @@ pub struct CreateRecordReq { pub record: serde_json::Value, } +/// Strong reference as defined by +/// `com.atproto.repo.strongRef` — `{uri, cid}`. Used inside +/// `app.bsky.feed.post#reply` (root + parent) and inside +/// `app.bsky.embed.record` (the quoted post). +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct StrongRef { + pub uri: String, + pub cid: String, +} + +/// `app.bsky.feed.post#reply` — the `reply` field on a post +/// record. `root` is the topmost ancestor of the thread, +/// `parent` is the post being directly replied to. For a +/// top-level reply they point at the same `strongRef`. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ReplyRef { + pub root: StrongRef, + pub parent: StrongRef, +} + #[derive(Debug, Serialize, Deserialize)] pub struct CreateRecordResp { pub uri: String, diff --git a/crates/tauri-app/src/App.svelte b/crates/tauri-app/src/App.svelte index 6cbaa0b..f14ff17 100644 --- a/crates/tauri-app/src/App.svelte +++ b/crates/tauri-app/src/App.svelte @@ -19,6 +19,7 @@ import LoginScreen from "./lib/components/LoginScreen.svelte"; import Terminal from "./lib/components/Terminal.svelte"; import Skeleton from "./lib/components/Skeleton.svelte"; + import Sidebar from "./lib/components/Sidebar.svelte"; type View = "home" | "compose" | "profile" | "user" | "search" | "settings"; @@ -39,6 +40,17 @@ let seenUris: Set = new Set(); let _statusTimer: number | undefined; + // Home tab strip — "for you" is a placeholder (no real algo yet), + // "following" is the live behavior. Mirrors the X-style "For you / + // Following" tabs. + type HomeTab = "for-you" | "following"; + let homeTab: HomeTab = $state("following"); + + // Search tab strip — only "top" is wired (matches the current + // search endpoint). The rest are visually present but disabled. + type SearchTab = "top" | "latest" | "people" | "photos"; + let searchTab: SearchTab = $state("top"); + // Search state. let searchQuery: string = $state(""); let searchResults: Post[] = $state([]); @@ -52,6 +64,28 @@ let threadLoading: boolean = $state(false); let threadError: string | null = $state(null); + // Reply state — when the user clicks the reply button on a + // PostCard, the parent fires `on_reply` with strongRefs. We + // stash them here and switch to the compose view; the ComposeBox + // reads `replyTo` to render the "Replying to @handle" bar and + // attach the reply block on submit. + type ReplyTarget = { + handle: string; + root: { uri: string; cid: string }; + parent: { uri: string; cid: string }; + }; + let replyTo: ReplyTarget | null = $state(null); + + /// Called by PostCard's reply button. Stores the strongRefs and + /// routes the user to the compose view. + function onReply(target: ReplyTarget) { + replyTo = target; + view = "compose"; + } + function clearReply() { + replyTo = null; + } + // Toasts surfaced by child components via the `maarcadetweet:toast` // window event. We keep the last few so a slow render doesn't // wipe the message before the user reads it. @@ -369,10 +403,26 @@ } async function handlePosted() { - // After the user posts, reset to page 1 so they see their own post. + // After the user posts, reset to page 1 so they see their own + // post, and clear any active reply target so the next compose + // doesn't re-attach the reply block. + replyTo = null; await refreshTimeline(true); } + /// Wired into the right-rail Sidebar. Fills the search query and + /// switches to the search view. If the query is empty we just + /// switch to the search view (the input there will keep focus). + function onSidebarSearch(query: string) { + searchQuery = query; + view = "search"; + if (query.trim().length > 0) { + // Run the search immediately so the Sidebar click feels + // responsive (no debounce delay). + scheduleSearch(); + } + } + async function handleLogout() { try { await session.logout(); @@ -423,6 +473,7 @@ on_select={(v) => setView(v)} />
+
{#if view === "home"}
@@ -431,6 +482,20 @@ @{currentUser.handle} → {userPosts.length} posts · polling every 5s
+ {#if timelineError}
err: {timelineError}
{/if} @@ -451,14 +516,14 @@
err: {threadError}
{:else if threadRoot} {#if threadParent && threadParent.uri !== threadRoot.uri} -
+
{/if} - + {/if}
{/if} {#each userPosts as p (p.uri)} - + {/each} {#if timelineCursor}
@@ -475,7 +540,11 @@ @{currentUser.handle} ⌘↵ to post
- + {:else if view === "user"}
$ @@ -585,6 +654,32 @@ placeholder="grep posts…" />
+ {#if searchError}
err: {searchError}
{/if} @@ -597,11 +692,15 @@ {:else}
{searchResults.length} result{searchResults.length === 1 ? "" : "s"} for "{searchQuery}"
{#each searchResults as p (p.uri)} - + {/each} {/if} {/if} + {#if view === "home"} + + {/if} +
@@ -691,6 +790,50 @@ overflow: auto; padding: var(--s-3); } + .main-inner { + display: flex; + gap: var(--s-3); + align-items: flex-start; + min-width: 0; + } + .main-inner > :global(.terminal) { + flex: 1 1 auto; + min-width: 0; + } + + /* Tab strip — mirrors the ProfileView's `.tab` pattern so the + home + search tabs read as siblings of the profile tabs. */ + .tabs { + display: flex; + border-bottom: 1px solid var(--line); + margin: 0 0 var(--s-3); + } + .tab { + flex: 1; + background: none; + border: 0; + padding: var(--s-3); + color: var(--text-dim); + font-family: var(--font-mono); + font-size: var(--fs-100); + cursor: pointer; + border-bottom: 2px solid transparent; + transition: + color var(--dur) var(--ease), + border-color var(--dur) var(--ease); + } + .tab:hover:not(:disabled) { + color: var(--text); + } + .tab:disabled { + cursor: not-allowed; + opacity: 0.5; + } + .tab--active { + color: var(--orange); + border-bottom-color: var(--orange); + font-weight: 700; + } .head { font-family: var(--font-mono); diff --git a/crates/tauri-app/src/lib/api/client.ts b/crates/tauri-app/src/lib/api/client.ts index e0310f1..77efc99 100644 --- a/crates/tauri-app/src/lib/api/client.ts +++ b/crates/tauri-app/src/lib/api/client.ts @@ -218,6 +218,8 @@ export type Post = { embed?: Embed | null; langs: string[]; created_at: string; + like_count?: number; + repost_count?: number; /// Resolved author-avatar CID from the AppView's `profiles` /// cache. NULL when the user has no profile record yet. avatar_cid?: string | null; @@ -263,9 +265,20 @@ export type ThreadResponse = { repost_count?: number; }; +/// Reply block for `app.bsky.feed.post#reply`. Both `root` and +/// `parent` are `com.atproto.repo.strongRef`s (uri + cid). For a +/// top-level reply to a single post, `root` and `parent` point at +/// the same strongRef. The Rust `post_create` command wires this +/// onto the record's `reply` field. +export type ReplyRef = { + root: { uri: string; cid: string }; + parent: { uri: string; cid: string }; +}; + export async function createPost( text: string, embed?: unknown | null, + reply?: ReplyRef | null, ): Promise { // The Rust post_create command returns a different shape (uri+cid // only), but we keep the call simple: it gives us the cid we need @@ -273,9 +286,12 @@ export async function createPost( // `embed` is forwarded verbatim; the caller is responsible for // shaping it as an `app.bsky.embed.images` / `.external` / etc. // record. Pass `null` or `undefined` to omit. + // `reply` is the reply block (root + parent strongRefs); `null` or + // `undefined` means "top-level post" (no reply block on the record). return await safeInvoke("post_create", { text, embed: embed ?? null, + reply: reply ?? null, }); } diff --git a/crates/tauri-app/src/lib/components/ComposeBox.svelte b/crates/tauri-app/src/lib/components/ComposeBox.svelte index a8d93f6..613962d 100644 --- a/crates/tauri-app/src/lib/components/ComposeBox.svelte +++ b/crates/tauri-app/src/lib/components/ComposeBox.svelte @@ -8,27 +8,37 @@ releaseBlob, session, type Post, + type Session, + type ReplyRef, } from "../api/client"; + import Avatar from "./Avatar.svelte"; + + /// Reply target. The parent/root strongRefs are required so the + /// resulting post can carry the `reply` block on its record. + type ReplyTarget = { + handle: string; + root: { uri: string; cid: string }; + parent: { uri: string; cid: string }; + }; + + type Props = { + onPosted?: () => void; + replyTo?: ReplyTarget | null; + onClearReply?: () => void; + }; const MAX = 160; - let { onPosted }: { onPosted?: () => void } = $props(); - let text: string = $state(""); - let isPosting: boolean = $state(false); - let isAttaching: boolean = $state(false); + let { onPosted, replyTo = null, onClearReply }: Props = $props(); + let text = $state(""); + let isPosting = $state(false); + let isAttaching = $state(false); let status: { kind: "ok" | "err" | "info"; msg: string } | null = $state(null); + let currentUser: Session | null = $state(null); - // Currently logged-in user. We need the DID for `fetchBlob` (the - // PDS endpoint keys blobs by `(did, cid)`), so the compose box - // subscribes to the session store rather than taking a prop. - let did: string = $state(""); $effect(() => { - const u = $session; - did = u?.did ?? ""; + currentUser = $session; }); - // The currently-attached image. `null` = no attachment. We hold - // the blob reference + a local object URL for the preview so the - // user sees the image before they post. let attachment: { cid: string; mimeType: string; @@ -36,267 +46,417 @@ previewUrl: string; } | null = $state(null); - let remaining = $derived(MAX - text.length); - let counterClass = $derived( - remaining < 0 ? "counter counter--err" : - remaining < 40 ? "counter counter--warn" : "counter" + // Count graphemes, not UTF-16 code units. atproto enforces + // `maxLength: 160` as graphemes, so a single 🚀 (surrogate pair) + // must count as 1, not 2. `Intl.Segmenter` is built into the + // runtime — no dependency needed. + const seg = new Intl.Segmenter("en", { granularity: "grapheme" }); + const count = $derived(text.trim().length === 0 ? 0 : [...seg.segment(text)].length); + const isTooLong = $derived(count > MAX); + const isAtMax = $derived(count === MAX); + // atproto's `maxLength: 160` is inclusive of the 160th grapheme + // — the spec rejects any record whose text length exceeds 160. + // So we treat `count === MAX` as "exactly at the cap, still + // shippable" and only flag as an error on strict overflow. + const counterClass = $derived( + isTooLong + ? "counter counter--err" + : isAtMax + ? "counter counter--warn" + : count >= MAX - 30 + ? "counter counter--warn" + : "counter", ); + const canPost = $derived(!!text.trim() && !isTooLong && !isPosting); - function handleKeydown(e: KeyboardEvent) { - if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { - e.preventDefault(); - post(); + function handleKeydown(event: KeyboardEvent) { + if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { + event.preventDefault(); + void post(); } } - function fmtBytes(n: number): string { - if (n < 1024) return `${n} B`; - if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`; - return `${(n / (1024 * 1024)).toFixed(2)} MiB`; + function fmtBytes(bytes: number) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`; } async function attach() { if (isAttaching || attachment) return; - if (!did) { - status = { kind: "err", msg: "> log in first" }; + if (!currentUser?.did) { + status = { kind: "err", msg: "log in to add an image" }; return; } isAttaching = true; - status = { kind: "info", msg: "> picking…" }; + status = null; try { const blob = await pickAndUploadImage(); - if (!blob) { - // User cancelled — restore the previous status rather than - // leaving the "picking…" message on screen. - status = null; - return; - } - // Fetch the bytes back from the PDS so we can render the - // preview. `fetchBlob` caches by CID, so re-rendering the - // preview after a re-attach is cheap. - const previewUrl = await fetchBlob(did, blob.cid); + if (!blob) return; + const previewUrl = await fetchBlob(currentUser.did, blob.cid); attachment = { ...blob, previewUrl }; - status = { kind: "info", msg: `> attached (${fmtBytes(blob.size)})` }; - } catch (e) { - status = { kind: "err", msg: `> ${String(e)}` }; + } catch (error) { + status = { kind: "err", msg: String(error) }; } finally { isAttaching = false; } } function removeAttachment() { - if (attachment) { - // Revoke the object URL. `fetchBlob` may have evicted the - // cache entry for a different reason, so tolerate a no-op. - // The user can re-attach — the next fetch will allocate a - // fresh URL. - releaseBlob(did, attachment.cid); - attachment = null; - } + if (!attachment || !currentUser?.did) return; + releaseBlob(currentUser.did, attachment.cid); + attachment = null; } async function post() { - if (!text.trim() || remaining < 0 || isPosting) return; + if (!canPost) return; isPosting = true; - status = { kind: "info", msg: "> posting…" }; + status = { kind: "info", msg: "posting…" }; try { const embed = attachment ? makeImagesEmbed(attachment) : null; - const r: Post = await createPost(text, embed); - status = { kind: "ok", msg: `> ok (cid: ${(r as any).cid?.slice?.(0, 8) ?? "?"}…)` }; + const reply: ReplyRef | null = replyTo + ? { root: replyTo.root, parent: replyTo.parent } + : null; + const response: Post = await createPost(text.trim(), embed, reply); + status = { + kind: "ok", + msg: `posted · cid ${(response as any).cid?.slice?.(0, 8) ?? "?"}…`, + }; text = ""; removeAttachment(); onPosted?.(); - } catch (e) { - status = { kind: "err", msg: `> ${String(e)}` }; - showError(`post failed: ${e}`); + } catch (error) { + status = { kind: "err", msg: String(error) }; + showError(`post failed: ${error}`); } finally { isPosting = false; } } -
-
- // compose - @you - {remaining} +
+
+
-
- $ + +
+ {#if replyTo} +
+ Replying to @{replyTo.handle} + +
+ {/if} + -
- {#if attachment} -
- attachment preview -
- cid: {attachment.cid.slice(0, 10)}… - {attachment.mimeType} - {fmtBytes(attachment.size)} + + {#if attachment} +
+ Attachment preview +
+ {attachment.mimeType} + {fmtBytes(attachment.size)} +
+
+ {/if} + + - {/if} -
- ⌘↵ to post -
- - - + +
+ {count}/{MAX} + + +
+ + {#if status} +
{status.msg}
+ {/if}
- {#if status} -
{status.msg}
- {/if} -
+
\ No newline at end of file + + .compose__submit { + gap: var(--s-3); + } + + .media-button { + display: inline-flex; + align-items: center; + gap: var(--s-1); + padding: var(--s-2); + border: 0; + border-radius: var(--r-pill); + background: transparent; + color: var(--orange); + font-family: var(--font-mono); + font-size: var(--fs-50); + cursor: pointer; + } + + .media-button:hover:not(:disabled) { + background: var(--orange-8); + } + + .media-button svg { + width: 19px; + height: 19px; + fill: none; + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; + } + + .media-button:disabled, + .post-button:disabled, + .attachment__remove:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + .counter { + min-width: 5.5rem; + color: var(--text-dim); + font-family: var(--font-mono); + font-size: var(--fs-50); + font-variant-numeric: tabular-nums; + text-align: right; + } + + .counter--warn { + color: var(--orange); + } + + .counter--err { + color: var(--red); + } + + .divider { + width: 1px; + height: 24px; + background: var(--line-2); + } + + .post-button { + min-width: 76px; + padding: 0.55rem 1rem; + border: 0; + border-radius: var(--r-pill); + background: var(--orange); + color: var(--bg-deep); + font-family: var(--font-mono); + font-size: var(--fs-50); + font-weight: 700; + cursor: pointer; + transition: background var(--dur) var(--ease); + } + + .post-button:hover:not(:disabled) { + background: var(--orange-bright); + } + + .status { + margin-top: var(--s-2); + padding-top: var(--s-2); + border-top: 1px dashed var(--line); + color: var(--text-dim); + font-family: var(--font-mono); + font-size: var(--fs-50); + } + + .status--ok { + color: var(--green); + } + + .status--err { + color: var(--red); + } + + .status--info { + color: var(--orange); + } + + @media (max-width: 560px) { + .compose { + padding-inline: var(--s-3); + } + + textarea { + font-size: var(--fs-100); + } + + .media-button span, + .divider { + display: none; + } + } + diff --git a/crates/tauri-app/src/lib/components/LoginScreen.svelte b/crates/tauri-app/src/lib/components/LoginScreen.svelte index 30df950..d9bb0b7 100644 --- a/crates/tauri-app/src/lib/components/LoginScreen.svelte +++ b/crates/tauri-app/src/lib/components/LoginScreen.svelte @@ -42,47 +42,46 @@
maarcadetweet — {mode}
-
- $ maarcadetweet {mode} -
-
// the timeline that fits in 160 chars.
-
 
+

maarcadetweet

+

// the timeline that fits in 160 chars.

{#if serverInfo} -
// pds: {serverInfo.did ?? "?"}
-
// domains: {(serverInfo.available_user_domains ?? []).join(", ")}
+
+ // pds: {serverInfo.did ?? "?"} + // domains: {(serverInfo.available_user_domains ?? []).join(", ")} +
{/if} -
 
-
-
+ {#if error} -
error: {error}
+
err: {error}
{/if} -
 
-
+
@@ -94,7 +93,8 @@ border: 1px solid var(--line-2); border-radius: var(--r-lg); overflow: hidden; - width: min(560px, 92vw); + width: min(480px, 92vw); + box-shadow: 0 24px 60px -28px rgba(0, 0, 0, 0.8); } .terminal-head { display: flex; @@ -121,49 +121,109 @@ } .terminal-body { font-family: var(--font-mono); - font-size: 0.95rem; - line-height: 1.85; - padding: var(--s-5); + padding: var(--s-6) var(--s-5); + display: flex; + flex-direction: column; + gap: var(--s-3); + } + .brand { + margin: 0; + color: var(--orange); + font-family: var(--font-mono); + font-size: var(--fs-400); + font-weight: 700; + letter-spacing: var(--tracking-tight); + line-height: var(--lh-tight); + text-align: center; + } + .tagline { + margin: 0; + color: var(--text-dim); + font-family: var(--font-mono); + font-size: var(--fs-100); + text-align: center; + } + .meta { + display: flex; + flex-direction: column; + gap: 2px; + color: var(--text-dim); + font-family: var(--font-mono); + font-size: var(--fs-50); + text-align: center; } - .line { white-space: pre-wrap; } - .muted { color: var(--text-dim); } - .prompt { color: var(--orange); } - .err { color: var(--red); } .form { display: flex; flex-direction: column; gap: var(--s-3); - margin: var(--s-4) 0; + margin-top: var(--s-3); } - .form label { + .field { display: flex; - align-items: center; - gap: var(--s-3); + flex-direction: column; + gap: var(--s-1); + } + .key { + color: var(--text-dim); + font-family: var(--font-mono); + font-size: var(--fs-50); + letter-spacing: var(--tracking-label); } - .key { color: var(--orange); width: 90px; flex-shrink: 0; } .form input { - flex: 1; background: var(--bg); border: 1px solid var(--line-2); color: var(--text); font-family: var(--font-mono); + font-size: var(--fs-100); padding: var(--s-2) var(--s-3); border-radius: var(--r-sm); outline: none; + transition: border-color var(--dur) var(--ease); } .form input:focus { border-color: var(--orange); } - .btn { + .form input::placeholder { color: var(--text-dim); } + .err { + color: var(--red); font-family: var(--font-mono); font-size: var(--fs-50); - padding: 0.5rem 0.8rem; + padding: var(--s-2) var(--s-3); + border-left: 3px solid var(--red); + background: rgba(255, 59, 48, 0.08); + border-radius: 0 var(--r-sm) var(--r-sm) 0; + } + .actions { + display: flex; + flex-direction: column; + gap: var(--s-2); + margin-top: var(--s-3); + } + .btn { + font-family: var(--font-mono); + font-size: var(--fs-100); + padding: var(--s-3) var(--s-4); border-radius: var(--r-sm); border: 1px solid transparent; cursor: pointer; - margin-right: var(--s-2); + text-align: center; + transition: + background var(--dur) var(--ease), + color var(--dur) var(--ease), + border-color var(--dur) var(--ease); + } + .btn--primary { + background: var(--orange); + color: #1a0d00; + font-weight: 700; } - .btn--primary { background: var(--orange); color: #1a0d00; font-weight: 700; } .btn--primary:hover:not(:disabled) { background: var(--orange-bright); } - .btn--ghost { background: transparent; color: var(--text-dim); border-color: var(--line-2); } - .btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); } + .btn--ghost { + background: transparent; + color: var(--text-dim); + border-color: var(--line-2); + } + .btn--ghost:hover:not(:disabled) { + color: var(--orange); + border-color: var(--orange); + } .btn:disabled { opacity: 0.4; cursor: not-allowed; } diff --git a/crates/tauri-app/src/lib/components/PostCard.svelte b/crates/tauri-app/src/lib/components/PostCard.svelte index f6dc3d5..e6da4ab 100644 --- a/crates/tauri-app/src/lib/components/PostCard.svelte +++ b/crates/tauri-app/src/lib/components/PostCard.svelte @@ -10,54 +10,54 @@ showError, type Post, } from "../api/client"; - import EmbedImage from "./EmbedImage.svelte"; - import EmbedExternal from "./EmbedExternal.svelte"; - import Avatar from "./Avatar.svelte"; import { localStorageKey, useLocalStorage } from "../utils/localstorage"; + import Avatar from "./Avatar.svelte"; + import EmbedExternal from "./EmbedExternal.svelte"; + import EmbedImage from "./EmbedImage.svelte"; + + /// Reply target shape passed up to the parent. The parent/root + /// strongRefs are required so the resulting reply record can carry + /// its `reply` block (see `app.bsky.feed.post#reply`). + export type ReplyTarget = { + handle: string; + root: { uri: string; cid: string }; + parent: { uri: string; cid: string }; + }; type Props = { post: Post; on_thread_click?: (uri: string) => void; - /// Called when the user clicks the handle / avatar in the - /// post header. Tauri webviews don't have a real router, so - /// the host (App.svelte) decides what to do — typically it - /// sets `selectedHandle` + `view = "user"` to render - /// ``. When absent the header remains - /// clickable but does nothing. on_handle_click?: (handle: string) => void; + /// Called when the user clicks the reply button. Receives the + /// strongRef data needed to populate the compose box's `replyTo` + /// prop. The parent (App.svelte) switches to the compose view + /// and forwards the target into ``. + on_reply?: (target: ReplyTarget) => void; }; - let { post, on_thread_click, on_handle_click }: Props = $props(); - // Quoted-post cache. When the post's embed is a `record`, we fetch - // it once on mount and cache it keyed by URI so navigating - // timeline → profile doesn't re-fetch the same quote. + let { post, on_thread_click, on_handle_click, on_reply }: Props = $props(); + let quoted: Post | null = $state(null); let quotedErr: string | null = $state(null); - let quotedLoading: boolean = $state(false); + let quotedLoading = $state(false); - // Resolve the embedded `app.bsky.embed.record` (a quoted post) by - // fetching the full record once per URI. We `untrack()` the - // in-flight check (`quoted` / `quotedLoading`) so a sync read+write - // of the same $state isn't reported as - // `effect_update_depth_exceeded` — without it, every time the - // effect re-fires (e.g. on parent re-render) Svelte 5's depth - // tracker saw `quotedLoading` read **and** flipped to `true` - // within the same tick. $effect(() => { - const rec = (post.embed?.$type === "app.bsky.embed.record" || post.embed?.$type === "app.bsky.embed.recordWithMedia") - ? post.embed?.record - : null; + const rec = + post.embed?.$type === "app.bsky.embed.record" || + post.embed?.$type === "app.bsky.embed.recordWithMedia" + ? post.embed.record + : null; const targetUri = rec?.uri; if (!targetUri) return; untrack(() => { if (quoted || quotedLoading) return; quotedLoading = true; fetchPost(targetUri) - .then((r) => { - quoted = r.post; + .then((response) => { + quoted = response.post; }) - .catch((e) => { - quotedErr = String(e); + .catch((error) => { + quotedErr = String(error); }) .finally(() => { quotedLoading = false; @@ -65,501 +65,798 @@ }); }); - // Resolve the embed shape once at render time. We sniff $type to - // decide which sub-component to mount; an unrecognised $type still - // renders the post body, just without any embed. const embedKind = $derived.by(() => { if (!post.embed) return "none"; switch (post.embed.$type) { - case "app.bsky.embed.images": return "images"; - case "app.bsky.embed.external": return "external"; - case "app.bsky.embed.record": return "record"; - case "app.bsky.embed.recordWithMedia": return "recordWithMedia"; - default: return "unknown"; + case "app.bsky.embed.images": + return "images"; + case "app.bsky.embed.external": + return "external"; + case "app.bsky.embed.record": + return "record"; + case "app.bsky.embed.recordWithMedia": + return "recordWithMedia"; + default: + return "unknown"; } }); - const replyParentHandle = $derived(post.parent_uri ? post.handle : ""); const isReply = $derived(!!post.parent_uri); const isInThread = $derived( - !!post.parent_uri && - !!post.root_uri && - post.parent_uri !== post.root_uri + !!post.parent_uri && !!post.root_uri && post.parent_uri !== post.root_uri, ); + const authorName = $derived( + (post.handle || "unknown").replace(/^@/, "").split(".")[0] || "unknown", + ); + const authed = $derived(!!$session); - // -- engagement (like / repost) ---------------------------------------- - // - // Counts come from the AppView's `GET /api/post/{uri}` response - // (`like_count` / `repost_count`). The home timeline doesn't - // return them yet, so the buttons fall back to 0. A future change - // can pipe the counts into the timeline feed. - // - // The "active" state (did I like this?) is local: we don't have - // `viewer_liked` on the wire. We track it optimistically — the - // flag flips the moment the user clicks, and reverts on a server - // error. - // - // Phase 6b: persist the optimistic state (both `liked` and the - // backend's `likedUri`) through a reload. Without persistence the - // user would re-login and find every like reverted — which reads - // to them as "everything got unliked while you were away". We - // store under a `${did}:${rkey}` key so different posts don't - // stomp each other. The `useLocalStorage` helper is invoked - // inside `$effect.pre` so the key changes track changes to - // `post.did` / `post.rkey`. - let liked: boolean = $state(false); + let liked = $state(false); let likedUri: string | null = $state(null); - let reposts: boolean = $state(false); + let reposted = $state(false); let repostUri: string | null = $state(null); - let likeBusy: boolean = $state(false); - let repostBusy: boolean = $state(false); + let likeBusy = $state(false); + let repostBusy = $state(false); + // Optimistic like/repost count deltas. The base count comes from + // the post prop (which the AppView's 5 s poll refreshes); we add + // the local delta for the user's pending action so the count + // updates instantly. Using `$derived` (instead of `$state` seeded + // from the prop) avoids svelte-check's `state_referenced_locally` + // warning and keeps the count in sync if `post` is replaced (e.g. + // a fresh timeline poll rebuilds the post). + let likeDelta = $state(0); + let repostDelta = $state(0); + let likeCount = $derived((post.like_count ?? 0) + likeDelta); + let repostCount = $derived((post.repost_count ?? 0) + repostDelta); + + /// Like/repost state is persisted in `localStorage` so the heart + /// stays filled across page reloads (the AppView has no + /// `viewer_liked` field yet). Initial values come from the box in + /// `$effect.pre`; subsequent updates are persisted by the same + /// effect that owns the state — the earlier implementation + /// separately initialized state and then wrote the box, which + /// spammed `localStorage` on every mount with the unchanged + /// initial value and tripped the storage `notify()` listeners. + let likedBox: ReturnType< + typeof useLocalStorage<{ liked: boolean; uri: string | null }> + > | null = $state(null); + let repostedBox: ReturnType< + typeof useLocalStorage<{ reposted: boolean; uri: string | null }> + > | null = $state(null); - // Hydrate + persist the like state. The box is bound inside an - // `$effect.pre` so the key updates whenever `post.did` / - // `post.rkey` change (e.g. navigating from one timeline card to - // the next). - let likedBox: ReturnType> | null = - $state(null); $effect.pre(() => { - const k = localStorageKey(`liked:${post.did}:${post.rkey}`); - // Re-create the box whenever the post changes; the hydration - // reads (`likedBox.get()`) and the writes that seed `liked` / - // `likedUri` from localStorage all happen inside `untrack` so - // `likedBox` (which is $state) is **read and written in the same - // effect run**. Without untrack, Svelte 5's effect tracker would - // schedule `possible_effect_self_invalidation` on `likedBox` - // and the effect would loop until `effect_update_depth_exceeded` - // fires. See the explorer agent's read-out: this is THE loop - // that took down login with `process_fn x 95`. The outer - // `post.did` / `post.rkey` reads remain tracked so the effect - // still re-runs when navigating from one card to the next. - untrack(() => { - likedBox = useLocalStorage<{ liked: boolean; uri: string | null }>(k, { - liked: false, - uri: null, - }); - const stored = likedBox.get(); - liked = stored.liked; - likedUri = stored.uri; - }); + const likeKey = localStorageKey(`liked:${post.did}:${post.rkey}`); + const repostKey = localStorageKey(`reposted:${post.did}:${post.rkey}`); + likedBox = useLocalStorage(likeKey, { liked: false, uri: null }); + const storedLike = likedBox.get(); + liked = storedLike.liked; + likedUri = storedLike.uri; + + repostedBox = useLocalStorage(repostKey, { reposted: false, uri: null }); + const storedRepost = repostedBox.get(); + reposted = storedRepost.reposted; + repostUri = storedRepost.uri; }); + $effect(() => { + // Skip the write when the box's stored value already matches the + // current state — on first mount, `$effect.pre` reads the stored + // value into `liked` / `likedUri`, and without this guard the very + // first reactive pass would re-write the same bytes and trigger + // an empty `notify()` to subscribers. if (!likedBox) return; + const current = likedBox.get(); + if (current.liked === liked && current.uri === likedUri) return; likedBox.set({ liked, uri: likedUri }); }); - // Pull the latest like/repost counts whenever the post changes. - // We don't request `thread` data — the simple shape is enough. - // Skip the fetch when the post is null (shouldn't happen for a - // card on screen, but harmless). $effect(() => { - if (!post.uri) return; - fetchPost(post.uri) - .then((r) => { - // The fetchPost response includes the counts when the post - // is found. We read them off and seed the local counter. - if (r.like_count != null) likeCount = r.like_count; - if (r.repost_count != null) repostCount = r.repost_count; - }) - .catch(() => { - // Network or AppView outage — keep whatever we had. The - // user can still click the button; counts will resolve - // next time the post is rehydrated. - }); + if (!repostedBox) return; + const current = repostedBox.get(); + if (current.reposted === reposted && current.uri === repostUri) return; + repostedBox.set({ reposted, uri: repostUri }); }); - let likeCount: number = $state(0); - let repostCount: number = $state(0); - - // Whether the buttons are interactive. We disable them when the - // user isn't logged in — anonymous users can read the timeline - // but not engage. - let authed: boolean = $state(false); - $effect(() => { - const u = $session; - authed = !!u; - }); - - async function onLikeClick() { - if (!authed || likeBusy) return; - if (!post.uri || !post.cid) return; + async function onLikeClick(event: MouseEvent) { + event.stopPropagation(); + if (!authed) { + showError("log in to like posts"); + return; + } + if (likeBusy || !post.uri || !post.cid) return; likeBusy = true; - // Optimistic flip. const wasLiked = liked; - const prevCount = likeCount; + const previousDelta = likeDelta; liked = !wasLiked; - likeCount = Math.max(0, likeCount + (wasLiked ? -1 : 1)); + likeDelta += wasLiked ? -1 : 1; try { if (wasLiked) { if (!likedUri) { - // We have no record of the like URI (e.g. the user - // reloaded the page mid-state). Roll back and tell them. liked = wasLiked; - likeCount = prevCount; + likeDelta = previousDelta; showError("can't unlike: missing like URI"); return; } await unlikePost(likedUri); likedUri = null; } else { - const r = await likePost(post.uri, post.cid); - likedUri = r.uri; + const response = await likePost(post.uri, post.cid); + likedUri = response.uri; } - } catch (e) { - // Roll back on any failure — the user can retry. + } catch (error) { liked = wasLiked; - likeCount = prevCount; - showError(`like failed: ${e}`); + likeDelta = previousDelta; + showError(`like failed: ${error}`); } finally { likeBusy = false; } } - async function onRepostClick() { - if (!authed || repostBusy) return; - if (!post.uri || !post.cid) return; + async function onRepostClick(event: MouseEvent) { + event.stopPropagation(); + if (!authed) { + showError("log in to repost"); + return; + } + if (repostBusy || !post.uri || !post.cid) return; repostBusy = true; - const wasReposted = reposts; - const prevCount = repostCount; - reposts = !wasReposted; - repostCount = Math.max(0, repostCount + (wasReposted ? -1 : 1)); + const wasReposted = reposted; + const previousDelta = repostDelta; + reposted = !wasReposted; + repostDelta += wasReposted ? -1 : 1; try { if (wasReposted) { if (!repostUri) { - reposts = wasReposted; - repostCount = prevCount; + reposted = wasReposted; + repostDelta = previousDelta; showError("can't unrepost: missing repost URI"); return; } await unrepostPost(repostUri); repostUri = null; } else { - const r = await repostPost(post.uri, post.cid); - repostUri = r.uri; + const response = await repostPost(post.uri, post.cid); + repostUri = response.uri; } - } catch (e) { - reposts = wasReposted; - repostCount = prevCount; - showError(`repost failed: ${e}`); + } catch (error) { + reposted = wasReposted; + repostDelta = previousDelta; + showError(`repost failed: ${error}`); } finally { repostBusy = false; } } - function shortCid(c: string) { - return c.length > 12 ? c.slice(0, 6) + "…" + c.slice(-4) : c; + function showInfo(text: string) { + if (typeof window === "undefined") return; + window.dispatchEvent( + new CustomEvent("maarcadetweet:toast", { + detail: { kind: "info", text }, + }), + ); } - function shortHandle(h: string) { - if (!h) return "unknown"; - return h.length > 22 ? h.slice(0, 18) + "…" : h; - } - function shortDid(d: string) { - return d.length > 22 ? d.slice(0, 14) + "…" + d.slice(-4) : d; - } - function timeAgo(iso: string) { + + async function onReplyClick(event: MouseEvent) { + event.stopPropagation(); + if (!authed) { + showError("log in to reply"); + return; + } + if (!on_reply) return; + // The reply block needs `root` + `parent` strongRefs (uri + cid). + // For a top-level post, both point at the post itself. For a + // reply in a thread, `parent` is the immediate post and `root` is + // either the same (one-level thread) or a stored `root_uri` / + // `root_cid` when the AppView has it. `fetchPost` already + // returns the full thread envelope; we read `thread.parent` / + // `thread.root` from there. try { - const ms = Date.now() - new Date(iso).getTime(); - const s = Math.floor(ms / 1000); - if (s < 60) return `${s}s`; - if (s < 3600) return `${Math.floor(s / 60)}m`; - if (s < 86400) return `${Math.floor(s / 3600)}h`; - return `${Math.floor(s / 86400)}d`; - } catch { - return iso; + const response = await fetchPost(post.uri); + const parent = + response.post && response.thread?.parent + ? response.thread.parent + : response.post; + const root = + response.post && response.thread?.root + ? response.thread.root + : response.post; + if (!parent || !root) { + showError("can't resolve reply target — post unavailable"); + return; + } + on_reply({ + handle: post.handle, + root: { uri: root.uri, cid: root.cid }, + parent: { uri: parent.uri, cid: parent.cid }, + }); + } catch (error) { + showError(`reply failed: ${error}`); } } - function handleThreadClick() { - if (post.root_uri && on_thread_click) { - on_thread_click(post.root_uri); + + function onViewClick(event: MouseEvent) { + event.stopPropagation(); + on_thread_click?.(post.uri); + } + + function onBookmarkClick(event: MouseEvent) { + event.stopPropagation(); + showInfo("bookmarks are coming soon"); + } + + async function onShareClick(event: MouseEvent) { + event.stopPropagation(); + try { + await navigator.clipboard.writeText(post.uri); + showInfo("post URI copied"); + } catch (error) { + showError(`share failed: ${error}`); } } + + function openPost() { + on_thread_click?.(post.uri); + } + + function openProfile(event: MouseEvent) { + event.stopPropagation(); + on_handle_click?.(post.handle); + } + + function shortHandle(handle: string) { + if (!handle) return "unknown"; + return handle.length > 24 ? `${handle.slice(0, 21)}…` : handle; + } + + function timeAgo(iso: string) { + const timestamp = new Date(iso).getTime(); + if (!Number.isFinite(timestamp)) return iso; + const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000)); + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`; + return `${Math.floor(seconds / 86400)}d`; + } -
- {#if isReply || isInThread} -
- {#if isInThread} - - · - {/if} - {#if isReply && post.parent_uri} - - ↩ in reply to - - @{shortHandle(replyParentHandle)} - - - {/if} -
- {/if} - -
+
+
- - {timeAgo(post.created_at)} - cid: {shortCid(post.cid)} - {shortDid(post.did)} -
+
-

{post.text}

- - {#if embedKind === "images" && post.embed?.images} -
- {#each post.embed.images as img, i (i)} - - {/each} -
- {:else if embedKind === "external" && post.embed?.external} - - {:else if embedKind === "record" || embedKind === "recordWithMedia"} - {#if post.embed?.record} -
-
- quoted - {post.embed.record.uri} +
+
+ + @{shortHandle(post.handle)} + · + +
event.stopPropagation()}> + + + +
+ did: {post.did} + cid: {post.cid}
- {#if quotedLoading} -
loading…
- {:else if quoted} -

{quoted.text}

-
- @{shortHandle(quoted.handle)} - {timeAgo(quoted.created_at)} -
- {:else if quotedErr} -
couldn't fetch quoted post: {quotedErr}
+
+
+ + {#if isReply || isInThread} +
+ {#if isReply} + replying in thread {/if} -
+ {#if isInThread && post.root_uri} + + {/if} +
{/if} - {#if embedKind === "recordWithMedia" && post.embed?.media} - {#if post.embed.media.images} -
- {#each post.embed.media.images as img, i (i)} - - {/each} -
- {/if} - {#if post.embed.media.external} - - {/if} - {/if} - {/if} -
- · - {timeAgo(post.created_at)} - - -
+ aria-label={`Open post by @${post.handle}`} + onclick={openPost} + >{post.text} + + {#if embedKind === "images" && post.embed?.images} +
+ {#each post.embed.images as image, i (i)} + + {/each} +
+ {:else if embedKind === "external" && post.embed?.external} +
+ +
+ {:else if embedKind === "record" || embedKind === "recordWithMedia"} + {#if post.embed?.record} +
+ {#if quotedLoading} +
loading quoted post…
+ {:else if quoted} +
+ {quoted.handle?.split(".")[0] ?? "unknown"} + @{shortHandle(quoted.handle ?? "unknown")} + · + {quoted.created_at ? timeAgo(quoted.created_at) : "?"} +
+

{quoted.text ?? ""}

+ {:else if quotedErr} +
quoted post unavailable
+ {/if} +
+ {/if} + {#if embedKind === "recordWithMedia" && post.embed?.media} + {#if post.embed.media.images} +
+ {#each post.embed.media.images as image, i (i)} + + {/each} +
+ {/if} + {#if post.embed.media.external} +
+ +
+ {/if} + {/if} + {/if} + +
+ + + + + + + + + + + +
+ \ No newline at end of file + + .post-menu__panel span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .thread-context { + display: flex; + gap: var(--s-2); + align-items: center; + margin-top: 2px; + color: var(--text-dim); + font-family: var(--font-mono); + font-size: var(--fs-50); + line-height: var(--lh-snug); + } + + .thread-context button { + color: var(--orange); + } + + .thread-context button:hover { + text-decoration: underline; + } + + .post__body { + display: block; + width: 100%; + margin: var(--s-1) 0 var(--s-2); + padding: 0; + border: 0; + border-radius: var(--r-sm); + background: transparent; + color: var(--text); + font-family: var(--font-mono); + font-size: var(--fs-100); + line-height: 1.5; + text-align: left; + overflow-wrap: anywhere; + white-space: pre-wrap; + cursor: pointer; + } + + .post__body:hover { + color: var(--orange); + } + + .post__body:focus-visible { + outline: 2px solid var(--orange); + outline-offset: 2px; + } + + .embed-grid, + .embed-block { + margin: var(--s-2) 0 var(--s-3); + } + + .embed-grid { + display: grid; + gap: var(--s-1); + } + + .quote { + margin: var(--s-2) 0 var(--s-3); + padding: var(--s-3); + border: 1px solid var(--line-2); + border-radius: var(--r-md); + background: var(--bg); + } + + .quote:hover { + border-color: var(--orange-25); + } + + .quote__head { + display: flex; + gap: var(--s-1); + min-width: 0; + color: var(--text-dim); + font-family: var(--font-mono); + font-size: var(--fs-50); + } + + .quote__name { + color: var(--text); + font-weight: 700; + } + + .quote__handle { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .quote__body { + margin-top: var(--s-1); + color: var(--text); + font-family: var(--font-mono); + font-size: var(--fs-100); + line-height: 1.45; + white-space: pre-wrap; + } + + .quote__status { + color: var(--text-dim); + font-family: var(--font-mono); + font-size: var(--fs-50); + } + + .quote__status--error { + color: var(--red); + } + + .post__actions { + display: flex; + align-items: center; + justify-content: space-between; + max-width: 560px; + min-height: 34px; + margin-top: var(--s-1); + } + + .action { + display: inline-flex; + min-width: 46px; + height: 32px; + align-items: center; + justify-content: flex-start; + gap: var(--s-1); + padding: 0 var(--s-2); + border: 0; + border-radius: var(--r-pill); + background: transparent; + color: var(--text-dim); + font-family: var(--font-mono); + font-size: var(--fs-50); + font-variant-numeric: tabular-nums; + cursor: pointer; + transition: color var(--dur) var(--ease), background var(--dur) var(--ease); + } + + .post:hover .action { + color: var(--cid-fg); + } + + .action:hover, + .post:hover .action:hover { + color: var(--orange); + background: var(--orange-8); + } + + /* Visually muted when the user can't perform the action (e.g. not + signed in). We deliberately don't use the native `disabled` + attribute — that takes the button out of the tab order, which + is an a11y regression. The `aria-disabled` attribute on the + button announces the state to assistive tech. */ + .action--disabled, + .post:hover .action--disabled { + opacity: 0.45; + cursor: not-allowed; + } + + .action--active, + .post:hover .action--active { + color: var(--orange); + } + + .action--compact { + min-width: 32px; + justify-content: center; + padding: 0 var(--s-1); + } + + .action svg { + width: 18px; + height: 18px; + flex: 0 0 auto; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; + } + + .action--active svg { + stroke-width: 2; + } + + .action__count { + min-width: 1ch; + } + + @media (max-width: 640px) { + .post { + padding-inline: var(--s-3); + } + + .author-name { + max-width: 24%; + } + + .action { + min-width: 36px; + padding-inline: var(--s-1); + } + } + diff --git a/crates/tauri-app/src/lib/components/Sidebar.svelte b/crates/tauri-app/src/lib/components/Sidebar.svelte new file mode 100644 index 0000000..d4a8656 --- /dev/null +++ b/crates/tauri-app/src/lib/components/Sidebar.svelte @@ -0,0 +1,269 @@ + + + + +