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.
This commit is contained in:
tomdebone
2026-07-18 18:35:35 +02:00
parent 6ebf17b493
commit e4bcfbfa83
4 changed files with 671 additions and 614 deletions
+16 -280
View File
@@ -4,23 +4,18 @@
session,
pdsStatus,
fetchTimeline,
fetchProfile,
fetchSearch,
fetchPost,
openExternalUrl,
showError,
pickAndUploadImage,
setMyProfile,
type Session,
type Post,
type ProfileResponse,
} from "./lib/api/client";
import NavRail from "./lib/components/NavRail.svelte";
import StatusBar from "./lib/components/StatusBar.svelte";
import PostCard from "./lib/components/PostCard.svelte";
import ComposeBox from "./lib/components/ComposeBox.svelte";
import UserProfileView from "./lib/components/UserProfileView.svelte";
import Avatar from "./lib/components/Avatar.svelte";
import ProfileView from "./lib/components/ProfileView.svelte";
import LoginScreen from "./lib/components/LoginScreen.svelte";
import Terminal from "./lib/components/Terminal.svelte";
import Skeleton from "./lib/components/Skeleton.svelte";
@@ -44,50 +39,6 @@
let seenUris: Set<string> = new Set();
let _statusTimer: number | undefined;
// Profile state.
let profile: ProfileResponse | null = $state(null);
let profileLoading: boolean = $state(false);
let profileError: string | null = $state(null);
// Edit-profile state.
let editingProfile: boolean = $state(false);
let editProfileName: string = $state("");
let editProfileDesc: string = $state("");
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.
let searchQuery: string = $state("");
let searchResults: Post[] = $state([]);
@@ -133,7 +84,7 @@
/// Navigate to the "user" profile view for `handle`. Called from
/// `<PostCard on_handle_click>` and the avatar/handle buttons in
/// the post header. The actual profile fetch happens inside
/// `<UserProfileView>` on mount.
/// `<ProfileView>` on mount.
function openUserProfile(handle: string) {
selectedHandle = handle;
view = "user";
@@ -306,8 +257,8 @@
void refreshTimeline(true);
}
if (next === "profile") {
const handle = currentUser.handle;
void refreshProfile(handle);
// ProfileView fetches its own data on mount; nothing to
// preload here.
}
if (next === "search" && searchQuery.trim().length > 0) {
scheduleSearch();
@@ -376,19 +327,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() {
if (_searchDebounce) clearTimeout(_searchDebounce);
_searchDebounce = window.setTimeout(() => {
@@ -439,7 +377,6 @@
try {
await session.logout();
setView("home");
profile = null;
searchResults = [];
threadRoot = null;
threadParent = null;
@@ -451,13 +388,6 @@
// Derive a display handle. The session already gives us the user's
// real handle (e.g. "alice.bsky.social"). When the AppView decorates
// 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 /
// MAARCADETWEET_APPVIEW_URL (see `crates/tauri-app/src-tauri/src/lib.rs`).
// Used in the Settings view to show which backends the client is
@@ -552,156 +482,23 @@
<span class="title">// profile —</span>
<span class="as">@{selectedHandle}</span>
</div>
<UserProfileView
<ProfileView
handle={selectedHandle}
on_thread_click={openThread}
current_user_did={currentUser?.did ?? null}
/>
{:else if view === "profile"}
<div class="head">
<span class="prompt">$</span>
<span class="title">// profile —</span>
<span class="as">@{currentUser.handle}</span>
</div>
{#if profileLoading && !profile}
<Skeleton rows={4} />
{:else if profileError}
<div class="toast toast--err">err: {profileError}</div>
{: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}
</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 currentUser}
<div class="head">
<span class="prompt">$</span>
<span class="title">// profile —</span>
<span class="as">@{currentUser.handle}</span>
</div>
<ProfileView
handle={currentUser.handle}
on_thread_click={openThread}
current_user_did={currentUser.did}
/>
{/if}
{:else if view === "settings"}
<div class="head">
@@ -895,11 +692,6 @@
padding: var(--s-3);
}
.profile__actions {
display: flex;
gap: var(--s-2);
margin: var(--s-3) 0;
}
.head {
font-family: var(--font-mono);
font-size: var(--fs-50);
@@ -972,61 +764,6 @@
.btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); }
.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 {
position: fixed;
right: var(--s-4);
@@ -1066,7 +803,6 @@
border-color: var(--red);
}
.profile__h3,
.settings__h3 {
font-family: var(--font-mono);
font-size: var(--fs-50);