diff --git a/README.md b/README.md index 0bc33f3..dfd4077 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,13 @@ -# svelte cloudflare statusphere - -> **Work in progress** - -![screenshot](./screenshot.png) - - -**Demo:** https://statusphere.atmo.tools - - -svelte + cloudflare workers statusphere demo, built with lots of [`@atcute`](https://github.com/mary-ext/atcute) packages, [ufos.microcosm.blue](https://ufos.microcosm.blue/) (for recent status updates without its own backend), jetstream subscription for real-time updates and [@foxui](https://flo-bit.dev/ui-kit) for ui components. - -also doubles as a demo of `@atcute/oauth-node-client` for server-side oauth flows in cloudflare workers, with session storage in KV and HMAC-signed cookies and lots of useful functions. - -## Quick Start - -```sh -pnpm install -pnpm dev -``` - -Dev mode uses a loopback oauth client — no keys or cloudflare setup needed. Open the URL shown in the terminal and log in with any Bluesky handle. (The port is randomized per project in case you're running multiple projects at one — set `src/lib/atproto/port.ts`.) - -See [GETTING_STARTED.md](GETTING_STARTED.md) for production deployment, tunnel setup, and configuration. - -## Adding the oauth part to an existing project - -**With an AI agent** — paste this into Claude Code (or similar) in your existing repo: - -``` -add atproto oauth to this project https://raw.githubusercontent.com/flo-bit/svelte-cloudflare-statusphere/main/AGENT_SETUP.md -``` - -The [agent prompt](AGENT_SETUP.md) asks a few questions and sets everything up. - -**Manually** — see [SETUP.md](SETUP.md) for a step-by-step guide. - -## Project Structure - -``` -src/lib/atproto/ -├── auth.svelte.ts # Client-side auth state & login/logout/signup -├── image-helper.ts # Image compression + upload helpers -├── index.ts # Public exports -├── methods.ts # AT Protocol helpers (read/write/resolve) -├── port.ts # Dev server port (randomized per project) -├── settings.ts # Collections, scope, config constants -├── server/ -│ ├── oauth.ts # OAuthClient factory (loopback vs confidential) -│ ├── oauth.remote.ts # Remote functions: login, logout -│ ├── repo.remote.ts # Remote functions: putRecord, deleteRecord, uploadBlob -│ ├── session.ts # Session restoration from signed cookie -│ ├── profile.ts # Profile loading with optional KV cache -│ ├── kv-store.ts # Cloudflare KV-backed Store -│ └── signed-cookie.ts # HMAC-signed cookie helpers -└── scripts/ - ├── generate-key.ts - ├── generate-secret.ts - ├── setup-dev.ts - └── tunnel.ts - -src/routes/(oauth)/ -├── oauth/callback/+server.ts -├── oauth/jwks.json/+server.ts -└── oauth-client-metadata.json/+server.ts -``` - -## How It Works - -- **Auth**: Server-side OAuth via `@atcute/oauth-node-client`. Sessions stored in KV, identified by HMAC-signed `did` cookie. -- **Remote functions**: Write operations and auth actions use SvelteKit remote functions — type-safe server calls without manual API routes. -- **Dev mode**: Loopback client by default. Set `OAUTH_PUBLIC_URL` in `.env` for confidential client via tunnel. -- **Prod mode**: Confidential client with `private_key_jwt`, KV stores, `OAUTH_PUBLIC_URL` from `wrangler.jsonc`. - -## License - -MIT +# atmo social +svelte bsky client, wip ## todo -- make typesafe (with lexicons) \ No newline at end of file +### pages + +- search +- profile (show posts, followers, following, etc) +- feeds +- bookmarks +- settings \ No newline at end of file diff --git a/src/app.css b/src/app.css index 7ef747e..7562b82 100644 --- a/src/app.css +++ b/src/app.css @@ -57,4 +57,4 @@ --base-900: var(--color-mauve-900); --base-950: var(--color-mauve-950); } -} +} \ No newline at end of file diff --git a/src/lib/atproto/methods.ts b/src/lib/atproto/methods.ts index 8d60b13..39c7d3a 100644 --- a/src/lib/atproto/methods.ts +++ b/src/lib/atproto/methods.ts @@ -40,12 +40,18 @@ export async function resolveHandle({ handle }: { handle: Handle }) { return data; } +import { identityCache } from '$lib/cache.svelte'; + /** * Returns a DID given a handle or DID string. */ export async function actorToDid(actor: string): Promise { if (isDid(actor)) return actor; - return await resolveHandle({ handle: actor as Handle }); + const cached = identityCache.get(actor); + if (cached) return cached as Did; + const did = await resolveHandle({ handle: actor as Handle }); + identityCache.set(actor, did); + return did; } const didResolver = new CompositeDidDocumentResolver({ diff --git a/src/lib/atproto/server/chat.remote.ts b/src/lib/atproto/server/chat.remote.ts new file mode 100644 index 0000000..fd2050f --- /dev/null +++ b/src/lib/atproto/server/chat.remote.ts @@ -0,0 +1,108 @@ +import { error } from '@sveltejs/kit'; +import { command, getRequestEvent } from '$app/server'; +import { Client } from '@atcute/client'; +import type { Did } from '@atcute/lexicons'; +import * as v from 'valibot'; + +function getChatClient() { + const { locals } = getRequestEvent(); + if (!locals.session || !locals.did) error(401, 'Not authenticated'); + + return new Client({ + handler: locals.session, + proxy: { did: 'did:web:api.bsky.chat' as Did, serviceId: '#bsky_chat' } + }); +} + +export const listConvos = command( + v.object({ + status: v.optional(v.string()), + cursor: v.optional(v.string()) + }), + async (input) => { + const client = getChatClient(); + + const res = await client.get('chat.bsky.convo.listConvos', { + params: { + limit: 50, + ...(input.status ? { status: input.status } : {}), + ...(input.cursor ? { cursor: input.cursor } : {}) + } + }); + + if (!res.ok) error(res.status, 'Failed to list conversations'); + return { convos: res.data.convos, cursor: res.data.cursor ?? null }; + } +); + +export const getMessages = command( + v.object({ + convoId: v.string(), + cursor: v.optional(v.string()) + }), + async (input) => { + const client = getChatClient(); + + const res = await client.get('chat.bsky.convo.getMessages', { + params: { + convoId: input.convoId, + limit: 50, + ...(input.cursor ? { cursor: input.cursor } : {}) + } + }); + + if (!res.ok) error(res.status, 'Failed to load messages'); + return { messages: res.data.messages, cursor: res.data.cursor ?? null }; + } +); + +export const sendMessage = command( + v.object({ + convoId: v.string(), + text: v.string() + }), + async (input) => { + const client = getChatClient(); + + const res = await client.post('chat.bsky.convo.sendMessage', { + input: { + convoId: input.convoId, + message: { text: input.text } + } + }); + + if (!res.ok) error(res.status, 'Failed to send message'); + return res.data; + } +); + +export const acceptConvo = command( + v.object({ + convoId: v.string() + }), + async (input) => { + const client = getChatClient(); + + const res = await client.post('chat.bsky.convo.acceptConvo', { + input: { convoId: input.convoId } + }); + + if (!res.ok) error(res.status, 'Failed to accept conversation'); + return { ok: true }; + } +); + +export const updateRead = command( + v.object({ + convoId: v.string() + }), + async (input) => { + const client = getChatClient(); + + await client.post('chat.bsky.convo.updateRead', { + input: { convoId: input.convoId } + }).catch(() => {}); + + return { ok: true }; + } +); diff --git a/src/lib/atproto/server/feed.remote.ts b/src/lib/atproto/server/feed.remote.ts index b4b153a..5a109a6 100644 --- a/src/lib/atproto/server/feed.remote.ts +++ b/src/lib/atproto/server/feed.remote.ts @@ -2,6 +2,7 @@ import { error } from '@sveltejs/kit'; import { command, getRequestEvent } from '$app/server'; import * as v from 'valibot'; import type { ResourceUri } from '@atcute/lexicons'; +import { Client, simpleFetchHandler } from '@atcute/client'; import * as TID from '@atcute/tid'; export const likePost = command( @@ -56,6 +57,31 @@ export const unlikePost = command( } ); +export const getPostThread = command( + v.object({ + uri: v.string(), + depth: v.optional(v.number()), + parentHeight: v.optional(v.number()) + }), + async (input) => { + const { locals } = getRequestEvent(); + + const client = locals.client ?? new Client({ + handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) + }); + + const res = await client.get('app.bsky.feed.getPostThread', { + params: { + uri: input.uri as ResourceUri, + depth: input.depth ?? 10, + parentHeight: input.parentHeight ?? 0 + } + }); + if (!res.ok) error(res.status, 'Failed to load thread'); + return res.data; + } +); + export const loadFeed = command( v.object({ feedUri: v.string(), @@ -63,9 +89,12 @@ export const loadFeed = command( }), async (input) => { const { locals } = getRequestEvent(); - if (!locals.client) error(401, 'Not authenticated'); - const res = await locals.client.get('app.bsky.feed.getFeed', { + const client = locals.client ?? new Client({ + handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) + }); + + const res = await client.get('app.bsky.feed.getFeed', { params: { feed: input.feedUri as ResourceUri, limit: 30, diff --git a/src/lib/atproto/server/notifications.remote.ts b/src/lib/atproto/server/notifications.remote.ts new file mode 100644 index 0000000..e399b20 --- /dev/null +++ b/src/lib/atproto/server/notifications.remote.ts @@ -0,0 +1,59 @@ +import { error } from '@sveltejs/kit'; +import { command, getRequestEvent } from '$app/server'; +import * as v from 'valibot'; + +export const listNotifications = command( + v.object({ + cursor: v.optional(v.string()) + }), + async (input) => { + const { locals } = getRequestEvent(); + if (!locals.client || !locals.did) error(401, 'Not authenticated'); + + const res = await locals.client.get('app.bsky.notification.listNotifications', { + params: { + limit: 30, + ...(input.cursor ? { cursor: input.cursor } : {}) + } + }); + + if (!res.ok) error(res.status, 'Failed to list notifications'); + return { + notifications: res.data.notifications, + cursor: res.data.cursor ?? null, + seenAt: res.data.seenAt ?? null + }; + } +); + +export const getUnreadCount = command( + v.object({}), + async () => { + const { locals } = getRequestEvent(); + if (!locals.client || !locals.did) error(401, 'Not authenticated'); + + const res = await locals.client.get('app.bsky.notification.getUnreadCount', { + params: {} + }); + + if (!res.ok) error(res.status, 'Failed to get unread count'); + return { count: res.data.count }; + } +); + +export const updateSeen = command( + v.object({}), + async () => { + const { locals } = getRequestEvent(); + if (!locals.client || !locals.did) error(401, 'Not authenticated'); + + const res = await locals.client.post('app.bsky.notification.updateSeen', { + input: { + seenAt: new Date().toISOString() + } + }); + + if (!res.ok) error(res.status, 'Failed to update seen'); + return { ok: true }; + } +); diff --git a/src/lib/atproto/settings.ts b/src/lib/atproto/settings.ts index 63c6aa4..8456cc7 100644 --- a/src/lib/atproto/settings.ts +++ b/src/lib/atproto/settings.ts @@ -1,7 +1,7 @@ import { dev } from '$app/environment'; // OAuth scope — add scope.blob(), scope.rpc(), etc. as needed -export const scopes = ['atproto', 'transition:generic']; +export const scopes = ['atproto', 'transition:generic', 'transition:chat.bsky']; // set to false to disable signup export const ALLOW_SIGNUP = true; diff --git a/src/lib/cache.svelte.ts b/src/lib/cache.svelte.ts new file mode 100644 index 0000000..274139f --- /dev/null +++ b/src/lib/cache.svelte.ts @@ -0,0 +1,331 @@ +/** + * Unified client-side cache for the app. + * Persists across navigations within the same session. + */ + +import type { ChatBskyConvoDefs } from '@atcute/bluesky'; +import type { AppBskyNotificationListNotifications } from '@atcute/bluesky'; +import { getPostThread, loadFeed } from '$lib/atproto/server/feed.remote'; +import { listConvos, getMessages } from '$lib/atproto/server/chat.remote'; +import { listNotifications, getUnreadCount } from '$lib/atproto/server/notifications.remote'; + +type ConvoView = ChatBskyConvoDefs.ConvoView; +type Notification = AppBskyNotificationListNotifications.Notification; + +// --------------------------------------------------------------------------- +// Identity: handle <-> DID resolution cache +// --------------------------------------------------------------------------- + +export const identityCache = new Map(); + +// --------------------------------------------------------------------------- +// Profiles: actor (handle or DID) -> profile data +// --------------------------------------------------------------------------- + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const _profiles = new Map(); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function cacheProfile(profile: any) { + if (profile?.handle) _profiles.set(profile.handle, profile); + if (profile?.did) _profiles.set(profile.did, profile); + if (profile?.handle && profile?.did) { + identityCache.set(profile.handle, profile.did); + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function getCachedProfile(actor: string): any | undefined { + return _profiles.get(actor); +} + +// --------------------------------------------------------------------------- +// Posts: URI -> post view, URI -> thread +// --------------------------------------------------------------------------- + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const _posts = new Map(); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const _threads = new Map(); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function cachePost(post: any) { + if (!post?.uri) return; + _posts.set(post.uri, post); + if (post.author) cacheProfile(post.author); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function getCachedPost(uri: string): any | undefined { + return _posts.get(uri); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function getCachedThread(uri: string): any | undefined { + return _threads.get(uri); +} + +export function prefetchThread(uri: string) { + if (_threads.has(uri)) return; + getPostThread({ uri }) + .then((data) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((data as any).thread?.$type === 'app.bsky.feed.defs#threadViewPost') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + _threads.set(uri, (data as any).thread); + } + }) + .catch(() => {}); +} + +// --------------------------------------------------------------------------- +// Feed: reactive state for the main timeline +// --------------------------------------------------------------------------- + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let _feedPosts = $state([]); +let _feedCursor = $state(null); +let _feedLoaded = $state(false); +let _feedScrollY = $state(0); + +export const feedCache = { + get posts() { return _feedPosts; }, + set posts(v) { _feedPosts = v; }, + get cursor() { return _feedCursor; }, + set cursor(v) { _feedCursor = v; }, + get loaded() { return _feedLoaded; }, + set loaded(v) { _feedLoaded = v; }, + get scrollY() { return _feedScrollY; }, + set scrollY(v) { _feedScrollY = v; } +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let _pendingFeedPosts = $state([]); +let _pendingFeedCursor = $state(null); +let _hasPendingFeed = $state(false); +let _feedPollInterval: ReturnType | null = null; +let _feedUri: string | null = null; + +export const pendingFeed = { + get hasPending() { return _hasPendingFeed; } +}; + +export function setFeedUri(uri: string) { + _feedUri = uri; +} + +async function pollFeed() { + if (!_feedUri) return; + console.log('[poll] refreshing feed'); + try { + const result = await loadFeed({ feedUri: _feedUri }); + _pendingFeedPosts = JSON.parse(JSON.stringify(result.posts)); + _pendingFeedCursor = result.cursor; + _hasPendingFeed = true; + // Cache post authors for instant profile/DID resolution + for (const fp of _pendingFeedPosts) { + if (fp.post) cachePost(fp.post); + } + } catch { + // silent + } +} + +export function applyPendingFeed() { + if (!_hasPendingFeed) return; + _feedPosts = _pendingFeedPosts; + _feedCursor = _pendingFeedCursor; + _feedLoaded = true; + _feedScrollY = 0; + _hasPendingFeed = false; +} + +export function startFeedPoll() { + if (_feedPollInterval) return; + _feedPollInterval = setInterval(pollFeed, 60_000); +} + +export function stopFeedPoll() { + if (_feedPollInterval) { + clearInterval(_feedPollInterval); + _feedPollInterval = null; + } +} + +// --------------------------------------------------------------------------- +// Chat: reactive convo list + per-convo message cache +// --------------------------------------------------------------------------- + +let _acceptedConvos = $state([]); +let _requestConvos = $state([]); +let _convoListLoaded = $state(false); + +export const convoCache = { + get acceptedConvos() { return _acceptedConvos; }, + set acceptedConvos(v) { _acceptedConvos = v; }, + get requestConvos() { return _requestConvos; }, + set requestConvos(v) { _requestConvos = v; }, + get loaded() { return _convoListLoaded; }, + set loaded(v) { _convoListLoaded = v; } +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const _messages = new Map(); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function getCachedMessages(convoId: string): any[] | undefined { + return _messages.get(convoId); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function setCachedMessages(convoId: string, messages: any[]) { + _messages.set(convoId, messages); +} + +export function markConvoRead(convoId: string) { + const convo = _acceptedConvos.find((c) => c.id === convoId); + if (convo && convo.unreadCount > 0) { + convo.unreadCount = 0; + _acceptedConvos = [..._acceptedConvos]; // trigger reactivity + updateChatUnreadCount(); + } +} + +let _chatPrefetching = false; +let _chatPollInterval: ReturnType | null = null; + +let _unreadChatCount = $state(0); + +export const chatUnreadCount = { + get count() { return _unreadChatCount; }, + set count(v) { _unreadChatCount = v; } +}; + +function updateChatUnreadCount() { + _unreadChatCount = _acceptedConvos.reduce((sum, c) => sum + (c.unreadCount ?? 0), 0); +} + +async function pollChats() { + console.log('[poll] refreshing chats'); + try { + const [accepted, requests] = await Promise.all([ + listConvos({ status: 'accepted' }), + listConvos({ status: 'request' }) + ]); + _acceptedConvos = accepted.convos as ConvoView[]; + _requestConvos = requests.convos as ConvoView[]; + _convoListLoaded = true; + updateChatUnreadCount(); + } catch { + // silent + } +} + +export function startChatPoll() { + if (_chatPollInterval) return; + pollChats(); + _chatPollInterval = setInterval(pollChats, 30_000); +} + +export function stopChatPoll() { + if (_chatPollInterval) { + clearInterval(_chatPollInterval); + _chatPollInterval = null; + } +} + +export async function prefetchChats() { + if (_chatPrefetching || _convoListLoaded) return; + _chatPrefetching = true; + try { + const [accepted, requests] = await Promise.all([ + listConvos({ status: 'accepted' }), + listConvos({ status: 'request' }) + ]); + _acceptedConvos = accepted.convos as ConvoView[]; + _requestConvos = requests.convos as ConvoView[]; + _convoListLoaded = true; + updateChatUnreadCount(); + + // Prefetch messages for top 10 accepted convos + for (const convo of _acceptedConvos.slice(0, 10)) { + if (_messages.has(convo.id)) continue; + getMessages({ convoId: convo.id }) + .then((res) => _messages.set(convo.id, res.messages)) + .catch(() => {}); + } + } catch { + // silent fail + } finally { + _chatPrefetching = false; + } +} + +// --------------------------------------------------------------------------- +// Notifications: reactive list + unread count +// --------------------------------------------------------------------------- + +let _notifications = $state([]); +let _notifLoaded = $state(false); +let _unreadCount = $state(0); +let _notifCursor = $state(null); +let _seenAt = $state(null); + +export const notificationsCache = { + get notifications() { return _notifications; }, + set notifications(v) { _notifications = v; }, + get loaded() { return _notifLoaded; }, + set loaded(v) { _notifLoaded = v; }, + get unreadCount() { return _unreadCount; }, + set unreadCount(v) { _unreadCount = v; }, + get cursor() { return _notifCursor; }, + set cursor(v) { _notifCursor = v; }, + get seenAt() { return _seenAt; }, + set seenAt(v) { _seenAt = v; } +}; + +let _notifPrefetching = false; +let _pollInterval: ReturnType | null = null; + +async function pollUnread() { + console.log('[poll] refreshing notification count'); + try { + const result = await getUnreadCount({}); + _unreadCount = result.count; + } catch { + // silent + } +} + +export function startUnreadPoll() { + if (_pollInterval) return; + pollUnread(); + _pollInterval = setInterval(pollUnread, 30_000); +} + +export function stopUnreadPoll() { + if (_pollInterval) { + clearInterval(_pollInterval); + _pollInterval = null; + } +} + +export async function prefetchNotifications() { + if (_notifPrefetching || _notifLoaded) return; + _notifPrefetching = true; + try { + const [notifResult, countResult] = await Promise.all([ + listNotifications({}), + getUnreadCount({}) + ]); + _notifications = notifResult.notifications as Notification[]; + _notifCursor = notifResult.cursor; + _seenAt = notifResult.seenAt; + _unreadCount = countResult.count; + _notifLoaded = true; + } catch { + // silent fail + } finally { + _notifPrefetching = false; + } +} diff --git a/src/lib/post-cache.svelte.ts b/src/lib/post-cache.svelte.ts deleted file mode 100644 index 5acaff7..0000000 --- a/src/lib/post-cache.svelte.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Client, simpleFetchHandler } from '@atcute/client'; -import type { ResourceUri } from '@atcute/lexicons'; - -// Client-side cache for post views keyed by post URI -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const cache = new Map(); - -// Cache for thread data (post + replies) keyed by post URI -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const threadCache = new Map(); - -const publicClient = new Client({ - handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) -}); - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function cachePost(post: any) { - if (post?.uri) { - cache.set(post.uri, post); - } -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function getCachedPost(uri: string): any | undefined { - return cache.get(uri); -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function getCachedThread(uri: string): any | undefined { - return threadCache.get(uri); -} - -export function prefetchThread(uri: string) { - if (threadCache.has(uri)) return; - // Fire and forget - publicClient - .get('app.bsky.feed.getPostThread', { - params: { uri: uri as ResourceUri, depth: 10, parentHeight: 0 } - }) - .then((res) => { - if (res.ok && res.data.thread.$type === 'app.bsky.feed.defs#threadViewPost') { - threadCache.set(uri, res.data.thread); - } - }) - .catch(() => {}); -} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 11eb079..6958e32 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,17 +1,61 @@ - + + + + + {#if user.did} + {@const profileHref = `/p/${user.profile?.handle ?? user.did}`} + + {:else} + + {/if}
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f107bdc..9ab48ff 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,96 +1,77 @@
-
+
{#if loading}
- {:else if posts.length === 0} + {:else if feedCache.posts.length === 0}

No posts

{:else}
- {#each posts as feedPost, i (feedPost.post?.uri ? `${feedPost.post.uri}-${i}` : i)} + {#each feedCache.posts as feedPost, i (feedPost.post?.uri ? `${feedPost.post.uri}-${i}` : i)} {#if feedPost.post} {@const { postData, embeds } = blueskyPostToPostData(feedPost.post, 'https://bsky.app', feedPost.reason, feedPost.reply)}
{ + onmousedown={(e) => { + if (e.button !== 0) return; if ((e.target as HTMLElement).closest('a, button')) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const record = feedPost.post.record as any; @@ -213,6 +195,9 @@ }, repost: { count: postData.repostCount + }, + like: { + count: postData.likeCount } }} /> @@ -227,7 +212,7 @@
{/if} - {#if !cursor && posts.length > 0} + {#if !feedCache.cursor && feedCache.posts.length > 0}

You've reached the end

{/if} diff --git a/src/routes/chat/+layout.svelte b/src/routes/chat/+layout.svelte new file mode 100644 index 0000000..d462ba1 --- /dev/null +++ b/src/routes/chat/+layout.svelte @@ -0,0 +1,210 @@ + + +{#if !user.isLoggedIn} +
+
+ +

Log in to view your messages

+ +
+
+{:else} +
+ +
+
+

Messages

+ +
+ + +
+ + +
+ + {#if loading} +
+ +
+ {:else if displayedConvos.length === 0} +
+ +

+ {tab === 'accepted' ? 'No conversations yet' : 'No message requests'} +

+
+ {:else} + + {/if} +
+ + +
+
+ {@render children()} +
+
+
+{/if} diff --git a/src/routes/chat/+page.svelte b/src/routes/chat/+page.svelte new file mode 100644 index 0000000..25d3458 --- /dev/null +++ b/src/routes/chat/+page.svelte @@ -0,0 +1,10 @@ + + +
+
+ +

Select a conversation

+
+
diff --git a/src/routes/chat/[convoId]/+page.svelte b/src/routes/chat/[convoId]/+page.svelte new file mode 100644 index 0000000..83639d6 --- /dev/null +++ b/src/routes/chat/[convoId]/+page.svelte @@ -0,0 +1,329 @@ + + +{#if loading} +
+ +
+{:else if !member} +
+

Conversation not found

+
+{:else} + +
+ + + + + + + +
+ + +
+ {#if loadingOlder} +
+ +
+ {/if} + {#if allMessages.length === 0} +
+

No messages yet. Say hello!

+
+ {:else} + {#each allMessages as msg, i (msg.id)} + {@const isOwn = msg.sender.did === user.did} + {@const showHeader = shouldShowHeader(i)} + {#if isMessageView(msg)} +
+ {#if showHeader} + + {:else} +
+ {/if} +
+ {#if showHeader} +
+ + + {formatMessageTime(msg.sentAt)} + +
+ {/if} +

{msg.text}

+
+
+ {:else} +
+

Message deleted

+
+ {/if} + {/each} + + + {#each pendingMessages as msg (msg.id)} + {@const showHeader = allMessages.length === 0 && pendingMessages[0]?.id === msg.id + ? true + : (() => { + const lastReal = allMessages[allMessages.length - 1]; + const prevPendingIdx = pendingMessages.indexOf(msg) - 1; + const prev = prevPendingIdx >= 0 ? pendingMessages[prevPendingIdx] : lastReal; + if (!prev) return true; + const prevDid = 'sender' in prev ? prev.sender.did : user.did; + if (prevDid !== user.did) return true; + const prevTime = 'sentAt' in prev ? prev.sentAt : ''; + return new Date(msg.sentAt).getTime() - new Date(prevTime).getTime() > 5 * 60 * 1000; + })()} +
+ {#if showHeader} + + {:else} +
+ {/if} +
+ {#if showHeader} +
+ + {user.profile?.displayName ?? 'You'} + + + {formatMessageTime(msg.sentAt)} + +
+ {/if} +

{msg.text}

+
+
+ {/each} + {/if} +
+ + + {#if isRequest} +
+
+

+ {member.displayName ?? member.handle} wants to message you +

+
+ + + Ignore + +
+
+
+ {:else} +
+
+ + +
+
+ {/if} +{/if} diff --git a/src/routes/notifications/+page.svelte b/src/routes/notifications/+page.svelte new file mode 100644 index 0000000..1bbb482 --- /dev/null +++ b/src/routes/notifications/+page.svelte @@ -0,0 +1,309 @@ + + +{#if !user.isLoggedIn} +
+
+ +

Log in to view your notifications

+ +
+
+{:else} +
+
+ +
+

Notifications

+ +
+ + {#if loading} +
+ +
+ {:else if notificationsCache.notifications.length === 0} +
+ +

No notifications yet

+
+ {:else} +
+ {#each notificationsCache.notifications as notif, i (notif.uri + '-' + i)} + {@const Icon = reasonIcon(notif.reason)} + {@const postText = getPostText(notif)} + +
{ + if (e.button !== 0) return; + if ((e.target as HTMLElement).closest('a, button')) return; + navigateToNotification(notif); + }} + > + +
+
+ +
+ + + {reasonText(notif.reason)} + +
+ + {formatTime(notif.indexedAt)} + +
+ + + {#if ['reply', 'quote', 'mention'].includes(notif.reason) && postText} +

+ {postText} +

+ {/if} +
+
+ {/each} +
+ + {#if loadingMore} +
+ +
+ {/if} + + {#if !notificationsCache.cursor && notificationsCache.notifications.length > 0} +

You've reached the end

+ {/if} + +
+ {/if} +
+
+{/if} diff --git a/src/routes/p/[actor]/+page.svelte b/src/routes/p/[actor]/+page.svelte index cc644d7..dea3a76 100644 --- a/src/routes/p/[actor]/+page.svelte +++ b/src/routes/p/[actor]/+page.svelte @@ -2,27 +2,46 @@ import { onMount } from 'svelte'; import { page } from '$app/state'; import { UserProfile } from '@foxui/social'; - import { Loader2 } from '@lucide/svelte'; + import { Button } from '@foxui/core'; + import { Loader2, LogOut } from '@lucide/svelte'; + import { user, logout } from '$lib/atproto/auth.svelte'; import { actorToDid, getDetailedProfile } from '$lib/atproto/methods'; + import { getCachedProfile, cacheProfile } from '$lib/cache.svelte'; import { Client, simpleFetchHandler } from '@atcute/client'; + let isOwnProfile = $derived(user.did && profile?.did === user.did); + let loading = $state(true); let error = $state(null); // eslint-disable-next-line @typescript-eslint/no-explicit-any let profile = $state(null); onMount(async () => { + const actor = page.params.actor; + + // Show cached profile instantly + const cached = getCachedProfile(actor); + if (cached) { + profile = cached; + loading = false; + } + + // Always fetch full profile try { - const actor = page.params.actor; const did = await actorToDid(actor); const client = new Client({ handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) }); - profile = await getDetailedProfile({ did, client }); - if (!profile) error = 'Profile not found'; + const fresh = await getDetailedProfile({ did, client }); + if (fresh) { + profile = fresh; + cacheProfile(fresh); + } else if (!cached) { + error = 'Profile not found'; + } } catch (e) { console.error('Failed to load profile:', e); - error = 'Failed to load profile'; + if (!cached) error = 'Failed to load profile'; } finally { loading = false; } @@ -50,6 +69,14 @@ }} class="" /> + {#if isOwnProfile} +
+ +
+ {/if} {/if}
diff --git a/src/routes/p/[actor]/post/[rkey]/+page.svelte b/src/routes/p/[actor]/post/[rkey]/+page.svelte index e9d2a62..101b60c 100644 --- a/src/routes/p/[actor]/post/[rkey]/+page.svelte +++ b/src/routes/p/[actor]/post/[rkey]/+page.svelte @@ -2,17 +2,14 @@ import { onMount } from 'svelte'; import { goto } from '$app/navigation'; import { page } from '$app/state'; - import type { Snapshot } from './$types'; import { user } from '$lib/atproto/auth.svelte'; import { actorToDid } from '$lib/atproto/methods'; import { blueskyPostToPostData } from '@foxui/social'; import { Post, NestedComments } from '@foxui/social'; import type { PostData } from '@foxui/social'; - import { Loader2 } from '@lucide/svelte'; - import { likePost, unlikePost } from '$lib/atproto/server/feed.remote'; - import { getCachedPost, getCachedThread } from '$lib/post-cache.svelte'; - import { Client, simpleFetchHandler } from '@atcute/client'; - import type { ResourceUri } from '@atcute/lexicons'; + import { ArrowLeft, Loader2 } from '@lucide/svelte'; + import { likePost, unlikePost, getPostThread } from '$lib/atproto/server/feed.remote'; + import { getCachedPost, getCachedThread } from '$lib/cache.svelte'; let loading = $state(true); let loadingComments = $state(true); @@ -97,29 +94,7 @@ }; } - let restored = false; - - export const snapshot: Snapshot = { - capture: () => ({ - postView, - comments, - postViewMap, - likeState, - likeCountDelta - }), - restore: (data) => { - postView = data.postView; - comments = data.comments; - postViewMap = data.postViewMap; - likeState = data.likeState; - likeCountDelta = data.likeCountDelta; - loading = false; - restored = true; - } - }; - onMount(async () => { - if (restored) return; try { const did = await actorToDid(page.params.actor); @@ -145,25 +120,20 @@ loading = false; } - // Fetch full thread - const client = new Client({ - handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) - }); + // Fetch full thread (authenticated if logged in, for viewer state) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await getPostThread({ uri }) as any; - const res = await client.get('app.bsky.feed.getPostThread', { - params: { uri: uri as ResourceUri, depth: 10, parentHeight: 0 } - }); - - if (!res.ok || res.data.thread.$type !== 'app.bsky.feed.defs#threadViewPost') { + if (!data.thread || data.thread.$type !== 'app.bsky.feed.defs#threadViewPost') { if (!postView) error = 'Post not found'; return; } - postView = res.data.thread.post; + postView = data.thread.post; - if (res.data.thread.replies?.length) { + if (data.thread.replies?.length) { // eslint-disable-next-line @typescript-eslint/no-explicit-any - comments = threadToComments(res.data.thread.replies as any[]); + comments = threadToComments(data.thread.replies as any[]); } } catch (e) { console.error('Failed to load post:', e); @@ -180,7 +150,13 @@
-
+
+ {#if loading}
@@ -191,7 +167,7 @@
{:else if postView} {@const { postData, embeds } = blueskyPostToPostData(postView)} -
+
@@ -218,7 +195,7 @@
{:else if comments.length > 0} -
+
{/if} diff --git a/wrangler.jsonc b/wrangler.jsonc index 59047a6..86e5fee 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -12,7 +12,7 @@ "enabled": true }, "vars": { - "OAUTH_PUBLIC_URL": "https://statusphere.atmo.tools" + "OAUTH_PUBLIC_URL": "https://atmo.social" }, "kv_namespaces": [ {