Commit Graph
14 Commits
Author SHA1 Message Date
tomdebone 48ee25f217 feat(follow): end-to-end follow / unfollow with localStorage state
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.
2026-07-26 21:25:35 +02:00
tomdebone 4c71b76763 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.
2026-07-26 19:44:26 +02:00
tomdebone e6aa28ca4c fix(tauri-app): use absolute AppView URL for /api/profile fetch
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.
2026-07-18 18:53:35 +02:00
tomdebone e4bcfbfa83 fix(tauri-app): wrap profile methods in PdsHttpClient impl block
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.
2026-07-18 18:35:35 +02:00
tomdebone ffee5c6685 feat(tauri-app): profile view, Avatar component, handle navigation
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.
2026-07-18 17:57:15 +02:00
tomdebone 391448a845 chore(tauri): disable auto-update for dev; document production config
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).
2026-07-10 22:16:36 +02:00
tomdebone 647c3059b4 feat(tauri-app): Tauri 2 capabilities default + open_devtools in debug
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.
2026-07-07 20:54:10 +02:00
tomdebone 6a82c906de tauri-app: tray settings menu item
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'.
2026-07-07 18:57:45 +02:00
tomdebone 550b89673d feat(tauri-app): expand tray menu + open_external_url + Profile/Search items
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).
2026-07-07 17:31:09 +02:00
tomdebone 6a85f44bab fix(tauri-app): guard Tauri runtime in client.ts and main.ts
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.
2026-07-07 08:30:48 +02:00
tomdebone 0fbae97306 fix(tauri-app): vite bind 127.0.0.1 (not localhost)
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:`.
2026-07-07 07:22:33 +02:00
tomdebone b912132a05 tauri-app: 8a review fixes
- 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.
2026-07-06 18:53:09 +02:00
tomdebone 226cfdac5c tauri-app: switch to native title bar (fixes drag + clicks)
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).
2026-07-06 16:29:39 +02:00
tomdebone c586fd39c9 maarcadetweet: initial commit
AT Protocol PDS + AppView + Tauri Desktop Client, 160-char post limit.

- PDS (Rust + axum + sqlx)
  - Auth: createAccount, createSession, refreshSession
  - Records: createRecord, deleteRecord (race-safe via SELECT FOR UPDATE)
  - Feed: feed.like.create, feed.repost.create
  - Sync: getRepo, getBlocks, getLatestCommit, getRecord (with MST proof), listRepos
  - Identity: resolveHandle
  - MST: spec-conformant (at-mst crate, 27 tests)
  - Repo: signed commits, TID counter (monotonic, 4096 wrap safe)

- AppView (Rust + axum + sqlx)
  - Jetstream consumer (WebSocket, exponential backoff, 38k+ events indexed)
  - REST API: timeline/home (graph-aware), profile, search, post (with thread hydration)
  - Handle-sync worker (did:plc + did:web)
  - JSONB embed storage + thread columns (migration 0003)
  - Like/repost counter cache (migration 0004)

- Tauri 2 + Svelte 5 Desktop Client
  - System tray (Show/Compose/Quit menu)
  - OS notifications (tauri-plugin-notification)
  - Auto-update (tauri-plugin-updater, placeholder endpoint)
  - Window-state (tauri-plugin-window-state)
  - 160-char compose with live counter
  - Image/Link embed rendering
  - LocalStorage-persisted like state
  - Timeline with poll (prepend new posts)
  - Custom TitleBar (transparent, no decorations)
  - Orange/IBM Plex Mono maarcade design

Tests: 231 Rust + 9 vitest = 240 passed.
2026-07-05 20:01:31 +02:00