feat(tauri-app): X-style redesign — action bar, compose, tabs, sidebar, reply-mode

A two-pass rewrite of the home / compose / search / profile flows
to follow the X (Twitter) layout conventions while staying in our
monospace / orange-on-black terminal aesthetic. The WIP was
spotted by a parallel review agent which flagged 13 issues
(5 BLOCKER, 8 HIGH); a fix-pass agent then resolved them.

What changed
------------

**PostCard.svelte** — X-style action bar. Reply / repost / like /
view / bookmark / share buttons with live counts and orange
active-state fills. Liked / reposted states persist in
localStorage so the heart stays filled across reloads (the
AppView has no `viewer_liked` field yet). Hover shows the
action affordance. The whole-card click target is gone; the
action bar is the primary surface, and the body text is its
own button for 'view thread'.

**ComposeBox.svelte** — Avatar + textarea + bottom action row
with character counter and Post button. Counter uses
`Intl.Segmenter('en', { granularity: 'grapheme' })` so emoji
and ZWJ sequences count as 1 grapheme each (the atproto
`maxLength: 160` is grapheme-based, not UTF-16 code units).
`maxlength={MAX}` is set on the textarea itself so the browser
also enforces the cap. Counter flips through `counter` →
`counter--warn` → `counter--err` as the user approaches and
crosses the limit. Reply mode renders a 'Replying to @handle'
bar at the top of the compose card; the misleading
`@handle` prefix that *looked* like it was prepended to the
text is gone.

**Sidebar.svelte** (new) — 280px right-rail on the home view.
Three panels: a search shortcut (focus → switches to search
view), client-side trends (top 3 distinct authors in the
current timeline by post count), and a 'who to follow'
placeholder. Hidden below 900px viewport.

