feat(tauri-app): profile view, Avatar component, handle navigation
UI half of the profile feature. Mirrors the previous three commits
so the user can browse and edit profiles.
* `<Avatar did cid name size>` — reusable avatar component.
Falls back to an initial-letter (or "?" when name is empty) circle
when `cid` is null. Resolves the blob through the standard PDS
fetch path so it works for any author whose PDS the client can
reach.
* `<UserProfileView handle on_thread_click current_user_did>` —
public profile page. Fetches `GET /api/profile/<handle>` on
mount, renders the avatar / display name / bio / counts /
posts. The "edit profile" button is gated on
`current_user_did === profile.did` so a user browsing
someone else's profile can't issue an unintended `setMyProfile`
against their own DID.
* `PostCard` now renders an inline `<Avatar>` + clickable handle
button that calls a new `on_handle_click` prop. The clickable
area replaces the previous dead `<a href>` (Tauri webviews
have no router).
* `App.svelte` adds an `openUserProfile(handle)` handler that
sets `selectedHandle` + `view = "user"` and mounts
`<UserProfileView>`.
* New Tauri commands `profile_get_record` / `profile_set` in
`lib.rs` + matching client helpers `getMyProfile` /
`setMyProfile` in `client.ts`. The set command sends camelCase
field names; the PDS endpoint (previous commit) round-trips them
through `#[serde(rename_all = "camelCase")]`.
* Empty-state UX for users with no profile yet (new account, or a
third-party-PDS author whose profile the AppView hasn't indexed
yet): both the current-user "profile" view and the public
"user" view render a hint ("// no profile yet — click 'edit
profile' to set one up." / "// no profile yet.") instead of a
blank bio box.
* NavRail / NavRailHarness `View` union extended with "user"
so the navigation prop type accepts the new view.
This commit is contained in:
@@ -720,7 +720,65 @@ pub fn run() {
|
||||
pick_and_upload_image,
|
||||
show_notification,
|
||||
open_external_url,
|
||||
profile_get_record,
|
||||
profile_set,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running maarcadetweet");
|
||||
}
|
||||
#[tauri::command]
|
||||
async fn profile_get_record(
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<Option<serde_json::Value>, String> {
|
||||
let sess = state
|
||||
.store
|
||||
.load()
|
||||
.ok_or_else(|| "not logged in".to_string())?;
|
||||
state
|
||||
.pds
|
||||
.get_profile_record(&sess.did, &sess.access_jwt)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn profile_set(
|
||||
state: tauri::State<'_, AppState>,
|
||||
fields: serde_json::Value,
|
||||
) -> Result<(), String> {
|
||||
let sess = state
|
||||
.store
|
||||
.load()
|
||||
.ok_or_else(|| "not logged in".to_string())?;
|
||||
let display_name = fields
|
||||
.get("displayName")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string);
|
||||
let description = fields
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string);
|
||||
let avatar_blob_cid = fields
|
||||
.get("avatarBlobCid")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string);
|
||||
let banner_blob_cid = fields
|
||||
.get("bannerBlobCid")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string);
|
||||
state
|
||||
.pds
|
||||
.set_profile(
|
||||
&sess.did,
|
||||
&serde_json::json!({
|
||||
"displayName": display_name,
|
||||
"description": description,
|
||||
"avatarBlobCid": avatar_blob_cid,
|
||||
"bannerBlobCid": banner_blob_cid,
|
||||
}),
|
||||
&sess.access_jwt,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -385,3 +385,64 @@ pub struct UploadedBlobRef {
|
||||
#[serde(rename = "$link")]
|
||||
pub link: String,
|
||||
}
|
||||
/// `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
|
||||
/// record exists for that path. The server replies with a
|
||||
/// `{ "value": {...} | null }` envelope; we unwrap and return the
|
||||
/// inner value (which is the `app.bsky.actor.profile` JSON object
|
||||
/// keyed by the deserialized CBOR field names: `displayName`,
|
||||
/// `description`, `avatar`/{ ref, mimeType, size }, `banner`/...).
|
||||
pub async fn get_profile_record(
|
||||
&self,
|
||||
repo: &str,
|
||||
jwt: &str,
|
||||
) -> Result<Option<serde_json::Value>> {
|
||||
let url = format!(
|
||||
"{}/xrpc/com.atproto.repo.getRecord",
|
||||
self.base_url
|
||||
);
|
||||
let r = self
|
||||
.client
|
||||
.get(&url)
|
||||
.query(&[("repo", repo), ("collection", "app.bsky.actor.profile"), ("rkey", "self")])
|
||||
.bearer_auth(jwt)
|
||||
.send()
|
||||
.await?;
|
||||
if r.status().as_u16() == 404 {
|
||||
return Ok(None);
|
||||
}
|
||||
if !r.status().is_success() {
|
||||
let s = r.status();
|
||||
let body = r.text().await.unwrap_or_default();
|
||||
anyhow::bail!("getRecord returned {s}: {body}");
|
||||
}
|
||||
let v: serde_json::Value = r.json().await?;
|
||||
Ok(v.get("value").cloned().and_then(|x| if x.is_null() { None } else { Some(x) }))
|
||||
}
|
||||
|
||||
/// `POST /xrpc/app.bsky.actor.profile.set` — PDS-only convenience
|
||||
/// endpoint that does a read-modify-write of the profile record. The
|
||||
/// request body has the same shape as `app.bsky.actor.profile` minus
|
||||
/// the `$type` (added server-side).
|
||||
pub async fn set_profile(
|
||||
&self,
|
||||
repo: &str,
|
||||
profile: &serde_json::Value,
|
||||
jwt: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
let url = format!("{}/xrpc/app.bsky.actor.profile.set", self.base_url);
|
||||
let r = self
|
||||
.client
|
||||
.post(&url)
|
||||
.bearer_auth(jwt)
|
||||
.json(profile)
|
||||
.send()
|
||||
.await?;
|
||||
if !r.status().is_success() {
|
||||
let s = r.status();
|
||||
let body = r.text().await.unwrap_or_default();
|
||||
anyhow::bail!("setProfile returned {s}: {body}");
|
||||
}
|
||||
let v: serde_json::Value = r.json().await?;
|
||||
Ok(v.get("profile").cloned().unwrap_or(serde_json::Value::Null))
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
fetchPost,
|
||||
openExternalUrl,
|
||||
showError,
|
||||
pickAndUploadImage,
|
||||
setMyProfile,
|
||||
type Session,
|
||||
type Post,
|
||||
type ProfileResponse,
|
||||
@@ -17,13 +19,20 @@
|
||||
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 LoginScreen from "./lib/components/LoginScreen.svelte";
|
||||
import Terminal from "./lib/components/Terminal.svelte";
|
||||
import Skeleton from "./lib/components/Skeleton.svelte";
|
||||
|
||||
type View = "home" | "compose" | "profile" | "search" | "settings";
|
||||
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
|
||||
|
||||
let view: View = $state("home");
|
||||
// Handle for the "user" view (i.e. someone else's profile). The
|
||||
// "profile" view remains the current-user view (the NavRail icon
|
||||
// goes there). Selecting a handle (via the PostCard avatar link or
|
||||
// a future deep-link) navigates to "user" with `selectedHandle` set.
|
||||
let selectedHandle: string = $state("");
|
||||
let currentUser: Session | null = $state(null);
|
||||
let status: { did?: string; handle?: string; authenticated: boolean } = $state({ authenticated: false });
|
||||
|
||||
@@ -40,6 +49,45 @@
|
||||
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([]);
|
||||
@@ -81,6 +129,17 @@
|
||||
threadLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
function openUserProfile(handle: string) {
|
||||
selectedHandle = handle;
|
||||
view = "user";
|
||||
threadRoot = null;
|
||||
threadParent = null;
|
||||
}
|
||||
function closeThread() {
|
||||
threadRoot = null;
|
||||
threadParent = null;
|
||||
@@ -462,14 +521,14 @@
|
||||
<div class="toast toast--err">err: {threadError}</div>
|
||||
{:else if threadRoot}
|
||||
{#if threadParent && threadParent.uri !== threadRoot.uri}
|
||||
<div class="thread-parent"><PostCard post={threadParent} /></div>
|
||||
<div class="thread-parent"><PostCard post={threadParent} on_handle_click={openUserProfile} /></div>
|
||||
{/if}
|
||||
<PostCard post={threadRoot} />
|
||||
<PostCard post={threadRoot} on_handle_click={openUserProfile} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#each userPosts as p (p.uri)}
|
||||
<PostCard post={p} on_thread_click={openThread} />
|
||||
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} />
|
||||
{/each}
|
||||
{#if timelineCursor}
|
||||
<div class="loadmore">
|
||||
@@ -487,6 +546,17 @@
|
||||
<span class="meta">⌘↵ to post</span>
|
||||
</div>
|
||||
<ComposeBox onPosted={handlePosted} />
|
||||
{:else if view === "user"}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
<span class="title">// profile —</span>
|
||||
<span class="as">@{selectedHandle}</span>
|
||||
</div>
|
||||
<UserProfileView
|
||||
handle={selectedHandle}
|
||||
on_thread_click={openThread}
|
||||
current_user_did={currentUser?.did ?? null}
|
||||
/>
|
||||
{:else if view === "profile"}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
@@ -502,8 +572,81 @@
|
||||
<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"
|
||||
@@ -555,7 +698,7 @@
|
||||
{:else}
|
||||
<h3 class="profile__h3">// recent posts</h3>
|
||||
{#each profile.posts as p (p.uri)}
|
||||
<PostCard post={p} on_thread_click={openThread} />
|
||||
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} />
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
@@ -636,8 +779,7 @@
|
||||
{:else if view === "search"}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
<span class="title">// search</span>
|
||||
</div>
|
||||
<span class="title">// search —</span>
|
||||
<input
|
||||
class="search"
|
||||
type="text"
|
||||
@@ -645,6 +787,7 @@
|
||||
oninput={onSearchInput}
|
||||
placeholder="grep posts…"
|
||||
/>
|
||||
</div>
|
||||
{#if searchError}
|
||||
<div class="toast toast--err">err: {searchError}</div>
|
||||
{/if}
|
||||
@@ -657,7 +800,7 @@
|
||||
{:else}
|
||||
<div class="meta meta--results">{searchResults.length} result{searchResults.length === 1 ? "" : "s"} for "{searchQuery}"</div>
|
||||
{#each searchResults as p (p.uri)}
|
||||
<PostCard post={p} on_thread_click={openThread} />
|
||||
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} />
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -852,6 +995,16 @@
|
||||
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);
|
||||
|
||||
@@ -191,6 +191,9 @@ export type Post = {
|
||||
embed?: Embed | null;
|
||||
langs: string[];
|
||||
created_at: string;
|
||||
/// Resolved author-avatar CID from the AppView's `profiles`
|
||||
/// cache. NULL when the user has no profile record yet.
|
||||
avatar_cid?: string | null;
|
||||
};
|
||||
|
||||
export type TimelineResponse = {
|
||||
@@ -204,6 +207,11 @@ export type ProfileResponse = {
|
||||
posts: Post[];
|
||||
followers: number;
|
||||
following: number;
|
||||
display_name?: string | null;
|
||||
description?: string | null;
|
||||
avatar_cid?: string | null;
|
||||
banner_cid?: string | null;
|
||||
post_count: number;
|
||||
};
|
||||
|
||||
export type SearchResponse = {
|
||||
@@ -429,6 +437,33 @@ export async function listenTrayEvents(
|
||||
/// browser preview where no Tauri runtime is present, fall back
|
||||
/// to `window.open` and treat a popup-blocker denial as
|
||||
/// "fine, user can copy the URL themselves".
|
||||
export type ProfileRecord = {
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
avatar?: { ref: { $link: string }; mimeType?: string; size?: number };
|
||||
banner?: { ref: { $link: string }; mimeType?: string; size?: number };
|
||||
};
|
||||
|
||||
/// Read the authenticated user's `app.bsky.actor.profile` record.
|
||||
/// Returns `null` if no profile record exists yet (a brand-new
|
||||
/// account, or a user whose PDS hasn't pushed one).
|
||||
export async function getMyProfile(): Promise<ProfileRecord | null> {
|
||||
return await safeInvoke<ProfileRecord | null>("profile_get_record");
|
||||
}
|
||||
|
||||
/// Read-modify-write the authenticated user's profile. The Rust
|
||||
/// `profile_set` command fetches the existing record, overlays
|
||||
/// the supplied fields, and writes a new commit. `undefined` fields
|
||||
/// are preserved.
|
||||
export async function setMyProfile(fields: {
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
avatarBlobCid?: string;
|
||||
bannerBlobCid?: string;
|
||||
}): Promise<ProfileRecord | null> {
|
||||
return await safeInvoke<ProfileRecord | null>("profile_set", fields);
|
||||
}
|
||||
|
||||
export async function openExternalUrl(url: string): Promise<void> {
|
||||
try {
|
||||
if (isTauri()) {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { fetchBlob } from "../api/client";
|
||||
import { onDestroy } from "svelte";
|
||||
|
||||
type Props = {
|
||||
did: string;
|
||||
/** Blob-ref `$link` from a posts/post record or a profile
|
||||
* record. The Avatar component resolves the (did, cid) pair via
|
||||
* the existing PDS `getBlob` route through `fetchBlob`. NULL
|
||||
* falls back to the deterministic initial-letter SVG. */
|
||||
cid?: string | null;
|
||||
/** Human-readable name used for the initial-letter fallback and
|
||||
* the alt text. */
|
||||
name?: string | null;
|
||||
/** Pixel size. The same component is used at 24 px (PostCard),
|
||||
* 32 px (Header current-user avatar) and 88 px (Profile-View
|
||||
* header). */
|
||||
size?: number;
|
||||
};
|
||||
|
||||
let { did, cid = null, name = "", size = 32 }: Props = $props();
|
||||
const initial = $derived(
|
||||
((name || "").trim()[0] || "?").toUpperCase(),
|
||||
);
|
||||
let blobUrl: string | null = $state(null);
|
||||
let lastCid: string | null = null;
|
||||
|
||||
$effect(() => {
|
||||
// Drop the previous blob URL when the CID changes — keeps the
|
||||
// in-memory cache (managed by `fetchBlob`) lean and avoids leaking
|
||||
// object URLs across navigations.
|
||||
if (lastCid !== cid) {
|
||||
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
||||
blobUrl = null;
|
||||
lastCid = cid;
|
||||
}
|
||||
if (!cid) return;
|
||||
let cancelled = false;
|
||||
fetchBlob(did, cid)
|
||||
.then((u) => {
|
||||
if (!cancelled) blobUrl = u;
|
||||
else URL.revokeObjectURL(u);
|
||||
})
|
||||
.catch(() => {
|
||||
/* Fall back to the initial letter on fetch error. */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if blobUrl}
|
||||
<img
|
||||
class="avatar"
|
||||
style:width="{size}px"
|
||||
style:height="{size}px"
|
||||
src={blobUrl}
|
||||
alt={name ? `${name}'s avatar` : "avatar"}
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class="avatar avatar--fallback"
|
||||
style:width="{size}px"
|
||||
style:height="{size}px"
|
||||
style:font-size="{Math.max(10, Math.floor(size * 0.45))}px"
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.avatar {
|
||||
display: inline-block;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: var(--bg-elev, #1a1a1a);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.avatar--fallback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--text-dim, #888);
|
||||
border: 1px solid var(--line-2, #3a3a3a);
|
||||
}
|
||||
</style>
|
||||
@@ -4,7 +4,7 @@
|
||||
// `$bindable`, use a callback prop to bubble state changes up to
|
||||
// the parent.
|
||||
|
||||
type View = "home" | "compose" | "profile" | "search" | "settings";
|
||||
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
|
||||
|
||||
let {
|
||||
view = "home",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import NavRail from "./NavRail.svelte";
|
||||
|
||||
type View = "home" | "compose" | "profile" | "search" | "settings";
|
||||
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
|
||||
let view: View = $state("home");
|
||||
</script>
|
||||
|
||||
|
||||
@@ -12,10 +12,21 @@
|
||||
} from "../api/client";
|
||||
import EmbedImage from "./EmbedImage.svelte";
|
||||
import EmbedExternal from "./EmbedExternal.svelte";
|
||||
import Avatar from "./Avatar.svelte";
|
||||
import { localStorageKey, useLocalStorage } from "../utils/localstorage";
|
||||
|
||||
type Props = { post: Post; on_thread_click?: (uri: string) => void };
|
||||
let { post, on_thread_click }: Props = $props();
|
||||
type Props = {
|
||||
post: Post;
|
||||
on_thread_click?: (uri: string) => void;
|
||||
/// Called when the user clicks the handle / avatar in the
|
||||
/// post header. Tauri webviews don't have a real router, so
|
||||
/// the host (App.svelte) decides what to do — typically it
|
||||
/// sets `selectedHandle` + `view = "user"` to render
|
||||
/// `<UserProfileView>`. When absent the header remains
|
||||
/// clickable but does nothing.
|
||||
on_handle_click?: (handle: string) => void;
|
||||
};
|
||||
let { post, on_thread_click, on_handle_click }: Props = $props();
|
||||
|
||||
// Quoted-post cache. When the post's embed is a `record`, we fetch
|
||||
// it once on mount and cache it keyed by URI so navigating
|
||||
@@ -288,8 +299,24 @@
|
||||
{/if}
|
||||
|
||||
<header class="post__head">
|
||||
<span class="prompt">></span>
|
||||
<a class="handle" href={`/profile/${post.handle}`}>@{shortHandle(post.handle)}</a>
|
||||
<button
|
||||
class="avatar-btn"
|
||||
type="button"
|
||||
title="open profile"
|
||||
onclick={() => on_handle_click?.(post.handle)}
|
||||
>
|
||||
<Avatar
|
||||
did={post.did}
|
||||
cid={post.avatar_cid ?? null}
|
||||
name={post.handle}
|
||||
size={24}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="handle"
|
||||
type="button"
|
||||
onclick={() => on_handle_click?.(post.handle)}
|
||||
>@{shortHandle(post.handle)}</button>
|
||||
<span class="time">{timeAgo(post.created_at)}</span>
|
||||
<span class="cid" title={post.cid}>cid: {shortCid(post.cid)}</span>
|
||||
<span class="did" title={post.did}>{shortDid(post.did)}</span>
|
||||
@@ -409,7 +436,24 @@
|
||||
margin-bottom: var(--s-2);
|
||||
}
|
||||
.prompt { color: var(--orange); }
|
||||
.handle { color: var(--text); }
|
||||
.avatar-btn {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
}
|
||||
.avatar-btn:hover { opacity: 0.85; }
|
||||
.handle {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
}
|
||||
.handle:hover { color: var(--orange); }
|
||||
.time, .cid, .did { color: var(--cid-fg); }
|
||||
.did { color: var(--text-dim); }
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user