Two independent Svelte 5 effect-loop bugs that triggered the
same 'effect_update_depth_exceeded' guard:
1. PostCard.svelte: the embed-quote-fetch $effect and the
likedBox $effect.pre read a state variable (quotedLoading /
likedBox) and then synchronously wrote to it in the same
effect run. Svelte 5's effect tracker schedules
possible_effect_self_invalidation on the touched state, the
effect re-fires immediately, and the cycle trips the
'flush_count > 1000' guard. With ~30 PostCards mounting on
login the per-card loop compounds into the depth exceeded
error. Wrap the read+write blocks in untrack() so the
hydration flags don't contribute to the effect's dep set;
the outer 'post.embed.uri / post.did / post.rkey' reads
remain tracked so navigation between cards still triggers a
fresh hydrate.
2. App.svelte: the four $effect blocks (home-refresh,
home-poll, profile-refresh, search-debounce) lived and died
together. Even after splitting, Svelte 5 still flagged the
call chain into refreshTimeline / refreshProfile because
their sync prelude writes 'timelineLoading = true' /
'profileLoading = true' while the effect already tracks the
same downstream state via the proxy. Drive everything
imperatively through a single setView(v) function and move
the 5s poll into the session.subscribe callback, which fires
only on actual login/logout transitions. setView is the
single point that flips view AND triggers the right refresh
per destination — NavRail on_select, LoginScreen onLogin,
handleLogout, the 'back to timeline' button, and the tray
navigate-event bridge all route through it now.
Also: NavRail and StatusBar self-style their grid-area in
their own component styles ('grid-area: rail' / 'status') so
App.svelte doesn't need the fragile '$state.s-XXX > nav.rail'
cross-component selector that Svelte 5 was failing to match
in the Tauri webview, leaving rail buttons invisible to clicks.
971 lines
31 KiB
Svelte
971 lines
31 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from "svelte";
|
|
import {
|
|
session,
|
|
pdsStatus,
|
|
fetchTimeline,
|
|
fetchProfile,
|
|
fetchSearch,
|
|
fetchPost,
|
|
openExternalUrl,
|
|
showError,
|
|
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 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";
|
|
|
|
let view: View = $state("home");
|
|
let currentUser: Session | null = $state(null);
|
|
let status: { did?: string; handle?: string; authenticated: boolean } = $state({ authenticated: false });
|
|
|
|
// Home timeline state.
|
|
let userPosts: Post[] = $state([]);
|
|
let timelineCursor: string | null = $state(null);
|
|
let timelineLoading: boolean = $state(false);
|
|
let timelineError: string | null = $state(null);
|
|
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);
|
|
|
|
// Search state.
|
|
let searchQuery: string = $state("");
|
|
let searchResults: Post[] = $state([]);
|
|
let searchLoading: boolean = $state(false);
|
|
let searchError: string | null = $state(null);
|
|
let _searchDebounce: number | undefined;
|
|
|
|
// Thread state — when set, shows the thread root + parent + the focused post.
|
|
let threadRoot: Post | null = $state(null);
|
|
let threadParent: Post | null = $state(null);
|
|
let threadLoading: boolean = $state(false);
|
|
let threadError: string | null = $state(null);
|
|
|
|
// Toasts surfaced by child components via the `maarcadetweet:toast`
|
|
// window event. We keep the last few so a slow render doesn't
|
|
// wipe the message before the user reads it.
|
|
let toasts: { id: number; kind: "error" | "info"; text: string }[] = $state([]);
|
|
let _toastCounter = 0;
|
|
function pushToast(kind: "error" | "info", text: string) {
|
|
const id = ++_toastCounter;
|
|
toasts = [...toasts, { id, kind, text }];
|
|
setTimeout(() => {
|
|
toasts = toasts.filter((t) => t.id !== id);
|
|
}, 5000);
|
|
}
|
|
|
|
async function openThread(root_uri: string) {
|
|
threadLoading = true;
|
|
threadError = null;
|
|
threadRoot = null;
|
|
threadParent = null;
|
|
try {
|
|
const r = await fetchPost(root_uri);
|
|
threadRoot = r.post;
|
|
threadParent = r.thread.parent;
|
|
} catch (e) {
|
|
threadError = String(e);
|
|
} finally {
|
|
threadLoading = false;
|
|
}
|
|
}
|
|
function closeThread() {
|
|
threadRoot = null;
|
|
threadParent = null;
|
|
threadError = null;
|
|
}
|
|
|
|
// Tray / notification events dispatched from `main.ts` as DOM
|
|
// CustomEvents (so we don't depend on `@tauri-apps/api/event` here
|
|
// — `main.ts` is the only place that wires to the Tauri event bus).
|
|
function onNavigateToView(e: Event) {
|
|
const detail = (e as CustomEvent<{ view: View }>).detail;
|
|
if (detail?.view) setView(detail.view);
|
|
}
|
|
function onNotification(e: Event) {
|
|
const detail = (e as CustomEvent<{ title: string; body: string; url: string | null }>).detail;
|
|
if (detail) {
|
|
// Surface in the existing toast stack. The toast pill is
|
|
// clickable — clicking it navigates to the URL the notification
|
|
// was about (e.g. an at:// post URI). For OS-level
|
|
// notifications, the user has to first click the OS notification
|
|
// (which focuses the app) and then click the toast in the app
|
|
// to actually navigate; this is the limitation of the
|
|
// tauri-plugin-notification v2.x click callbacks.
|
|
pushToast(
|
|
"info",
|
|
`notif: ${detail.title} — ${detail.body}` + (detail.url ? " (click to open)" : ""),
|
|
);
|
|
// Save the URL on a per-toast basis via a side-channel so the
|
|
// toast pill can navigate when clicked. We attach it to the
|
|
// notification event for simplicity (re-look-up via last).
|
|
lastNotificationUrl = detail.url ?? null;
|
|
}
|
|
}
|
|
|
|
let lastNotificationUrl: string | null = $state(null);
|
|
|
|
function openLastNotification() {
|
|
const url = lastNotificationUrl;
|
|
if (!url) return;
|
|
if (url.startsWith("at://")) {
|
|
// at://<did>/app.twi.post/<rkey> or at://<did>/app.bsky.feed.post/<rkey>
|
|
const parts = url.replace(/^at:\/\//, "").split("/");
|
|
const rkey = parts[parts.length - 1];
|
|
const did = parts[0];
|
|
if (rkey && did) {
|
|
// Use the existing thread-context machinery to open the post.
|
|
void openThread(`${did}/app.twi.post/${rkey}`);
|
|
lastNotificationUrl = null;
|
|
}
|
|
} else {
|
|
// unknown scheme — just open home
|
|
setView("home");
|
|
}
|
|
}
|
|
|
|
async function copyToClipboard(text: string) {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
// Surface a tiny inline confirmation by reusing the toast stack.
|
|
// We dispatch the toast event (not pushToast directly) so the
|
|
// notification toast gets the close-on-click behavior.
|
|
const ev = new CustomEvent("maarcadetweet:notification", {
|
|
detail: {
|
|
title: "clipboard",
|
|
body: `copied: ${text.length > 40 ? text.slice(0, 37) + "…" : text}`,
|
|
url: null,
|
|
},
|
|
});
|
|
window.dispatchEvent(ev);
|
|
} catch (e) {
|
|
pushToast("error", `> clipboard failed: ${String(e)}`);
|
|
}
|
|
}
|
|
|
|
// We need to hoist these into the onMount closure (which is below)
|
|
// so the addEventListener / removeEventListener pair stays paired.
|
|
// (`_toastHandler` is the function below; the other two are
|
|
// reassigned inside the async onMount body.)
|
|
let _navigateHandler: ((e: Event) => void) | null = null;
|
|
let _notificationHandler: ((e: Event) => void) | null = null;
|
|
|
|
// Track cleanup refs WITHOUT awaiting before registering them.
|
|
// Svelte 5's `onDestroy` throws when called after the parent component
|
|
// context has been torn down (i.e. after an `await` in `onMount`).
|
|
// Register everything synchronously inside `onMount`, then do async
|
|
// work afterward.
|
|
let _sessionUnsub: (() => void) | null = null;
|
|
let _destroyRegistered = false;
|
|
function registerCleanup(teardown: () => void) {
|
|
if (_destroyRegistered) return;
|
|
onDestroy(teardown);
|
|
_destroyRegistered = true;
|
|
}
|
|
|
|
function _toastHandler(ev: Event) {
|
|
const e = ev as CustomEvent<{ kind: "error" | "info"; text: string }>;
|
|
if (!e.detail) return;
|
|
pushToast(e.detail.kind, e.detail.text);
|
|
}
|
|
|
|
onMount(() => {
|
|
registerCleanup(() => {
|
|
if (_sessionUnsub) _sessionUnsub();
|
|
if (_statusTimer) clearInterval(_statusTimer);
|
|
if (_pollTimer != null) clearInterval(_pollTimer);
|
|
if (_searchDebounce) clearTimeout(_searchDebounce);
|
|
if (typeof window !== "undefined") {
|
|
window.removeEventListener("maarcadetweet:toast", _toastHandler);
|
|
window.removeEventListener("maarcadetweet:navigate", _navigateHandler!);
|
|
window.removeEventListener("maarcadetweet:notification", _notificationHandler!);
|
|
}
|
|
});
|
|
|
|
// Now safe to await.
|
|
(async () => {
|
|
await session.load();
|
|
_sessionUnsub = session.subscribe((s) => {
|
|
currentUser = s;
|
|
status = { ...status, did: s?.did, handle: s?.handle, authenticated: !!s };
|
|
// Drive the 5s poll off the session lifecycle instead of a
|
|
// reactive effect — the effect form kept tripping Svelte 5's
|
|
// depth guard.
|
|
if (s) startPoll();
|
|
else stopPoll();
|
|
});
|
|
|
|
if (typeof window !== "undefined") {
|
|
_navigateHandler = onNavigateToView;
|
|
_notificationHandler = onNotification;
|
|
window.addEventListener("maarcadetweet:toast", _toastHandler);
|
|
window.addEventListener("maarcadetweet:navigate", _navigateHandler);
|
|
window.addEventListener("maarcadetweet:notification", _notificationHandler);
|
|
}
|
|
|
|
_statusTimer = window.setInterval(async () => {
|
|
try {
|
|
status = await pdsStatus();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}, 5000);
|
|
})();
|
|
});
|
|
|
|
// Imperative view-switching. We dispatch from a single function
|
|
// (called by NavRail on_select, the LoginScreen onLogin path, and
|
|
// the tray-event bridge) so every view transition runs the same
|
|
// side effects in one place. Previously this was four separate
|
|
// `$effect` blocks that read `view` / `currentUser` and called
|
|
// `refreshTimeline` / `refreshProfile` / `scheduleSearch`. Svelte
|
|
// 5's depth tracker kept aborting with `effect_update_depth_exceeded`
|
|
// because the sync portions of those refresh functions (`timelineLoading
|
|
// = true`, `profileLoading = true`) wrote $state that the effect's
|
|
// proxy-tracking had flagged as a self-write. Driving everything
|
|
// imperatively from a setter sidesteps the reactive cycle.
|
|
function setView(next: View) {
|
|
const prev = view;
|
|
view = next;
|
|
if (!currentUser) return;
|
|
|
|
// Entering home from elsewhere — pull a fresh timeline and
|
|
// (re)start the poll timer. Leaving home clears it.
|
|
if (next === "home" && prev !== "home") {
|
|
void refreshTimeline(true);
|
|
}
|
|
if (next === "profile") {
|
|
const handle = currentUser.handle;
|
|
void refreshProfile(handle);
|
|
}
|
|
if (next === "search" && searchQuery.trim().length > 0) {
|
|
scheduleSearch();
|
|
}
|
|
}
|
|
|
|
let _pollTimer: number | undefined;
|
|
function startPoll() {
|
|
if (_pollTimer != null) return;
|
|
_pollTimer = window.setInterval(() => {
|
|
if (view === "home") void refreshTimeline(false);
|
|
}, 5000);
|
|
}
|
|
function stopPoll() {
|
|
if (_pollTimer == null) return;
|
|
window.clearInterval(_pollTimer);
|
|
_pollTimer = undefined;
|
|
}
|
|
|
|
async function refreshTimeline(reset: boolean) {
|
|
if (!currentUser) return;
|
|
timelineLoading = true;
|
|
timelineError = null;
|
|
try {
|
|
const r = await fetchTimeline(currentUser.did, null, 30);
|
|
if (reset) {
|
|
// Full replace on explicit refresh.
|
|
seenUris = new Set(r.posts.map((p) => p.uri));
|
|
userPosts = r.posts;
|
|
timelineCursor = r.cursor;
|
|
} else {
|
|
// Poll: prepend posts we haven't seen yet.
|
|
const fresh: Post[] = [];
|
|
for (const p of r.posts) {
|
|
if (!seenUris.has(p.uri)) {
|
|
fresh.push(p);
|
|
seenUris.add(p.uri);
|
|
}
|
|
}
|
|
if (fresh.length > 0) userPosts = [...fresh, ...userPosts];
|
|
}
|
|
} catch (e) {
|
|
timelineError = String(e);
|
|
// Keep whatever we had on a transient failure.
|
|
} finally {
|
|
timelineLoading = false;
|
|
}
|
|
}
|
|
|
|
async function loadMoreTimeline() {
|
|
if (!currentUser || !timelineCursor || timelineLoading) return;
|
|
timelineLoading = true;
|
|
try {
|
|
const r = await fetchTimeline(currentUser.did, timelineCursor, 30);
|
|
for (const p of r.posts) {
|
|
if (!seenUris.has(p.uri)) {
|
|
seenUris.add(p.uri);
|
|
userPosts = [...userPosts, p];
|
|
}
|
|
}
|
|
timelineCursor = r.cursor;
|
|
} catch (e) {
|
|
timelineError = String(e);
|
|
} finally {
|
|
timelineLoading = false;
|
|
}
|
|
}
|
|
|
|
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(() => {
|
|
void runSearch();
|
|
}, 250);
|
|
}
|
|
|
|
function onSearchInput() {
|
|
// Empty input -> immediately clear results (no debounce).
|
|
if (searchQuery.trim().length === 0) {
|
|
if (_searchDebounce) {
|
|
clearTimeout(_searchDebounce);
|
|
_searchDebounce = undefined;
|
|
}
|
|
searchResults = [];
|
|
searchError = null;
|
|
return;
|
|
}
|
|
scheduleSearch();
|
|
}
|
|
|
|
async function runSearch() {
|
|
const needle = searchQuery.trim();
|
|
if (!needle) {
|
|
searchResults = [];
|
|
searchError = null;
|
|
return;
|
|
}
|
|
searchLoading = true;
|
|
searchError = null;
|
|
try {
|
|
const r = await fetchSearch(needle, 30);
|
|
searchResults = r.posts;
|
|
} catch (e) {
|
|
searchError = String(e);
|
|
searchResults = [];
|
|
} finally {
|
|
searchLoading = false;
|
|
}
|
|
}
|
|
|
|
async function handlePosted() {
|
|
// After the user posts, reset to page 1 so they see their own post.
|
|
await refreshTimeline(true);
|
|
}
|
|
|
|
async function handleLogout() {
|
|
try {
|
|
await session.logout();
|
|
setView("home");
|
|
profile = null;
|
|
searchResults = [];
|
|
threadRoot = null;
|
|
threadParent = null;
|
|
userPosts = [];
|
|
} catch (e) {
|
|
showError(`logout failed: ${String(e)}`);
|
|
}
|
|
}
|
|
// 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
|
|
// talking to. Kept as plain helpers so they can be swapped for a
|
|
// `pds_describe`/`appview_describe` Tauri command later.
|
|
function pdsBase(): string {
|
|
if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_PDS_URL) {
|
|
return (import.meta as any).env.VITE_PDS_URL as string;
|
|
}
|
|
return "http://127.0.0.1:2583";
|
|
}
|
|
function appviewBase(): string {
|
|
if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_APPVIEW_URL) {
|
|
return (import.meta as any).env.VITE_APPVIEW_URL as string;
|
|
}
|
|
return "http://127.0.0.1:2584";
|
|
}
|
|
</script>
|
|
|
|
{#if !currentUser}
|
|
<div class="login-wrap">
|
|
<LoginScreen
|
|
onLogin={(s) => {
|
|
currentUser = s;
|
|
setView("home");
|
|
}}
|
|
/>
|
|
</div>
|
|
{:else}
|
|
<div class="shell">
|
|
<NavRail
|
|
{view}
|
|
on_select={(v) => setView(v)}
|
|
/>
|
|
<div class="main">
|
|
<Terminal title={view === "home" ? "maarcadetweet — home" : `maarcadetweet — ${view}`}>
|
|
{#if view === "home"}
|
|
<div class="head">
|
|
<span class="prompt">$</span>
|
|
<span class="title">// home —</span>
|
|
<span class="as">@{currentUser.handle}</span>
|
|
<span class="meta">→ {userPosts.length} posts · polling every 5s</span>
|
|
</div>
|
|
{#if timelineError}
|
|
<div class="toast toast--err">err: {timelineError}</div>
|
|
{/if}
|
|
{#if timelineLoading && userPosts.length === 0}
|
|
<Skeleton rows={3} />
|
|
{:else if userPosts.length === 0}
|
|
<div class="empty">// timeline is empty. compose your first post →</div>
|
|
{:else}
|
|
{#if threadRoot}
|
|
<div class="thread-modal">
|
|
<header class="thread-modal__head">
|
|
<span class="crumb">// thread</span>
|
|
<button class="btn--ghost" onclick={closeThread}>close</button>
|
|
</header>
|
|
{#if threadLoading}
|
|
<Skeleton rows={2} />
|
|
{:else if threadError}
|
|
<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>
|
|
{/if}
|
|
<PostCard post={threadRoot} />
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{#each userPosts as p (p.uri)}
|
|
<PostCard post={p} on_thread_click={openThread} />
|
|
{/each}
|
|
{#if timelineCursor}
|
|
<div class="loadmore">
|
|
<button class="btn btn--ghost" onclick={() => void loadMoreTimeline()} disabled={timelineLoading}>
|
|
{timelineLoading ? "loading…" : "load more"}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
{:else if view === "compose"}
|
|
<div class="head">
|
|
<span class="prompt">$</span>
|
|
<span class="title">// compose —</span>
|
|
<span class="as">@{currentUser.handle}</span>
|
|
<span class="meta">⌘↵ to post</span>
|
|
</div>
|
|
<ComposeBox onPosted={handlePosted} />
|
|
{: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>
|
|
</header>
|
|
|
|
<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} />
|
|
{/each}
|
|
{/if}
|
|
</section>
|
|
{/if}
|
|
{:else if view === "settings"}
|
|
<div class="head">
|
|
<span class="prompt">$</span>
|
|
<span class="title">// settings</span>
|
|
</div>
|
|
<section class="settings">
|
|
<h3 class="settings__h3">// account</h3>
|
|
<dl class="settings__rows">
|
|
<div>
|
|
<dt>handle</dt>
|
|
<dd>@{currentUser?.handle ?? "?"}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>did</dt>
|
|
<dd class="did-cell">{currentUser?.did ?? "?"}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>posts in cache</dt>
|
|
<dd>{userPosts.length}</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
<h3 class="settings__h3">// actions</h3>
|
|
<div class="settings__actions">
|
|
<button
|
|
class="btn btn--ghost"
|
|
type="button"
|
|
onclick={() =>
|
|
currentUser && copyToClipboard(currentUser.did)}
|
|
>copy my did</button>
|
|
<button
|
|
class="btn btn--ghost"
|
|
type="button"
|
|
onclick={() =>
|
|
openExternalUrl(
|
|
`https://bsky.app/profile/${currentUser?.handle ?? ""}`,
|
|
)}
|
|
>open profile in browser</button>
|
|
<button
|
|
class="btn btn--ghost"
|
|
type="button"
|
|
onclick={() => setView("home")}
|
|
>← back to timeline</button>
|
|
</div>
|
|
|
|
<h3 class="settings__h3">// about</h3>
|
|
<dl class="settings__rows">
|
|
<div>
|
|
<dt>app</dt>
|
|
<dd>maarcadetweet</dd>
|
|
</div>
|
|
<div>
|
|
<dt>version</dt>
|
|
<dd>0.1.0</dd>
|
|
</div>
|
|
<div>
|
|
<dt>backend</dt>
|
|
<dd>{pdsBase()}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>appview</dt>
|
|
<dd>{appviewBase()}</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
<div class="settings__signout">
|
|
<button
|
|
class="btn btn--ghost btn--danger"
|
|
type="button"
|
|
onclick={handleLogout}
|
|
>sign out</button>
|
|
</div>
|
|
</section>
|
|
{:else if view === "search"}
|
|
<div class="head">
|
|
<span class="prompt">$</span>
|
|
<span class="title">// search</span>
|
|
</div>
|
|
<input
|
|
class="search"
|
|
type="text"
|
|
bind:value={searchQuery}
|
|
oninput={onSearchInput}
|
|
placeholder="grep posts…"
|
|
/>
|
|
{#if searchError}
|
|
<div class="toast toast--err">err: {searchError}</div>
|
|
{/if}
|
|
{#if searchLoading}
|
|
<Skeleton rows={2} />
|
|
{:else if searchQuery.trim().length === 0}
|
|
<div class="empty">// type to search…</div>
|
|
{:else if searchResults.length === 0}
|
|
<div class="empty">// no posts match "{searchQuery}"</div>
|
|
{: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} />
|
|
{/each}
|
|
{/if}
|
|
{/if}
|
|
</Terminal>
|
|
</div>
|
|
<StatusBar did={status.did ?? ""} authenticated={status.authenticated} />
|
|
</div>
|
|
{#if toasts.length > 0}
|
|
<div class="toasts" role="status" aria-live="polite">
|
|
{#each toasts as t (t.id)}
|
|
<button
|
|
class="toast-pill"
|
|
class:toast-pill--err={t.kind === "error"}
|
|
type="button"
|
|
onclick={() => {
|
|
// If this toast was a notification with a URL, navigate
|
|
// to it before dismissing. Otherwise just dismiss.
|
|
if (lastNotificationUrl) {
|
|
openLastNotification();
|
|
}
|
|
toasts = toasts.filter((x) => x.id !== t.id);
|
|
}}
|
|
oncontextmenu={(e) => {
|
|
// Right-click dismisses without navigating — useful when
|
|
// the user clicks a notification toast by mistake.
|
|
e.preventDefault();
|
|
lastNotificationUrl = null;
|
|
toasts = toasts.filter((x) => x.id !== t.id);
|
|
}}
|
|
title={lastNotificationUrl ? "click to open · right-click to dismiss" : "dismiss"}
|
|
>
|
|
{t.kind === "error" ? "err" : "info"}: {t.text}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
<style>
|
|
/* The whole page is a flex column filling the viewport. The
|
|
shell is grid-laid-out with NavRail | main + statusbar. The
|
|
main area is overflow:auto so long timelines scroll
|
|
inside the main area, while the statusbar stays pinned at the
|
|
bottom. With this layout the body is `flex: 1` child of the
|
|
global html/body flex container, so the shell fills the
|
|
viewport regardless of how much content is in it. Without
|
|
this, the shell collapses to its content height (because
|
|
`flex: 1` requires a flex parent) and the main area has no
|
|
scroll target — clicks still work but you cannot scroll. */
|
|
/* :global() escapes Svelte's CSS scoping so the rules below
|
|
target the actual <html> and <body> elements, not just
|
|
elements with the Svelte component class. Without this, the
|
|
flex layout was silently a no-op and the shell collapsed to
|
|
its content height, leaving the viewport blank. */
|
|
:global(html), :global(body) {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
}
|
|
:global(#app) {
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
}
|
|
|
|
.login-wrap {
|
|
flex: 1 1 auto;
|
|
display: grid;
|
|
place-items: center;
|
|
min-height: 0;
|
|
}
|
|
|
|
.shell {
|
|
flex: 1 1 auto;
|
|
display: grid;
|
|
grid-template-rows: 1fr 24px;
|
|
grid-template-columns: 88px 1fr;
|
|
grid-template-areas:
|
|
"rail main"
|
|
"rail status";
|
|
min-height: 0;
|
|
}
|
|
/* NavRail and StatusBar self-assign their own grid-area
|
|
(`grid-area: rail` / `grid-area: status`) in their component
|
|
styles, so the parent doesn't need any :global() child
|
|
selectors. The `.main` slot is just the next sibling; we set
|
|
its grid-area explicitly below. */
|
|
.main {
|
|
grid-area: main;
|
|
overflow: auto;
|
|
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);
|
|
color: var(--text-dim);
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: var(--s-3);
|
|
align-items: center;
|
|
padding: 0 var(--s-2) var(--s-3);
|
|
border-bottom: 1px dashed var(--line);
|
|
margin-bottom: var(--s-3);
|
|
}
|
|
.prompt { color: var(--orange); }
|
|
.title { color: var(--orange); font-weight: 700; }
|
|
.as { color: var(--text); }
|
|
.meta { color: var(--text-dim); margin-left: auto; font-variant-numeric: tabular-nums; }
|
|
.meta--results { padding: var(--s-2) var(--s-5); margin: 0; }
|
|
|
|
.toast {
|
|
font-family: var(--font-mono);
|
|
font-size: var(--fs-50);
|
|
padding: var(--s-2) var(--s-3);
|
|
margin: 0 var(--s-5) var(--s-3);
|
|
border-left: 3px solid var(--red);
|
|
background: rgba(255, 59, 48, 0.08);
|
|
color: var(--red);
|
|
border-radius: 0 var(--r-sm) var(--r-sm) 0;
|
|
}
|
|
.toast--err { border-left-color: var(--red); }
|
|
|
|
.empty {
|
|
font-family: var(--font-mono);
|
|
font-size: var(--fs-50);
|
|
color: var(--text-dim);
|
|
padding: var(--s-4) var(--s-5);
|
|
font-style: italic;
|
|
}
|
|
|
|
.search {
|
|
display: block;
|
|
width: calc(100% - 2 * var(--s-5));
|
|
margin: 0 var(--s-5) var(--s-3);
|
|
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;
|
|
}
|
|
.search:focus { border-color: var(--orange); }
|
|
.search::placeholder { color: var(--text-dim); }
|
|
|
|
.loadmore {
|
|
display: flex;
|
|
justify-content: center;
|
|
padding: var(--s-4) var(--s-5);
|
|
}
|
|
.btn {
|
|
font-family: var(--font-mono);
|
|
font-size: var(--fs-50);
|
|
padding: 0.4rem 0.8rem;
|
|
border-radius: var(--r-sm);
|
|
border: 1px solid transparent;
|
|
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); }
|
|
.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;
|
|
}
|
|
.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);
|
|
bottom: calc(24px + var(--s-3));
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--s-2);
|
|
z-index: 100;
|
|
max-width: 360px;
|
|
}
|
|
.toast-pill {
|
|
font-family: var(--font-mono);
|
|
font-size: var(--fs-50);
|
|
padding: var(--s-2) var(--s-3);
|
|
border-radius: var(--r-sm);
|
|
background: var(--bg-elev);
|
|
color: var(--text);
|
|
border: 1px solid var(--line-2);
|
|
cursor: pointer;
|
|
text-align: left;
|
|
line-height: 1.4;
|
|
}
|
|
.toast-pill:hover { border-color: var(--orange); }
|
|
.toast-pill--err {
|
|
color: var(--red);
|
|
border-color: var(--red);
|
|
background: rgba(255, 59, 48, 0.08);
|
|
}
|
|
|
|
.btn--danger {
|
|
color: var(--red);
|
|
border-color: var(--red);
|
|
}
|
|
.btn--danger:hover:not(:disabled) {
|
|
background: rgba(255, 59, 48, 0.08);
|
|
color: var(--red);
|
|
border-color: var(--red);
|
|
}
|
|
|
|
.profile__h3,
|
|
.settings__h3 {
|
|
font-family: var(--font-mono);
|
|
font-size: var(--fs-50);
|
|
color: var(--text-dim);
|
|
letter-spacing: 0.04em;
|
|
margin: var(--s-4) 0 var(--s-2);
|
|
font-weight: 400;
|
|
}
|
|
|
|
.did-cell {
|
|
word-break: break-all;
|
|
font-size: var(--fs-50);
|
|
}
|
|
|
|
.settings {
|
|
padding: 0 var(--s-3);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--s-2);
|
|
}
|
|
.settings__rows {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--s-1);
|
|
padding: var(--s-2) var(--s-4);
|
|
margin: 0 0 var(--s-4);
|
|
font-family: var(--font-mono);
|
|
font-size: var(--fs-50);
|
|
}
|
|
.settings__rows > div {
|
|
display: flex;
|
|
gap: var(--s-3);
|
|
}
|
|
.settings__rows dt {
|
|
color: var(--text-dim);
|
|
letter-spacing: 0.04em;
|
|
min-width: 9rem;
|
|
}
|
|
.settings__rows dd {
|
|
margin: 0;
|
|
color: var(--text);
|
|
}
|
|
.settings__actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: var(--s-2);
|
|
margin: 0 0 var(--s-4);
|
|
}
|
|
.settings__signout {
|
|
margin-top: var(--s-4);
|
|
padding-top: var(--s-4);
|
|
border-top: 1px dashed var(--line);
|
|
}
|
|
</style>
|