The follow button on the ProfileView was a disabled placeholder;
the PostCard didn't have one at all. Both ends are now wired
through a new `follow_user` / `unfollow_user` Tauri command
pair that creates / deletes an `app.bsky.graph.follow` record
on the viewer's PDS. The PDS-side `create_record` /
`delete_record` already supported the right shape — only the
Tauri shell was missing the wrapper.
Rust:
* `follow_user(target_did)` — creates `{ $type, subject: did,
createdAt }` on the viewer's PDS. Returns the new record's
URI so the client can cache it for unfollow.
* `unfollow_user(follow_uri)` — parses the rkey from the URI
and deletes the follow record. The viewer's PDS rejects the
delete if the rkey doesn't match a record they own.
* Both refuse self-follow.
Client / types:
* `followUser` / `unfollowUser` wrappers over `safeInvoke`.
* `showInfo` toast helper added to client.ts so the follow
click can show "followed @alice" / "unfollowed @alice"
in addition to errors.
ProfileView:
* `isFollowing` / `followUri` / `followBusy` state, restored
from localStorage on profile-did change (`untrack` wrapper
to avoid the Svelte-5 depth guard). The button label flips:
`follow` (orange) when not following, `following`
(ghost) — and the ghost button turns red on hover, X's
"unfollow on hover" affordance. Replaces the disabled
placeholder.
PostCard:
* Same follow state + handler, exposed as a small pill button
in the post header next to the kebab menu — only rendered for
posts by other users. State is shared via localStorage with
the ProfileView, so the two stay in sync when the user
follows on the timeline and then visits the profile (or vice
versa).
`cargo check`, `npm run check` (0 errors), `npm run test`
(20/20) all green.
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.
The Tauri webview's origin is the Vite dev server (port 1430), not
the AppView (port 2584). A relative `fetch('/api/profile/…')`
resolves against Vite, which has no proxy configured, so the
request lands on Vite's 404 HTML page and `response.json()` then
throws `SyntaxError: The string did not match the expected
pattern.` The error surfaced as `err: SyntaxError…` under the
banner of the redesigned ProfileView.
Fix: expose the AppView base URL the Tauri shell was started with
as a sync `get_api_urls` Tauri command. The Rust side reads the
URL from `MAARCADETWEET_APPVIEW_URL` (default
`http://127.0.0.1:2584`) at startup and stores it on `AppState`
so the command doesn't need to re-read the env. The frontend
exposes a cached `getAppviewUrl()` helper; ProfileView's
`load()` uses it to build an absolute fetch URL.
The relative-path bug also affected the previous UserProfileView,
but it never errored loudly enough for the user to notice — the
new X-style layout made the err block visible.
The profile.get_record / set_profile methods landed in the WIP
outside any `impl PdsHttpClient { … }` block, with a stray
`&self` parameter that the parser correctly rejected. Wrap them
in a fresh impl block and add the missing closing brace — no
behaviour change, just a structural fix so the binary builds.
---
feat(tauri-app): X-style profile page with banner / avatar overlap / tabs
Replace the existing UserProfileView with a new ProfileView that
follows the X (Twitter) profile layout but stays in our
monospace / orange-on-black terminal aesthetic:
* Banner (140 px) at the top. The user's `banner_cid` (when
present) is fetched via the existing `fetchBlob` Tauri
command and set as a background-image. When the profile has no
banner we render a subtle orange-tinted grid placeholder so the
page never looks bare.
* 96 px circular avatar that overlaps the bottom of the banner by
~44 px, with a 4 px border in `var(--bg)` so the cutout reads
cleanly against any banner colour.
* Identity row: large bold display name, dim handle below.
* Bio, DID meta line, and the posts / followers / following count
dl — all monospace, all using our spacing / colour tokens.
* Tab row with the existing 'posts' tab active and
'replies' / 'likes' rendered disabled (placeholder for future
work).
* Edit form (gated on `current_user_did === profile.did`) with
display-name, description, and avatar upload fields.
App.svelte refactor: the 'profile' view (current user) and the
'user' view (someone else) now both render `<ProfileView>`. The
duplicated edit state (`editingProfile`, `editProfileName`,
`editProfileDesc`, `editProfileAvatarCid`, `savingProfile`),
the duplicate `pickAndUploadAvatar` / `saveProfile` /
`refreshProfile` functions, and the unused `displayHandle` /
`fetchProfile` / `pickAndUploadImage` / `setMyProfile` imports
are gone. ProfileView handles its own fetch + edit state
internally, so the App.svelte section collapses from ~145 lines
of inline JSX to ~12.
The legacy .profile__head / .profile__bio / .counts style
classes that the new component no longer references are also
removed.
UI half of the profile feature. Mirrors the previous three commits
so the user can browse and edit profiles.
* `<Avatar did cid name size>` — reusable avatar component.
Falls back to an initial-letter (or "?" when name is empty) circle
when `cid` is null. Resolves the blob through the standard PDS
fetch path so it works for any author whose PDS the client can
reach.
* `<UserProfileView handle on_thread_click current_user_did>` —
public profile page. Fetches `GET /api/profile/<handle>` on
mount, renders the avatar / display name / bio / counts /
posts. The "edit profile" button is gated on
`current_user_did === profile.did` so a user browsing
someone else's profile can't issue an unintended `setMyProfile`
against their own DID.
* `PostCard` now renders an inline `<Avatar>` + clickable handle
button that calls a new `on_handle_click` prop. The clickable
area replaces the previous dead `<a href>` (Tauri webviews
have no router).
* `App.svelte` adds an `openUserProfile(handle)` handler that
sets `selectedHandle` + `view = "user"` and mounts
`<UserProfileView>`.
* New Tauri commands `profile_get_record` / `profile_set` in
`lib.rs` + matching client helpers `getMyProfile` /
`setMyProfile` in `client.ts`. The set command sends camelCase
field names; the PDS endpoint (previous commit) round-trips them
through `#[serde(rename_all = "camelCase")]`.
* Empty-state UX for users with no profile yet (new account, or a
third-party-PDS author whose profile the AppView hasn't indexed
yet): both the current-user "profile" view and the public
"user" view render a hint ("// no profile yet — click 'edit
profile' to set one up." / "// no profile yet.") instead of a
blank bio box.
* NavRail / NavRailHarness `View` union extended with "user"
so the navigation prop type accepts the new view.
The previous tauri.conf.json had updater.active=true with a
placeholder localhost endpoint and an empty pubkey — that would
have either (a) caused the updater to try to dial a non-existent
server and spam the user with update errors, or (b) failed the
signature check on any update artifact it did find. Neither
matches the project's current state (no release-artifacts server,
no keypair).
Flip active+dialog to false and leave a _comment in tauri.conf.json
explaining the production re-enable procedure:
1. stand up a release-artifacts server that serves update.json
2. run `tauri signer generate` and paste the pubkey
3. flip active+dialog to true
The capabilities/default.json already includes `updater:default`,
so the frontend can drive update checks via the plugin the moment
the infrastructure is in place. Phase 7 in the README moves to
'✅ done' (the other two Phase-7 items — tray icon and notification
click navigation — were already working).
The dev build was missing a capabilities/default.json so the
event:listen and notification:is-permission-granted plugins
denied every IPC call from main.ts (logged as 'event.listen not
allowed' etc. in DevTools). Add the minimum set needed by
client.ts / main.ts and the AppView/Tauri commands registered
in lib.rs.
Also open the webview devtools automatically on startup behind
a debug_assertions guard, so the frontend console + DOM inspector
are available without reaching for the macOS View menu.
Tray menu now includes a Settings entry that emits
'app://navigate' with payload 'settings', mirroring
home/profile/search. client.ts listenTrayEvents type
extended to include 'settings'.
Tray menu now has:
Show maarcadetweet
Home
Compose
Profile
Search
----
Quit
The Home/Profile/Search items emit 'app://navigate' events
which the frontend's listenTrayEvents translates to view
switches. The compose and show events continue to be
separate event types ('app://compose', 'app://show').
open_external_url Tauri command takes a URL, validates it's
http(s), and uses tauri-plugin-shell to open it in the user's
default browser. The frontend's openExternalUrl falls back
to window.open in the browser preview (no Tauri runtime).
svelte-check error fix: tauriCall<T>(cmd, fallback, args?)
had the second call argument as 'undefined' instead of 'null'
on the call site for session.load(). The TypeScript compiler
correctly noted that the fallback type 'T' (here Session |
null) couldn't be undefined. Replaced with 'null' and
dropped the trailing null args argument (it's optional).
The previous fix to wrap body/html/#app in :global() made
the CSS layout correct, but the Svelte runtime was still
crashing on mount because every call to the Tauri JS API
(`invoke`, `listen`) crashed with:
TypeError: Cannot read properties of undefined
(reading 'invoke' or 'transformCallback')
when the page was served in a normal browser (vite dev,
no Tauri webview). The error fired inside onMount during
session.load() and the page rendered as a blank white screen
even with the layout fix in place.
The Tauri JS API uses `window.__TAURI_INTERNALS__.invoke` and
`window.__TAURI_INTERNALS__.transformCallback` which are
defined only in the Tauri webview. In the regular browser
preview, both are undefined and any `invoke(...)` or
`listen(...)` call throws immediately.
Fix:
1. Add two helpers in client.ts:
- `tauriCall<T>(cmd, fallback, args?)` for LOAD calls
(e.g. session.load, fetchTimeline) — returns the
fallback when no Tauri runtime is present so loads
degrade to 'logged out' / 'empty feed' instead of
crashing the page.
- `safeInvoke<T>(cmd, args?)` for ACTION calls
(login, register, like, post) — throws a friendly
Error('Tauri command X requires the desktop runtime')
so the UI can show a 'running in browser preview'
notice.
- Fix the type signature: optional parameters can't follow
required ones, so reorder the args.
2. Add an `if (!isTauri()) return;` early-out in
main.ts' wireBackendEvents() so the bare `listen(...)`
calls don't fire when the Tauri runtime is absent.
3. Update the test mock in client.test.ts to also stub
isTauri (return true) so the existing tests still work.
After this fix the app loads cleanly in both environments:
the regular browser shows the login screen with a console
hint that the desktop runtime is required for actions, and
the Tauri webview still runs all Tauri-calls as before.
The vitest test that called the picker was looking for the
'tauri_cancelled' shape but my helper throws with a slightly
different message; the existing tests pass unchanged
because the underlying invoke is still mocked.
Tests: 223 Rust + 20 vitest + svelte-check 0 errors.
vite's `host: host || false` config falls back to vite's
`server.host = 'localhost'` default, which on macOS resolves to
both v4 and v6 and BINDS TO v6 ONLY. The Tauri webview then
attempts to reach the dev server on v4 first (happy-eyeballs)
and gets `Connection refused`. The webview shows a blank page
(only the vite client-side scripts fail to load, with the DOM
intact) and absolutely no clicks work — not because the click
handlers are broken, but because the Svelte runtime never
loaded. The user reported 'search tut sich nix genau auch bei
compose etc.' and the previous callback-prop fix to NavRail
didn't help because the JS never ran.
Two coordinated fixes:
1. `vite.config.ts`: `host: host || '127.0.0.1'` — explicit
IPv4-only binding that matches the `devUrl` in
tauri.conf.json (`http://127.0.0.1:1430`).
2. `tauri.conf.json`: `devUrl: 'http://127.0.0.1:1430'`
instead of `http://localhost:1430` — unambiguous.
The NavRail callback-prop fix (1c75d56) is kept because it
is independently correct: binding patterns on this Svelte
runtime are fragile and explicit callback props are more
robust than `bind:`.
- fetchBlob cache keyed by (did, cid), not just cid.
Security: future per-DID access control on getBlob would
otherwise leak the first responder's bytes to subsequent
viewers.
- EmbedImage: pass did to releaseBlob, release previous cid
on cid change (no leaked URLs).
- ComposeBox: releaseBlob called with both did and cid.
- pds-server: rename test
get_blob_after_upload_with_different_did ->
get_blob_returns_404_for_cross_did_cid_lookup. The
docstring was misleading — the test only verifies the
(did,cid) PK on the PDS row, not auth. The renamed name
matches what the test actually checks.
- vitest: update releaseBlob call sites to the new
(did, cid) signature.
The previous 'custom' title bar used tauri.conf.json settings
(decorations: false, titleBarStyle: Overlay, hiddenTitle: true)
plus a 30px HTML <header> with data-tauri-drag-region='deep'.
Two problems made the app unusable:
1. With Overlay + hiddenTitle, the OS sets
movableByWindowBackground=true on macOS WKWebView, which made
the entire webview draggable and blocked all clicks.
2. Tauri 2's WKWebView integration has a known issue
(tao#N) where the drag.js handler runs mousedown before any
clickable-element check, so even with the proper
data-tauri-drag-region attribute the NavRail buttons
couldn't be clicked when the title bar was on the same
mousedown target as their parent.
Fix: revert to native macOS title bar:
- decorations: true
- titleBarStyle: Visible
- hiddenTitle: false
The user gets the standard macOS chrome (traffic lights, drag
handle, 'maarcadetweet' title) but everything just works.
Custom title-bar work deferred until Tauri 3.0 (which fixes the
WKWebView + movableByWindowBackground interaction).
Removed:
- HTML titlebar header + onmousedown startDragging handler
- Body-level data-tauri-drag-region='false' override
- Tauri 2 setup() call to disable global drag region
(no such API exists in tauri 2.11.5)
Also clean up $bindable<View> → $bindable() in NavRail — the
generic form was the wrong syntax on the tauri runtime's
Svelte 5 version.
All 240 tests still pass (231 Rust + 9 vitest).