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:
@@ -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<string> = 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)}
|
||||
/>
|
||||
<div class="main">
|
||||
<div class="main-inner">
|
||||
<Terminal title={view === "home" ? "maarcadetweet — home" : `maarcadetweet — ${view}`}>
|
||||
{#if view === "home"}
|
||||
<div class="head">
|
||||
@@ -431,6 +482,20 @@
|
||||
<span class="as">@{currentUser.handle}</span>
|
||||
<span class="meta">→ {userPosts.length} posts · polling every 5s</span>
|
||||
</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}
|
||||
<div class="toast toast--err">err: {timelineError}</div>
|
||||
{/if}
|
||||
@@ -451,14 +516,14 @@
|
||||
<div class="toast toast--err">err: {threadError}</div>
|
||||
{:else if threadRoot}
|
||||
{#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}
|
||||
<PostCard post={threadRoot} on_handle_click={openUserProfile} />
|
||||
<PostCard post={threadRoot} on_handle_click={openUserProfile} on_reply={onReply} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#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}
|
||||
{#if timelineCursor}
|
||||
<div class="loadmore">
|
||||
@@ -475,7 +540,11 @@
|
||||
<span class="as">@{currentUser.handle}</span>
|
||||
<span class="meta">⌘↵ to post</span>
|
||||
</div>
|
||||
<ComposeBox onPosted={handlePosted} />
|
||||
<ComposeBox
|
||||
onPosted={handlePosted}
|
||||
replyTo={replyTo}
|
||||
onClearReply={clearReply}
|
||||
/>
|
||||
{:else if view === "user"}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
@@ -585,6 +654,32 @@
|
||||
placeholder="grep posts…"
|
||||
/>
|
||||
</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}
|
||||
<div class="toast toast--err">err: {searchError}</div>
|
||||
{/if}
|
||||
@@ -597,11 +692,15 @@
|
||||
{:else}
|
||||
<div class="meta meta--results">{searchResults.length} result{searchResults.length === 1 ? "" : "s"} for "{searchQuery}"</div>
|
||||
{#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}
|
||||
{/if}
|
||||
{/if}
|
||||
</Terminal>
|
||||
{#if view === "home"}
|
||||
<Sidebar posts={userPosts} onSearch={onSidebarSearch} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBar did={status.did ?? ""} authenticated={status.authenticated} />
|
||||
</div>
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user