**App.svelte** — Home tabs (`for you` disabled + `following`
active, mirroring ProfileView's tab CSS exactly), search
tabs (`top` active, `latest` / `people` / `photos`
disabled, foundation laid for backend work), login card
centered with a brand title + tagline. New `replyTo` state
plumbs the reply click chain end-to-end.

End-to-end reply chain
---------------------

User clicks reply on a PostCard →
`PostCard.onReplyClick` → `fetchPost(uri)` to resolve
root / parent strongRefs → `onReply(target)` →
`App.svelte` sets `replyTo` + switches to compose view →
`ComposeBox` includes the `reply` block in `createPost` →
`post_create` Tauri command (lib.rs:112-150) attaches
`{root,parent}` strongRefs to the record body →
`PdsHttpClient::create_record` (pds_client.rs:214) writes the
post to the PDS with the reply block. The earlier WIP had a
visual '@handle' prefix that *looked* like it shipped with
the post text but didn't; this is now removed.

Review findings addressed
-------------------------

BLOCKER 1: Reply mode end-to-end. (B2-B5, H6-H13 trivial;
implementation agent handled all 13 in one pass.)

`Intl.Segmenter` counts emoji correctly (`🇯🇵` = 1 grapheme,
not 4 code units). All action buttons have `aria-label` +
`aria-pressed` where applicable; `disabled` is replaced with
`aria-disabled` + opacity so the buttons stay in the tab
order for keyboard users. The double-fire on avatar / author
is gone (the article no longer has `role="link"`, and
`openProfile` calls `event.stopPropagation()`). The
quoted-post null-guard crash is fixed with optional chaining.
Like / repost counts are derived from `post.like_count` +
a local optimistic delta so the value stays in sync when the
timeline poll rebuilds the post (also kills the two
`state_referenced_locally` warnings svelte-check was
flagging).

Verification
------------

* `cargo check --manifest-path crates/tauri-app/src-tauri/Cargo.toml` → 0 errors (2 pre-existing warnings).
* `npm run check` → 0 errors (2 pre-existing warnings: the `<details>` a11y in the post-menu kebab and one residual CSS unused-selector).
* `npm run test` → 20/20 passing (localStorage, client, NavRail).
* Reply chain end-to-end trace verified: PostCard `onReplyClick` → `fetchPost` → `on_reply` → App.svelte `replyTo` → ComposeBox `createPost` → Rust `post_create` → `create_record` → PDS.
This commit is contained in:
tomdebone
2026-07-26 19:44:26 +02:00
parent aba84cbaa9
commit 4c71b76763
8 changed files with 1641 additions and 665 deletions
+11
View File
@@ -113,6 +113,7 @@ async fn post_create(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
text: String, text: String,
embed: Option<serde_json::Value>, embed: Option<serde_json::Value>,
reply: Option<pds_client::ReplyRef>,
) -> Result<serde_json::Value, String> { ) -> Result<serde_json::Value, String> {
let sess = state let sess = state
.store .store
@@ -130,6 +131,16 @@ async fn post_create(
record["embed"] = emb; 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 let resp = state
.pds .pds
.create_record(&sess.did, "app.twi.post", record, &sess.access_jwt) .create_record(&sess.did, "app.twi.post", record, &sess.access_jwt)
@@ -43,6 +43,26 @@ pub struct CreateRecordReq {
pub record: serde_json::Value, 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)] #[derive(Debug, Serialize, Deserialize)]
pub struct CreateRecordResp { pub struct CreateRecordResp {
pub uri: String, pub uri: String,
+149 -6
View File
@@ -19,6 +19,7 @@
import LoginScreen from "./lib/components/LoginScreen.svelte"; import LoginScreen from "./lib/components/LoginScreen.svelte";
import Terminal from "./lib/components/Terminal.svelte"; import Terminal from "./lib/components/Terminal.svelte";
import Skeleton from "./lib/components/Skeleton.svelte"; import Skeleton from "./lib/components/Skeleton.svelte";
import Sidebar from "./lib/components/Sidebar.svelte";
type View = "home" | "compose" | "profile" | "user" | "search" | "settings"; type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
@@ -39,6 +40,17 @@
let seenUris: Set<string> = new Set(); let seenUris: Set<string> = new Set();
let _statusTimer: number | undefined; 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. // Search state.
let searchQuery: string = $state(""); let searchQuery: string = $state("");
let searchResults: Post[] = $state([]); let searchResults: Post[] = $state([]);
@@ -52,6 +64,28 @@
let threadLoading: boolean = $state(false); let threadLoading: boolean = $state(false);
let threadError: string | null = $state(null); 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` // Toasts surfaced by child components via the `maarcadetweet:toast`
// window event. We keep the last few so a slow render doesn't // window event. We keep the last few so a slow render doesn't
// wipe the message before the user reads it. // wipe the message before the user reads it.
@@ -369,10 +403,26 @@
} }
async function handlePosted() { 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); 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() { async function handleLogout() {
try { try {
await session.logout(); await session.logout();
@@ -423,6 +473,7 @@
on_select={(v) => setView(v)} on_select={(v) => setView(v)}
/> />
<div class="main"> <div class="main">
<div class="main-inner">
<Terminal title={view === "home" ? "maarcadetweet — home" : `maarcadetweet — ${view}`}> <Terminal title={view === "home" ? "maarcadetweet — home" : `maarcadetweet — ${view}`}>
{#if view === "home"} {#if view === "home"}
<div class="head"> <div class="head">
@@ -431,6 +482,20 @@
<span class="as">@{currentUser.handle}</span> <span class="as">@{currentUser.handle}</span>
<span class="meta">→ {userPosts.length} posts · polling every 5s</span> <span class="meta">→ {userPosts.length} posts · polling every 5s</span>
</div> </div>
<nav class="tabs" aria-label="Timeline">
<button
class="tab"
type="button"
disabled
title="for you — algo coming soon"
>for you</button>
<button
class="tab"
class:tab--active={homeTab === "following"}
type="button"
onclick={() => (homeTab = "following")}
>following</button>
</nav>
{#if timelineError} {#if timelineError}
<div class="toast toast--err">err: {timelineError}</div> <div class="toast toast--err">err: {timelineError}</div>
{/if} {/if}
@@ -451,14 +516,14 @@
<div class="toast toast--err">err: {threadError}</div> <div class="toast toast--err">err: {threadError}</div>
{:else if threadRoot} {:else if threadRoot}
{#if threadParent && threadParent.uri !== threadRoot.uri} {#if threadParent && threadParent.uri !== threadRoot.uri}
<div class="thread-parent"><PostCard post={threadParent} on_handle_click={openUserProfile} /></div> <div class="thread-parent"><PostCard post={threadParent} on_handle_click={openUserProfile} on_reply={onReply} /></div>
{/if} {/if}
<PostCard post={threadRoot} on_handle_click={openUserProfile} /> <PostCard post={threadRoot} on_handle_click={openUserProfile} on_reply={onReply} />
{/if} {/if}
</div> </div>
{/if} {/if}
{#each userPosts as p (p.uri)} {#each userPosts as p (p.uri)}
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} /> <PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} on_reply={onReply} />
{/each} {/each}
{#if timelineCursor} {#if timelineCursor}
<div class="loadmore"> <div class="loadmore">
@@ -475,7 +540,11 @@
<span class="as">@{currentUser.handle}</span> <span class="as">@{currentUser.handle}</span>
<span class="meta">⌘↵ to post</span> <span class="meta">⌘↵ to post</span>
</div> </div>
<ComposeBox onPosted={handlePosted} /> <ComposeBox
onPosted={handlePosted}
replyTo={replyTo}
onClearReply={clearReply}
/>
{:else if view === "user"} {:else if view === "user"}
<div class="head"> <div class="head">
<span class="prompt">$</span> <span class="prompt">$</span>
@@ -585,6 +654,32 @@
placeholder="grep posts…" placeholder="grep posts…"
/> />
</div> </div>
<nav class="tabs" aria-label="Search sections">
<button
class="tab"
class:tab--active={searchTab === "top"}
type="button"
onclick={() => (searchTab = "top")}
>top</button>
<button
class="tab"
type="button"
disabled
title="latest — coming soon"
>latest</button>
<button
class="tab"
type="button"
disabled
title="people — coming soon"
>people</button>
<button
class="tab"
type="button"
disabled
title="photos — coming soon"
>photos</button>
</nav>
{#if searchError} {#if searchError}
<div class="toast toast--err">err: {searchError}</div> <div class="toast toast--err">err: {searchError}</div>
{/if} {/if}
@@ -597,11 +692,15 @@
{:else} {:else}
<div class="meta meta--results">{searchResults.length} result{searchResults.length === 1 ? "" : "s"} for "{searchQuery}"</div> <div class="meta meta--results">{searchResults.length} result{searchResults.length === 1 ? "" : "s"} for "{searchQuery}"</div>
{#each searchResults as p (p.uri)} {#each searchResults as p (p.uri)}
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} /> <PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} on_reply={onReply} />
{/each} {/each}
{/if} {/if}
{/if} {/if}
</Terminal> </Terminal>
{#if view === "home"}
<Sidebar posts={userPosts} onSearch={onSidebarSearch} />
{/if}
</div>
</div> </div>
<StatusBar did={status.did ?? ""} authenticated={status.authenticated} /> <StatusBar did={status.did ?? ""} authenticated={status.authenticated} />
</div> </div>
@@ -691,6 +790,50 @@
overflow: auto; overflow: auto;
padding: var(--s-3); 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 { .head {
font-family: var(--font-mono); font-family: var(--font-mono);
+16
View File
@@ -218,6 +218,8 @@ export type Post = {
embed?: Embed | null; embed?: Embed | null;
langs: string[]; langs: string[];
created_at: string; created_at: string;
like_count?: number;
repost_count?: number;
/// Resolved author-avatar CID from the AppView's `profiles` /// Resolved author-avatar CID from the AppView's `profiles`
/// cache. NULL when the user has no profile record yet. /// cache. NULL when the user has no profile record yet.
avatar_cid?: string | null; avatar_cid?: string | null;
@@ -263,9 +265,20 @@ export type ThreadResponse = {
repost_count?: number; 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( export async function createPost(
text: string, text: string,
embed?: unknown | null, embed?: unknown | null,
reply?: ReplyRef | null,
): Promise<Post> { ): Promise<Post> {
// The Rust post_create command returns a different shape (uri+cid // 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 // 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 // `embed` is forwarded verbatim; the caller is responsible for
// shaping it as an `app.bsky.embed.images` / `.external` / etc. // shaping it as an `app.bsky.embed.images` / `.external` / etc.
// record. Pass `null` or `undefined` to omit. // 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<any>("post_create", { return await safeInvoke<any>("post_create", {
text, text,
embed: embed ?? null, embed: embed ?? null,
reply: reply ?? null,
}); });
} }
@@ -8,27 +8,37 @@
releaseBlob, releaseBlob,
session, session,
type Post, type Post,
type Session,
type ReplyRef,
} from "../api/client"; } 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; const MAX = 160;
let { onPosted }: { onPosted?: () => void } = $props(); let { onPosted, replyTo = null, onClearReply }: Props = $props();
let text: string = $state(""); let text = $state("");
let isPosting: boolean = $state(false); let isPosting = $state(false);
let isAttaching: boolean = $state(false); let isAttaching = $state(false);
let status: { kind: "ok" | "err" | "info"; msg: string } | null = $state(null); 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(() => { $effect(() => {
const u = $session; currentUser = $session;
did = u?.did ?? "";
}); });
// 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: { let attachment: {
cid: string; cid: string;
mimeType: string; mimeType: string;
@@ -36,267 +46,417 @@
previewUrl: string; previewUrl: string;
} | null = $state(null); } | null = $state(null);
let remaining = $derived(MAX - text.length); // Count graphemes, not UTF-16 code units. atproto enforces
let counterClass = $derived( // `maxLength: 160` as graphemes, so a single 🚀 (surrogate pair)
remaining < 0 ? "counter counter--err" : // must count as 1, not 2. `Intl.Segmenter` is built into the
remaining < 40 ? "counter counter--warn" : "counter" // 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) { function handleKeydown(event: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
e.preventDefault(); event.preventDefault();
post(); void post();
} }
} }
function fmtBytes(n: number): string { function fmtBytes(bytes: number) {
if (n < 1024) return `${n} B`; if (bytes < 1024) return `${bytes} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
return `${(n / (1024 * 1024)).toFixed(2)} MiB`; return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
} }
async function attach() { async function attach() {
if (isAttaching || attachment) return; if (isAttaching || attachment) return;
if (!did) { if (!currentUser?.did) {
status = { kind: "err", msg: "> log in first" }; status = { kind: "err", msg: "log in to add an image" };
return; return;
} }
isAttaching = true; isAttaching = true;
status = { kind: "info", msg: "> picking…" }; status = null;
try { try {
const blob = await pickAndUploadImage(); const blob = await pickAndUploadImage();
if (!blob) { if (!blob) return;
// User cancelled — restore the previous status rather than const previewUrl = await fetchBlob(currentUser.did, blob.cid);
// 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);
attachment = { ...blob, previewUrl }; attachment = { ...blob, previewUrl };
status = { kind: "info", msg: `> attached (${fmtBytes(blob.size)})` }; } catch (error) {
} catch (e) { status = { kind: "err", msg: String(error) };
status = { kind: "err", msg: `> ${String(e)}` };
} finally { } finally {
isAttaching = false; isAttaching = false;
} }
} }
function removeAttachment() { function removeAttachment() {
if (attachment) { if (!attachment || !currentUser?.did) return;
// Revoke the object URL. `fetchBlob` may have evicted the releaseBlob(currentUser.did, attachment.cid);
// cache entry for a different reason, so tolerate a no-op. attachment = null;
// The user can re-attach — the next fetch will allocate a
// fresh URL.
releaseBlob(did, attachment.cid);
attachment = null;
}
} }
async function post() { async function post() {
if (!text.trim() || remaining < 0 || isPosting) return; if (!canPost) return;
isPosting = true; isPosting = true;
status = { kind: "info", msg: "> posting…" }; status = { kind: "info", msg: "posting…" };
try { try {
const embed = attachment ? makeImagesEmbed(attachment) : null; const embed = attachment ? makeImagesEmbed(attachment) : null;
const r: Post = await createPost(text, embed); const reply: ReplyRef | null = replyTo
status = { kind: "ok", msg: `> ok (cid: ${(r as any).cid?.slice?.(0, 8) ?? "?"}…)` }; ? { 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 = ""; text = "";
removeAttachment(); removeAttachment();
onPosted?.(); onPosted?.();
} catch (e) { } catch (error) {
status = { kind: "err", msg: `> ${String(e)}` }; status = { kind: "err", msg: String(error) };
showError(`post failed: ${e}`); showError(`post failed: ${error}`);
} finally { } finally {
isPosting = false; isPosting = false;
} }
} }
</script> </script>
<div class="compose"> <section class="compose" aria-label={replyTo ? `Reply to @${replyTo.handle}` : "Compose a post"}>
<div class="compose__head"> <div class="compose__avatar">
<span class="title">// compose</span> <Avatar
<span class="handle">@you</span> did={currentUser?.did ?? ""}
<span class={counterClass}>{remaining}</span> name={currentUser?.handle ?? "you"}
size={40}
/>
</div> </div>
<div class="compose__body">
<span class="prompt">$</span> <div class="compose__content">
{#if replyTo}
<div class="replying">
<span>Replying to <b>@{replyTo.handle}</b></span>
<button type="button" onclick={onClearReply} title="cancel reply" aria-label="Cancel reply">×</button>
</div>
{/if}
<textarea <textarea
bind:value={text} bind:value={text}
onkeydown={handleKeydown} onkeydown={handleKeydown}
placeholder="// what's happening in 160 chars?" placeholder={replyTo ? `Reply to @${replyTo.handle}` : "What's happening?"}
rows="3" rows="3"
maxlength="500" maxlength={MAX}
aria-label="Post text"
></textarea> ></textarea>
</div>
{#if attachment} {#if attachment}
<div class="compose__attach"> <div class="attachment">
<img <img src={attachment.previewUrl} alt="Attachment preview" />
class="compose__preview" <div class="attachment__meta">
src={attachment.previewUrl} <span>{attachment.mimeType}</span>
alt="attachment preview" <span>{fmtBytes(attachment.size)}</span>
/> </div>
<div class="compose__attach-meta"> <button
<span class="compose__attach-cid" title={attachment.cid}>cid: {attachment.cid.slice(0, 10)}…</span> type="button"
<span class="compose__attach-mime">{attachment.mimeType}</span> class="attachment__remove"
<span class="compose__attach-size">{fmtBytes(attachment.size)}</span> onclick={removeAttachment}
disabled={isPosting}
title="remove image"
aria-label="Remove image"
>×</button>
</div> </div>
{/if}
<div class="compose__footer">
<button <button
type="button" type="button"
class="compose__attach-remove" class="media-button"
onclick={removeAttachment}
disabled={isPosting}
title="remove attachment"
>×</button>
</div>
{/if}
<div class="compose__foot">
<span class="hint">⌘↵ to post</span>
<div class="actions">
<button
type="button"
class="btn btn--ghost"
onclick={attach} onclick={attach}
disabled={isAttaching || !!attachment || isPosting} disabled={isAttaching || !!attachment || isPosting}
title={attachment ? "image already attached" : "attach image"} title={attachment ? "one image already attached" : "add image"}
> >
{isAttaching ? "picking…" : "📎"} <svg viewBox="0 0 24 24" aria-hidden="true">
</button> <rect x="3" y="4" width="18" height="16" rx="2" />
<button class="btn btn--ghost" onclick={() => (text = "")} disabled={!text || isPosting}>draft</button> <circle cx="8.5" cy="9" r="1.5" />
<button class="btn btn--primary" onclick={post} disabled={!text.trim() || remaining < 0 || isPosting}> <path d="m4 17 5-5 4 4 3-3 4 4" />
{isPosting ? "posting…" : "post"} </svg>
<span>{isAttaching ? "adding…" : "image"}</span>
</button> </button>
<div class="compose__submit">
<span class={counterClass}>{count}/{MAX}</span>
<span class="divider" aria-hidden="true"></span>
<button
class="post-button"
type="button"
onclick={post}
disabled={!canPost}
>
{isPosting ? "Posting…" : replyTo ? "Reply" : "Post"}
</button>
</div>
</div> </div>
{#if status}
<div class="status status--{status.kind}" role="status">{status.msg}</div>
{/if}
</div> </div>
{#if status} </section>
<div class="status status--{status.kind}">{status.msg}</div>
{/if}
</div>
<style> <style>
.compose { .compose {
background: var(--bg-elev); display: grid;
border: 1px solid var(--line-2); grid-template-columns: 40px minmax(0, 1fr);
border-radius: var(--r-md);
margin: var(--s-4) var(--s-5);
}
.compose__head {
display: flex;
align-items: center;
gap: var(--s-3); gap: var(--s-3);
padding: var(--s-2) var(--s-4); padding: var(--s-4);
background: var(--bg-deep);
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
font-family: var(--font-mono);
font-size: var(--fs-50);
}
.title { color: var(--orange); }
.handle { color: var(--text-dim); flex: 1; }
.counter { color: var(--text-dim); font-variant-numeric: tabular-nums; }
.counter--warn { color: var(--orange); }
.counter--err { color: var(--red); letter-spacing: 0.05em; }
.compose__body {
display: flex;
align-items: flex-start;
gap: var(--s-2);
padding: var(--s-3) var(--s-4);
}
.prompt {
color: var(--orange);
font-family: var(--font-mono);
font-size: var(--fs-100);
line-height: 1.7;
}
textarea {
flex: 1;
background: transparent;
border: 0;
color: var(--text);
font-family: var(--font-sans);
font-size: var(--fs-100);
line-height: 1.6;
resize: vertical;
outline: none;
padding: 0;
}
textarea::placeholder { color: var(--text-dim); }
.compose__attach {
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-3) var(--s-4);
background: var(--bg-deep); background: var(--bg-deep);
border-top: 1px dashed var(--line);
} }
.compose__preview {
width: 64px; .compose__avatar {
height: 64px; padding-top: 2px;
object-fit: cover;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
background: var(--bg);
} }
.compose__attach-meta {
display: flex; .compose__content {
flex-direction: column; min-width: 0;
gap: 2px;
flex: 1;
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
} }
.compose__attach-cid { color: var(--cid-fg); }
.compose__attach-mime, .replying {
.compose__attach-size { font-variant-numeric: tabular-nums; }
.compose__attach-remove {
background: transparent;
border: 1px solid var(--line-2);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-100);
width: 28px;
height: 28px;
border-radius: var(--r-sm);
cursor: pointer;
line-height: 1;
}
.compose__attach-remove:hover:not(:disabled) {
color: var(--red);
border-color: var(--red);
}
.compose__attach-remove:disabled { opacity: 0.4; cursor: not-allowed; }
.compose__foot {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: var(--s-2) var(--s-4); margin-bottom: var(--s-2);
border-top: 1px solid var(--line); color: var(--text-dim);
}
.hint { font-family: var(--font-mono); font-size: var(--fs-50); color: var(--text-dim); }
.actions { display: flex; gap: var(--s-2); }
.btn {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: var(--fs-50); font-size: var(--fs-50);
padding: 0.4rem 0.8rem; }
border-radius: var(--r-sm);
border: 1px solid transparent; .replying b {
color: var(--orange);
font-weight: 400;
}
.replying button {
display: grid;
width: 24px;
height: 24px;
place-items: center;
border: 0;
border-radius: var(--r-pill);
background: transparent;
color: var(--text-dim);
font: inherit;
cursor: pointer; cursor: pointer;
transition: background var(--dur) var(--ease), color var(--dur) var(--ease);
} }
.btn--ghost { color: var(--text-dim); border-color: var(--line-2); background: transparent; }
.btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); } .replying button:hover {
.btn--primary { background: var(--orange); color: #1a0d00; font-weight: 700; } background: var(--orange-8);
.btn--primary:hover:not(:disabled) { background: var(--orange-bright); } color: var(--orange);
.btn:disabled { opacity: 0.4; cursor: not-allowed; } }
.status {
textarea {
display: block;
width: 100%;
min-height: 88px;
padding: var(--s-1) 0 var(--s-3);
resize: vertical;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font-family: var(--font-mono);
font-size: var(--fs-200);
line-height: 1.45;
}
textarea::placeholder {
color: var(--text-dim);
}
.attachment {
position: relative;
margin-bottom: var(--s-3);
overflow: hidden;
border: 1px solid var(--line-2);
border-radius: var(--r-lg);
background: var(--bg);
}
.attachment img {
width: 100%;
max-height: 320px;
object-fit: cover;
}
.attachment__meta {
display: flex;
justify-content: space-between;
padding: var(--s-2) var(--s-3);
color: var(--text-dim);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: var(--fs-50); font-size: var(--fs-50);
padding: var(--s-2) var(--s-4); }
.attachment__remove {
position: absolute;
top: var(--s-2);
right: var(--s-2);
display: grid;
width: 30px;
height: 30px;
place-items: center;
border: 1px solid var(--line-2);
border-radius: var(--r-pill);
background: var(--bg-elev);
color: var(--text);
font-family: var(--font-mono);
font-size: var(--fs-100);
cursor: pointer;
}
.attachment__remove:hover:not(:disabled) {
border-color: var(--orange);
color: var(--orange);
}
.compose__footer,
.compose__submit {
display: flex;
align-items: center;
}
.compose__footer {
min-height: 40px;
justify-content: space-between;
gap: var(--s-3);
padding-top: var(--s-2);
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
} }
.status--ok { color: var(--green); }
.status--err { color: var(--red); } .compose__submit {
.status--info { color: var(--orange); } gap: var(--s-3);
</style> }
.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;
}
}
</style>
@@ -42,47 +42,46 @@
<div class="t">maarcadetweet — {mode}</div> <div class="t">maarcadetweet — {mode}</div>
</div> </div>
<div class="terminal-body"> <div class="terminal-body">
<div class="line"> <h1 class="brand">maarcadetweet</h1>
<span class="prompt">$</span> maarcadetweet {mode} <p class="tagline">// the timeline that fits in 160 chars.</p>
</div>
<div class="line muted">// the timeline that fits in 160 chars.</div>
<div class="line">&nbsp;</div>
{#if serverInfo} {#if serverInfo}
<div class="line muted">// pds: {serverInfo.did ?? "?"}</div> <div class="meta">
<div class="line muted">// domains: {(serverInfo.available_user_domains ?? []).join(", ")}</div> <span>// pds: {serverInfo.did ?? "?"}</span>
<span>// domains: {(serverInfo.available_user_domains ?? []).join(", ")}</span>
</div>
{/if} {/if}
<div class="line">&nbsp;</div> <form class="form" onsubmit={(e) => { e.preventDefault(); submit(); }}>
<div class="form"> <label class="field">
<label> <span class="key">handle</span>
<span class="key">handle:</span>
<input <input
type="text" type="text"
bind:value={handle} bind:value={handle}
placeholder="alice.maarcadetweet.local" placeholder="alice.maarcadetweet.local"
disabled={busy} disabled={busy}
autocomplete="username"
/> />
</label> </label>
<label> <label class="field">
<span class="key">password:</span> <span class="key">password</span>
<input <input
type="password" type="password"
bind:value={password} bind:value={password}
placeholder="≥ 8 chars" placeholder="≥ 8 chars"
disabled={busy} disabled={busy}
onkeydown={(e) => e.key === "Enter" && submit()} onkeydown={(e) => e.key === "Enter" && submit()}
autocomplete={mode === "register" ? "new-password" : "current-password"}
/> />
</label> </label>
</div> </form>
{#if error} {#if error}
<div class="line err">error: {error}</div> <div class="err">err: {error}</div>
{/if} {/if}
<div class="line">&nbsp;</div> <div class="actions">
<div class="line">
<button class="btn btn--primary" onclick={submit} disabled={busy || !handle || !password}> <button class="btn btn--primary" onclick={submit} disabled={busy || !handle || !password}>
{busy ? "..." : mode === "register" ? "create account" : "login"} {busy ? "..." : mode === "register" ? "create account" : "log in"}
</button> </button>
<button class="btn btn--ghost" onclick={() => (mode = mode === "register" ? "login" : "register")} disabled={busy}> <button class="btn btn--ghost" onclick={() => (mode = mode === "register" ? "login" : "register")} disabled={busy}>
{mode === "register" ? "have an account? login" : "no account? register"} {mode === "register" ? "have an account? log in" : "no account? register"}
</button> </button>
</div> </div>
</div> </div>
@@ -94,7 +93,8 @@
border: 1px solid var(--line-2); border: 1px solid var(--line-2);
border-radius: var(--r-lg); border-radius: var(--r-lg);
overflow: hidden; overflow: hidden;
width: min(560px, 92vw); width: min(480px, 92vw);
box-shadow: 0 24px 60px -28px rgba(0, 0, 0, 0.8);
} }
.terminal-head { .terminal-head {
display: flex; display: flex;
@@ -121,49 +121,109 @@
} }
.terminal-body { .terminal-body {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.95rem; padding: var(--s-6) var(--s-5);
line-height: 1.85; display: flex;
padding: var(--s-5); 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 { .form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--s-3); gap: var(--s-3);
margin: var(--s-4) 0; margin-top: var(--s-3);
} }
.form label { .field {
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: var(--s-3); 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 { .form input {
flex: 1;
background: var(--bg); background: var(--bg);
border: 1px solid var(--line-2); border: 1px solid var(--line-2);
color: var(--text); color: var(--text);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: var(--fs-100);
padding: var(--s-2) var(--s-3); padding: var(--s-2) var(--s-3);
border-radius: var(--r-sm); border-radius: var(--r-sm);
outline: none; outline: none;
transition: border-color var(--dur) var(--ease);
} }
.form input:focus { border-color: var(--orange); } .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-family: var(--font-mono);
font-size: var(--fs-50); 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-radius: var(--r-sm);
border: 1px solid transparent; border: 1px solid transparent;
cursor: pointer; 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--primary:hover:not(:disabled) { background: var(--orange-bright); }
.btn--ghost { background: transparent; color: var(--text-dim); border-color: var(--line-2); } .btn--ghost {
.btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); } 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; } .btn:disabled { opacity: 0.4; cursor: not-allowed; }
</style> </style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,269 @@
<script lang="ts">
import type { Post } from "../api/client";
type Props = {
/// Posts currently in the home timeline. The Sidebar derives
/// the trends list client-side from these (top 3 DIDs by post
/// count), so no new backend endpoint is required.
posts: Post[];
/// Switches the App's view to "search" and populates the
/// search query. Wired by the parent App.
onSearch: (query: string) => void;
};
let { posts, onSearch }: Props = $props();
let query: string = $state("");
function submit() {
onSearch(query.trim());
}
/// Aggregate by DID; cheapest possible counter (no fetchProfile).
/// We surface the handles for display, but the actual handle
/// resolution still comes from the `handle` field baked into
/// each post by the AppView. Skips empty DIDs defensively.
const trends = $derived.by(() => {
const counts = new Map<string, { did: string; handle: string; count: number }>();
for (const p of posts) {
if (!p.did) continue;
const existing = counts.get(p.did);
if (existing) {
existing.count += 1;
} else {
counts.set(p.did, { did: p.did, handle: p.handle, count: 1 });
}
}
const sorted = Array.from(counts.values()).sort((a, b) => b.count - a.count);
return sorted.slice(0, 3);
});
const placeholders = [
{ handle: "alice.bsky.social", why: "shared network" },
{ handle: "bob.bsky.social", why: "popular in feed" },
{ handle: "carol.bsky.social", why: "trending" },
];
</script>
<aside class="sidebar" aria-label="Discover">
<section class="panel">
<label class="panel__search">
<span class="prompt">$</span>
<input
type="text"
bind:value={query}
onfocus={() => onSearch("")}
onkeydown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submit();
}
}}
placeholder="grep posts…"
aria-label="Search posts"
/>
</label>
</section>
<section class="panel">
<h3 class="panel__title">// trends</h3>
{#if trends.length === 0}
<p class="panel__empty">// no posts yet — start the timeline.</p>
{:else}
<ul class="trends">
{#each trends as t (t.did)}
<li>
<button
type="button"
class="trend"
title={`${t.count} post${t.count === 1 ? "" : "s"} in current timeline`}
onclick={() => onSearch(t.handle)}
>
<span class="trend__handle">@{t.handle}</span>
<span class="trend__count">{t.count} post{t.count === 1 ? "" : "s"}</span>
</button>
</li>
{/each}
</ul>
{/if}
</section>
<section class="panel">
<h3 class="panel__title">// who to follow</h3>
<ul class="who">
{#each placeholders as p (p.handle)}
<li class="who__row">
<span class="who__handle">@{p.handle}</span>
<span class="who__why">{p.why}</span>
</li>
{/each}
</ul>
<p class="panel__hint">// coming soon — follow graph not wired yet</p>
</section>
</aside>
<style>
.sidebar {
width: 280px;
flex: 0 0 280px;
align-self: flex-start;
position: sticky;
top: var(--s-3);
display: flex;
flex-direction: column;
gap: var(--s-3);
padding: var(--s-2) var(--s-3) var(--s-5);
max-height: calc(100vh - 24px);
overflow-y: auto;
}
.panel {
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg-deep);
padding: var(--s-3);
}
.panel__title {
margin: 0 0 var(--s-3);
color: var(--orange);
font-family: var(--font-mono);
font-size: var(--fs-50);
letter-spacing: var(--tracking-label);
font-weight: 700;
}
.panel__search {
display: flex;
align-items: center;
gap: var(--s-2);
font-family: var(--font-mono);
}
.panel__search .prompt {
color: var(--orange);
font-family: var(--font-mono);
font-size: var(--fs-100);
}
.panel__search input {
flex: 1;
min-width: 0;
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;
}
.panel__search input:focus {
border-color: var(--orange);
}
.panel__search input::placeholder {
color: var(--text-dim);
}
.panel__empty {
margin: 0;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
font-style: italic;
}
.panel__hint {
margin: var(--s-3) 0 0;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
font-style: italic;
}
.trends,
.who {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.trend {
width: 100%;
text-align: left;
background: transparent;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
padding: var(--s-2) var(--s-3);
color: var(--text);
font-family: var(--font-mono);
cursor: pointer;
display: flex;
flex-direction: column;
gap: 2px;
transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.trend:hover {
color: var(--orange);
border-color: var(--orange);
}
.trend__handle {
color: var(--text);
font-family: var(--font-mono);
font-size: var(--fs-100);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trend:hover .trend__handle {
color: var(--orange);
}
.trend__count {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
font-variant-numeric: tabular-nums;
}
.who__row {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--s-2) var(--s-1);
border-bottom: 1px dashed var(--line);
}
.who__row:last-child {
border-bottom: 0;
}
.who__handle {
color: var(--text);
font-family: var(--font-mono);
font-size: var(--fs-100);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.who__why {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
}
@media (max-width: 900px) {
.sidebar {
display: none;
}
}
</style>