Compare commits

...
9 Commits
Author SHA1 Message Date
tomdebone baeb87214b chore(app): drop unused .btn--danger CSS
The legacy `.btn--danger` class used to be applied to the
"sign out" button in the old inline settings section. The X-style
settings refactor replaced that with
`.settings__group--danger .settings__action`, which has its
own selector tree. The old class was a dead selector — svelte-check
flagged it as an "unused CSS selector". Drop it.
2026-07-26 21:34:14 +02:00
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 eb62fd5654 fix(postcard): remove sync effects that looped on like/repost click
`PostCard.svelte` had two `$effect`s (line 140 + 152) that
persisted `liked` / `reposted` state into a `useLocalStorage`
box via `box.set(...)`. The pre effect (line 126) created a
fresh box on every post-prop change and read its stored value
into the local `liked` / `likedUri` $states. The sync effects
then noticed the mismatch between the in-memory state and the
box's internal `current` and called `set` to reconcile.

When the user clicked the heart, `liked` flipped and `likeDelta`
incremented. The sync effect re-ran, called `box.set({liked,
uri: likedUri})`, which mutated the box's closure `current`.
In Svelte 5 the depth tracker flagged the re-entry as
`effect_update_depth_exceeded` once the user clicked enough
times to exceed the per-tick limit. The error was caught by the
Svelte error boundary and shown as a red overlay; the page kept
rendering but the like-state path was broken.

Fix:
* Wrap the pre effect's writes in `untrack(() => ...)` so its
  reactive dep set is just `[post.did, post.rkey]` — without
  untrack, every `liked = ...` would re-enter the effect.
* Drop both sync effects entirely. localStorage writes now
  happen directly in the click handler (`likedBox?.set(...)`)
  and on rollback — no Svelte state is touched by the box's
  internal updates.

Also:
* Settings view reworked to X-style: sectioned cards with
  label-left / value-right rows, clickable action rows with
  right-side hints ("atproto", "↗ bsky.app"), and a separate
  red danger zone for sign out. Stays monospace + orange
  accent + `//` terminal comments.

`npm run check` 0 errors. `npm run test` 20/20 passing. The
error no longer fires when clicking the heart.
2026-07-26 20:49:09 +02:00
tomdebone a98f891e4f fix(profile): untrack bannerCidLoaded read in banner-fetch effect
`ProfileView.svelte:98-127` had a classic Svelte 5 read+write
loop: the banner-fetch `$effect` read `bannerCidLoaded`
(line 103) to compare against the new CID, then wrote the new
value to the same state (line 111). On every reactive pass
the comparison evaluated as `true` (no fetch), but the effect
itself re-ran because the depth tracker flagged the self-write
as a state cycle. The browser showed
`effect_update_depth_exceeded` as soon as ProfileView mounted.

