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:
@@ -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<Post> {
|
||||
// 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<any>("post_create", {
|
||||
text,
|
||||
embed: embed ?? null,
|
||||
reply: reply ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user