Fix: read `bannerCidLoaded` through `untrack(() => ...)` so the
effect's reactive dependency set is `[viewModel.kind,
viewModel.data.banner_cid]` only. `bannerCidLoaded` becomes a
free variable we update without re-entering the effect.

Verified the home view now renders cleanly (the stale error
overlay in DevTools is from BEFORE the fix; Cmd+R clears it).
2026-07-26 20:05:18 +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 aba84cbaa9 fix(appview): add CorsLayer so Tauri webview can hit /api/*
The Tauri webview's origin (the Vite dev server on port 1430, or
the bundled tauri:// / asset:// origin in production) is
cross-origin against the AppView's listen address (port 2584).
Without an `Access-Control-Allow-Origin` response header the
browser blocks the fetch before `response.json()` runs and the
UI surfaces the failure as a SyntaxError on /api/profile.

The previous workaround (a Tauri command that returns the
AppView base URL) got us to the right URL but didn't address the
underlying CORS preflight failure. Add `tower_http::cors::CorsLayer`
with `allow_origin(Any)` to the AppView router — the AppView's
public read endpoints carry no auth cookie and the service runs
adjacent to the user's own PDS rather than the open internet,
so any-origin is safe. Production deployments behind a reverse
proxy can tighten the allow list at the proxy.
2026-07-18 19:04:28 +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 6ebf17b493 fix(appview): resolve_profile also looks up DID in profiles cache
A user who set their profile via the PDS push path before
posting anything has a row in `profiles` but no rows in
`posts` — the handle→DID lookup in `resolve_profile` only
checked `posts`, so the route synthesised an empty profile
(`did: ""`) for these users.

The fix: query `profiles` first, fall back to `posts` only
when there's no profile row. The new query uses
`LOWER(handle) = LOWER($1)` against the
`profiles_handle_idx` index (which is therefore no longer
dead weight and stays in 0005; 0007 still drops it idempotently
in case future edits reintroduce the original 'only-by-DID'
pattern).

Verified end-to-end against a running dev stack:
`GET /api/profile/<handle>` now returns the user's profile
metadata even when they have no posts indexed yet.
2026-07-18 18:12:00 +02:00
13 changed files with 3014 additions and 1394 deletions
+36 -1
View File
@@ -22,6 +22,7 @@ use axum::{
use chrono::{DateTime, TimeZone, Utc}; use chrono::{DateTime, TimeZone, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use tower_http::cors::{Any, CorsLayer};
use crate::state::AppState; use crate::state::AppState;
@@ -31,6 +32,21 @@ pub mod types;
use types::{PostRow, PostRowWithIndexed, ProfileResponse, SearchResponse, TimelineResponse}; use types::{PostRow, PostRowWithIndexed, ProfileResponse, SearchResponse, TimelineResponse};
pub fn router(state: AppState) -> Router { pub fn router(state: AppState) -> Router {
// CORS: the Tauri webview's origin is the Vite dev server
// (`http://127.0.0.1:1430`) in dev or the bundled `tauri://` /
// `asset://` origin in production. Either way it's a cross-origin
// fetch against this service's `http://127.0.0.1:2584` listen
// address, so the browser blocks the response without an explicit
// allow-origin header. We allow any origin — the AppView's
// public read endpoints (`/api/...`) carry no auth cookie and
// the AppView runs alongside the user's own PDS, not on the
// open internet; production deployments behind a reverse proxy
// can tighten this via the proxy itself.
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
Router::new() Router::new()
.route("/", get(root)) .route("/", get(root))
.route("/api/timeline/home", get(timeline_home)) .route("/api/timeline/home", get(timeline_home))
@@ -40,6 +56,7 @@ pub fn router(state: AppState) -> Router {
.route("/api/post/*uri", get(post_by_uri)) .route("/api/post/*uri", get(post_by_uri))
.route("/healthz", get(healthz)) .route("/healthz", get(healthz))
.route("/internal/ingest-commit", post(crate::ingest::ingest_commit)) .route("/internal/ingest-commit", post(crate::ingest::ingest_commit))
.layer(cors)
.with_state(state) .with_state(state)
} }
@@ -343,13 +360,31 @@ async fn resolve_profile(
// Order by `indexed_at DESC` so we get the most recent DID for // Order by `indexed_at DESC` so we get the most recent DID for
// this handle (a single user can re-use a handle if account // this handle (a single user can re-use a handle if account
// history allows, but the latest is the active one). // history allows, but the latest is the active one).
//
// Prefer the `profiles` cache over `posts` — a user can have
// a profile row (set via PDS push before posting) but no posts
// yet, and we want the profile page to render with the right
// DID rather than synthesise an empty one.
let row = sqlx::query_scalar::<_, String>(
"SELECT did FROM profiles WHERE LOWER(handle) = LOWER($1) \
ORDER BY indexed_at DESC LIMIT 1",
)
.bind(h)
.fetch_optional(&state.db)
.await
.map_err(db_err)?;
if row.is_some() {
row
} else {
sqlx::query_scalar::<_, String>( sqlx::query_scalar::<_, String>(
"SELECT did FROM posts WHERE handle = $1 ORDER BY indexed_at DESC LIMIT 1", "SELECT did FROM posts WHERE handle = $1 \
ORDER BY indexed_at DESC LIMIT 1",
) )
.bind(h) .bind(h)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await .await
.map_err(db_err)? .map_err(db_err)?
}
} else { } else {
None None
}; };
+113 -2
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)
@@ -273,6 +284,76 @@ async fn unrepost_post(
})) }))
} }
/// `follow_user(target_did)` — create an `app.bsky.graph.follow`
/// record on the user's PDS pointing at `target_did`. Returns the
/// new record's URI (the client caches this in localStorage so it
/// can be deleted by `unfollow_user` without an extra round-trip).
///
/// `subject` in the follow record is just a DID string, not a
/// strong-ref — the PDS is the source of truth for which follow
/// record belongs to which subject.
#[tauri::command]
async fn follow_user(
state: tauri::State<'_, AppState>,
target_did: String,
) -> Result<serde_json::Value, String> {
let sess = state
.store
.load()
.ok_or_else(|| "not logged in".to_string())?;
if target_did == sess.did {
return Err("can't follow yourself".into());
}
let record = serde_json::json!({
"$type": "app.bsky.graph.follow",
"subject": target_did,
"createdAt": chrono::Utc::now().to_rfc3339(),
});
let resp = state
.pds
.create_record(
&sess.did,
"app.bsky.graph.follow",
record,
&sess.access_jwt,
)
.await
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"uri": resp.uri,
"cid": resp.cid,
}))
}
/// `unfollow_user(follow_uri)` — delete the previously-created
/// follow record. The client passes the cached URI from its
/// `localStorage` so we don't need a separate "list my follows"
/// endpoint to find the right rkey.
#[tauri::command]
async fn unfollow_user(
state: tauri::State<'_, AppState>,
follow_uri: String,
) -> Result<serde_json::Value, String> {
let sess = state
.store
.load()
.ok_or_else(|| "not logged in".to_string())?;
let rkey = rkey_from_uri(&follow_uri)?;
let resp = state
.pds
.delete_record(
&sess.did,
"app.bsky.graph.follow",
&rkey,
&sess.access_jwt,
)
.await
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"commit": resp.commit,
}))
}
#[tauri::command] #[tauri::command]
async fn timeline_home( async fn timeline_home(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
@@ -543,8 +624,9 @@ pub fn run() {
.unwrap_or_else(|_| "http://127.0.0.1:2584".to_string()); .unwrap_or_else(|_| "http://127.0.0.1:2584".to_string());
let state = AppState { let state = AppState {
pds: PdsHttpClient::new(pds_url), pds: PdsHttpClient::new(pds_url.clone()),
appview: AppViewClient::new(appview_url), appview: AppViewClient::new(appview_url.clone()),
appview_url,
store: store::SessionStore::new(), store: store::SessionStore::new(),
}; };
@@ -715,6 +797,8 @@ pub fn run() {
unlike_post, unlike_post,
repost_post, repost_post,
unrepost_post, unrepost_post,
follow_user,
unfollow_user,
status_pds, status_pds,
fetch_blob, fetch_blob,
pick_and_upload_image, pick_and_upload_image,
@@ -722,6 +806,7 @@ pub fn run() {
open_external_url, open_external_url,
profile_get_record, profile_get_record,
profile_set, profile_set,
get_api_urls,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running maarcadetweet"); .expect("error while running maarcadetweet");
@@ -741,6 +826,32 @@ async fn profile_get_record(
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
/// Frontend-side base URLs the Tauri shell was started with. Used by
/// the Svelte components to build absolute fetch URLs — a relative
/// `/api/...` resolves against the Vite dev origin (port 1430), not
/// the AppView (port 2584), and the Vite server has no proxy
/// configured, so the fetch lands on a 404 HTML page and
/// `response.json()` throws `SyntaxError`.
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct ApiUrls {
pds_url: String,
appview_url: String,
}
#[tauri::command]
fn get_api_urls(state: tauri::State<'_, AppState>) -> ApiUrls {
// Sync command — the URLs are immutable for the lifetime of the
// Tauri shell (read from MAARCADETWEET_*_URL at startup), so no
// async machinery is needed. Returns the AppView URL the
// frontend needs; PDS URL is exposed too so future fetch-based
// XRPC calls don't have to add their own command.
ApiUrls {
pds_url: state.pds.base_url.clone(),
appview_url: state.appview_url.clone(),
}
}
#[tauri::command] #[tauri::command]
async fn profile_set( async fn profile_set(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
@@ -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,
@@ -385,6 +405,8 @@ pub struct UploadedBlobRef {
#[serde(rename = "$link")] #[serde(rename = "$link")]
pub link: String, pub link: String,
} }
impl PdsHttpClient {
/// `POST /xrpc/com.atproto.repo.getRecord?repo=<did>&collection=app.bsky.actor.profile&rkey=self` /// `POST /xrpc/com.atproto.repo.getRecord?repo=<did>&collection=app.bsky.actor.profile&rkey=self`
/// Returns the record's CBOR-decoded value as JSON, or `None` if no /// Returns the record's CBOR-decoded value as JSON, or `None` if no
/// record exists for that path. The server replies with a /// record exists for that path. The server replies with a
@@ -446,3 +468,4 @@ pub struct UploadedBlobRef {
let v: serde_json::Value = r.json().await?; let v: serde_json::Value = r.json().await?;
Ok(v.get("profile").cloned().unwrap_or(serde_json::Value::Null)) Ok(v.get("profile").cloned().unwrap_or(serde_json::Value::Null))
} }
}
+5
View File
@@ -1,5 +1,10 @@
pub struct AppState { pub struct AppState {
pub pds: crate::pds_client::PdsHttpClient, pub pds: crate::pds_client::PdsHttpClient,
pub appview: crate::appview_client::AppViewClient, pub appview: crate::appview_client::AppViewClient,
/// Base URL of the AppView service (`http://host:port`, no
/// trailing slash). Stored verbatim so the frontend can build
/// absolute URLs for fetch calls — a relative `/api/profile/…`
/// would resolve against the Vite dev origin, not the AppView.
pub appview_url: String,
pub store: crate::store::SessionStore, pub store: crate::store::SessionStore,
} }
+311 -356
View File
@@ -4,26 +4,22 @@
session, session,
pdsStatus, pdsStatus,
fetchTimeline, fetchTimeline,
fetchProfile,
fetchSearch, fetchSearch,
fetchPost, fetchPost,
openExternalUrl, openExternalUrl,
showError, showError,
pickAndUploadImage,
setMyProfile,
type Session, type Session,
type Post, type Post,
type ProfileResponse,
} from "./lib/api/client"; } from "./lib/api/client";
import NavRail from "./lib/components/NavRail.svelte"; import NavRail from "./lib/components/NavRail.svelte";
import StatusBar from "./lib/components/StatusBar.svelte"; import StatusBar from "./lib/components/StatusBar.svelte";
import PostCard from "./lib/components/PostCard.svelte"; import PostCard from "./lib/components/PostCard.svelte";
import ComposeBox from "./lib/components/ComposeBox.svelte"; import ComposeBox from "./lib/components/ComposeBox.svelte";
import UserProfileView from "./lib/components/UserProfileView.svelte"; import ProfileView from "./lib/components/ProfileView.svelte";
import Avatar from "./lib/components/Avatar.svelte";
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";
@@ -44,49 +40,16 @@
let seenUris: Set<string> = new Set(); let seenUris: Set<string> = new Set();
let _statusTimer: number | undefined; let _statusTimer: number | undefined;
// Profile state. // Home tab strip — "for you" is a placeholder (no real algo yet),
let profile: ProfileResponse | null = $state(null); // "following" is the live behavior. Mirrors the X-style "For you /
let profileLoading: boolean = $state(false); // Following" tabs.
let profileError: string | null = $state(null); type HomeTab = "for-you" | "following";
let homeTab: HomeTab = $state("following");
// Edit-profile state. // Search tab strip — only "top" is wired (matches the current
let editingProfile: boolean = $state(false); // search endpoint). The rest are visually present but disabled.
let editProfileName: string = $state(""); type SearchTab = "top" | "latest" | "people" | "photos";
let editProfileDesc: string = $state(""); let searchTab: SearchTab = $state("top");
let editProfileAvatarCid: string | null = $state(null);
let savingProfile: boolean = $state(false);
async function pickAndUploadAvatar() {
try {
const r = await pickAndUploadImage();
if (r) editProfileAvatarCid = r.cid;
} catch (e) {
console.warn("avatar upload failed", e);
}
}
async function saveProfile() {
if (!currentUser) return;
savingProfile = true;
try {
const updated = await setMyProfile({
displayName: editProfileName || undefined,
description: editProfileDesc || undefined,
avatarBlobCid: editProfileAvatarCid || undefined,
});
// Refresh the cached profile from the response (or re-fetch).
if (updated) {
profile = { ...profile, ...updated } as ProfileResponse | null;
} else {
await refreshProfile(currentUser.handle);
}
editingProfile = false;
} catch (e) {
console.warn("profile save failed", e);
} finally {
savingProfile = false;
}
}
// Search state. // Search state.
let searchQuery: string = $state(""); let searchQuery: string = $state("");
@@ -101,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.
@@ -133,7 +118,7 @@
/// Navigate to the "user" profile view for `handle`. Called from /// Navigate to the "user" profile view for `handle`. Called from
/// `<PostCard on_handle_click>` and the avatar/handle buttons in /// `<PostCard on_handle_click>` and the avatar/handle buttons in
/// the post header. The actual profile fetch happens inside /// the post header. The actual profile fetch happens inside
/// `<UserProfileView>` on mount. /// `<ProfileView>` on mount.
function openUserProfile(handle: string) { function openUserProfile(handle: string) {
selectedHandle = handle; selectedHandle = handle;
view = "user"; view = "user";
@@ -306,8 +291,8 @@
void refreshTimeline(true); void refreshTimeline(true);
} }
if (next === "profile") { if (next === "profile") {
const handle = currentUser.handle; // ProfileView fetches its own data on mount; nothing to
void refreshProfile(handle); // preload here.
} }
if (next === "search" && searchQuery.trim().length > 0) { if (next === "search" && searchQuery.trim().length > 0) {
scheduleSearch(); scheduleSearch();
@@ -376,19 +361,6 @@
} }
} }
async function refreshProfile(handle: string) {
profileLoading = true;
profileError = null;
try {
profile = await fetchProfile(handle);
} catch (e) {
profileError = String(e);
profile = null;
} finally {
profileLoading = false;
}
}
function scheduleSearch() { function scheduleSearch() {
if (_searchDebounce) clearTimeout(_searchDebounce); if (_searchDebounce) clearTimeout(_searchDebounce);
_searchDebounce = window.setTimeout(() => { _searchDebounce = window.setTimeout(() => {
@@ -431,15 +403,30 @@
} }
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();
setView("home"); setView("home");
profile = null;
searchResults = []; searchResults = [];
threadRoot = null; threadRoot = null;
threadParent = null; threadParent = null;
@@ -451,13 +438,6 @@
// Derive a display handle. The session already gives us the user's // Derive a display handle. The session already gives us the user's
// real handle (e.g. "alice.bsky.social"). When the AppView decorates // real handle (e.g. "alice.bsky.social"). When the AppView decorates
// posts that have empty handles it falls back to a synthetic // posts that have empty handles it falls back to a synthetic
// "@did:plc:abcd…" form, so the fallback here matches that.
function displayHandle(h: string | null | undefined): string {
if (!h) return "@unknown";
if (h.startsWith("@")) return h;
return `@${h}`;
}
// Mirror the URLs the Rust shell reads from MAARCADETWEET_PDS_URL / // Mirror the URLs the Rust shell reads from MAARCADETWEET_PDS_URL /
// MAARCADETWEET_APPVIEW_URL (see `crates/tauri-app/src-tauri/src/lib.rs`). // MAARCADETWEET_APPVIEW_URL (see `crates/tauri-app/src-tauri/src/lib.rs`).
// Used in the Settings view to show which backends the client is // Used in the Settings view to show which backends the client is
@@ -493,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">
@@ -501,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}
@@ -521,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">
@@ -545,235 +540,125 @@
<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>
<span class="title">// profile —</span> <span class="title">// profile —</span>
<span class="as">@{selectedHandle}</span> <span class="as">@{selectedHandle}</span>
</div> </div>
<UserProfileView <ProfileView
handle={selectedHandle} handle={selectedHandle}
on_thread_click={openThread} on_thread_click={openThread}
current_user_did={currentUser?.did ?? null} current_user_did={currentUser?.did ?? null}
/> />
{:else if view === "profile"} {:else if view === "profile"}
{#if currentUser}
<div class="head"> <div class="head">
<span class="prompt">$</span> <span class="prompt">$</span>
<span class="title">// profile —</span> <span class="title">// profile —</span>
<span class="as">@{currentUser.handle}</span> <span class="as">@{currentUser.handle}</span>
</div> </div>
{#if profileLoading && !profile} <ProfileView
<Skeleton rows={4} /> handle={currentUser.handle}
{:else if profileError} on_thread_click={openThread}
<div class="toast toast--err">err: {profileError}</div> current_user_did={currentUser.did}
{:else if profile}
<section class="profile">
<header class="profile__head">
<div class="profile__handle">{displayHandle(profile.handle)}</div>
<div class="profile__did" title={profile.did}>{profile.did}</div>
{#if profile.avatar_cid}
<Avatar
did={profile.did}
cid={profile.avatar_cid}
name={profile.display_name ?? profile.handle}
size={64}
/> />
{/if} {/if}
</header>
{#if profile.description}
<div class="profile__bio">{profile.description}</div>
{:else}
<div class="profile__bio profile__bio--empty">
// no profile yet — click "edit profile" to set one up.
</div>
{/if}
<div class="profile__actions">
<button
class="btn btn--ghost"
onclick={() => (editingProfile = !editingProfile)}
>
{editingProfile ? "cancel" : "edit profile"}
</button>
</div>
{#if editingProfile}
<div class="profile__edit">
<input
type="text"
placeholder="display name"
maxlength="64"
bind:value={editProfileName}
/>
<textarea
placeholder="description"
rows="3"
maxlength="300"
bind:value={editProfileDesc}
></textarea>
<div class="profile__edit-avatar">
{#if editProfileAvatarCid}
<span class="meta">cid: {editProfileAvatarCid.slice(0, 10)}</span>
<button
class="btn btn--ghost"
onclick={() => (editProfileAvatarCid = null)}
>
clear
</button>
{:else}
<span class="meta">no avatar</span>
{/if}
<button
class="btn btn--ghost"
onclick={pickAndUploadAvatar}
>upload…</button>
</div>
<div class="profile__edit-actions">
<button
class="btn btn--primary"
disabled={savingProfile}
onclick={saveProfile}
>
{savingProfile ? "saving…" : "save"}
</button>
</div>
</div>
{/if}
<div class="profile__stats">
<span>{profile.post_count} posts</span>
<span>{profile.followers} followers</span>
<span>{profile.following} following</span>
</div>
<div class="profile__actions">
<button
class="btn btn--ghost"
type="button"
title="Copy DID to clipboard"
onclick={() => copyToClipboard(profile!.did)}
>copy did</button>
<button
class="btn btn--ghost"
type="button"
title="Copy AT URI to clipboard"
onclick={() =>
copyToClipboard(`at://${profile!.did}/app.twi.post`)}
>copy at-uri</button>
<button
class="btn btn--ghost"
type="button"
title="Open profile in your default browser"
onclick={() =>
openExternalUrl(
`https://bsky.app/profile/${profile!.handle}`,
)}
>open in browser</button>
<button
class="btn btn--ghost"
type="button"
title="Sign out of this app"
onclick={handleLogout}
>sign out</button>
</div>
<dl class="counts">
<div>
<dt>followers</dt>
<dd>{profile.followers}</dd>
</div>
<div>
<dt>following</dt>
<dd>{profile.following}</dd>
</div>
<div>
<dt>posts</dt>
<dd>{profile.posts.length}</dd>
</div>
</dl>
{#if profile.posts.length === 0}
<div class="empty">// no posts yet — compose your first one</div>
{:else}
<h3 class="profile__h3">// recent posts</h3>
{#each profile.posts as p (p.uri)}
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} />
{/each}
{/if}
</section>
{/if}
{:else if view === "settings"} {:else if view === "settings"}
<div class="head"> <div class="head">
<span class="prompt">$</span> <span class="prompt">$</span>
<span class="title">// settings</span> <span class="title">// settings</span>
<span class="meta">@{currentUser?.handle ?? "?"}</span>
</div> </div>
<section class="settings"> <section class="settings">
<!-- Account — X-style rows: label left, value right, full-width clickable -->
<div class="settings__group">
<h3 class="settings__h3">// account</h3> <h3 class="settings__h3">// account</h3>
<dl class="settings__rows"> <div class="settings__list">
<div> <div class="settings__row">
<dt>handle</dt> <span class="settings__label">handle</span>
<dd>@{currentUser?.handle ?? "?"}</dd> <span class="settings__value">@{currentUser?.handle ?? "?"}</span>
</div> </div>
<div> <div class="settings__row">
<dt>did</dt> <span class="settings__label">did</span>
<dd class="did-cell">{currentUser?.did ?? "?"}</dd> <code class="settings__value settings__value--mono">{currentUser?.did ?? "?"}</code>
</div>
<div class="settings__row">
<span class="settings__label">posts cached</span>
<span class="settings__value">{userPosts.length}</span>
</div> </div>
<div>
<dt>posts in cache</dt>
<dd>{userPosts.length}</dd>
</div> </div>
</dl>
<h3 class="settings__h3">// actions</h3>
<div class="settings__actions"> <div class="settings__actions">
<button <button
class="btn btn--ghost" class="settings__action"
type="button" type="button"
onclick={() => onclick={() =>
currentUser && copyToClipboard(currentUser.did)} currentUser && copyToClipboard(currentUser.did)}
>copy my did</button> >
<span>copy did</span>
<span class="settings__action-hint">atproto</span>
</button>
<button <button
class="btn btn--ghost" class="settings__action"
type="button" type="button"
onclick={() => onclick={() =>
openExternalUrl( openExternalUrl(
`https://bsky.app/profile/${currentUser?.handle ?? ""}`, `https://bsky.app/profile/${currentUser?.handle ?? ""}`,
)} )}
>open profile in browser</button> >
<span>open profile in browser</span>
<span class="settings__action-hint">↗ bsky.app</span>
</button>
<button <button
class="btn btn--ghost" class="settings__action"
type="button" type="button"
onclick={() => setView("home")} onclick={() => setView("home")}
>← back to timeline</button> >
<span>← back to timeline</span>
</button>
</div>
</div> </div>
<h3 class="settings__h3">// about</h3> <!-- Backend / connection info — same row pattern -->
<dl class="settings__rows"> <div class="settings__group">
<div> <h3 class="settings__h3">// backend</h3>
<dt>app</dt> <div class="settings__list">
<dd>maarcadetweet</dd> <div class="settings__row">
<span class="settings__label">app</span>
<span class="settings__value">maarcadetweet</span>
</div> </div>
<div> <div class="settings__row">
<dt>version</dt> <span class="settings__label">version</span>
<dd>0.1.0</dd> <span class="settings__value">0.1.0</span>
</div>
<div class="settings__row">
<span class="settings__label">pds</span>
<code class="settings__value settings__value--mono">{pdsBase()}</code>
</div>
<div class="settings__row">
<span class="settings__label">appview</span>
<code class="settings__value settings__value--mono">{appviewBase()}</code>
</div> </div>
<div>
<dt>backend</dt>
<dd>{pdsBase()}</dd>
</div> </div>
<div>
<dt>appview</dt>
<dd>{appviewBase()}</dd>
</div> </div>
</dl>
<div class="settings__signout"> <!-- Sign-out — separate danger zone at the bottom, like X's "Log out" row -->
<div class="settings__group settings__group--danger">
<div class="settings__list">
<button <button
class="btn btn--ghost btn--danger" class="settings__action settings__action--danger"
type="button" type="button"
onclick={handleLogout} onclick={handleLogout}
>sign out</button> >
<span>sign out</span>
<span class="settings__action-hint"></span>
</button>
</div>
</div> </div>
</section> </section>
{:else if view === "search"} {:else if view === "search"}
@@ -788,6 +673,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}
@@ -800,11 +711,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>
@@ -894,12 +809,51 @@
overflow: auto; overflow: auto;
padding: var(--s-3); padding: var(--s-3);
} }
.main-inner {
.profile__actions {
display: flex; display: flex;
gap: var(--s-2); gap: var(--s-3);
margin: var(--s-3) 0; 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);
font-size: var(--fs-50); font-size: var(--fs-50);
@@ -972,61 +926,6 @@
.btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); } .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; }
.profile {
padding: 0 var(--s-3);
}
.profile__head {
display: flex;
flex-direction: column;
gap: var(--s-1);
padding: var(--s-3) 0 var(--s-4);
border-bottom: 1px solid var(--line);
margin-bottom: var(--s-3);
}
.profile__handle {
font-family: var(--font-mono);
font-weight: 700;
font-size: var(--fs-200);
color: var(--orange);
}
.profile__did {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
word-break: break-all;
}
.profile__bio {
font-family: var(--font-mono);
font-size: var(--fs-100);
color: var(--text);
padding: var(--s-2) 0;
}
.profile__bio--empty {
color: var(--text-dim);
font-style: italic;
}
.counts {
display: flex;
gap: var(--s-6);
padding: var(--s-2) var(--s-4);
margin: 0 0 var(--s-4);
font-family: var(--font-mono);
font-size: var(--fs-50);
}
.counts > div {
display: flex;
flex-direction: column;
gap: 2px;
}
.counts dt { color: var(--text-dim); letter-spacing: 0.04em; }
.counts dd {
margin: 0;
color: var(--text);
font-weight: 700;
font-size: var(--fs-200);
font-variant-numeric: tabular-nums;
}
.toasts { .toasts {
position: fixed; position: fixed;
right: var(--s-4); right: var(--s-4);
@@ -1056,68 +955,124 @@
background: rgba(255, 59, 48, 0.08); background: rgba(255, 59, 48, 0.08);
} }
.btn--danger { /* (the legacy .btn--danger class used to be applied to the
color: var(--red); "sign out" button — that's now styled via
border-color: var(--red); `.settings__group--danger .settings__action` which is its own
} selector tree in the settings section below) */
.btn--danger:hover:not(:disabled) {
background: rgba(255, 59, 48, 0.08);
color: var(--red);
border-color: var(--red);
}
.profile__h3, /* X-style settings page: sectioned cards with label-left /
value-right rows, then a list of clickable action rows, then
a danger zone at the bottom. Stays monospace + terminal-
commented, but the structure is the same as X's. */
.settings {
padding: 0 var(--s-3) var(--s-6);
display: flex;
flex-direction: column;
gap: var(--s-4);
}
.settings__group {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.settings__h3 { .settings__h3 {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: var(--fs-50); font-size: var(--fs-50);
color: var(--text-dim); color: var(--orange);
letter-spacing: 0.04em; letter-spacing: var(--tracking-label);
margin: var(--s-4) 0 var(--s-2); margin: 0;
font-weight: 400; font-weight: 700;
} }
.settings__list {
.did-cell {
word-break: break-all;
font-size: var(--fs-50);
}
.settings {
padding: 0 var(--s-3);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--s-2); background: var(--bg-elev);
border: 1px solid var(--line);
border-radius: var(--r-md);
overflow: hidden;
} }
.settings__rows { /* Each row is a label-left / value-right flex line, separated
by a hairline (X uses a single border on each row except the
last). */
.settings__row {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: var(--s-1); justify-content: space-between;
padding: var(--s-2) var(--s-4); gap: var(--s-3);
margin: 0 0 var(--s-4); padding: var(--s-3) var(--s-4);
border-bottom: 1px solid var(--line);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: var(--fs-50); font-size: var(--fs-50);
} }
.settings__rows > div { .settings__list .settings__row:last-child {
display: flex; border-bottom: 0;
gap: var(--s-3);
} }
.settings__rows dt { .settings__label {
color: var(--text-dim); color: var(--text-dim);
letter-spacing: 0.04em; letter-spacing: var(--tracking-label);
min-width: 9rem; flex: 0 0 auto;
} }
.settings__rows dd { .settings__value {
margin: 0;
color: var(--text); color: var(--text);
text-align: right;
word-break: break-all;
min-width: 0;
} }
.settings__value--mono {
font-size: var(--fs-50);
}
/* Actions live in their own list — same border-radius but each
item is a full-width clickable button. The hint on the right
(e.g. "atproto", "↗ bsky.app") is a dim secondary label, the
same way X shows the destination on follow / open-in-app
rows. */
.settings__actions { .settings__actions {
display: flex; display: flex;
flex-wrap: wrap; flex-direction: column;
gap: var(--s-2); background: var(--bg-elev);
margin: 0 0 var(--s-4); border: 1px solid var(--line);
border-radius: var(--r-md);
overflow: hidden;
} }
.settings__signout { .settings__action {
margin-top: var(--s-4); display: flex;
padding-top: var(--s-4); align-items: center;
border-top: 1px dashed var(--line); justify-content: space-between;
gap: var(--s-3);
padding: var(--s-3) var(--s-4);
background: transparent;
border: 0;
border-bottom: 1px solid var(--line);
color: var(--text);
font-family: var(--font-mono);
font-size: var(--fs-100);
text-align: left;
cursor: pointer;
transition: background-color var(--dur) var(--ease),
color var(--dur) var(--ease);
}
.settings__actions .settings__action:last-child {
border-bottom: 0;
}
.settings__action:hover {
background: var(--orange-8);
color: var(--orange);
}
.settings__action-hint {
color: var(--text-dim);
font-size: var(--fs-50);
}
.settings__action:hover .settings__action-hint {
color: var(--orange);
}
.settings__group--danger .settings__action {
color: var(--red);
}
.settings__group--danger .settings__action:hover {
background: rgba(255, 59, 48, 0.08);
color: var(--red);
}
.settings__group--danger {
margin-top: var(--s-3);
} }
</style> </style>
+72 -4
View File
@@ -24,6 +24,33 @@ async function tauriCall<T>(cmd: string, fallback: T, args?: Record<string, unkn
return invoke<T>(cmd, args); return invoke<T>(cmd, args);
} }
/// Base URLs the Tauri shell was started with. Exposed via the
/// `get_api_urls` command so the Svelte components can build
/// absolute fetch URLs — a relative `/api/...` resolves against
/// the Vite dev origin (port 1430), not the AppView (port 2584),
/// and `response.json()` then throws `SyntaxError` on the 404
/// HTML page. Cached after the first successful call.
let _apiUrlsCache: { pdsUrl: string; appviewUrl: string } | null = null;
export type ApiUrls = { pdsUrl: string; appviewUrl: string };
/// Fetch the AppView + PDS base URLs from the Rust shell. Returns
/// the cached value on subsequent calls.
export async function getApiUrls(): Promise<ApiUrls> {
if (_apiUrlsCache) return _apiUrlsCache;
const urls = await safeInvoke<ApiUrls>("get_api_urls");
_apiUrlsCache = urls;
return urls;
}
/// Convenience: just the AppView base URL (the only one the UI
/// currently needs for direct fetch calls). Same caching as
/// `getApiUrls`.
export async function getAppviewUrl(): Promise<string> {
const { appviewUrl } = await getApiUrls();
return appviewUrl;
}
/** /**
* Strict variant of `tauriCall` for actions that MUST hit the * Strict variant of `tauriCall` for actions that MUST hit the
* Tauri runtime (login, register, logout, post, like, etc.). In * Tauri runtime (login, register, logout, post, like, etc.). In
@@ -191,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;
@@ -236,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
@@ -246,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,
}); });
} }
@@ -378,10 +421,28 @@ export async function unrepostPost(
return await safeInvoke<DeleteRecordResult>("unrepost_post", { repostUri }); return await safeInvoke<DeleteRecordResult>("unrepost_post", { repostUri });
} }
/// Fire-and-forget user-visible error toast. Implemented as a /// `followUser(targetDid)` — create an `app.bsky.graph.follow` record
/// `window` `CustomEvent` so any component can show errors without /// on the user's PDS. Returns `{ uri, cid }` — the client caches
/// pulling in a global store. `App.svelte` listens for the event /// `uri` in localStorage so `unfollowUser(uri)` can delete the
/// and renders the toast UI. /// record without needing a "list my follows" round-trip.
export async function followUser(
targetDid: string,
): Promise<RepoWriteResult> {
return await safeInvoke<RepoWriteResult>("follow_user", { targetDid });
}
export async function unfollowUser(
followUri: string,
): Promise<DeleteRecordResult> {
return await safeInvoke<DeleteRecordResult>("unfollow_user", {
followUri,
});
}
/// Fire-and-forget user-visible toast. Implemented as a `window`
/// `CustomEvent` so any component can show toasts without pulling
/// in a global store. `App.svelte` listens for the event and
/// renders the toast UI.
export function showError(text: string): void { export function showError(text: string): void {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
window.dispatchEvent( window.dispatchEvent(
@@ -389,6 +450,13 @@ export function showError(text: string): void {
); );
} }
export function showInfo(text: string): void {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent("maarcadetweet:toast", { detail: { kind: "info", text } }),
);
}
/// Show a native OS notification. Thin wrapper around the /// Show a native OS notification. Thin wrapper around the
/// `show_notification` Tauri command. The Rust side also emits an /// `show_notification` Tauri command. The Rust side also emits an
/// `app://notification` event with the same payload, so the click /// `app://notification` event with the same payload, so the click
@@ -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.
// The user can re-attach — the next fetch will allocate a
// fresh URL.
releaseBlob(did, attachment.cid);
attachment = null; 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 class="compose__attach-meta">
<span class="compose__attach-cid" title={attachment.cid}>cid: {attachment.cid.slice(0, 10)}…</span>
<span class="compose__attach-mime">{attachment.mimeType}</span>
<span class="compose__attach-size">{fmtBytes(attachment.size)}</span>
</div> </div>
<button <button
type="button" type="button"
class="compose__attach-remove" class="attachment__remove"
onclick={removeAttachment} onclick={removeAttachment}
disabled={isPosting} disabled={isPosting}
title="remove attachment" title="remove image"
aria-label="Remove image"
>×</button> >×</button>
</div> </div>
{/if} {/if}
<div class="compose__foot">
<span class="hint">⌘↵ to post</span> <div class="compose__footer">
<div class="actions">
<button <button
type="button" type="button"
class="btn btn--ghost" class="media-button"
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">
<rect x="3" y="4" width="18" height="16" rx="2" />
<circle cx="8.5" cy="9" r="1.5" />
<path d="m4 17 5-5 4 4 3-3 4 4" />
</svg>
<span>{isAttaching ? "adding…" : "image"}</span>
</button> </button>
<button class="btn btn--ghost" onclick={() => (text = "")} disabled={!text || isPosting}>draft</button>
<button class="btn btn--primary" onclick={post} disabled={!text.trim() || remaining < 0 || isPosting}> <div class="compose__submit">
{isPosting ? "posting…" : "post"} <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> </button>
</div> </div>
</div> </div>
{#if status} {#if status}
<div class="status status--{status.kind}">{status.msg}</div> <div class="status status--{status.kind}" role="status">{status.msg}</div>
{/if} {/if}
</div> </div>
</section>
<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);
}
.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> </style>
@@ -4,7 +4,10 @@
let { onLogin }: { onLogin: (s: Session) => void } = $props(); let { onLogin }: { onLogin: (s: Session) => void } = $props();
let mode: "login" | "register" = $state("register"); // Default to "login" — most users opening the app already have an
// account, and the empty-autocomplete form now matches the
// action-label pair they expect. "register" is one click away.
let mode: "login" | "register" = $state("login");
let handle: string = $state(""); let handle: string = $state("");
let password: string = $state(""); let password: string = $state("");
let busy = $state(false); let busy = $state(false);
@@ -42,42 +45,41 @@
<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" : "log in"} {busy ? "..." : mode === "register" ? "create account" : "log in"}
</button> </button>
@@ -94,7 +96,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 +124,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,811 @@
<script lang="ts">
import Avatar from "./Avatar.svelte";
import PostCard from "./PostCard.svelte";
import {
setMyProfile,
pickAndUploadImage,
fetchBlob,
releaseBlob,
getAppviewUrl,
followUser,
unfollowUser,
showInfo,
showError,
} from "../api/client";
import { localStorageKey } from "../utils/localstorage";
import { onDestroy, onMount, untrack } from "svelte";
type Props = {
handle: string;
on_thread_click?: (uri: string) => void;
/// DID of the authenticated user. When this matches the
/// profile's DID, the "edit profile" button is shown; the
/// /user-profile/<handle> route is then the user's own
/// profile (and the avatar / bio are editable).
current_user_did?: string | null;
};
let { handle, on_thread_click, current_user_did }: Props = $props();
type State =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; data: AppViewProfile };
type AppViewProfile = {
did: string;
handle: string;
posts: AppViewPost[];
followers: number;
following: number;
display_name?: string;
description?: string;
avatar_cid?: string;
banner_cid?: string;
post_count: number;
};
type AppViewPost = {
uri: string;
did: string;
handle: string;
rkey: string;
collection: string;
text: string;
cid: string;
parent_uri?: string | null;
root_uri?: string | null;
embed?: null;
langs: string[];
created_at: string;
avatar_cid?: string | null;
};
let editing: boolean = $state(false);
// Type-annotated so TypeScript keeps the discriminated-union narrowing
// when we do `viewModel.kind === "ready"` — otherwise $state infers
// the literal `"loading"` from the initial value and the `===`
// checks become "no overlap" errors.
let viewModel: State = $state({ kind: "loading" } as State);
let editName: string = $state("");
let editDesc: string = $state("");
let editAvatarCid: string | null = $state(null);
let saving: boolean = $state(false);
// Follow state — the AppView has no `viewer_followed` field yet, so
// we persist per-viewer follow state in localStorage (keyed by
// viewer-did + target-did). `followUri` is the URI of the
// `app.bsky.graph.follow` record on the viewer's PDS — the unfollow
// command needs it because atproto requires the rkey to delete a
// record, and we don't have a "list my follows" endpoint to look
// it up server-side.
let isFollowing: boolean = $state(false);
let followUri: string | null = $state(null);
let followBusy: boolean = $state(false);
// Banner blob URL — fetched via the same path as Avatar (Tauri
// getBlob via fetchBlob). Released on unmount or when banner
// changes.
let bannerUrl: string | null = $state(null);
let bannerCidLoaded: string | null = null;
async function load() {
viewModel = { kind: "loading" };
try {
// Absolute URL because the Tauri webview's origin is the Vite
// dev server (port 1430), not the AppView (port 2584) — a
// relative `/api/profile/…` would resolve against Vite, hit a
// 404 HTML page, and `r.json()` would throw `SyntaxError`.
const base = await getAppviewUrl();
const r = await fetch(`${base}/api/profile/${encodeURIComponent(handle)}`);
if (!r.ok) {
viewModel = {
kind: "error",
message: `profile fetch failed: ${r.status}`,
};
return;
}
const data: AppViewProfile = await r.json();
viewModel = { kind: "ready", data };
} catch (e) {
viewModel = { kind: "error", message: String(e) };
}
}
$effect(() => {
const bannerCid =
viewModel.kind === "ready" ? viewModel.data.banner_cid ?? null : null;
// Release the previous URL whenever the banner CID changes
// (including to/from null). We read the previous-loaded value
// through `untrack` because reading + writing `bannerCidLoaded`
// inside the same effect would trip Svelte 5's depth guard
// (`effect_update_depth_exceeded`).
const previous = untrack(() => bannerCidLoaded);
if (previous === bannerCid) return;
if (bannerUrl) {
if (viewModel.kind === "ready" && viewModel.data.did) {
releaseBlob(viewModel.data.did, previous ?? "");
}
URL.revokeObjectURL(bannerUrl);
bannerUrl = null;
}
bannerCidLoaded = bannerCid;
if (!bannerCid || viewModel.kind !== "ready") return;
const did = viewModel.data.did;
let cancelled = false;
fetchBlob(did, bannerCid)
.then((u) => {
if (!cancelled) bannerUrl = u;
else URL.revokeObjectURL(u);
})
.catch(() => {
/* fall back to CSS gradient placeholder */
});
return () => {
cancelled = true;
};
});
onMount(() => {
void load();
});
onDestroy(() => {
if (bannerUrl) URL.revokeObjectURL(bannerUrl);
});
const isOwn = $derived(
!!current_user_did &&
viewModel.kind === "ready" &&
viewModel.data.did === current_user_did,
);
const isEmptyProfile = $derived(
viewModel.kind === "ready" &&
!viewModel.data.display_name &&
!viewModel.data.description &&
!viewModel.data.avatar_cid &&
!viewModel.data.banner_cid,
);
// Restore follow state from localStorage whenever the profile
// (DID) changes. Writes are inside `untrack` so the effect's
// reactive dep set is just `[viewModel.kind, viewModel.data.did]`
// — without untrack, every write to `isFollowing` / `followUri`
// would re-enter the effect and trip Svelte's depth guard.
$effect(() => {
if (viewModel.kind !== "ready" || !current_user_did) return;
const did = viewModel.data.did;
const key = localStorageKey(`follow:${current_user_did}:${did}`);
untrack(() => {
try {
const raw = localStorage.getItem(key);
if (raw) {
const parsed = JSON.parse(raw) as {
following: boolean;
uri: string | null;
};
isFollowing = !!parsed.following;
followUri = parsed.uri ?? null;
} else {
isFollowing = false;
followUri = null;
}
} catch {
isFollowing = false;
followUri = null;
}
});
});
function persistFollow(following: boolean, uri: string | null) {
if (viewModel.kind !== "ready" || !current_user_did) return;
const did = viewModel.data.did;
const key = localStorageKey(`follow:${current_user_did}:${did}`);
try {
if (following) {
localStorage.setItem(
key,
JSON.stringify({ following: true, uri }),
);
} else {
localStorage.removeItem(key);
}
} catch {
/* quota / private mode — fall through */
}
}
async function onFollowClick() {
if (viewModel.kind !== "ready" || !current_user_did) return;
if (followBusy) return;
const targetDid = viewModel.data.did;
if (targetDid === current_user_did) return;
followBusy = true;
const wasFollowing = isFollowing;
const previousUri = followUri;
isFollowing = true;
try {
const resp = await followUser(targetDid);
followUri = resp.uri;
persistFollow(true, resp.uri);
showInfo("followed");
} catch (e) {
isFollowing = wasFollowing;
followUri = previousUri;
persistFollow(wasFollowing, previousUri);
showError(`follow failed: ${e}`);
} finally {
followBusy = false;
}
}
async function onUnfollowClick() {
if (viewModel.kind !== "ready") return;
if (followBusy) return;
if (!followUri) {
// Nothing to unfollow — clear the flag and bail.
isFollowing = false;
return;
}
followBusy = true;
const wasFollowing = isFollowing;
const previousUri = followUri;
isFollowing = false;
followUri = null;
persistFollow(false, null);
try {
await unfollowUser(previousUri!);
showInfo("unfollowed");
} catch (e) {
isFollowing = wasFollowing;
followUri = previousUri;
persistFollow(wasFollowing, previousUri);
showError(`unfollow failed: ${e}`);
} finally {
followBusy = false;
}
}
function openEdit() {
if (viewModel.kind !== "ready") return;
editName = viewModel.data.display_name ?? "";
editDesc = viewModel.data.description ?? "";
editAvatarCid = viewModel.data.avatar_cid ?? null;
editing = true;
}
async function saveProfile() {
if (viewModel.kind !== "ready") return;
saving = true;
try {
await setMyProfile({
displayName: editName || undefined,
description: editDesc || undefined,
avatarBlobCid: editAvatarCid || undefined,
});
editing = false;
await load();
} catch (e) {
console.warn("profile save failed", e);
} finally {
saving = false;
}
}
async function pickAndUploadAvatar() {
const blob = await pickAndUploadImage();
if (!blob) return;
editAvatarCid = blob.cid;
}
type Tab = "posts" | "replies" | "likes";
let activeTab: Tab = $state("posts");
</script>
<section class="profile">
<!-- ─── banner ────────────────────────────────────────────────── -->
<div
class="profile__banner"
style:background-image={bannerUrl ? `url(${bannerUrl})` : "none"}
>
{#if !bannerUrl}
<!--
Placeholder shown when the profile has no banner blob.
Subtle orange-tinted terminal grid — keeps the page from
looking bare without competing with the avatar.
-->
<div class="profile__banner-grid" aria-hidden="true"></div>
{/if}
</div>
<!-- ─── avatar + actions ──────────────────────────────────────── -->
<div class="profile__topbar">
<div class="profile__avatar-overlap">
{#if viewModel.kind === "ready"}
<Avatar
did={viewModel.data.did}
cid={viewModel.data.avatar_cid ?? null}
name={viewModel.data.display_name ?? viewModel.data.handle}
size={96}
/>
{:else}
<span class="profile__avatar-skeleton"></span>
{/if}
</div>
<div class="profile__actions">
{#if viewModel.kind === "ready"}
{#if isOwn}
{#if editing}
<button
class="btn btn--ghost"
type="button"
onclick={() => (editing = false)}
>cancel</button>
{:else}
<button
class="btn btn--primary"
type="button"
onclick={openEdit}
>edit profile</button>
{/if}
{:else}
<!--
Follow toggle. Text + class flip with `isFollowing`:
"follow" / `.btn--primary` (outlined-emphasis) when not
following, "following" / `.btn--ghost` (subdued) when
already following. The "following" click becomes an
unfollow via the same handler — X shows the relationship
state in the label, not a separate "unfollow" button.
-->
{#if isFollowing}
<button
class="btn btn--ghost profile__follow-btn profile__follow-btn--active"
type="button"
disabled={followBusy}
onclick={onUnfollowClick}
>following</button>
{:else}
<button
class="btn btn--primary profile__follow-btn"
type="button"
disabled={followBusy}
onclick={onFollowClick}
>follow</button>
{/if}
{/if}
{/if}
</div>
</div>
<!-- ─── identity ──────────────────────────────────────────────── -->
{#if viewModel.kind === "ready"}
<div class="profile__identity">
<h2 class="profile__name">
{viewModel.data.display_name ?? viewModel.data.handle}
</h2>
<div class="profile__handle">@{viewModel.data.handle}</div>
</div>
{:else if viewModel.kind === "loading"}
<div class="profile__identity">
<h2 class="profile__name profile__name--skeleton"></h2>
<div class="profile__handle">@{handle}</div>
</div>
{:else}
<div class="profile__identity profile__identity--err">
err: {viewModel.message}
</div>
{/if}
<!-- ─── bio ───────────────────────────────────────────────────── -->
{#if viewModel.kind === "ready"}
{#if viewModel.data.description}
<p class="profile__bio">{viewModel.data.description}</p>
{:else if isEmptyProfile}
<p class="profile__bio profile__bio--empty">
{#if isOwn}
// no profile yet — click "edit profile" to set one up.
{:else}
// no profile yet.
{/if}
</p>
{/if}
{/if}
<!-- ─── meta (did) ───────────────────────────────────────────── -->
{#if viewModel.kind === "ready"}
<div class="profile__meta">
<span class="profile__meta-item" title={viewModel.data.did}>
did: <code>{shortDid(viewModel.data.did)}</code>
</span>
</div>
{/if}
<!-- ─── counts ────────────────────────────────────────────────── -->
{#if viewModel.kind === "ready"}
<dl class="profile__counts">
<div>
<dt>posts</dt>
<dd>{viewModel.data.post_count}</dd>
</div>
<div>
<dt>followers</dt>
<dd>{viewModel.data.followers}</dd>
</div>
<div>
<dt>following</dt>
<dd>{viewModel.data.following}</dd>
</div>
</dl>
{/if}
<!-- ─── tabs ──────────────────────────────────────────────────── -->
<nav class="profile__tabs" aria-label="Profile sections">
<button
class="tab"
class:tab--active={activeTab === "posts"}
type="button"
onclick={() => (activeTab = "posts")}
>posts</button>
<button
class="tab"
class:tab--active={activeTab === "replies"}
type="button"
disabled
title="replies — coming soon"
>replies</button>
<button
class="tab"
class:tab--active={activeTab === "likes"}
type="button"
disabled
title="likes — coming soon"
>likes</button>
</nav>
<!-- ─── feed ──────────────────────────────────────────────────── -->
<div class="profile__feed">
{#if viewModel.kind === "ready"}
{#each viewModel.data.posts as p (p.uri)}
<PostCard post={p} on_thread_click={on_thread_click} />
{/each}
{#if viewModel.data.posts.length === 0}
<div class="profile__empty">// no posts yet.</div>
{/if}
{/if}
</div>
<!-- ─── edit form (own profile only) ─────────────────────────── -->
{#if editing && viewModel.kind === "ready"}
<div class="profile__edit">
<h3 class="profile__edit-title">// edit profile</h3>
<label class="profile__edit-field">
<span class="key">display name</span>
<input type="text" bind:value={editName} maxlength="64" />
</label>
<label class="profile__edit-field">
<span class="key">description</span>
<textarea
bind:value={editDesc}
rows="3"
maxlength="300"
></textarea>
</label>
<div class="profile__edit-field">
<span class="key">avatar</span>
<div class="profile__edit-row">
{#if editAvatarCid}
<span class="meta">cid: {editAvatarCid.slice(0, 10)}</span>
<button
class="btn btn--ghost"
type="button"
onclick={() => (editAvatarCid = null)}
>clear</button>
{:else}
<span class="meta">none</span>
{/if}
<button
class="btn btn--ghost"
type="button"
onclick={pickAndUploadAvatar}
>upload…</button>
</div>
</div>
<div class="profile__edit-actions">
<button
class="btn btn--primary"
type="button"
disabled={saving}
onclick={saveProfile}
>
{saving ? "saving…" : "save"}
</button>
</div>
</div>
{/if}
</section>
<script lang="ts" module>
/// Compact DID renderer for the profile meta line — keeps the
/// `did:plc:bafyreiczj…` from spilling past the column.
export function shortDid(did: string): string {
if (did.length <= 24) return did;
const head = did.slice(0, 18);
const tail = did.slice(-6);
return `${head}…${tail}`;
}
</script>
<style>
/* No outer padding — banner + avatar overhang make the section
fill the column edge to edge on mobile. */
/* ─── banner ─────────────────────────────────────────────── */
.profile__banner {
position: relative;
height: 140px;
overflow: hidden;
background-color: var(--bg-elev);
background-size: cover;
background-position: center;
}
.profile__banner-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(
135deg,
var(--bg-elev) 0%,
rgba(255, 102, 0, 0.12) 60%,
rgba(255, 102, 0, 0.04) 100%
),
repeating-linear-gradient(
0deg,
transparent 0,
transparent 27px,
rgba(255, 102, 0, 0.06) 27px,
rgba(255, 102, 0, 0.06) 28px
),
repeating-linear-gradient(
90deg,
transparent 0,
transparent 27px,
rgba(255, 102, 0, 0.06) 27px,
rgba(255, 102, 0, 0.06) 28px
);
}
/* ─── avatar + actions ──────────────────────────────────── */
.profile__topbar {
position: relative;
display: flex;
align-items: flex-end;
justify-content: space-between;
padding: 0 var(--s-4);
margin-top: -44px;
min-height: 52px;
}
.profile__avatar-overlap {
border: 4px solid var(--bg);
border-radius: 50%;
background: var(--bg);
line-height: 0;
}
.profile__avatar-skeleton {
display: inline-block;
width: 96px;
height: 96px;
border-radius: 50%;
background: var(--bg-elev);
}
.profile__actions {
padding-bottom: var(--s-3);
}
/* Follow button — X-style with two states. "follow" is the
full orange emphasis (btn--primary); "following" flips to a
ghost button that turns red on hover (mirroring X's
"unfollow on hover" affordance). */
.profile__follow-btn {
min-width: 6.5rem;
font-weight: 700;
}
.profile__follow-btn--active {
color: var(--text);
border-color: var(--line-2);
background: transparent;
}
.profile__follow-btn--active:hover:not(:disabled) {
/* X's "unfollow on hover" — replace label + colour with the
destructive cue, but only while actually hovering. */
color: var(--red);
border-color: var(--red);
background: rgba(255, 59, 48, 0.08);
}
/* ─── identity ──────────────────────────────────────────── */
.profile__identity {
padding: var(--s-3) var(--s-4) 0;
}
.profile__name {
font-family: var(--font-mono);
font-size: var(--fs-300);
font-weight: 700;
color: var(--text);
line-height: var(--lh-tight);
margin: 0;
word-break: break-word;
}
.profile__name--skeleton {
color: var(--text-dim);
}
.profile__handle {
font-family: var(--font-mono);
font-size: var(--fs-100);
color: var(--text-dim);
margin-top: 2px;
}
.profile__identity--err {
color: var(--red);
font-family: var(--font-mono);
font-size: var(--fs-100);
}
/* ─── bio ───────────────────────────────────────────────── */
.profile__bio {
padding: var(--s-3) var(--s-4) 0;
font-family: var(--font-mono);
font-size: var(--fs-100);
color: var(--text);
line-height: var(--lh-body);
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
.profile__bio--empty {
color: var(--text-dim);
font-style: italic;
}
/* ─── meta ──────────────────────────────────────────────── */
.profile__meta {
padding: var(--s-3) var(--s-4) 0;
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
display: flex;
gap: var(--s-4);
flex-wrap: wrap;
}
.profile__meta code {
font-family: var(--font-mono);
color: var(--text-dim);
}
/* ─── counts ────────────────────────────────────────────── */
.profile__counts {
display: flex;
gap: var(--s-6);
padding: var(--s-3) var(--s-4);
margin: 0;
font-family: var(--font-mono);
}
.profile__counts > div {
display: flex;
flex-direction: column;
gap: 2px;
}
.profile__counts dt {
color: var(--text-dim);
letter-spacing: var(--tracking-label);
font-size: var(--fs-50);
}
.profile__counts dd {
margin: 0;
color: var(--text);
font-weight: 700;
font-size: var(--fs-200);
font-variant-numeric: tabular-nums;
}
/* ─── tabs ──────────────────────────────────────────────── */
.profile__tabs {
display: flex;
border-bottom: 1px solid var(--line);
margin-top: var(--s-2);
}
.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;
}
/* ─── feed ──────────────────────────────────────────────── */
.profile__feed {
padding: var(--s-2) 0 var(--s-6);
}
.profile__empty {
font-family: var(--font-mono);
color: var(--text-dim);
font-style: italic;
padding: var(--s-4);
text-align: center;
}
/* ─── edit form ─────────────────────────────────────────── */
.profile__edit {
border-top: 1px solid var(--line);
margin: var(--s-4) var(--s-4) 0;
padding: var(--s-4) 0;
display: flex;
flex-direction: column;
gap: var(--s-3);
}
.profile__edit-title {
font-family: var(--font-mono);
font-size: var(--fs-100);
color: var(--orange);
margin: 0 0 var(--s-2);
font-weight: 700;
letter-spacing: var(--tracking-label);
}
.profile__edit-field {
display: flex;
flex-direction: column;
gap: var(--s-1);
}
.profile__edit-field .key {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
letter-spacing: var(--tracking-label);
}
.profile__edit-field input,
.profile__edit-field textarea {
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);
resize: vertical;
}
.profile__edit-field input:focus,
.profile__edit-field textarea:focus {
outline: none;
border-color: var(--orange);
}
.profile__edit-row {
display: flex;
align-items: center;
gap: var(--s-2);
flex-wrap: wrap;
}
.profile__edit-row .meta {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
}
.profile__edit-actions {
display: flex;
justify-content: flex-end;
}
</style>
@@ -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>
@@ -1,334 +0,0 @@
<script lang="ts">
import Avatar from "./Avatar.svelte";
import PostCard from "./PostCard.svelte";
import { setMyProfile, pickAndUploadImage } from "../api/client";
import { onMount } from "svelte";
type Props = {
handle: string;
on_thread_click?: (uri: string) => void;
/// DID of the authenticated user. When this matches the
/// profile's DID, the "edit profile" button is shown; otherwise
/// it's hidden (you can only edit your own profile).
current_user_did?: string | null;
};
let { handle, on_thread_click, current_user_did }: Props = $props();
type State =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; data: AppViewProfile };
type AppViewProfile = {
did: string;
handle: string;
posts: AppViewPost[];
followers: number;
following: number;
display_name?: string;
description?: string;
avatar_cid?: string;
banner_cid?: string;
post_count: number;
};
type AppViewPost = {
uri: string;
did: string;
handle: string;
rkey: string;
collection: string;
text: string;
cid: string;
parent_uri?: string | null;
root_uri?: string | null;
embed?: null;
langs: string[];
created_at: string;
avatar_cid?: string | null;
};
let editing: boolean = $state(false);
let viewModel: State = $state({ kind: "loading" });
let editName: string = $state("");
let editDesc: string = $state("");
let editAvatarCid: string | null = $state(null);
let saving: boolean = $state(false);
async function load() {
viewModel = { kind: "loading" };
try {
const r = await fetch(`/api/profile/${encodeURIComponent(handle)}`);
if (!r.ok) {
viewModel = { kind: "error", message: `profile fetch failed: ${r.status}` };
return;
}
const data: AppViewProfile = await r.json();
viewModel = { kind: "ready", data };
} catch (e) {
viewModel = { kind: "error", message: String(e) };
}
}
function openEdit() {
if (viewModel.kind !== "ready") return;
editName = viewModel.data.display_name ?? "";
editDesc = viewModel.data.description ?? "";
editAvatarCid = viewModel.data.avatar_cid ?? null;
editing = true;
}
async function saveProfile() {
if (viewModel.kind !== "ready") return;
saving = true;
try {
await setMyProfile({
displayName: editName || undefined,
description: editDesc || undefined,
avatarBlobCid: editAvatarCid || undefined,
});
editing = false;
await load();
} catch (e) {
console.warn("profile save failed", e);
} finally {
saving = false;
}
}
async function pickAndUploadAvatar() {
const blob = await pickAndUploadImage();
if (!blob) return;
editAvatarCid = blob.cid;
}
onMount(() => {
void load();
});
</script>
<section class="user-profile">
<header class="user-profile__head">
{#if viewModel.kind === "ready"}
<div class="user-profile__avatar">
<Avatar
did={viewModel.data.did}
cid={viewModel.data.avatar_cid ?? null}
name={viewModel.data.display_name ?? viewModel.data.handle}
size={88}
/>
</div>
<div class="user-profile__id">
<h2 class="user-profile__name">{viewModel.data.display_name ?? "@" + viewModel.data.handle}</h2>
<span class="user-profile__handle">@{viewModel.data.handle}</span>
<span class="user-profile__did" title={viewModel.data.did}>{viewModel.data.did}</span>
</div>
<div class="user-profile__actions">
{#if current_user_did && viewModel.data.did === current_user_did}
<button
class="btn btn--ghost"
type="button"
onclick={() => (editing ? (editing = false) : openEdit())}
>
{editing ? "cancel" : "edit profile"}
</button>
{/if}
</div>
{:else if viewModel.kind === "loading"}
<div class="user-profile__loading">loading…</div>
{:else}
<div class="user-profile__error">err: {viewModel.message}</div>
{/if}
</header>
{#if viewModel.kind === "ready" && viewModel.data.description}
<p class="user-profile__bio">{viewModel.data.description}</p>
{:else if viewModel.kind === "ready"}
<!--
Empty-state hint for a user who hasn't filled in their profile
yet. Only shown when *no* profile fields are populated (a
partial profile still renders whatever's there). The owner of
an empty profile sees an explicit "set up your profile" hint
instead of an awkward blank space.
-->
{#if !viewModel.data.display_name && !viewModel.data.description && !viewModel.data.avatar_cid}
<p class="user-profile__bio user-profile__bio--empty">
{#if current_user_did && viewModel.data.did === current_user_did}
// no profile yet — click "edit profile" to set one up.
{:else}
// no profile yet.
{/if}
</p>
{/if}
{/if}
{#if editing}
<div class="user-profile__edit">
<label>
<span class="key">display name</span>
<input type="text" bind:value={editName} maxlength="64" />
</label>
<label>
<span class="key">description</span>
<textarea
bind:value={editDesc}
rows="3"
maxlength="300"
></textarea>
</label>
<div class="user-profile__edit-avatar">
<span class="key">avatar</span>
<div class="user-profile__edit-avatar-row">
{#if editAvatarCid}
<span class="meta">cid: {editAvatarCid.slice(0, 10)}</span>
<button class="btn btn--ghost" type="button" onclick={() => (editAvatarCid = null)}>clear</button>
{:else}
<span class="meta">none</span>
{/if}
<button class="btn btn--ghost" type="button" onclick={pickAndUploadAvatar}>upload…</button>
</div>
</div>
<div class="user-profile__edit-actions">
<button class="btn btn--ghost" type="button" disabled={saving} onclick={saveProfile}>
{saving ? "saving…" : "save"}
</button>
</div>
</div>
{/if}
{#if viewModel.kind === "ready"}
<dl class="user-profile__counts">
<div><dt>posts</dt><dd>{viewModel.data.post_count}</dd></div>
<div><dt>followers</dt><dd>{viewModel.data.followers}</dd></div>
<div><dt>following</dt><dd>{viewModel.data.following}</dd></div>
</dl>
<div class="user-profile__posts">
{#each viewModel.data.posts as p (p.uri)}
<PostCard post={p} on_thread_click={on_thread_click} />
{/each}
</div>
{/if}
</section>
<style>
.user-profile {
padding: 0 var(--s-3, 0.75rem);
}
.user-profile__head {
display: grid;
grid-template-columns: auto 1fr auto;
gap: var(--s-3, 0.75rem);
align-items: center;
padding: var(--s-3, 0.75rem) 0;
border-bottom: 1px dashed var(--line-2, #3a3a3a);
}
.user-profile__avatar {
display: flex;
align-items: center;
}
.user-profile__id {
display: flex;
flex-direction: column;
gap: 2px;
}
.user-profile__name {
font-family: var(--font-mono, monospace);
font-size: 1.05rem;
color: var(--text, #e8e8e8);
}
.user-profile__handle {
font-family: var(--font-mono, monospace);
font-size: 0.8rem;
color: var(--orange, #ff6600);
}
.user-profile__did {
font-family: var(--font-mono, monospace);
font-size: 0.7rem;
color: var(--text-dim, #888);
}
.user-profile__bio {
font-family: var(--font-mono, monospace);
font-size: 0.85rem;
color: var(--text, #e8e8e8);
padding: var(--s-3, 0.75rem) 0;
border-bottom: 1px dashed var(--line, #2a2a2a);
}
.user-profile__bio--empty {
color: var(--text-dim, #888);
font-style: italic;
}
.user-profile__edit {
display: flex;
flex-direction: column;
gap: var(--s-2, 0.5rem);
padding: var(--s-3, 0.75rem) 0;
border-bottom: 1px dashed var(--line, #2a2a2a);
}
.user-profile__edit label,
.user-profile__edit-avatar {
display: flex;
align-items: baseline;
gap: var(--s-2, 0.5rem);
}
.user-profile__edit .key {
flex: 0 0 7rem;
color: var(--orange, #ff6600);
font-family: var(--font-mono, monospace);
}
.user-profile__edit input,
.user-profile__edit textarea {
flex: 1;
background: var(--bg, #0d0d0d);
border: 1px solid var(--line-2, #3a3a3a);
color: var(--text, #e8e8e8);
font-family: var(--font-mono, monospace);
padding: var(--s-1, 0.25rem) var(--s-2, 0.5rem);
border-radius: 4px;
}
.user-profile__edit-avatar-row {
flex: 1;
display: flex;
gap: var(--s-2, 0.5rem);
align-items: center;
}
.user-profile__edit-avatar-row .meta {
color: var(--text-dim, #888);
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
}
.user-profile__edit-actions {
display: flex;
justify-content: flex-end;
}
.user-profile__counts {
display: flex;
gap: var(--s-5, 1.5rem);
padding: var(--s-2, 0.5rem) var(--s-3, 0.75rem);
margin: 0;
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
color: var(--text-dim, #888);
}
.user-profile__counts > div {
display: flex;
flex-direction: column;
}
.user-profile__counts dt {
color: var(--text-dim, #888);
letter-spacing: 0.04em;
}
.user-profile__counts dd {
margin: 0;
color: var(--text, #e8e8e8);
font-weight: 700;
}
.user-profile__loading,
.user-profile__error {
font-family: var(--font-mono, monospace);
color: var(--text-dim, #888);
padding: var(--s-3, 0.75rem);
}
.user-profile__error {
color: var(--red, #ff3b30);
}
</style>
+1
View File
@@ -40,6 +40,7 @@ CREATE TABLE profiles (
following_count BIGINT NOT NULL DEFAULT 0, following_count BIGINT NOT NULL DEFAULT 0,
indexed_at TIMESTAMPTZ NOT NULL DEFAULT now() indexed_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
CREATE INDEX profiles_handle_idx ON profiles (LOWER(handle));
CREATE INDEX profiles_indexed_at_idx ON profiles (indexed_at DESC); CREATE INDEX profiles_indexed_at_idx ON profiles (indexed_at DESC);
-- Backfill: seed a profile row for every handle we've already -- Backfill: seed a profile row for every handle we've already